From f79400a69146dcd300d15b93c39455a87b6c9ac0 Mon Sep 17 00:00:00 2001 From: Type <79042381+TypeFor@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:30:38 +0200 Subject: [PATCH 01/15] ADD - add SkyPhone banking app Includes the required banking configuration, SQL migration, and English locale entries. --- frontend/pnpm-workspace.yaml | 3 +- frontend/src/App.vue | 4 + frontend/src/assets/img/app-icons/banking.svg | 16 + frontend/src/assets/main.css | 658 +++++++++++++++++- frontend/src/config/apps.test.ts | 17 +- frontend/src/config/apps.ts | 25 +- frontend/src/stores/banking.test.ts | 63 ++ frontend/src/stores/banking.ts | 45 ++ frontend/src/stores/phone.ts | 63 ++ frontend/src/types/apps.ts | 1 + frontend/src/types/banking.ts | 25 + frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/BankingApp.vue | 419 +++++++++++ frontend/testserver/index.cjs | 66 ++ sky_phone/config/config.lua | 12 +- sky_phone/config/locales/en.lua | 27 + sky_phone/fxmanifest.lua | 7 +- sky_phone/source/client/main.lua | 8 + sky_phone/source/html/index.html | 4 +- sky_phone/source/server/banking.lua | 184 +++++ sky_phone/source/server/db_migrate.lua | 18 + sky_phone/sql/install.sql | 13 + 22 files changed, 1663 insertions(+), 16 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/banking.svg create mode 100644 frontend/src/stores/banking.test.ts create mode 100644 frontend/src/stores/banking.ts create mode 100644 frontend/src/types/banking.ts create mode 100644 frontend/src/views/apps/BankingApp.vue create mode 100644 sky_phone/source/server/banking.lua diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index c6f4a1e..d508289 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,3 +1,4 @@ +allowBuilds: + esbuild: set this to true or false onlyBuiltDependencies: - esbuild - diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3648726..65ffab7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -21,6 +21,7 @@ import SimPhonePicker, { import { PHONE_FRAME_IMAGES } from '@/config/appearance' import { useClockStore } from '@/stores/clock' import { useCallsStore } from '@/stores/calls' +import { useBankingStore } from '@/stores/banking' import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' import { useMediaStore } from '@/stores/media' @@ -67,6 +68,7 @@ const phone = usePhoneStore() const account = useAccountStore() const clock = useClockStore() const calls = useCallsStore() +const banking = useBankingStore() const mail = useMailStore() const media = useMediaStore() const notes = useNotesStore() @@ -155,6 +157,8 @@ function onMessage(event: MessageEvent): void { void calls.loadContacts() } 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') && diff --git a/frontend/src/assets/img/app-icons/banking.svg b/frontend/src/assets/img/app-icons/banking.svg new file mode 100644 index 0000000..78924fa --- /dev/null +++ b/frontend/src/assets/img/app-icons/banking.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 26f0f99..84f759c 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1096,15 +1096,671 @@ 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-header { + position: absolute; + z-index: 5; + top: 0; + right: 0; + left: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 54px 22px 13px; + background: linear-gradient(to bottom, rgb(7 13 29 / 85%), transparent); +} + +.banking-header > div { + min-width: 0; +} + +.banking-header span { + display: block; + color: rgb(208 220 248 / 64%); + font-size: 11px; + letter-spacing: 0.03em; +} + +.banking-header h1 { + overflow: hidden; + margin: 2px 0 0; + font-size: 17px; + font-weight: 650; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.banking-profile { + position: relative; + display: grid; + width: 42px; + height: 42px; + flex: 0 0 auto; + place-items: center; + border: 1px solid rgb(255 255 255 / 16%); + border-radius: 15px; + background: linear-gradient(145deg, rgb(65 130 255 / 42%), rgb(19 35 78 / 48%)); + box-shadow: inset 0 1px rgb(255 255 255 / 18%), 0 9px 28px rgb(0 0 0 / 22%); + color: #dce9ff; + backdrop-filter: blur(18px) saturate(145%); + -webkit-backdrop-filter: blur(18px) saturate(145%); +} + +.banking-profile .banking-spin { + position: absolute; + right: -2px; + bottom: -2px; + padding: 3px; + border-radius: 50%; + background: var(--bank-blue); + box-sizing: content-box; +} + +.banking-spin { animation: banking-spin 0.75s linear infinite; } + +@keyframes banking-spin { to { transform: rotate(360deg); } } + +.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-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 { + position: relative; + overflow: hidden; + padding: 22px 20px 20px; + border: 1px solid rgb(135 178 255 / 22%); + border-radius: 27px; + background: + linear-gradient(145deg, rgb(50 106 218 / 38%), rgb(15 29 67 / 36%)), + rgb(255 255 255 / 3%); + box-shadow: inset 0 1px rgb(255 255 255 / 16%), 0 22px 52px rgb(0 0 0 / 26%); + backdrop-filter: blur(26px) saturate(150%); + -webkit-backdrop-filter: blur(26px) saturate(150%); +} + +.banking-balance::after { + position: absolute; + width: 150px; + height: 150px; + top: -92px; + right: -24px; + border-radius: 50%; + background: var(--bank-blue); + box-shadow: 0 0 68px 25px rgb(45 118 255 / 48%); + content: ''; + opacity: 0.25; +} + +.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(3, 1fr); + gap: 9px; + margin: 13px 0 18px; +} + +.banking-actions button { + display: flex; + align-items: center; + flex-direction: column; + gap: 7px; + padding: 12px 5px 10px; + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 19px; + background: rgb(255 255 255 / 5.5%); + box-shadow: inset 0 1px rgb(255 255 255 / 9%); + color: rgb(237 243 255 / 82%); + font-size: 10px; + backdrop-filter: blur(17px); + -webkit-backdrop-filter: blur(17px); +} + +.banking-actions button span { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 13px; + background: linear-gradient(145deg, rgb(60 129 255 / 34%), rgb(25 54 115 / 25%)); + color: #8fb8ff; +} + +.banking-card { + margin: 0 0 18px !important; + padding: 16px !important; + border: 1px solid rgb(255 255 255 / 10%) !important; + border-radius: 24px !important; + background: linear-gradient(145deg, rgb(255 255 255 / 8%), rgb(255 255 255 / 3%)) !important; + box-shadow: inset 0 1px rgb(255 255 255 / 9%), 0 18px 38px rgb(0 0 0 / 18%) !important; + color: #fff !important; + backdrop-filter: blur(24px) saturate(140%); + -webkit-backdrop-filter: blur(24px) saturate(140%); +} + +.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-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 17px; +} + +.banking-account-row + .banking-account-row { margin-top: 7px; } + +.banking-account-row--primary { + position: relative; + overflow: hidden; + border: 1px solid rgb(125 174 255 / 17%); + background: linear-gradient(120deg, rgb(37 103 230 / 38%), rgb(36 72 143 / 15%)); +} + +.banking-account-row > span:first-child { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 auto; + place-items: center; + border-radius: 12px; + background: rgb(255 255 255 / 9%); + color: #9fc0ff; +} + +.banking-account-row div { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.banking-account-row small { + color: rgb(216 227 250 / 54%); + font-size: 9px; +} + +.banking-account-row strong { + margin-top: 2px; + font-size: 16px; + font-weight: 620; +} + +.banking-card-mark { + position: relative; + width: 32px !important; + background: transparent !important; +} + +.banking-card-mark::before, +.banking-card-mark::after { + position: absolute; + width: 20px; + height: 20px; + top: 8px; + border-radius: 50%; + background: rgb(255 255 255 / 64%); + content: ''; +} + +.banking-card-mark::before { left: 0; } +.banking-card-mark::after { right: 0; background: rgb(120 164 255 / 65%); } + +.banking-transactions { padding: 0 3px; } +.banking-transactions--all { margin-top: 18px; } + +.banking-transaction-list { + overflow: hidden; + border: 1px solid rgb(255 255 255 / 8%); + border-radius: 22px; + background: rgb(255 255 255 / 3.5%); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.banking-transaction { + display: flex; + align-items: center; + gap: 11px; + padding: 12px; +} + +.banking-transaction + .banking-transaction { + border-top: 1px solid rgb(255 255 255 / 6%); +} + +.banking-transaction > span { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 auto; + place-items: center; + border-radius: 12px; + background: rgb(255 106 120 / 10%); + color: #ff8793; +} + +.banking-transaction > span.is-incoming { + background: rgb(78 230 164 / 10%); + color: var(--bank-green); +} + +.banking-transaction > div { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.banking-transaction strong { + overflow: hidden; + font-size: 11px; + font-weight: 570; + text-overflow: ellipsis; + white-space: nowrap; +} + +.banking-transaction small { + margin-top: 2px; + color: rgb(210 221 245 / 43%); + font-size: 8px; +} + +.banking-transaction b { + flex: 0 0 auto; + color: #f2a0a8; + font-size: 11px; + font-weight: 620; +} + +.banking-transaction 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-tabbar { + position: absolute; + z-index: 10; + right: 16px; + bottom: 22px; + left: 16px; + width: auto; + padding: 0; +} + +.banking-tabbar__background { + border: 1px solid rgb(255 255 255 / 13%); + border-radius: 24px; + background: rgb(14 22 45 / 72%); + box-shadow: inset 0 1px rgb(255 255 255 / 12%), 0 16px 48px rgb(0 0 0 / 48%); + backdrop-filter: blur(30px) saturate(165%); + -webkit-backdrop-filter: blur(30px) saturate(165%); +} + +.banking-tabbar__inner { + gap: 4px; + padding: 7px; +} + +.banking-tabbar .k-link { + color: rgb(210 222 247 / 48%); + font-size: 10px; +} + +.banking-tabbar .k-tabbar-link-active { + color: #93b9ff; +} + +.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-modal-backdrop { + position: absolute; + z-index: 30; + inset: 0; + display: flex; + align-items: flex-end; + padding: 14px 14px 20px; + background: rgb(0 3 12 / 58%); + backdrop-filter: blur(9px); + -webkit-backdrop-filter: blur(9px); +} + +.banking-modal { + position: relative; + width: 100%; + padding: 22px 18px 18px; + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 29px; + background: + radial-gradient(circle at 16% 0%, rgb(47 117 255 / 24%), transparent 42%), + rgb(13 22 45 / 92%); + box-shadow: inset 0 1px rgb(255 255 255 / 14%), 0 24px 70px rgb(0 0 0 / 55%); + backdrop-filter: blur(34px) saturate(165%); + -webkit-backdrop-filter: blur(34px) saturate(165%); +} + +.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-modal h2 { + margin: 14px 0 4px; + font-size: 20px; + letter-spacing: -0.035em; +} + +.banking-modal > p { + margin: 0 0 15px; + color: rgb(213 225 248 / 52%); + font-size: 10px; + line-height: 1.45; +} + +.banking-modal label { + display: block; + margin-top: 11px; +} + +.banking-modal label > span { + display: block; + margin: 0 0 6px 4px; + color: rgb(218 229 251 / 62%); + font-size: 9px; +} + +.banking-modal input { + width: 100%; + height: 48px; + padding: 0 14px; + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 15px; + outline: none; + background: rgb(255 255 255 / 5%); + color: #fff; + font-size: 14px; +} + +.banking-modal input:focus { + border-color: rgb(72 137 255 / 62%); + box-shadow: 0 0 0 3px rgb(45 118 255 / 11%); +} + +.banking-amount-input { position: relative; } + +.banking-amount-input i { + position: absolute; + top: 50%; + left: 14px; + color: #7faaff; + font-size: 15px; + font-style: normal; + transform: translateY(-50%); +} + +.banking-amount-input input { padding-left: 31px; } + +.banking-modal .banking-form-error { + margin: 10px 3px 0; + color: #ff8b98; + font-size: 9px; +} + +.banking-modal > button:last-child { + margin-top: 15px; + background: linear-gradient(135deg, #397fff, #1a55d1) !important; + box-shadow: 0 12px 28px rgb(25 78 190 / 30%); +} + +.banking-modal > button:last-child > span { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} .weather-navbar::after { opacity: 0; } diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 0140978..b9b7d26 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -8,16 +8,20 @@ describe('app registry', () => { expect(PHONE_APPS.every((app) => app.route === `/apps/${app.id}`)).toBe( true, ) - expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe( - true, - ) + expect( + PHONE_APPS.filter( + (app) => + !app.iconImage.startsWith('data:image/svg+xml') && + !app.iconImage.endsWith('.webp'), + ).map((app) => ({ id: app.id, image: app.iconImage })), + ).toEqual([]) expect(PHONE_APPS.find((app) => app.id === 'phone')).toMatchObject({ dockOrder: 0, labelKey: 'Apps.phone.name', route: '/apps/phone', }) expect(PHONE_APPS.find((app) => app.id === 'mail')).toMatchObject({ - gridOrder: 5, + gridOrder: 6, labelKey: 'Apps.mail.name', route: '/apps/mail', }) @@ -26,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.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 07fd30c..4f19210 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -10,6 +10,7 @@ import { Settings, ShoppingBag, CloudSun, + Landmark, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw } from 'vue' @@ -24,6 +25,7 @@ import photosIcon from '@/assets/img/app-icons/gallery.webp' import phoneIcon from '@/assets/img/app-icons/phone.webp' import settingsIcon from '@/assets/img/app-icons/settings.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' +import bankingIcon from '@/assets/img/app-icons/banking.svg' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ @@ -55,10 +57,23 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ }, { component: markRaw( - defineAsyncComponent(() => import('@/views/apps/MailApp.vue')), + 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', + }, + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/MailApp.vue')), + ), + dockOrder: null, + gridOrder: 6, icon: markRaw(Mail), iconClass: '', iconImage: mailIcon, @@ -71,7 +86,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')), ), dockOrder: null, - gridOrder: 6, + gridOrder: 7, icon: markRaw(NotebookPen), iconClass: '', iconImage: notesIcon, @@ -136,7 +151,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')), ), dockOrder: null, - gridOrder: 7, + gridOrder: 8, icon: markRaw(Images), iconClass: 'app-icon--photos', iconImage: photosIcon, @@ -149,7 +164,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')), ), dockOrder: null, - gridOrder: 8, + gridOrder: 9, icon: markRaw(ShoppingBag), iconClass: 'app-icon--store', iconImage: appStoreIcon, @@ -162,7 +177,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')), ), dockOrder: null, - gridOrder: 9, + gridOrder: 11, icon: markRaw(Settings), iconClass: 'app-icon--settings', iconImage: settingsIcon, diff --git a/frontend/src/stores/banking.test.ts b/frontend/src/stores/banking.test.ts new file mode 100644 index 0000000..9029ddf --- /dev/null +++ b/frontend/src/stores/banking.test.ts @@ -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('withdraw', 50000) + + expect(banking.overview).toEqual(overview) + expect(banking.error).toBe('insufficient_funds') + }) +}) diff --git a/frontend/src/stores/banking.ts b/frontend/src/stores/banking.ts new file mode 100644 index 0000000..84fc79e --- /dev/null +++ b/frontend/src/stores/banking.ts @@ -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 { + this.isLoading = true + const response = await nuiCall('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> { + this.isLoading = true + const response = await nuiCall(`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 + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0d0f8b5..9b36a1d 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -127,6 +127,69 @@ const defaultLocales: LocaleTree = { default: 'The phone request failed.', }, }, + banking: { + name: 'Banking', + welcome: 'Welcome back', + totalBalance: 'Total Balance', + recentPeriod: 'in recent activity', + actions: 'Banking actions', + send: 'Send', + deposit: 'Deposit', + withdraw: 'Withdraw', + 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', + 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', + }, + deposit: { + title: 'Deposit cash', + body: 'Move cash from your wallet into your bank account.', + submit: 'Deposit cash', + }, + withdraw: { + title: 'Withdraw cash', + body: 'Move money from your bank account into your wallet.', + submit: 'Withdraw cash', + }, + }, + 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: 'SkyBase banking is currently unavailable.', + request_failed: 'The banking request failed.', + default: 'The banking request failed.', + }, + }, calculator: { name: 'Calculator' }, map: { name: 'Map', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index f41235b..7a002f0 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -6,6 +6,7 @@ export type PhoneAppId = | 'camera' | 'clock' | 'weather' + | 'banking' | 'mail' | 'map' | 'notes' diff --git a/frontend/src/types/banking.ts b/frontend/src/types/banking.ts new file mode 100644 index 0000000..6e7fa3c --- /dev/null +++ b/frontend/src/types/banking.ts @@ -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 = 'deposit' | 'withdraw' | 'transfer' diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 5acd580..2e2d322 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -53,6 +53,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< camera: { enabled: true, sounds: true }, clock: { 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 }, diff --git a/frontend/src/views/apps/BankingApp.vue b/frontend/src/views/apps/BankingApp.vue new file mode 100644 index 0000000..14d020f --- /dev/null +++ b/frontend/src/views/apps/BankingApp.vue @@ -0,0 +1,419 @@ + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index e237df0..ba0006e 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -11,6 +11,17 @@ let authenticated = false let draft = null let linkedAccount = null let mockNotes = [] +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 }, +] const deviceData = {} const accountDevices = [ { @@ -94,6 +105,61 @@ function counts() { app.post('/api/:endpoint', (request, response) => { console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint + 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:deposit' || + endpoint === 'banking:withdraw' || + endpoint === 'banking:transfer' + ) { + const amount = Number(request.body.amount) + if (!Number.isSafeInteger(amount) || amount <= 0) { + response.json({ success: false, error: 'invalid_request' }) + return + } + if (endpoint === 'banking:deposit' && mockCashBalance < amount) { + response.json({ success: false, error: 'insufficient_funds' }) + return + } + if (endpoint !== 'banking:deposit' && mockBankBalance < amount) { + response.json({ success: false, error: 'insufficient_funds' }) + return + } + const kind = endpoint === 'banking:deposit' + ? 'deposit' + : endpoint === 'banking:withdraw' + ? 'withdrawal' + : 'transfer_out' + if (kind === 'deposit') { + mockCashBalance -= amount + mockBankBalance += amount + } else if (kind === 'withdrawal') { + mockBankBalance -= amount + mockCashBalance += amount + } else { + 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, diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 5f0b0b2..231a3bb 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -14,8 +14,8 @@ Config.Bridge = { Config.Command = "phone" Config.Phone = { - Item = "sky_phone", - DevelopmentCommand = false, + Item = "phone", + DevelopmentCommand = true, DeviceName = "iFruit Phone", } @@ -46,3 +46,11 @@ Config.Mail = { PageSize = 50, AuthAttemptsPerMinute = 5, } + +Config.Banking = { + Currency = "$", + MinimumAmount = 1, + MaximumAmount = 1000000, + ActionsPerMinute = 12, + HistoryLimit = 50, +} diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 55f86e2..6fd1593 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -60,6 +60,33 @@ Locales["en"] = { default = "The phone request failed.", }, }, + banking = { + name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity", + actions = "Banking actions", send = "Send", deposit = "Deposit", withdraw = "Withdraw", + 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", + 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" }, + deposit = { title = "Deposit cash", body = "Move cash from your wallet into your bank account.", submit = "Deposit cash" }, + withdraw = { title = "Withdraw cash", body = "Move money from your bank account into your wallet.", submit = "Withdraw cash" }, + }, + 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 = "SkyBase banking is currently unavailable.", request_failed = "The banking request failed.", + default = "The banking request failed.", + }, + }, calculator = { name = "Calculator" }, camera = { name = "Camera", shutter = "Take photo", flip = "Flip camera", flash = "Toggle flash", controls = "Camera controls", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index a414025..7045436 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -13,6 +13,7 @@ escrow_ignore { } shared_scripts { + '@sky_base/source/import.lua', 'config/init.lua', 'source/bridge/shared.lua', 'source/shared/imei.lua', @@ -43,6 +44,7 @@ server_scripts { 'source/server/calls.lua', 'source/server/notes.lua', 'source/server/mail.lua', + 'source/server/banking.lua', } files { @@ -53,4 +55,7 @@ files { ui_page 'source/html/index.html' -dependency 'oxmysql' +dependencies { + 'oxmysql', + 'sky_base', +} diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 801757a..61a12b3 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -43,6 +43,10 @@ local server_callbacks = { "calls:answer", "calls:decline", "calls:hangup", + "banking:overview", + "banking:deposit", + "banking:withdraw", + "banking:transfer", } local function get_locale() @@ -283,6 +287,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:call:incoming", function(data) notification_focus = true SetNuiFocus(true, true) diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index 2d66aff..f265ac7 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + +
diff --git a/sky_phone/source/server/banking.lua b/sky_phone/source/server/banking.lua new file mode 100644 index 0000000..f44f893 --- /dev/null +++ b/sky_phone/source/server/banking.lua @@ -0,0 +1,184 @@ +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 = Sky.FW.GetFirstname(source) + local lastname = Sky.FW.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 = Sky.FW.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(Sky.FW.GetAccountMoney(source, "bank")) or 0), + cash = math.max(0, tonumber(Sky.FW.GetAccountMoney(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:deposit", 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) + if not amount then + return { success = false, error = "invalid_request" } + end + if not Sky.FW.RemoveAccountMoney(source, "cash", amount) then + return { success = false, error = "insufficient_funds" } + end + local added = pcall(Sky.FW.AddAccountMoney, source, "bank", amount) + if not added then + Sky.FW.AddAccountMoney(source, "cash", amount) + return { success = false, error = "transfer_failed" } + end + record_transaction(identifier, "deposit", amount, "", "cash-deposit") + return { success = true, data = overview(source, identifier) } +end) + +Bridge.Callbacks.Register("sky_phone:banking:withdraw", 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) + if not amount then + return { success = false, error = "invalid_request" } + end + if not Sky.FW.RemoveAccountMoney(source, "bank", amount) then + return { success = false, error = "insufficient_funds" } + end + local added = pcall(Sky.FW.AddAccountMoney, source, "cash", amount) + if not added then + Sky.FW.AddAccountMoney(source, "bank", amount) + return { success = false, error = "transfer_failed" } + end + record_transaction(identifier, "withdrawal", amount, "", "cash-withdrawal") + 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 = Sky.FW.GetIdentifier(target) + if type(target_identifier) ~= "string" or target_identifier == "" then + return { success = false, error = "target_not_found" } + end + if not Sky.FW.RemoveAccountMoney(source, "bank", amount) then + return { success = false, error = "insufficient_funds" } + end + local added = pcall(Sky.FW.AddAccountMoney, target, "bank", amount) + if not added then + Sky.FW.AddAccountMoney(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) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 536fb54..f9aafb1 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -318,6 +318,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", + }, } Bridge.Database.Migrate("sky_phone", schema) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 773ca24..f6645d6 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -161,3 +161,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` ( FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE, 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; From dd2b5442b6e0c1051e4bdf0a08630a7885248b55 Mon Sep 17 00:00:00 2001 From: Type <79042381+TypeFor@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:58:55 +0200 Subject: [PATCH 02/15] ENH - polish phone UI and repository hygiene --- .gitignore | 1 + frontend/.gitignore | 2 + frontend/eslint.config.ts | 2 +- frontend/src/assets/main.css | 368 ++++--------------------- frontend/src/views/apps/BankingApp.vue | 286 ++++++++++--------- sky_phone/source/html/index.html | 4 +- 6 files changed, 227 insertions(+), 436 deletions(-) diff --git a/.gitignore b/.gitignore index 6d0d6a3..2fa4d78 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /sky_phone/source/html +.pnpm-store/ diff --git a/frontend/.gitignore b/frontend/.gitignore index 5d3c6e3..b482045 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,4 +1,6 @@ node_modules/ dist/ +.vite/ *.local +dev-server*.log diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index 2279af8..faefcab 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -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, diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 42df43f..11569c8 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1202,54 +1202,26 @@ button { opacity: 0.2; } -.banking-header { +.banking-navbar { + --k-safe-area-top: 46px; position: absolute; z-index: 5; top: 0; right: 0; left: 0; - display: flex; - align-items: center; - justify-content: space-between; - padding: 54px 22px 13px; - background: linear-gradient(to bottom, rgb(7 13 29 / 85%), transparent); -} - -.banking-header > div { - min-width: 0; -} - -.banking-header span { - display: block; - color: rgb(208 220 248 / 64%); - font-size: 11px; - letter-spacing: 0.03em; -} - -.banking-header h1 { - overflow: hidden; - margin: 2px 0 0; - font-size: 17px; - font-weight: 650; - letter-spacing: -0.02em; - text-overflow: ellipsis; - white-space: nowrap; + color: #fff; } .banking-profile { position: relative; display: grid; - width: 42px; - height: 42px; + width: 36px; + height: 36px; flex: 0 0 auto; place-items: center; - border: 1px solid rgb(255 255 255 / 16%); - border-radius: 15px; - background: linear-gradient(145deg, rgb(65 130 255 / 42%), rgb(19 35 78 / 48%)); - box-shadow: inset 0 1px rgb(255 255 255 / 18%), 0 9px 28px rgb(0 0 0 / 22%); + border-radius: 50%; + background: transparent; color: #dce9ff; - backdrop-filter: blur(18px) saturate(145%); - -webkit-backdrop-filter: blur(18px) saturate(145%); } .banking-profile .banking-spin { @@ -1297,30 +1269,8 @@ button { } .banking-balance { - position: relative; - overflow: hidden; padding: 22px 20px 20px; - border: 1px solid rgb(135 178 255 / 22%); border-radius: 27px; - background: - linear-gradient(145deg, rgb(50 106 218 / 38%), rgb(15 29 67 / 36%)), - rgb(255 255 255 / 3%); - box-shadow: inset 0 1px rgb(255 255 255 / 16%), 0 22px 52px rgb(0 0 0 / 26%); - backdrop-filter: blur(26px) saturate(150%); - -webkit-backdrop-filter: blur(26px) saturate(150%); -} - -.banking-balance::after { - position: absolute; - width: 150px; - height: 150px; - top: -92px; - right: -24px; - border-radius: 50%; - background: var(--bank-blue); - box-shadow: 0 0 68px 25px rgb(45 118 255 / 48%); - content: ''; - opacity: 0.25; } .banking-balance__label { @@ -1361,47 +1311,67 @@ button { .banking-actions { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: 9px; margin: 13px 0 18px; } -.banking-actions button { +.banking-action { display: flex; align-items: center; - flex-direction: column; - gap: 7px; - padding: 12px 5px 10px; - border: 1px solid rgb(255 255 255 / 10%); + justify-content: center; + gap: 8px; + min-height: 64px; + padding: 11px 12px; border-radius: 19px; - background: rgb(255 255 255 / 5.5%); - box-shadow: inset 0 1px rgb(255 255 255 / 9%); color: rgb(237 243 255 / 82%); font-size: 10px; - backdrop-filter: blur(17px); - -webkit-backdrop-filter: blur(17px); } -.banking-actions button span { +.banking-action__icon { display: grid; - width: 38px; - height: 38px; + width: 34px; + height: 34px; + flex: 0 0 auto; place-items: center; - border-radius: 13px; + 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; - padding: 16px !important; - border: 1px solid rgb(255 255 255 / 10%) !important; - border-radius: 24px !important; - background: linear-gradient(145deg, rgb(255 255 255 / 8%), rgb(255 255 255 / 3%)) !important; - box-shadow: inset 0 1px rgb(255 255 255 / 9%), 0 18px 38px rgb(0 0 0 / 18%) !important; - color: #fff !important; - backdrop-filter: blur(24px) saturate(140%); - -webkit-backdrop-filter: blur(24px) saturate(140%); +} + +.banking-transaction-card { + overflow: hidden; } .banking-section-title { @@ -1425,140 +1395,22 @@ button { font-size: 10px; } -.banking-account-row { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - border-radius: 17px; +.banking-account-list, +.banking-transaction-list { + margin: 0 !important; } -.banking-account-row + .banking-account-row { margin-top: 7px; } - -.banking-account-row--primary { - position: relative; - overflow: hidden; - border: 1px solid rgb(125 174 255 / 17%); - background: linear-gradient(120deg, rgb(37 103 230 / 38%), rgb(36 72 143 / 15%)); -} - -.banking-account-row > span:first-child { - display: grid; - width: 36px; - height: 36px; - flex: 0 0 auto; - place-items: center; - border-radius: 12px; - background: rgb(255 255 255 / 9%); - color: #9fc0ff; -} - -.banking-account-row div { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; -} - -.banking-account-row small { - color: rgb(216 227 250 / 54%); - font-size: 9px; -} - -.banking-account-row strong { - margin-top: 2px; - font-size: 16px; - font-weight: 620; -} - -.banking-card-mark { - position: relative; - width: 32px !important; - background: transparent !important; -} - -.banking-card-mark::before, -.banking-card-mark::after { - position: absolute; - width: 20px; - height: 20px; - top: 8px; - border-radius: 50%; - background: rgb(255 255 255 / 64%); - content: ''; -} - -.banking-card-mark::before { left: 0; } -.banking-card-mark::after { right: 0; background: rgb(120 164 255 / 65%); } - .banking-transactions { padding: 0 3px; } .banking-transactions--all { margin-top: 18px; } -.banking-transaction-list { - overflow: hidden; - border: 1px solid rgb(255 255 255 / 8%); - border-radius: 22px; - background: rgb(255 255 255 / 3.5%); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); -} - -.banking-transaction { - display: flex; - align-items: center; - gap: 11px; - padding: 12px; -} - -.banking-transaction + .banking-transaction { - border-top: 1px solid rgb(255 255 255 / 6%); -} - -.banking-transaction > span { - display: grid; - width: 36px; - height: 36px; - flex: 0 0 auto; - place-items: center; - border-radius: 12px; - background: rgb(255 106 120 / 10%); - color: #ff8793; -} - -.banking-transaction > span.is-incoming { - background: rgb(78 230 164 / 10%); - color: var(--bank-green); -} - -.banking-transaction > div { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; -} - -.banking-transaction strong { - overflow: hidden; - font-size: 11px; - font-weight: 570; - text-overflow: ellipsis; - white-space: nowrap; -} - -.banking-transaction small { - margin-top: 2px; - color: rgb(210 221 245 / 43%); - font-size: 8px; -} - -.banking-transaction b { +.banking-transaction-list b { flex: 0 0 auto; color: #f2a0a8; font-size: 11px; font-weight: 620; } -.banking-transaction b.is-incoming { color: var(--bank-green); } +.banking-transaction-list b.is-incoming { color: var(--bank-green); } .banking-no-transactions { padding: 24px 12px; @@ -1570,39 +1422,6 @@ button { text-align: center; } -.banking-tabbar { - position: absolute; - z-index: 10; - right: 16px; - bottom: 22px; - left: 16px; - width: auto; - padding: 0; -} - -.banking-tabbar__background { - border: 1px solid rgb(255 255 255 / 13%); - border-radius: 24px; - background: rgb(14 22 45 / 72%); - box-shadow: inset 0 1px rgb(255 255 255 / 12%), 0 16px 48px rgb(0 0 0 / 48%); - backdrop-filter: blur(30px) saturate(165%); - -webkit-backdrop-filter: blur(30px) saturate(165%); -} - -.banking-tabbar__inner { - gap: 4px; - padding: 7px; -} - -.banking-tabbar .k-link { - color: rgb(210 222 247 / 48%); - font-size: 10px; -} - -.banking-tabbar .k-tabbar-link-active { - color: #93b9ff; -} - .banking-activity-hero { display: flex; align-items: center; @@ -1693,30 +1512,10 @@ button { font-size: 8px; } -.banking-modal-backdrop { - position: absolute; - z-index: 30; - inset: 0; - display: flex; - align-items: flex-end; - padding: 14px 14px 20px; - background: rgb(0 3 12 / 58%); - backdrop-filter: blur(9px); - -webkit-backdrop-filter: blur(9px); -} - -.banking-modal { +.banking-sheet__content { position: relative; width: 100%; padding: 22px 18px 18px; - border: 1px solid rgb(255 255 255 / 14%); - border-radius: 29px; - background: - radial-gradient(circle at 16% 0%, rgb(47 117 255 / 24%), transparent 42%), - rgb(13 22 45 / 92%); - box-shadow: inset 0 1px rgb(255 255 255 / 14%), 0 24px 70px rgb(0 0 0 / 55%); - backdrop-filter: blur(34px) saturate(165%); - -webkit-backdrop-filter: blur(34px) saturate(165%); } .banking-modal__close { @@ -1742,75 +1541,30 @@ button { box-shadow: 0 12px 28px rgb(22 76 196 / 36%), inset 0 1px rgb(255 255 255 / 28%); } -.banking-modal h2 { +.banking-sheet__content h2 { margin: 14px 0 4px; font-size: 20px; letter-spacing: -0.035em; } -.banking-modal > p { +.banking-sheet__content > p { margin: 0 0 15px; color: rgb(213 225 248 / 52%); font-size: 10px; line-height: 1.45; } -.banking-modal label { - display: block; - margin-top: 11px; -} +.banking-form-list { margin: 12px 0 0 !important; } -.banking-modal label > span { - display: block; - margin: 0 0 6px 4px; - color: rgb(218 229 251 / 62%); - font-size: 9px; -} - -.banking-modal input { - width: 100%; - height: 48px; - padding: 0 14px; - border: 1px solid rgb(255 255 255 / 10%); - border-radius: 15px; - outline: none; - background: rgb(255 255 255 / 5%); - color: #fff; - font-size: 14px; -} - -.banking-modal input:focus { - border-color: rgb(72 137 255 / 62%); - box-shadow: 0 0 0 3px rgb(45 118 255 / 11%); -} - -.banking-amount-input { position: relative; } - -.banking-amount-input i { - position: absolute; - top: 50%; - left: 14px; - color: #7faaff; - font-size: 15px; - font-style: normal; - transform: translateY(-50%); -} - -.banking-amount-input input { padding-left: 31px; } - -.banking-modal .banking-form-error { +.banking-sheet__content .banking-form-error { margin: 10px 3px 0; color: #ff8b98; font-size: 9px; } -.banking-modal > button:last-child { - margin-top: 15px; - background: linear-gradient(135deg, #397fff, #1a55d1) !important; - box-shadow: 0 12px 28px rgb(25 78 190 / 30%); -} +.banking-sheet__content > button:last-child { margin-top: 15px; } -.banking-modal > button:last-child > span { +.banking-sheet__content > button:last-child > span { display: flex; align-items: center; justify-content: center; diff --git a/frontend/src/views/apps/BankingApp.vue b/frontend/src/views/apps/BankingApp.vue index 14d020f..c9c7fc4 100644 --- a/frontend/src/views/apps/BankingApp.vue +++ b/frontend/src/views/apps/BankingApp.vue @@ -2,10 +2,18 @@ import { kButton, kCard, + kGlass, + kIcon, + kList, + kListInput, + kListItem, + kNavbar, kPage, kPreloader, + kSheet, kTabbar, kTabbarLink, + kToolbarPane, } from 'konsta/vue' import { ArrowDownLeft, @@ -162,27 +170,29 @@ onMounted(() => void banking.load()) - + diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index 4ee8e94..6bc38b4 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + +
From ea2da5729d24471de545b4f2f478e1694196ab3d Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 21:13:23 +0200 Subject: [PATCH 03/15] ADD - implement DarkChat service --- sky_phone/config/config.lua | 22 + sky_phone/config/locales/en.lua | 21 + sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/main.lua | 33 + sky_phone/source/server/darkchat.lua | 848 +++++++++++++++++++++++++ sky_phone/source/server/db_migrate.lua | 155 +++++ 6 files changed, 1080 insertions(+) create mode 100644 sky_phone/source/server/darkchat.lua diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 79cdbe3..7a8db69 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -47,6 +47,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, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 0e6ecaf..2ecaeb2 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -36,6 +36,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", searchGifs = "Search GIFs", loadMore = "Load More", photosLater = "Photos · coming later", videosLater = "Videos · coming later", + voiceMessage = "Voice message", sending = "Sending", failed = "Not delivered", delivered = "Delivered", read = "Read", replying = "Replying to", + messageDeleted = "Message deleted", securityUpdate = "Security settings updated", timerChanged = "Disappearing messages: {timer}", + timerOff = "Off", timerAfterRead = "After reading", timerMinute = "1 minute", timerFiveMinutes = "5 minutes", timerHour = "1 hour", timerDay = "24 hours", timerWeek = "7 days", + copied = "Copied", reply = "Reply", copy = "Copy", deleteForMe = "Delete for me", deleteForBoth = "Delete for both", report = "Report", + contactSecurity = "Contact & Security", chatSince = "Private chat since {date}", notifications = "Notifications", readReceipts = "Read receipts", disappearing = "Disappearing messages", contactAlias = "Contact alias", saveContact = "Save Contact", addContact = "Add Contact", contactSaved = "Contact saved", removeContact = "Remove Contact", block = "Block User", unblock = "Unblock User", clearChat = "Clear Chat", chatCleared = "Chat cleared", + myIdentity = "My Dark Identity", alias = "Alias", notificationPrivacy = "Notification privacy", notificationFull = "Full · alias and message", notificationPrivate = "Private · generic message", notificationHidden = "Invisible · badge only", shareActivity = "Share activity status", inviteCode = "Private invitation code", copyInvite = "Copy Invite", privacyDisclaimer = "DarkChat stores messages on the server and does not claim end-to-end encryption.", + unknownIdentity = "Unknown Identity", unknownIdentityBody = "Only continue if you expected this identity. The account is not discoverable through public search.", openSecureChat = "Open Private Chat", + reportUser = "Report User", reportSpam = "Spam", reportHarassment = "Harassment", reportThreats = "Threats", reportIllegal = "Illegal content", reportOther = "Other", reportDetails = "Optional details", submitReport = "Submit Report", reported = "Report submitted", + microphoneUnavailable = "The microphone is unavailable.", recordingTooLarge = "The voice message is too large.", + errors = { + not_authenticated = "Sign in to your iFruit account first.", invalid_dark_id = "Enter a valid Dark-ID or invitation code.", profile_not_found = "This private identity was not found.", self_chat = "You cannot message your own identity.", conversation_not_found = "This conversation is unavailable.", blocked = "Messages are blocked in this conversation.", invalid_message = "Enter a valid message.", invalid_gif = "This GIF is invalid.", invalid_voice = "This voice message is invalid.", invalid_profile = "Check your alias and privacy settings.", rate_limited = "Too many requests. Try again shortly.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", default = "DarkChat could not complete the request.", + }, + }, messages = { name = "Messages", newMessage = "New message from {sender}", compose = "New Message", search = "Search", to = "To:", message = "Message", send = "Send", details = "Details", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 18cc5f2..1423294 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -45,6 +45,7 @@ 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/marketplace.lua', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 40a8e41..1ab77d3 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -76,6 +76,20 @@ local server_callbacks = { "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", } @@ -380,6 +394,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) diff --git a/sky_phone/source/server/darkchat.lua b/sky_phone/source/server/darkchat.lua new file mode 100644 index 0000000..87368c0 --- /dev/null +++ b/sky_phone/source/server/darkchat.lua @@ -0,0 +1,848 @@ +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 + 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 + 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) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index b1ef08b..b2d437a 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -657,6 +657,161 @@ local schema = { }, 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', '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) From db29a3dd07e263f43e9330235822e6661356443c Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Thu, 6 Aug 2026 21:13:36 +0200 Subject: [PATCH 04/15] ADD - build DarkChat phone app --- frontend/src/App.vue | 41 + .../src/assets/img/app-icons/darkchat.svg | 11 + frontend/src/assets/main.css | 390 +++++++++ frontend/src/components/AppIcon.vue | 3 + .../src/components/DarkChatVoiceMessage.vue | 96 +++ frontend/src/config/apps.ts | 15 + frontend/src/stores/darkchat.test.ts | 81 ++ frontend/src/stores/darkchat.ts | 155 ++++ frontend/src/stores/phone.ts | 21 + frontend/src/types/apps.ts | 1 + frontend/src/types/darkchat.ts | 90 +++ frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/DarkChatApp.vue | 743 ++++++++++++++++++ frontend/testserver/index.cjs | 216 +++++ sky_phone/source/html/index.html | 4 +- 15 files changed, 1866 insertions(+), 2 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/darkchat.svg create mode 100644 frontend/src/components/DarkChatVoiceMessage.vue create mode 100644 frontend/src/stores/darkchat.test.ts create mode 100644 frontend/src/stores/darkchat.ts create mode 100644 frontend/src/types/darkchat.ts create mode 100644 frontend/src/views/apps/DarkChatApp.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a012683..bb4ae2c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -26,6 +26,7 @@ import { useCallsStore } from '@/stores/calls' import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' import { useMessagesStore } from '@/stores/messages' +import { useDarkChatStore } from '@/stores/darkchat' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' @@ -52,6 +53,7 @@ type AppMessage = { | MailEventData | MarketplaceEventData | MessagesEventData + | DarkChatEventData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -79,6 +81,16 @@ type MessagesEventData = { title?: string } +type DarkChatEventData = { + conversationId?: string + device?: PhoneNotificationDevicePayload + notificationMode?: 'full' | 'private' | 'hidden' + preview?: string + sender?: string + text?: string + title?: string +} + type MarketplaceEventData = { counts?: MarketplaceCounts device?: PhoneNotificationDevicePayload @@ -109,6 +121,7 @@ const games = useGamesStore() const calls = useCallsStore() const mail = useMailStore() const messages = useMessagesStore() +const darkchat = useDarkChatStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -159,6 +172,7 @@ function hydratePhone(payload: PhoneOpenPayload): void { else marketplace.setCounts({ active: 0, unread: 0 }) void calls.bootstrap() void messages.loadConversations() + if (payload.account?.email) void darkchat.bootstrap() } async function hydrateDevelopmentPhone(): Promise { @@ -313,6 +327,33 @@ function onMessage(event: MessageEvent): 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 ( diff --git a/frontend/src/assets/img/app-icons/darkchat.svg b/frontend/src/assets/img/app-icons/darkchat.svg new file mode 100644 index 0000000..de63f3d --- /dev/null +++ b/frontend/src/assets/img/app-icons/darkchat.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 33946fe..b63af5a 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -582,6 +582,396 @@ button { transition-duration: 0.18s; } } + +/* DarkChat is intentionally independent from the phone appearance setting. */ +.phone-app:has(.darkchat-page), +.phone-app:has(.darkchat-page) .phone-app-window, +.phone-app:has(.darkchat-page) .phone-app-window__content { + background: #000 !important; + color-scheme: dark; +} +.phone-app:has(.darkchat-page) .phone-status-bar { + color: #fff !important; + --status-bar-color: #fff; +} +.app-icon--darkchat { background: #050507; } +.darkchat-page { + --dc-purple: #8b5cf6; + --dc-purple-dark: #5b21b6; + --dc-blue: #0a84ff; + --dc-red: #ff453a; + --dc-surface: #1c1c1e; + --dc-surface-2: #29292c; + --dc-border: #38383a; + --dc-muted: #8e8e93; + position: absolute; + inset: 0; + overflow: hidden; + background: #000; + color: #f5f5f7; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif; +} +.darkchat-page button, +.darkchat-page input, +.darkchat-page textarea, +.darkchat-page select { font: inherit; } +.darkchat-page button { color: inherit; } +.darkchat-page input, +.darkchat-page textarea, +.darkchat-page select { color: #f5f5f7; } +.darkchat-page button:disabled { opacity: .34; } +.darkchat-page button:active:not(:disabled) { transform: scale(.96); } +.darkchat-round { + width: 39px; + height: 39px; + flex: none; + display: grid; + place-items: center; + padding: 0; + border: 1px solid var(--dc-border); + border-radius: 50%; + background: var(--dc-surface); + transition: transform .14s ease, background .2s ease; +} +.darkchat-round--large { width: 43px; height: 43px; } +.darkchat-round.active { background: var(--dc-purple); border-color: var(--dc-purple); } +.darkchat-pill { + min-width: 67px; + height: 37px; + padding: 0 15px; + border: 1px solid var(--dc-border); + border-radius: 19px; + background: var(--dc-surface); + font-size: 13px; + font-weight: 600; +} +.darkchat-gate { + height: 100%; + padding: 58px 27px 30px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; +} +.darkchat-gate > span:not(.darkchat-loader) { + width: 74px; + height: 74px; + display: grid; + place-items: center; + border-radius: 24px; + background: linear-gradient(145deg, var(--dc-purple), var(--dc-purple-dark)); + box-shadow: 0 18px 45px rgb(91 33 182 / 40%); +} +.darkchat-gate h1 { margin: 21px 0 8px; font-size: 27px; } +.darkchat-gate p { margin: 0; color: #d1d1d6; font-size: 14px; line-height: 1.45; } +.darkchat-gate small { margin-top: 13px; color: var(--dc-muted); font-size: 11px; } +.darkchat-loader { + width: 27px; + height: 27px; + display: block; + border: 3px solid rgb(139 92 246 / 25%); + border-top-color: var(--dc-purple); + border-radius: 50%; + animation: darkchat-spin .7s linear infinite; +} +.darkchat-loader--small { width: 17px; height: 17px; border-width: 2px; } +.darkchat-inbox, +.darkchat-sheet-page, +.darkchat-thread { height: 100%; padding: 44px 0 25px; box-sizing: border-box; } +.darkchat-inbox { display: flex; flex-direction: column; } +.darkchat-inbox__header, +.darkchat-sheet-page > header, +.darkchat-thread__header { + height: 62px; + padding: 5px 15px 7px; + display: grid; + align-items: center; + grid-template-columns: 1fr auto 1fr; + box-sizing: border-box; + background: #000; +} +.darkchat-inbox__header > strong, +.darkchat-sheet-page > header > strong { font-size: 17px; font-weight: 720; letter-spacing: -.2px; } +.darkchat-inbox__header > :last-child, +.darkchat-sheet-page > header > :last-child { justify-self: end; } +.darkchat-security-strip { + height: 28px; + margin: 0 15px 4px; + padding: 0 10px; + display: flex; + align-items: center; + gap: 6px; + border: 1px solid rgb(139 92 246 / 20%); + border-radius: 9px; + background: rgb(139 92 246 / 8%); + color: #b9a3ff; + font-size: 9px; +} +.darkchat-security-strip span { flex: 1; } +.darkchat-security-strip button { padding: 0; border: 0; background: transparent; color: #b9a3ff; font-size: 8px; } +.darkchat-conversations { min-height: 0; flex: 1; padding: 3px 12px 76px; overflow-y: auto; } +.darkchat-conversation { + position: relative; + width: 100%; + min-height: 67px; + padding: 8px 3px; + display: flex; + align-items: center; + gap: 10px; + border: 0; + background: transparent; + text-align: left; +} +.darkchat-conversation::after { + content: ""; + position: absolute; + right: 0; + bottom: 0; + left: 59px; + height: 1px; + background: #262628; +} +.darkchat-unread { + position: absolute; + left: -4px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--dc-purple); + box-shadow: 0 0 10px rgb(139 92 246 / 80%); +} +.darkchat-avatar { + width: 49px; + height: 49px; + flex: none; + display: grid; + place-items: center; + border: 1px solid rgb(255 255 255 / 17%); + border-radius: 50%; + color: rgb(255 255 255 / 90%); + font-size: 23px; + font-weight: 800; + text-shadow: 0 1px 7px rgb(0 0 0 / 35%); +} +.darkchat-avatar--small { width: 37px; height: 37px; font-size: 18px; } +.darkchat-avatar--header { width: 39px; height: 39px; font-size: 18px; } +.darkchat-conversation__body { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; } +.darkchat-conversation__body > span { display: flex; align-items: center; gap: 4px; } +.darkchat-conversation__body strong { min-width: 0; flex: 1; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-conversation__body time { color: var(--dc-muted); font-size: 10px; } +.darkchat-conversation__body svg { color: #636366; } +.darkchat-conversation__body small { overflow: hidden; color: var(--dc-muted); font-size: 11px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-timer-icon { position: absolute; right: 4px; bottom: 7px; color: #a78bfa; } +.darkchat-empty { flex: 1; padding: 15px 40px 80px; display: flex; align-items: center; justify-content: center; flex-direction: column; text-align: center; } +.darkchat-empty > span { width: 57px; height: 57px; display: grid; place-items: center; border-radius: 20px; background: var(--dc-surface); color: var(--dc-purple); } +.darkchat-empty h2 { margin: 14px 0 5px; font-size: 18px; } +.darkchat-empty p { margin: 0; color: var(--dc-muted); font-size: 12px; line-height: 1.4; } +.darkchat-inbox__toolbar { + position: absolute; + z-index: 5; + right: 12px; + bottom: 28px; + left: 12px; + display: flex; + align-items: center; + gap: 8px; +} +.darkchat-inbox__toolbar label { + height: 43px; + padding: 0 13px; + flex: 1; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--dc-border); + border-radius: 23px; + background: rgb(28 28 30 / 94%); + backdrop-filter: blur(22px); +} +.darkchat-inbox__toolbar input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 12px; } +.darkchat-sheet-page { overflow-y: auto; padding-right: 15px; padding-left: 15px; } +.darkchat-sheet-page > header { position: sticky; z-index: 4; top: -1px; margin: 0 -15px; } +.darkchat-new-hero { padding: 14px 23px 19px; text-align: center; } +.darkchat-new-hero > span { width: 59px; height: 59px; margin: auto; display: grid; place-items: center; border-radius: 20px; background: linear-gradient(145deg, var(--dc-purple), var(--dc-purple-dark)); } +.darkchat-new-hero h2 { margin: 12px 0 4px; font-size: 19px; } +.darkchat-new-hero p { margin: 0; color: var(--dc-muted); font-size: 11px; line-height: 1.4; } +.darkchat-input { margin: 9px 0; padding: 9px 12px; display: flex; flex-direction: column; gap: 5px; border: 1px solid var(--dc-border); border-radius: 12px; background: var(--dc-surface); } +.darkchat-input span { color: var(--dc-muted); font-size: 9px; text-transform: uppercase; letter-spacing: .5px; } +.darkchat-input input { border: 0; outline: 0; background: transparent; font-size: 13px; } +.darkchat-primary, +.darkchat-danger { + width: 100%; + min-height: 40px; + margin: 3px 0 14px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 0; + border-radius: 12px; + background: linear-gradient(135deg, var(--dc-purple), var(--dc-purple-dark)); + font-size: 13px; + font-weight: 700; +} +.darkchat-danger { background: rgb(255 69 58 / 14%); color: var(--dc-red) !important; } +.darkchat-sheet-page h3 { margin: 17px 5px 5px; color: var(--dc-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .5px; } +.darkchat-contact-row { + width: 100%; + min-height: 54px; + padding: 7px 5px; + display: flex; + align-items: center; + gap: 9px; + border: 0; + border-bottom: 1px solid #272729; + background: transparent; + text-align: left; +} +.darkchat-contact-row > span:nth-child(2) { min-width: 0; flex: 1; display: flex; flex-direction: column; } +.darkchat-contact-row strong { font-size: 13px; } +.darkchat-contact-row small { color: var(--dc-muted); font-size: 9px; } +.darkchat-contact-row svg { color: var(--dc-muted); } +.darkchat-qr-card { margin: 18px 0 6px; padding: 15px; display: grid; grid-template-columns: 72px 1fr; align-items: center; gap: 12px; border: 1px solid var(--dc-border); border-radius: 18px; background: var(--dc-surface); } +.darkchat-faux-qr { width: 72px; height: 72px; display: grid; place-items: center; border-radius: 10px; background: #fff; color: #111; } +.darkchat-qr-card > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.darkchat-qr-card > span strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; } +.darkchat-qr-card > span small { color: var(--dc-muted); font-size: 9px; } +.darkchat-qr-card > button { grid-column: 1 / -1; height: 35px; display: flex; align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: 10px; background: rgb(139 92 246 / 16%); color: #c4b5fd; font-size: 11px; } +.darkchat-thread { display: flex; flex-direction: column; padding-bottom: 79px; } +.darkchat-thread--panel { padding-bottom: 342px; } +.darkchat-thread__header { position: relative; flex: none; grid-template-columns: 42px 1fr 42px; } +.darkchat-thread__identity { min-width: 0; padding: 0; display: flex; align-items: center; justify-content: center; gap: 8px; border: 0; background: transparent; } +.darkchat-thread__identity > span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; } +.darkchat-thread__identity strong { max-width: 142px; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-thread__identity small { color: var(--dc-muted); font-size: 8px; } +.darkchat-thread__identity svg { color: var(--dc-muted); } +.darkchat-thread__meta { height: 21px; flex: none; display: flex; align-items: center; justify-content: center; gap: 3px; border-bottom: 1px solid #1b1b1d; color: var(--dc-muted); font-size: 8px; } +.darkchat-thread__messages { min-height: 0; padding: 12px 13px 18px; flex: 1; display: flex; flex-direction: column; align-items: flex-start; gap: 5px; overflow-y: auto; } +.darkchat-day { align-self: center; margin: 4px 0 8px; color: var(--dc-muted); font-size: 9px; font-weight: 600; } +.darkchat-system { max-width: 86%; margin: 7px auto; display: flex; align-items: center; gap: 4px; color: var(--dc-muted); font-size: 8px; text-align: center; } +.darkchat-message { + position: relative; + max-width: 82%; + padding: 7px 10px 6px; + display: flex; + flex-direction: column; + gap: 3px; + border: 0; + border-radius: 15px; + background: var(--dc-surface-2); + font-size: 12px; + line-height: 1.3; + text-align: left; + overflow-wrap: anywhere; + animation: darkchat-message-in .28s cubic-bezier(.32,.72,0,1) both; +} +.darkchat-message--sent { align-self: flex-end; background: linear-gradient(145deg, #8b5cf6, #6d28d9); } +.darkchat-message--received::before, +.darkchat-message--sent::after { content: ""; position: absolute; bottom: 0; width: 9px; height: 11px; } +.darkchat-message--received::before { left: -5px; border-bottom-right-radius: 10px; background: var(--dc-surface-2); } +.darkchat-message--sent::after { right: -5px; border-bottom-left-radius: 10px; background: #6d28d9; } +.darkchat-message > small { align-self: flex-end; color: rgb(255 255 255 / 57%); font-size: 7px; white-space: nowrap; } +.darkchat-message--received > small { color: var(--dc-muted); } +.darkchat-message--failed { outline: 1px solid var(--dc-red); } +.darkchat-message > img { width: min(181px, 62vw); max-height: 175px; margin: -5px -8px 1px; border-radius: 12px; object-fit: cover; } +.darkchat-reply-preview { padding: 4px 6px; display: flex; align-items: center; gap: 4px; overflow: hidden; border-left: 2px solid #c4b5fd; border-radius: 4px; background: rgb(0 0 0 / 18%); color: rgb(255 255 255 / 72%); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-reactions { position: absolute; right: 4px; bottom: -15px; z-index: 2; padding: 1px 5px; border: 2px solid #000; border-radius: 9px; background: #343438; font-size: 9px; white-space: nowrap; } +.darkchat-message:has(.darkchat-reactions) { margin-bottom: 12px; } +.darkchat-replying { position: absolute; z-index: 6; right: 55px; bottom: 72px; left: 55px; min-height: 39px; padding: 5px 8px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--dc-border); border-radius: 11px; background: rgb(28 28 30 / 96%); color: #c4b5fd; } +.darkchat-replying > span { min-width: 0; flex: 1; display: flex; flex-direction: column; } +.darkchat-replying small { font-size: 7px; text-transform: uppercase; } +.darkchat-replying strong { overflow: hidden; color: #fff; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-replying button { width: 25px; height: 25px; display: grid; place-items: center; border: 0; border-radius: 50%; background: #343438; } +.darkchat-composer { position: absolute; z-index: 7; right: 12px; bottom: 28px; left: 12px; display: flex; align-items: flex-end; gap: 8px; } +.darkchat-composer > label { min-height: 41px; padding: 6px 6px 6px 13px; flex: 1; display: flex; align-items: center; gap: 5px; border: 1px solid var(--dc-border); border-radius: 21px; background: var(--dc-surface); } +.darkchat-composer textarea { min-height: 19px; max-height: 70px; min-width: 0; padding: 2px 0; flex: 1; resize: none; border: 0; outline: 0; background: transparent; font-size: 12px; line-height: 17px; } +.darkchat-composer label button { width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: #a78bfa; } +.darkchat-actions-bubbles { position: absolute; z-index: 8; bottom: 76px; left: 14px; display: flex; flex-direction: column-reverse; align-items: flex-start; gap: 6px; } +.darkchat-actions-bubbles button { min-height: 32px; padding: 3px 10px 3px 4px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--dc-border); border-radius: 17px; background: var(--dc-surface); font-size: 9px; box-shadow: 0 7px 19px rgb(0 0 0 / 45%); animation: darkchat-bubble-in .23s cubic-bezier(.32,.72,0,1) both; } +.darkchat-actions-bubbles button > span { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 50%; background: rgb(139 92 246 / 18%); color: #c4b5fd; font-size: 15px; } +.darkchat-gif-panel, +.darkchat-page .messages-full-emoji-picker { position: absolute; z-index: 8; right: 0; bottom: 0; left: 0; height: 285px; border-top: 1px solid var(--dc-border); background: rgb(20 20 22 / 97%); color: #fff; backdrop-filter: blur(25px); } +.darkchat-gif-panel > header { height: 37px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; } +.darkchat-gif-panel > header strong { font-size: 13px; } +.darkchat-gif-panel > header button { border: 0; background: transparent; color: #a78bfa; font-size: 11px; } +.darkchat-gif-panel > label { height: 31px; margin: 0 9px 6px; padding: 0 9px; display: flex; align-items: center; gap: 6px; border-radius: 16px; background: #2c2c2e; color: var(--dc-muted); } +.darkchat-gif-panel > label input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 10px; } +.darkchat-gif-panel > div { height: 175px; padding: 0 9px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px; overflow-y: auto; } +.darkchat-gif-panel > div button { min-height: 80px; padding: 0; overflow: hidden; border: 0; border-radius: 9px; background: #2c2c2e; } +.darkchat-gif-panel > div img { width: 100%; height: 100%; object-fit: cover; } +.darkchat-load-more { width: calc(100% - 18px); height: 28px; margin: 4px 9px; border: 0; border-radius: 9px; background: #2c2c2e; color: #a78bfa !important; font-size: 10px; } +.darkchat-page .messages-full-emoji-picker { animation: darkchat-panel-in .28s cubic-bezier(.32,.72,0,1) both; } +.darkchat-page .messages-full-emoji-picker > header button, +.darkchat-page .messages-full-emoji-picker__search, +.darkchat-page .messages-full-emoji-picker nav button.active { background: #2c2c2e; color: #fff; } +.darkchat-page .messages-full-emoji-picker__search input { color: #fff; } +.darkchat-page .messages-full-emoji-picker nav { border-color: var(--dc-border); } +.darkchat-recorder { position: absolute; z-index: 9; right: 12px; bottom: 28px; left: 12px; height: 43px; padding: 0 7px; display: flex; align-items: center; gap: 6px; border: 1px solid var(--dc-border); border-radius: 22px; background: var(--dc-surface); } +.darkchat-recorder > button { width: 29px; height: 29px; display: grid; place-items: center; border: 0; border-radius: 50%; background: #2c2c2e; } +.darkchat-recorder > button:last-child { color: #a78bfa; } +.darkchat-recorder > i { width: 7px; height: 7px; border-radius: 50%; background: var(--dc-red); animation: messages-record-pulse 1s ease infinite; } +.darkchat-recorder time { font-size: 10px; } +.darkchat-recorder > span { height: 26px; min-width: 0; flex: 1; display: flex; align-items: center; justify-content: center; gap: 1px; overflow: hidden; } +.darkchat-recorder > span b { width: 2px; flex: none; border-radius: 2px; background: #a78bfa; } +.darkchat-voice { min-width: 177px; display: flex; align-items: center; gap: 7px; } +.darkchat-voice > button { width: 26px; height: 26px; display: grid; place-items: center; padding: 0; flex: none; border: 0; border-radius: 50%; background: rgb(255 255 255 / 17%); } +.darkchat-voice > div { min-width: 0; flex: 1; } +.darkchat-voice__wave { height: 23px; display: flex; align-items: center; gap: 1px; } +.darkchat-voice__wave i { width: 2px; border-radius: 2px; background: rgb(255 255 255 / 38%); } +.darkchat-voice__wave i.active { background: #fff; } +.darkchat-voice > div small { display: block; font-size: 7px; } +.darkchat-voice .darkchat-voice__speed { width: 27px; color: #fff; font-size: 7px; font-weight: 800; } +.darkchat-profile-hero { padding: 15px 0 17px; display: flex; align-items: center; flex-direction: column; } +.darkchat-profile-hero .darkchat-avatar { width: 67px; height: 67px; font-size: 30px; box-shadow: 0 13px 32px rgb(91 33 182 / 32%); } +.darkchat-profile-hero h2 { margin: 10px 0 3px; font-size: 20px; } +.darkchat-profile-hero button { padding: 4px 8px; display: flex; align-items: center; gap: 5px; border: 0; border-radius: 11px; background: var(--dc-surface); color: #c4b5fd; font-size: 9px; } +.darkchat-profile-hero small { margin-top: 6px; color: var(--dc-muted); font-size: 8px; } +.darkchat-settings-group, +.darkchat-danger-group { margin: 10px 0; overflow: hidden; border: 1px solid var(--dc-border); border-radius: 13px; background: var(--dc-surface); } +.darkchat-settings-group label, +.darkchat-danger-group button { width: 100%; min-height: 43px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 11px; } +.darkchat-settings-group label:last-child, +.darkchat-danger-group button:last-child { border-bottom: 0; } +.darkchat-settings-group label > span, +.darkchat-danger-group button { justify-content: flex-start; } +.darkchat-settings-group label > span { display: flex; align-items: center; gap: 8px; } +.darkchat-settings-group label > span svg { color: #a78bfa; } +.darkchat-settings-group input[type="checkbox"] { width: 36px; height: 21px; accent-color: var(--dc-purple); } +.darkchat-select select { min-width: 90px; border: 0; outline: 0; background: transparent; color: #a78bfa; font-size: 9px; text-align: right; } +.darkchat-danger-group button { color: var(--dc-red); text-align: left; } +.darkchat-save { padding: 7px; border: 0; background: transparent; color: #a78bfa !important; font-size: 11px; } +.darkchat-privacy-note { margin: 14px 4px 4px; display: flex; align-items: flex-start; gap: 7px; color: var(--dc-muted); font-size: 9px; line-height: 1.4; } +.darkchat-privacy-note svg { flex: none; color: #a78bfa; } +.darkchat-modal-backdrop { position: absolute; z-index: 40; inset: 0; padding: 44px 18px 27px; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 67%); backdrop-filter: blur(10px); } +.darkchat-modal, +.darkchat-message-menu { width: 100%; padding: 17px; display: flex; align-items: stretch; flex-direction: column; border: 1px solid var(--dc-border); border-radius: 21px; background: rgb(28 28 30 / 98%); box-shadow: 0 24px 70px rgb(0 0 0 / 60%); animation: darkchat-modal-in .3s cubic-bezier(.32,.72,0,1) both; } +.darkchat-modal { text-align: center; } +.darkchat-modal__icon { width: 52px; height: 52px; margin: 0 auto; display: grid; place-items: center; border-radius: 17px; background: rgb(139 92 246 / 16%); color: #a78bfa; } +.darkchat-modal__icon--danger { background: rgb(255 69 58 / 14%); color: var(--dc-red); } +.darkchat-modal h2 { margin: 11px 0 6px; font-size: 18px; } +.darkchat-modal p { margin: 0 0 10px; color: var(--dc-muted); font-size: 10px; line-height: 1.4; } +.darkchat-modal > strong { margin-bottom: 12px; color: #c4b5fd; font-size: 12px; } +.darkchat-modal > button:not(.darkchat-primary):not(.darkchat-danger) { min-height: 35px; border: 0; background: transparent; color: var(--dc-muted); font-size: 11px; } +.darkchat-modal select, +.darkchat-modal textarea { margin-bottom: 8px; padding: 9px; border: 1px solid var(--dc-border); border-radius: 10px; outline: 0; background: #2c2c2e; font-size: 11px; } +.darkchat-modal textarea { min-height: 65px; resize: none; } +.darkchat-message-menu { padding: 7px; } +.darkchat-message-menu > button { min-height: 39px; padding: 0 11px; display: flex; align-items: center; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 11px; text-align: left; } +.darkchat-message-menu > button:last-child { justify-content: center; border-bottom: 0; color: var(--dc-muted); } +.darkchat-message-menu > button.danger { color: var(--dc-red); } +.darkchat-reaction-row { margin: -30px 4px 8px; padding: 8px 5px; display: flex; justify-content: space-around; border: 1px solid var(--dc-border); border-radius: 20px; background: #2c2c2e; box-shadow: 0 7px 20px rgb(0 0 0 / 35%); } +.darkchat-reaction-row button { width: 34px; height: 31px; padding: 0; border: 0; border-radius: 13px; background: transparent; font-size: 18px; } +.darkchat-toast { position: absolute; z-index: 60; bottom: 95px; left: 50%; max-width: 80%; padding: 8px 13px; transform: translateX(-50%); border: 1px solid var(--dc-border); border-radius: 14px; background: rgb(44 44 46 / 96%); box-shadow: 0 8px 30px rgb(0 0 0 / 45%); font-size: 10px; text-align: center; } +.darkchat-toast-enter-active, +.darkchat-toast-leave-active { transition: opacity .2s ease, transform .2s ease; } +.darkchat-toast-enter-from, +.darkchat-toast-leave-to { opacity: 0; transform: translate(-50%, 8px); } +@keyframes darkchat-spin { to { transform: rotate(360deg); } } +@keyframes darkchat-message-in { from { opacity: 0; transform: translateY(8px) scale(.94); } } +@keyframes darkchat-bubble-in { from { opacity: 0; transform: translate(-8px, 6px) scale(.8); } } +@keyframes darkchat-panel-in { from { transform: translateY(100%); } } +@keyframes darkchat-modal-in { from { opacity: 0; transform: translateY(18px) scale(.92); } } .springboard-track { width: 300%; height: 100%; diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 7c65c0c..5f96a04 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -5,6 +5,7 @@ import { useRouter } from 'vue-router' import { useMailStore } from '@/stores/mail' import { useMarketplaceStore } from '@/stores/marketplace' +import { useDarkChatStore } from '@/stores/darkchat' import { usePhoneStore } from '@/stores/phone' import type { PhoneAppDefinition } from '@/types/apps' @@ -23,11 +24,13 @@ const props = withDefaults( const phone = usePhoneStore() const mail = useMailStore() const marketplace = useMarketplaceStore() +const darkchat = useDarkChatStore() const router = useRouter() const iconFailed = ref(false) const unreadCount = computed(() => { if (props.app.id === 'mail') return mail.counts.unread if (props.app.id === 'citymarkt') return marketplace.counts.unread + if (props.app.id === 'darkchat') return darkchat.unreadCount return 0 }) const notificationBadgeColors = { diff --git a/frontend/src/components/DarkChatVoiceMessage.vue b/frontend/src/components/DarkChatVoiceMessage.vue new file mode 100644 index 0000000..2e29cb7 --- /dev/null +++ b/frontend/src/components/DarkChatVoiceMessage.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 60a9faa..0335ff2 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -13,6 +13,7 @@ import { Mail, MapPinned, MessageCircle, + ShieldCheck, NotebookPen, Phone, Settings, @@ -32,6 +33,7 @@ import calendarIcon from '@/assets/img/app-icons/calendar.svg' import mailIcon from '@/assets/img/app-icons/mail.webp' import mapIcon from '@/assets/img/app-icons/map.webp' import messagesIcon from '@/assets/img/app-icons/sms.webp' +import darkChatIcon from '@/assets/img/app-icons/darkchat.svg' import notesIcon from '@/assets/img/app-icons/notes.webp' import photosIcon from '@/assets/img/app-icons/gallery.webp' import phoneIcon from '@/assets/img/app-icons/phone.webp' @@ -105,6 +107,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.messages.name', route: '/apps/messages', }, + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/DarkChatApp.vue')), + ), + dockOrder: null, + gridOrder: 22, + icon: markRaw(ShieldCheck), + iconClass: 'app-icon--darkchat', + iconImage: darkChatIcon, + id: 'darkchat', + labelKey: 'Apps.darkchat.name', + route: '/apps/darkchat', + }, { component: markRaw( defineAsyncComponent(() => import('@/views/apps/MapApp.vue')), diff --git a/frontend/src/stores/darkchat.test.ts b/frontend/src/stores/darkchat.test.ts new file mode 100644 index 0000000..811ab04 --- /dev/null +++ b/frontend/src/stores/darkchat.test.ts @@ -0,0 +1,81 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useDarkChatStore } from '@/stores/darkchat' +import type { DarkChatMessage, DarkChatThread } from '@/types/darkchat' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) +const mockNuiCall = vi.mocked(nuiCall) + +const conversation: DarkChatThread['conversation'] = { + blockedByPeer: false, + createdAt: '2026-08-06 20:00:00', + disappearingSeconds: 3600, + id: 'conversation-id', + notificationsEnabled: true, + peer: { alias: 'Nova', avatarSeed: 42, darkId: 'dark:N0VA-41KQ', id: 2 }, + readReceipts: true, +} + +function message(id: string): DarkChatMessage { + return { + body: 'Quiet channel', + conversationId: conversation.id, + createdAt: '2026-08-06 20:01:00', + direction: 'sent', + id, + messageType: 'text', + reactions: {}, + } +} + +describe('darkchat store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('optimistically sends and confirms a private message', async () => { + let resolveSend: ((value: { data: DarkChatMessage; success: true }) => void) | undefined + const pending = new Promise<{ data: DarkChatMessage; success: true }>((resolve) => (resolveSend = resolve)) + mockNuiCall + .mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true }) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + .mockImplementationOnce(() => pending) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + + const store = useDarkChatStore() + await store.openThread(conversation.id) + const sending = store.send({ body: 'Quiet channel', messageType: 'text' }) + expect(store.messages[0]).toMatchObject({ deliveryStatus: 'sending', body: 'Quiet channel' }) + + resolveSend?.({ data: message('server-message'), success: true }) + await sending + expect(store.messages[0]).toMatchObject({ deliveryStatus: 'delivered', id: 'server-message' }) + }) + + it('keeps failed messages visible for delivery feedback', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true }) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + .mockResolvedValueOnce({ error: 'blocked', success: false }) + + const store = useDarkChatStore() + await store.openThread(conversation.id) + await store.send({ body: 'Quiet channel', messageType: 'text' }) + expect(store.messages[0].deliveryStatus).toBe('failed') + }) + + it('loads protected voice data once', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { mime: 'audio/webm;codecs=opus', payload: 'ZmFrZQ==' }, + success: true, + }) + const store = useDarkChatStore() + expect(await store.loadMedia('voice-id')).toBe(true) + expect(await store.loadMedia('voice-id')).toBe(true) + expect(store.mediaSources['voice-id']).toBe('data:audio/webm;codecs=opus;base64,ZmFrZQ==') + expect(mockNuiCall).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/stores/darkchat.ts b/frontend/src/stores/darkchat.ts new file mode 100644 index 0000000..088d133 --- /dev/null +++ b/frontend/src/stores/darkchat.ts @@ -0,0 +1,155 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import type { + DarkChatBootstrap, + DarkChatContact, + DarkChatConversation, + DarkChatConversationSummary, + DarkChatMessage, + DarkChatOutgoing, + DarkChatProfile, + DarkChatThread, +} from '@/types/darkchat' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useDarkChatStore = defineStore('darkchat', () => { + const profile = ref(null) + const contacts = ref([]) + const conversations = ref([]) + const activeConversation = ref(null) + const messages = ref([]) + const mediaSources = ref>({}) + const loading = ref(false) + const lastError = ref(null) + const unreadCount = computed(() => + conversations.value.reduce((total, conversation) => total + conversation.unread, 0), + ) + + async function bootstrap(): Promise { + loading.value = true + const response = await nuiCall('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 { + loading.value = true + const response = await nuiCall('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 { + const response = await nuiCall('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> { + const response = await nuiCall<{ conversationId: string }>('darkchat:start', { identifier }) + if (response.success) await refreshInbox() + return response + } + + async function send(outgoing: DarkChatOutgoing): Promise> { + if (!activeConversation.value) return { success: false, error: 'invalid_conversation' } + const clientId = `dark-pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const optimistic: DarkChatMessage = { + body: outgoing.body ?? '', + clientId, + conversationId: activeConversation.value.id, + createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '), + deliveryStatus: 'sending', + direction: 'sent', + id: clientId, + mediaDurationMs: outgoing.mediaDurationMs, + mediaMime: outgoing.mediaMime, + mediaWaveform: outgoing.mediaWaveform, + messageType: outgoing.messageType, + reactions: {}, + replyToId: outgoing.replyToId, + } + messages.value.push(optimistic) + if (outgoing.messageType === 'voice' && outgoing.mediaPayload) { + mediaSources.value[clientId] = `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` + } + const response = await nuiCall('darkchat:send', { + ...outgoing, + conversationId: activeConversation.value.id, + }) + const index = messages.value.findIndex((message) => message.clientId === clientId) + if (!response.success || !response.data) { + if (index >= 0) messages.value[index].deliveryStatus = 'failed' + return response + } + const source = mediaSources.value[clientId] + delete mediaSources.value[clientId] + if (source) mediaSources.value[response.data.id] = source + if (index >= 0) { + messages.value[index] = { ...response.data, clientId, deliveryStatus: 'delivered' } + } + await refreshInbox() + return response + } + + async function loadMedia(messageId: string): Promise { + 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): Promise { + 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, + } +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 6125088..1f27f1e 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -29,6 +29,27 @@ const namespaceQueues = new Map>() const defaultLocales: LocaleTree = { Apps: { + darkchat: { + name: 'DarkChat', newMessage: 'New DarkChat message from {sender}', privateNotification: 'New DarkChat message', + signInBody: 'DarkChat identities are linked to your private iFruit account.', signInHint: 'Sign in through Settings to continue.', + security: 'Security', privateNetwork: 'Private invitation-only network', noResults: 'No Results', noResultsBody: 'Try another alias or Dark-ID.', + noChats: 'No Private Chats', noChatsBody: 'Connect with a Dark-ID or invitation code. There is no public search.', newChat: 'New Chat', + connectPrivately: 'Connect privately', newChatBody: 'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.', darkIdOrInvite: 'Dark-ID or invitation code', continue: 'Continue', contacts: 'DarkChat Contacts', shareIdentity: 'Tap to share your private identity', + message: 'Dark message', activeNow: 'Activity shared', encryptedSession: 'Private session', serverPrivate: 'Private server-stored conversation', + emoji: 'Emoji', gif: 'GIF', gifs: 'GIFs', searchGifs: 'Search GIFs', loadMore: 'Load More', photosLater: 'Photos · coming later', videosLater: 'Videos · coming later', + voiceMessage: 'Voice message', sending: 'Sending', failed: 'Not delivered', delivered: 'Delivered', read: 'Read', replying: 'Replying to', + messageDeleted: 'Message deleted', securityUpdate: 'Security settings updated', timerChanged: 'Disappearing messages: {timer}', + timerOff: 'Off', timerAfterRead: 'After reading', timerMinute: '1 minute', timerFiveMinutes: '5 minutes', timerHour: '1 hour', timerDay: '24 hours', timerWeek: '7 days', + copied: 'Copied', reply: 'Reply', copy: 'Copy', deleteForMe: 'Delete for me', deleteForBoth: 'Delete for both', report: 'Report', + contactSecurity: 'Contact & Security', chatSince: 'Private chat since {date}', notifications: 'Notifications', readReceipts: 'Read receipts', disappearing: 'Disappearing messages', contactAlias: 'Contact alias', saveContact: 'Save Contact', addContact: 'Add Contact', contactSaved: 'Contact saved', removeContact: 'Remove Contact', block: 'Block User', unblock: 'Unblock User', clearChat: 'Clear Chat', chatCleared: 'Chat cleared', + myIdentity: 'My Dark Identity', alias: 'Alias', notificationPrivacy: 'Notification privacy', notificationFull: 'Full · alias and message', notificationPrivate: 'Private · generic message', notificationHidden: 'Invisible · badge only', shareActivity: 'Share activity status', inviteCode: 'Private invitation code', copyInvite: 'Copy Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.', + unknownIdentity: 'Unknown Identity', unknownIdentityBody: 'Only continue if you expected this identity. The account is not discoverable through public search.', openSecureChat: 'Open Private Chat', + reportUser: 'Report User', reportSpam: 'Spam', reportHarassment: 'Harassment', reportThreats: 'Threats', reportIllegal: 'Illegal content', reportOther: 'Other', reportDetails: 'Optional details', submitReport: 'Submit Report', reported: 'Report submitted', + microphoneUnavailable: 'The microphone is unavailable.', recordingTooLarge: 'The voice message is too large.', + errors: { + not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.', + }, + }, messages: { name: 'Messages', newMessage: 'New message from {sender}', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 375fef1..29e7cef 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -3,6 +3,7 @@ import type { Component } from 'vue' export type PhoneAppId = | 'phone' | 'messages' + | 'darkchat' | 'calculator' | 'camera' | 'clock' diff --git a/frontend/src/types/darkchat.ts b/frontend/src/types/darkchat.ts new file mode 100644 index 0000000..71040f4 --- /dev/null +++ b/frontend/src/types/darkchat.ts @@ -0,0 +1,90 @@ +import type { DatabaseDateValue } from '@/utils/date' + +export type DarkChatMessageType = 'text' | 'emoji' | 'gif' | 'voice' | 'system' +export type DarkChatNotificationMode = 'full' | 'private' | 'hidden' + +export type DarkChatIdentity = { + id: number + darkId: string + alias: string + originalAlias?: string + avatarSeed: number + activityVisible?: boolean + isContact?: boolean + blocked?: boolean +} + +export type DarkChatProfile = DarkChatIdentity & { + inviteCode: string + notificationMode: DarkChatNotificationMode + activityVisible: boolean + createdAt: DatabaseDateValue +} + +export type DarkChatContact = DarkChatIdentity & { + createdAt: DatabaseDateValue +} + +export type DarkChatConversationSummary = { + id: string + peer: DarkChatIdentity + disappearingSeconds: number + blocked: boolean + lastMessage: string + lastMessageType: DarkChatMessageType + lastMessageAt: DatabaseDateValue + unread: number +} + +export type DarkChatConversation = { + id: string + peer: DarkChatIdentity + disappearingSeconds: number + notificationsEnabled: boolean + readReceipts: boolean + blockedByPeer: boolean + createdAt: DatabaseDateValue +} + +export type DarkChatMessage = { + id: string + clientId?: string + conversationId: string + direction: 'sent' | 'received' + senderProfileId?: number + messageType: DarkChatMessageType + body: string + mediaMime?: string | null + mediaPayload?: string | null + mediaDurationMs?: number | null + mediaWaveform?: number[] | null + replyToId?: string | null + replyBody?: string | null + reactions: Record + expiresAt?: DatabaseDateValue | null + createdAt: DatabaseDateValue + readAt?: DatabaseDateValue | null + deletedForEveryone?: boolean + deliveryStatus?: 'sending' | 'delivered' | 'failed' +} + +export type DarkChatBootstrap = { + profile: DarkChatProfile + contacts: DarkChatContact[] + conversations: DarkChatConversationSummary[] +} + +export type DarkChatThread = { + conversation: DarkChatConversation + messages: DarkChatMessage[] +} + +export type DarkChatOutgoing = { + body?: string + messageType: 'text' | 'emoji' | 'gif' | 'voice' + mediaPayload?: string + mediaMime?: string + mediaDurationMs?: number + mediaWaveform?: number[] + replyToId?: string +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index f96575e..a6bd33c 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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 }, diff --git a/frontend/src/views/apps/DarkChatApp.vue b/frontend/src/views/apps/DarkChatApp.vue new file mode 100644 index 0000000..01b7ff5 --- /dev/null +++ b/frontend/src/views/apps/DarkChatApp.vue @@ -0,0 +1,743 @@ + + +