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/App.vue b/frontend/src/App.vue index 18baae9..a68a404 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -24,9 +24,11 @@ import { PHONE_FRAME_IMAGES } from '@/config/appearance' import { useClockStore } from '@/stores/clock' import { useGamesStore } from '@/features/games/store' import { useCallsStore } from '@/stores/calls' +import { useBankingStore } from '@/stores/banking' import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' import { useMessagesStore } from '@/stores/messages' +import { useDarkChatStore } from '@/stores/darkchat' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' @@ -54,6 +56,7 @@ type AppMessage = { | MailEventData | MarketplaceEventData | MessagesEventData + | DarkChatEventData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -81,6 +84,16 @@ type MessagesEventData = { title?: string } +type DarkChatEventData = { + conversationId?: string + device?: PhoneNotificationDevicePayload + notificationMode?: 'full' | 'private' | 'hidden' + preview?: string + sender?: string + text?: string + title?: string +} + type MarketplaceEventData = { counts?: MarketplaceCounts device?: PhoneNotificationDevicePayload @@ -109,8 +122,10 @@ const account = useAccountStore() const clock = useClockStore() const games = useGamesStore() const calls = useCallsStore() +const banking = useBankingStore() const mail = useMailStore() const messages = useMessagesStore() +const darkchat = useDarkChatStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -161,6 +176,7 @@ function hydratePhone(payload: PhoneOpenPayload): void { else marketplace.setCounts({ active: 0, unread: 0 }) void calls.bootstrap() void messages.loadConversations() + if (payload.account?.email) void darkchat.bootstrap() } async function hydrateDevelopmentPhone(): Promise { @@ -315,8 +331,37 @@ 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 (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/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 e43a0e1..558ac3f 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -582,6 +582,418 @@ button { transition-duration: 0.18s; } } + +/* DarkChat is intentionally independent from the phone appearance setting. */ +.phone-app:has(.darkchat-page), +.phone-app:has(.darkchat-page) .phone-app-window, +.phone-app:has(.darkchat-page) .phone-app-window__content { + background: #000 !important; + color-scheme: dark; +} +.phone-app:has(.darkchat-page) .phone-status-bar { + color: #fff !important; + --status-bar-color: #fff; +} +.app-icon--darkchat { background: #050507; } +.darkchat-page { + --dc-purple: #8b5cf6; + --dc-purple-dark: #5b21b6; + --dc-blue: #0a84ff; + --dc-red: #ff453a; + --dc-surface: #1c1c1e; + --dc-surface-2: #29292c; + --dc-border: #38383a; + --dc-muted: #8e8e93; + position: absolute; + inset: 0; + overflow: hidden; + background: #000; + color: #f5f5f7; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif; +} +.darkchat-page button, +.darkchat-page input, +.darkchat-page textarea, +.darkchat-page select { font: inherit; } +.darkchat-page button { color: inherit; } +.darkchat-page input, +.darkchat-page textarea, +.darkchat-page select { color: #f5f5f7; } +.darkchat-page button:disabled { opacity: .34; } +.darkchat-page button:active:not(:disabled) { transform: scale(.96); } +.darkchat-round { + width: 39px; + height: 39px; + flex: none; + display: grid; + place-items: center; + padding: 0; + border: 1px solid var(--dc-border); + border-radius: 50%; + background: var(--dc-surface); + transition: transform .14s ease, background .2s ease; +} +.darkchat-round--large { width: 43px; height: 43px; } +.darkchat-round.active { background: var(--dc-purple); border-color: var(--dc-purple); } +.darkchat-back { + width: 46px; + height: 46px; + border-color: #3a3a3c; + background: linear-gradient(145deg, rgb(44 44 46 / 96%), rgb(28 28 30 / 92%)); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 9%), 0 5px 18px rgb(0 0 0 / 42%); + color: #fff; + line-height: 0; +} +.darkchat-back svg { display: block; margin: 0; } +.darkchat-pill { + width: 54px; + min-width: 54px; + height: 37px; + padding: 0 9px; + border: 1px solid var(--dc-border); + border-radius: 19px; + background: var(--dc-surface); + font-size: 13px; + font-weight: 600; +} +.darkchat-gate { + height: 100%; + padding: 58px 27px 30px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; +} +.darkchat-gate > span:not(.darkchat-loader) { + width: 74px; + height: 74px; + display: grid; + place-items: center; + border-radius: 24px; + background: linear-gradient(145deg, var(--dc-purple), var(--dc-purple-dark)); + box-shadow: 0 18px 45px rgb(91 33 182 / 40%); +} +.darkchat-gate h1 { margin: 21px 0 8px; font-size: 27px; } +.darkchat-gate p { margin: 0; color: #d1d1d6; font-size: 14px; line-height: 1.45; } +.darkchat-gate small { margin-top: 13px; color: var(--dc-muted); font-size: 11px; } +.darkchat-loader { + width: 27px; + height: 27px; + display: block; + border: 3px solid rgb(139 92 246 / 25%); + border-top-color: var(--dc-purple); + border-radius: 50%; + animation: darkchat-spin .7s linear infinite; +} +.darkchat-loader--small { width: 17px; height: 17px; border-width: 2px; } +.darkchat-inbox, +.darkchat-sheet-page, +.darkchat-thread { height: 100%; padding: 44px 0 25px; box-sizing: border-box; } +.darkchat-inbox { display: flex; flex-direction: column; } +.darkchat-inbox__header, +.darkchat-sheet-page > header, +.darkchat-thread__header { + height: 62px; + padding: 5px 15px 7px; + display: grid; + align-items: center; + grid-template-columns: 1fr auto 1fr; + box-sizing: border-box; + background: #000; +} +.darkchat-inbox__header > strong, +.darkchat-sheet-page > header > strong { font-size: 17px; font-weight: 720; letter-spacing: -.2px; } +.darkchat-inbox__header > :last-child, +.darkchat-sheet-page > header > :last-child { justify-self: end; } +.darkchat-security-strip { + height: 32px; + margin: 0 15px 4px; + padding: 0 10px; + display: flex; + align-items: center; + gap: 6px; + border: 1px solid rgb(139 92 246 / 20%); + border-radius: 9px; + background: rgb(139 92 246 / 8%); + color: #b9a3ff; + font-size: 11px; +} +.darkchat-security-strip span { flex: 1; } +.darkchat-security-strip button { padding: 0; border: 0; background: transparent; color: #b9a3ff; font-size: 10px; } +.darkchat-conversations { min-height: 0; flex: 1; padding: 3px 12px 76px; overflow-y: auto; } +.darkchat-conversation { + position: relative; + width: 100%; + min-height: 67px; + padding: 8px 3px; + display: flex; + align-items: center; + gap: 10px; + border: 0; + background: transparent; + text-align: left; +} +.darkchat-conversation::after { + content: ""; + position: absolute; + right: 0; + bottom: 0; + left: 59px; + height: 1px; + background: #262628; +} +.darkchat-unread { + position: absolute; + left: -4px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--dc-purple); + box-shadow: 0 0 10px rgb(139 92 246 / 80%); +} +.darkchat-avatar { + width: 49px; + height: 49px; + flex: none; + display: grid; + place-items: center; + border: 1px solid rgb(255 255 255 / 17%); + border-radius: 50%; + color: rgb(255 255 255 / 90%); + font-size: 23px; + font-weight: 800; + text-shadow: 0 1px 7px rgb(0 0 0 / 35%); +} +.darkchat-avatar--small { width: 37px; height: 37px; font-size: 18px; } +.darkchat-avatar--header { width: 39px; height: 39px; font-size: 18px; } +.darkchat-conversation__body { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; } +.darkchat-conversation__body > span { display: flex; align-items: center; gap: 4px; } +.darkchat-conversation__body strong { min-width: 0; flex: 1; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-conversation__body time { color: var(--dc-muted); font-size: 11px; } +.darkchat-conversation__body svg { color: #636366; } +.darkchat-conversation__body small { overflow: hidden; color: var(--dc-muted); font-size: 13px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-timer-icon { position: absolute; right: 4px; bottom: 7px; color: #a78bfa; } +.darkchat-empty { flex: 1; padding: 15px 40px 80px; display: flex; align-items: center; justify-content: center; flex-direction: column; text-align: center; } +.darkchat-empty > span { width: 57px; height: 57px; display: grid; place-items: center; border-radius: 20px; background: var(--dc-surface); color: var(--dc-purple); } +.darkchat-empty h2 { margin: 14px 0 5px; font-size: 18px; } +.darkchat-empty p { margin: 0; color: var(--dc-muted); font-size: 12px; line-height: 1.4; } +.darkchat-inbox__toolbar { + position: absolute; + z-index: 5; + right: 12px; + bottom: 28px; + left: 12px; + display: flex; + align-items: center; + gap: 8px; +} +.darkchat-inbox__toolbar label { + height: 43px; + padding: 0 13px; + flex: 1; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--dc-border); + border-radius: 23px; + background: rgb(28 28 30 / 94%); + backdrop-filter: blur(22px); +} +.darkchat-inbox__toolbar input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 14px; } +.darkchat-sheet-page { overflow-y: auto; padding-right: 15px; padding-left: 15px; } +.darkchat-sheet-page > header { position: sticky; z-index: 4; top: -1px; margin: 0 -15px; } +.darkchat-new-hero { padding: 14px 23px 19px; text-align: center; } +.darkchat-new-hero > span { width: 59px; height: 59px; margin: auto; display: grid; place-items: center; border-radius: 20px; background: linear-gradient(145deg, var(--dc-purple), var(--dc-purple-dark)); } +.darkchat-new-hero h2 { margin: 12px 0 4px; font-size: 19px; } +.darkchat-new-hero p { margin: 0; color: var(--dc-muted); font-size: 11px; line-height: 1.4; } +.darkchat-input { margin: 9px 0; padding: 9px 12px; display: flex; flex-direction: column; gap: 5px; border: 1px solid var(--dc-border); border-radius: 12px; background: var(--dc-surface); } +.darkchat-input span { color: var(--dc-muted); font-size: 9px; text-transform: uppercase; letter-spacing: .5px; } +.darkchat-input input { border: 0; outline: 0; background: transparent; font-size: 13px; } +.darkchat-primary, +.darkchat-danger { + width: 100%; + min-height: 40px; + margin: 3px 0 14px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 0; + border-radius: 12px; + background: linear-gradient(135deg, var(--dc-purple), var(--dc-purple-dark)); + font-size: 13px; + font-weight: 700; +} +.darkchat-danger { background: rgb(255 69 58 / 14%); color: var(--dc-red) !important; } +.darkchat-sheet-page h3 { margin: 17px 5px 5px; color: var(--dc-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .5px; } +.darkchat-contact-row { + width: 100%; + min-height: 54px; + padding: 7px 5px; + display: flex; + align-items: center; + gap: 9px; + border: 0; + border-bottom: 1px solid #272729; + background: transparent; + text-align: left; +} +.darkchat-contact-row > span:nth-child(2) { min-width: 0; flex: 1; display: flex; flex-direction: column; } +.darkchat-contact-row strong { font-size: 13px; } +.darkchat-contact-row small { color: var(--dc-muted); font-size: 9px; } +.darkchat-contact-row svg { color: var(--dc-muted); } +.darkchat-qr-card { margin: 18px 0 6px; padding: 15px; display: grid; grid-template-columns: 72px 1fr; align-items: center; gap: 12px; border: 1px solid var(--dc-border); border-radius: 18px; background: var(--dc-surface); } +.darkchat-faux-qr { width: 72px; height: 72px; display: grid; place-items: center; border-radius: 10px; background: #fff; color: #111; } +.darkchat-qr-card > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.darkchat-qr-card > span strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; } +.darkchat-qr-card > span small { color: var(--dc-muted); font-size: 9px; } +.darkchat-qr-card > button { grid-column: 1 / -1; height: 35px; display: flex; align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: 10px; background: rgb(139 92 246 / 16%); color: #c4b5fd; font-size: 11px; } +.darkchat-thread { display: flex; flex-direction: column; padding-bottom: 79px; } +.darkchat-thread--panel { padding-bottom: 342px; } +.darkchat-thread__header { position: relative; flex: none; grid-template-columns: 50px 1fr 50px; } +.darkchat-thread__identity { min-width: 0; padding: 0; display: flex; align-items: center; justify-content: center; gap: 8px; border: 0; background: transparent; } +.darkchat-thread__identity > span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; } +.darkchat-thread__identity strong { max-width: 142px; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-thread__identity small { color: var(--dc-muted); font-size: 10px; } +.darkchat-thread__identity svg { color: var(--dc-muted); } +.darkchat-thread__meta { height: 25px; flex: none; display: flex; align-items: center; justify-content: center; gap: 4px; border-bottom: 1px solid #1b1b1d; color: var(--dc-muted); font-size: 10px; } +.darkchat-thread__messages { min-height: 0; padding: 14px 15px 20px; flex: 1; display: flex; flex-direction: column; align-items: flex-start; gap: 7px; overflow-y: auto; } +.darkchat-day { align-self: center; margin: 5px 0 9px; color: var(--dc-muted); font-size: 11px; font-weight: 600; } +.darkchat-system { max-width: 86%; margin: 8px auto; display: flex; align-items: center; gap: 5px; color: var(--dc-muted); font-size: 10px; text-align: center; } +.darkchat-message { + position: relative; + max-width: 86%; + padding: 9px 12px 8px; + display: flex; + flex-direction: column; + gap: 4px; + border: 0; + border-radius: 18px; + background: var(--dc-surface-2); + font-size: 15px; + line-height: 1.32; + text-align: left; + overflow-wrap: anywhere; + animation: darkchat-message-in .28s cubic-bezier(.32,.72,0,1) both; +} +.darkchat-message--sent { align-self: flex-end; background: linear-gradient(145deg, #8b5cf6, #6d28d9); } +.darkchat-message--received::before, +.darkchat-message--sent::after { content: ""; position: absolute; bottom: 0; width: 9px; height: 11px; } +.darkchat-message--received::before { left: -5px; border-bottom-right-radius: 10px; background: var(--dc-surface-2); } +.darkchat-message--sent::after { right: -5px; border-bottom-left-radius: 10px; background: #6d28d9; } +.darkchat-message > small { align-self: flex-end; color: rgb(255 255 255 / 57%); font-size: 9px; white-space: nowrap; } +.darkchat-message--received > small { color: var(--dc-muted); } +.darkchat-message--failed { outline: 1px solid var(--dc-red); } +.darkchat-message > img, +.darkchat-message > video { width: min(228px, 74vw); max-height: 240px; margin: -7px -10px 2px; border-radius: 14px; object-fit: cover; } +.darkchat-message > video { background: #000; } +.darkchat-reply-preview { padding: 5px 7px; display: flex; align-items: center; gap: 5px; overflow: hidden; border-left: 2px solid #c4b5fd; border-radius: 5px; background: rgb(0 0 0 / 18%); color: rgb(255 255 255 / 72%); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-reactions { position: absolute; right: 4px; bottom: -15px; z-index: 2; padding: 1px 5px; border: 2px solid #000; border-radius: 9px; background: #343438; font-size: 9px; white-space: nowrap; } +.darkchat-message:has(.darkchat-reactions) { margin-bottom: 12px; } +.darkchat-replying { position: absolute; z-index: 6; right: 55px; bottom: 72px; left: 55px; min-height: 39px; padding: 5px 8px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--dc-border); border-radius: 11px; background: rgb(28 28 30 / 96%); color: #c4b5fd; } +.darkchat-replying > span { min-width: 0; flex: 1; display: flex; flex-direction: column; } +.darkchat-replying small { font-size: 9px; text-transform: uppercase; } +.darkchat-replying strong { overflow: hidden; color: #fff; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-replying button { width: 25px; height: 25px; display: grid; place-items: center; border: 0; border-radius: 50%; background: #343438; } +.darkchat-composer { position: absolute; z-index: 7; right: 12px; bottom: 28px; left: 12px; display: flex; align-items: flex-end; gap: 8px; } +.darkchat-composer > label { min-height: 47px; padding: 7px 7px 7px 14px; flex: 1; display: flex; align-items: center; gap: 6px; border: 1px solid var(--dc-border); border-radius: 24px; background: var(--dc-surface); } +.darkchat-composer textarea { min-height: 21px; max-height: 76px; min-width: 0; padding: 2px 0; flex: 1; resize: none; border: 0; outline: 0; background: transparent; font-size: 14px; line-height: 19px; } +.darkchat-composer label button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: #a78bfa; } +.darkchat-actions-bubbles { position: absolute; z-index: 8; bottom: 76px; left: 14px; display: flex; flex-direction: column-reverse; align-items: flex-start; gap: 6px; } +.darkchat-actions-bubbles button { min-height: 40px; padding: 4px 13px 4px 5px; display: flex; align-items: center; gap: 9px; border: 1px solid var(--dc-border); border-radius: 20px; background: var(--dc-surface); font-size: 12px; box-shadow: 0 7px 19px rgb(0 0 0 / 45%); animation: darkchat-bubble-in .23s cubic-bezier(.32,.72,0,1) both; } +.darkchat-actions-bubbles button > span { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: rgb(139 92 246 / 18%); color: #c4b5fd; font-size: 18px; } +.darkchat-gif-panel, +.darkchat-page .messages-full-emoji-picker { position: absolute; z-index: 8; right: 0; bottom: 0; left: 0; height: 285px; border-top: 1px solid var(--dc-border); background: rgb(20 20 22 / 97%); color: #fff; backdrop-filter: blur(25px); } +.darkchat-gif-panel > header { height: 37px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; } +.darkchat-gif-panel > header strong { font-size: 13px; } +.darkchat-gif-panel > header button { border: 0; background: transparent; color: #a78bfa; font-size: 11px; } +.darkchat-gif-panel > label { height: 31px; margin: 0 9px 6px; padding: 0 9px; display: flex; align-items: center; gap: 6px; border-radius: 16px; background: #2c2c2e; color: var(--dc-muted); } +.darkchat-gif-panel > label input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 10px; } +.darkchat-gif-panel > div { height: 175px; padding: 0 9px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px; overflow-y: auto; } +.darkchat-gif-panel > div button { min-height: 80px; padding: 0; overflow: hidden; border: 0; border-radius: 9px; background: #2c2c2e; } +.darkchat-gif-panel > div img { width: 100%; height: 100%; object-fit: cover; } +.darkchat-load-more { width: calc(100% - 18px); height: 28px; margin: 4px 9px; border: 0; border-radius: 9px; background: #2c2c2e; color: #a78bfa !important; font-size: 10px; } +.darkchat-page .messages-full-emoji-picker { animation: darkchat-panel-in .28s cubic-bezier(.32,.72,0,1) both; } +.darkchat-page .messages-full-emoji-picker > header button, +.darkchat-page .messages-full-emoji-picker__search, +.darkchat-page .messages-full-emoji-picker nav button.active { background: #2c2c2e; color: #fff; } +.darkchat-page .messages-full-emoji-picker__search input { color: #fff; } +.darkchat-page .messages-full-emoji-picker nav { border-color: var(--dc-border); } +.darkchat-recorder { position: absolute; z-index: 9; right: 12px; bottom: 28px; left: 12px; height: 43px; padding: 0 7px; display: flex; align-items: center; gap: 6px; border: 1px solid var(--dc-border); border-radius: 22px; background: var(--dc-surface); } +.darkchat-recorder > button { width: 29px; height: 29px; display: grid; place-items: center; border: 0; border-radius: 50%; background: #2c2c2e; } +.darkchat-recorder > button:last-child { color: #a78bfa; } +.darkchat-recorder > i { width: 7px; height: 7px; border-radius: 50%; background: var(--dc-red); animation: messages-record-pulse 1s ease infinite; } +.darkchat-recorder time { font-size: 10px; } +.darkchat-recorder > span { height: 26px; min-width: 0; flex: 1; display: flex; align-items: center; justify-content: center; gap: 1px; overflow: hidden; } +.darkchat-recorder > span b { width: 2px; flex: none; border-radius: 2px; background: #a78bfa; } +.darkchat-voice { min-width: 197px; display: flex; align-items: center; gap: 8px; } +.darkchat-voice > button { width: 30px; height: 30px; display: grid; place-items: center; padding: 0; flex: none; border: 0; border-radius: 50%; background: rgb(255 255 255 / 17%); } +.darkchat-voice > div { min-width: 0; flex: 1; } +.darkchat-voice__wave { height: 27px; display: flex; align-items: center; gap: 1px; } +.darkchat-voice__wave i { width: 2px; border-radius: 2px; background: rgb(255 255 255 / 38%); } +.darkchat-voice__wave i.active { background: #fff; } +.darkchat-voice > div small { display: block; font-size: 9px; } +.darkchat-voice .darkchat-voice__speed { width: 30px; color: #fff; font-size: 9px; font-weight: 800; } +.darkchat-profile-hero { padding: 15px 0 17px; display: flex; align-items: center; flex-direction: column; } +.darkchat-profile-hero .darkchat-avatar { width: 67px; height: 67px; font-size: 30px; box-shadow: 0 13px 32px rgb(91 33 182 / 32%); } +.darkchat-profile-hero h2 { margin: 10px 0 3px; font-size: 20px; } +.darkchat-profile-hero button { padding: 5px 9px; display: flex; align-items: center; gap: 5px; border: 0; border-radius: 11px; background: var(--dc-surface); color: #c4b5fd; font-size: 11px; } +.darkchat-profile-hero small { margin-top: 6px; color: var(--dc-muted); font-size: 10px; } +.darkchat-settings-group, +.darkchat-danger-group { margin: 10px 0; overflow: hidden; border: 1px solid var(--dc-border); border-radius: 13px; background: var(--dc-surface); } +.darkchat-settings-group { overflow: visible; } +.darkchat-settings-group label, +.darkchat-settings-group .darkchat-select, +.darkchat-danger-group button { width: 100%; min-height: 50px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 13px; } +.darkchat-settings-group label:last-child, +.darkchat-settings-group .darkchat-select:last-child, +.darkchat-danger-group button:last-child { border-bottom: 0; } +.darkchat-settings-group label > span, +.darkchat-settings-group .darkchat-select > span, +.darkchat-danger-group button { justify-content: flex-start; } +.darkchat-settings-group label > span, +.darkchat-settings-group .darkchat-select > span { display: flex; align-items: center; gap: 8px; } +.darkchat-settings-group label > span svg, +.darkchat-settings-group .darkchat-select > span svg { color: #a78bfa; } +.darkchat-settings-group input[type="checkbox"] { width: 36px; height: 21px; accent-color: var(--dc-purple); } +.darkchat-select { position: relative; z-index: 2; } +.darkchat-danger-group button { color: var(--dc-red); text-align: left; } +.darkchat-save { padding: 7px; border: 0; background: transparent; color: #a78bfa !important; font-size: 13px; } +.darkchat-privacy-note { margin: 14px 4px 4px; display: flex; align-items: flex-start; gap: 7px; color: var(--dc-muted); font-size: 11px; line-height: 1.45; } +.darkchat-privacy-note svg { flex: none; color: #a78bfa; } +.darkchat-modal-backdrop { position: absolute; z-index: 40; inset: 0; padding: 44px 18px 27px; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 67%); backdrop-filter: blur(10px); } +.darkchat-modal, +.darkchat-message-menu { width: 100%; padding: 17px; display: flex; align-items: stretch; flex-direction: column; border: 1px solid var(--dc-border); border-radius: 21px; background: rgb(28 28 30 / 98%); box-shadow: 0 24px 70px rgb(0 0 0 / 60%); animation: darkchat-modal-in .3s cubic-bezier(.32,.72,0,1) both; } +.darkchat-modal { text-align: center; } +.darkchat-modal__icon { width: 52px; height: 52px; margin: 0 auto; display: grid; place-items: center; border-radius: 17px; background: rgb(139 92 246 / 16%); color: #a78bfa; } +.darkchat-modal__icon--danger { background: rgb(255 69 58 / 14%); color: var(--dc-red); } +.darkchat-modal h2 { margin: 11px 0 6px; font-size: 18px; } +.darkchat-modal p { margin: 0 0 10px; color: var(--dc-muted); font-size: 12px; line-height: 1.45; } +.darkchat-modal > strong { margin-bottom: 12px; color: #c4b5fd; font-size: 12px; } +.darkchat-modal > button:not(.darkchat-primary):not(.darkchat-danger) { min-height: 40px; border: 0; background: transparent; color: var(--dc-muted); font-size: 13px; } +.darkchat-modal select, +.darkchat-modal textarea { margin-bottom: 8px; padding: 9px; border: 1px solid var(--dc-border); border-radius: 10px; outline: 0; background: #2c2c2e; font-size: 11px; } +.darkchat-report-select { width: 100%; margin-bottom: 8px; } +.darkchat-report-select .darkchat-choice__trigger, +.darkchat-report-select .darkchat-choice__menu { width: 100%; } +.darkchat-modal textarea { min-height: 65px; resize: none; } +.darkchat-message-menu { padding: 7px; } +.darkchat-message-menu > button { min-height: 46px; padding: 0 12px; display: flex; align-items: center; gap: 10px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 13px; text-align: left; } +.darkchat-message-menu > button:last-child { justify-content: center; border-bottom: 0; color: var(--dc-muted); } +.darkchat-message-menu > button.danger { color: var(--dc-red); } +.darkchat-reaction-row { margin: -30px 4px 8px; padding: 8px 5px; display: flex; justify-content: space-around; border: 1px solid var(--dc-border); border-radius: 20px; background: #2c2c2e; box-shadow: 0 7px 20px rgb(0 0 0 / 35%); } +.darkchat-reaction-row button { width: 34px; height: 31px; padding: 0; border: 0; border-radius: 13px; background: transparent; font-size: 18px; } +.darkchat-toast { position: absolute; z-index: 60; bottom: 95px; left: 50%; max-width: 80%; padding: 8px 13px; transform: translateX(-50%); border: 1px solid var(--dc-border); border-radius: 14px; background: rgb(44 44 46 / 96%); box-shadow: 0 8px 30px rgb(0 0 0 / 45%); font-size: 10px; text-align: center; } +.darkchat-toast-enter-active, +.darkchat-toast-leave-active { transition: opacity .2s ease, transform .2s ease; } +.darkchat-toast-enter-from, +.darkchat-toast-leave-to { opacity: 0; transform: translate(-50%, 8px); } +@keyframes darkchat-spin { to { transform: rotate(360deg); } } +@keyframes darkchat-message-in { from { opacity: 0; transform: translateY(8px) scale(.94); } } +@keyframes darkchat-bubble-in { from { opacity: 0; transform: translate(-8px, 6px) scale(.8); } } +@keyframes darkchat-panel-in { from { transform: translateY(100%); } } +@keyframes darkchat-modal-in { from { opacity: 0; transform: translateY(18px) scale(.92); } } .springboard-track { height: 100%; display: flex; @@ -605,6 +1017,15 @@ button { grid-template-columns: repeat(4, 1fr); gap: 28px 15px; } +.app-grid-slot { + min-height: 73px; +} +.app-icon-item { + position: relative; + min-width: 0; + display: flex; + justify-content: center; +} .app-icon-button { min-width: 0; border: 0; @@ -618,6 +1039,52 @@ button { cursor: pointer; text-shadow: 0 1px 4px #000b; } +.app-icon-item--dragging { + z-index: 20; + opacity: 0.35; +} +.app-icon-remove { + position: absolute; + z-index: 4; + top: -8px; + left: 1px; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 50%; + background: none; + color: black; + display: grid; + place-items: center; + cursor: pointer; + text-shadow: none; +} +.app-icon-remove__badge { + width: 22px; + height: 22px; + min-width: 22px; + min-height: 22px; + padding: 0; + border: 1px solid #ffffff73; + box-shadow: 0 1px 4px #0009; +} +.app-icon-item--editing:not(.app-icon-item--dragging) { + animation: app-icon-wobble 0.15s ease-in-out infinite alternate; + transform-origin: 50% 45%; +} +.app-icon-item--editing:nth-child(even):not(.app-icon-item--dragging) { + animation-direction: alternate-reverse; + animation-delay: -0.075s; +} +@keyframes app-icon-wobble { + from { + transform: rotate(-1.5deg) translateY(-0.4px); + } + to { + transform: rotate(1.5deg) translateY(0.4px); + } +} .app-icon-anchor { position: relative; display: inline-grid; @@ -697,8 +1164,8 @@ button { right: 15px; height: 82px; padding: 13px 18px; - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: repeat(4, 1fr); border: 1px solid #ffffff28; border-radius: 27px; background: #ffffff22; @@ -708,6 +1175,39 @@ button { backdrop-filter: blur(24px) saturate(150%); -webkit-backdrop-filter: blur(24px) saturate(150%); } +.app-dock-slot { + min-width: 0; + height: 56px; +} +.springboard-edit-done { + position: absolute; + z-index: 12; + top: 45px; + right: 18px; + min-width: 51px; + min-height: 30px; + padding: 0 13px; + border: 1px solid #ffffff38; + border-radius: 999px; + color: white; + font-size: 12px; + font-weight: 650; + line-height: 28px; + text-align: center; + text-shadow: 0 1px 3px #000a; + cursor: pointer; +} +.edit-done-enter-active, +.edit-done-leave-active { + transition: + opacity 0.18s ease, + transform 0.18s ease; +} +.edit-done-enter-from, +.edit-done-leave-to { + opacity: 0; + transform: scale(0.88); +} .page-indicator { position: absolute; bottom: 132px; @@ -1014,6 +1514,11 @@ button { height: 44px; border-radius: 11px; } +@media (prefers-reduced-motion: reduce) { + .app-icon-item--editing:not(.app-icon-item--dragging) { + animation: none; + } +} .app-library-group .app-icon-button--compact .app-icon { width: 56px; height: 56px; @@ -1158,15 +1663,416 @@ button { linear-gradient(165deg, #7897b5 0%, #536d8b 48%, #283c58 100%); } .weather-navbar { + --k-safe-area-top: 46px; --tw-bg-opacity: 0; position: absolute; z-index: 3; - top: 46px; + top: 0; right: 0; left: 0; background: transparent !important; color: #fff; } + +/* Banking */ +.banking-app { + --bank-blue: #2d76ff; + --bank-cyan: #57d5ff; + --bank-green: #4ee6a4; + position: relative; + height: 100%; + overflow: hidden; + background: + radial-gradient(circle at 92% 4%, rgb(45 118 255 / 28%), transparent 32%), + linear-gradient(160deg, #0c1834 0%, #080d1d 44%, #050710 100%) !important; + color: #fff; + isolation: isolate; +} + +.banking-app::before { + position: absolute; + z-index: -1; + inset: 0; + background-image: + linear-gradient(rgb(255 255 255 / 2.4%) 1px, transparent 1px), + linear-gradient(90deg, rgb(255 255 255 / 2.4%) 1px, transparent 1px); + background-size: 42px 42px; + content: ''; + mask-image: linear-gradient(to bottom, #000, transparent 66%); +} + +.banking-app__aurora { + position: absolute; + z-index: -1; + width: 240px; + height: 180px; + top: 160px; + left: -145px; + border-radius: 50%; + background: #215dea; + filter: blur(78px); + opacity: 0.2; +} + +.banking-navbar { + --k-safe-area-top: 46px; + position: absolute; + z-index: 5; + top: 0; + right: 0; + left: 0; + color: #fff; +} + +.banking-scroll { + position: absolute; + inset: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 108px 17px 112px; + scrollbar-width: none; +} + +.banking-scroll::-webkit-scrollbar { display: none; } + +.banking-pull-refresh { + position: absolute; + z-index: 4; + top: 78px; + right: 0; + left: 0; + display: flex; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: + opacity 0.16s ease, + transform 0.16s ease; +} + +.banking-pull-refresh.is-visible { opacity: 1; } + +.banking-loading, +.banking-empty { + position: absolute; + inset: 100px 24px 82px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + color: rgb(226 235 255 / 72%); + text-align: center; +} + +.banking-empty p { + margin: 0 0 8px; + color: rgb(208 220 248 / 58%); + font-size: 12px; +} + +.banking-balance { + padding: 22px 20px 20px; + border-radius: 27px; +} + +.banking-balance__label { + display: flex; + align-items: center; + justify-content: space-between; + color: rgb(226 235 255 / 67%); + font-size: 12px; +} + +.banking-balance__label small { + padding: 4px 8px; + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 99px; + background: rgb(0 0 0 / 12%); + font-size: 9px; +} + +.banking-balance > strong { + display: block; + margin-top: 6px; + font-size: 36px; + font-weight: 600; + letter-spacing: -0.055em; +} + +.banking-balance__trend { + margin-top: 8px; + color: rgb(208 220 248 / 57%); + font-size: 10px; +} + +.banking-balance__trend span { + margin-right: 4px; + color: var(--bank-green); + font-weight: 650; +} + +.banking-actions { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 9px; + margin: 13px 0 18px; +} + +.banking-action { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 64px; + padding: 11px 12px; + border-radius: 19px; + color: rgb(237 243 255 / 82%); + font-size: 10px; +} + +.banking-action__icon { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + place-items: center; + border-radius: 11px; + background: linear-gradient(145deg, rgb(60 129 255 / 34%), rgb(25 54 115 / 25%)); + color: #8fb8ff; +} + +.banking-action b { + font-size: 11px; + font-weight: 610; +} + +.banking-action--primary { + grid-column: 1 / -1; + justify-content: flex-start; + min-height: 58px; + padding: 10px 13px; + color: #fff; +} + +.banking-action--primary .banking-action__icon { + background: rgb(255 255 255 / 15%); + color: #fff; +} + +.banking-action--primary svg:last-child { + margin-left: auto; + opacity: 0.78; +} + +.banking-action--secondary { + justify-content: flex-start; +} + +.banking-card { + margin: 0 0 18px !important; +} + +.banking-transaction-card { + overflow: hidden; +} + +.banking-section-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 11px; +} + +.banking-section-title h2 { + margin: 0; + font-size: 15px; + font-weight: 630; + letter-spacing: -0.025em; +} + +.banking-section-title > svg { color: rgb(220 232 255 / 43%); } + +.banking-section-title button { + color: #7ca8ff; + font-size: 10px; +} + +.banking-account-list, +.banking-transaction-list { + margin: 0 !important; +} + +.banking-transactions { padding: 0 3px; } +.banking-transactions--all { margin-top: 18px; } + +.banking-transaction-list b { + flex: 0 0 auto; + color: #f2a0a8; + font-size: 11px; + font-weight: 620; +} + +.banking-transaction-list b.is-incoming { color: var(--bank-green); } + +.banking-no-transactions { + padding: 24px 12px; + border: 1px solid rgb(255 255 255 / 8%); + border-radius: 22px; + background: rgb(255 255 255 / 3%); + color: rgb(213 225 248 / 47%); + font-size: 11px; + text-align: center; +} + +.banking-activity-hero { + display: flex; + align-items: center; + flex-direction: column; + padding: 16px 0 20px; + text-align: center; +} + +.banking-activity-hero span { + color: rgb(214 225 250 / 55%); + font-size: 11px; +} + +.banking-activity-hero strong { + margin: 5px 0; + color: var(--bank-green); + font-size: 31px; + font-weight: 590; + letter-spacing: -0.045em; +} + +.banking-activity-hero small { + color: rgb(214 225 250 / 38%); + font-size: 9px; +} + +.banking-chart-card { padding: 18px 14px 15px !important; } + +.banking-chart-legend { + display: flex; + justify-content: flex-end; + gap: 12px; + color: rgb(218 229 251 / 50%); + font-size: 8px; +} + +.banking-chart-legend span { display: flex; align-items: center; gap: 4px; } + +.banking-chart-legend i { + width: 6px; + height: 6px; + border-radius: 50%; + background: #58647f; +} + +.banking-chart-legend i.is-incoming { background: var(--bank-blue); } + +.banking-chart { + display: grid; + height: 145px; + grid-template-columns: repeat(7, 1fr); + gap: 5px; + margin-top: 14px; +} + +.banking-chart__day { + display: flex; + align-items: center; + justify-content: flex-end; + flex-direction: column; + gap: 7px; +} + +.banking-chart__day > div { + display: flex; + align-items: flex-end; + justify-content: center; + width: 100%; + height: 116px; + gap: 3px; +} + +.banking-chart__day i { + width: 7px; + min-height: 5px; + border-radius: 6px; + background: linear-gradient(to top, #343e57, #65718d); + transition: height 0.35s ease; +} + +.banking-chart__day i.is-incoming { + background: linear-gradient(to top, #1d55d8, #3d87ff); + box-shadow: 0 0 14px rgb(45 118 255 / 33%); +} + +.banking-chart__day span { + color: rgb(217 227 249 / 45%); + font-size: 8px; +} + +.banking-sheet__content { + position: relative; + width: 100%; + padding: 22px 18px 18px; +} + +.banking-modal__close { + position: absolute; + top: 15px; + right: 15px; + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 50%; + background: rgb(255 255 255 / 8%); + color: rgb(226 235 255 / 65%); +} + +.banking-modal__icon { + display: grid; + width: 45px; + height: 45px; + place-items: center; + border-radius: 15px; + background: linear-gradient(145deg, #3c83ff, #1849b6); + box-shadow: 0 12px 28px rgb(22 76 196 / 36%), inset 0 1px rgb(255 255 255 / 28%); +} + +.banking-sheet__content h2 { + margin: 14px 0 4px; + font-size: 20px; + letter-spacing: -0.035em; +} + +.banking-sheet__content > p { + margin: 0 0 15px; + color: rgb(213 225 248 / 52%); + font-size: 10px; + line-height: 1.45; +} + +.banking-form-list { margin: 12px 0 0 !important; } + +.banking-sheet__content .banking-form-error { + margin: 10px 3px 0; + color: #ff8b98; + font-size: 9px; +} + +.banking-sheet__content > button:last-child { margin-top: 15px; } + +.banking-sheet__content > button:last-child > span { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} .weather-navbar::after { opacity: 0; } @@ -1914,13 +2820,21 @@ button { color: #000; } -.reference-store { - padding: 0 0 79px; - background: #0e0e0e; +.app-store-page { + display: flex; + flex-direction: column; + padding: 44px 0 24px; + background: #020202; + color: #fff; +} +.phone-app--light .app-store-page { + background: #f2f2f7; + color: #000; } .store-scroll { - height: 100%; - padding: 52px 19px 26px; + min-height: 0; + flex: 1; + padding: 0 19px 26px; overflow-y: auto; } .store-title { @@ -2034,7 +2948,7 @@ button { .reference-store .store-feature { margin: 0 0 20px; } -.reference-store .store-list { +.app-store-page .store-list { padding: 0; } diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 7c65c0c..25e3df5 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -1,10 +1,13 @@ diff --git a/frontend/src/components/DarkChatSelect.vue b/frontend/src/components/DarkChatSelect.vue new file mode 100644 index 0000000..804ae57 --- /dev/null +++ b/frontend/src/components/DarkChatSelect.vue @@ -0,0 +1,144 @@ + + + + + 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.test.ts b/frontend/src/config/apps.test.ts index 68d8968..6300ec9 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -30,6 +30,11 @@ describe('app registry', () => { labelKey: 'Apps.weather.name', route: '/apps/weather', }) + expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({ + gridOrder: 5, + labelKey: 'Apps.banking.name', + route: '/apps/banking', + }) expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({ gridOrder: 21, labelKey: 'Apps.calendar.name', @@ -113,7 +118,7 @@ describe('app registry', () => { PHONE_APPS.filter((app) => app.category === 'social').map( (app) => app.id, ), - ).toEqual(['local-pages', 'radio', 'phone', 'mail']) + ).toEqual(['local-pages', 'radio', 'phone', 'darkchat', 'mail']) expect( PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 247e2e6..cd073a2 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -13,12 +13,14 @@ import { Mail, MapPinned, MessageCircle, + ShieldCheck, NotebookPen, Phone, RadioTower, Settings, ShoppingBag, CloudSun, + Landmark, Wind, Tag, MapPinHouse, @@ -33,6 +35,7 @@ import calendarIcon from '@/assets/img/app-icons/calendar.svg' import mailIcon from '@/assets/img/app-icons/mail.webp' import mapIcon from '@/assets/img/app-icons/map.webp' import messagesIcon from '@/assets/img/app-icons/sms.webp' +import darkChatIcon from '@/assets/img/app-icons/darkchat.svg' import notesIcon from '@/assets/img/app-icons/notes.webp' import radioIcon from '@/assets/img/app-icons/radio.svg' import photosIcon from '@/assets/img/app-icons/gallery.webp' @@ -46,6 +49,7 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp' import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp' import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' +import bankingIcon from '@/assets/img/app-icons/banking.svg' import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp' import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' import type { @@ -125,6 +129,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.messages.name', route: '/apps/messages', }, + { + category: 'social', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/DarkChatApp.vue')), + ), + dockOrder: null, + gridOrder: 22, + icon: markRaw(ShieldCheck), + iconClass: 'app-icon--darkchat', + iconImage: darkChatIcon, + id: 'darkchat', + labelKey: 'Apps.darkchat.name', + route: '/apps/darkchat', + }, { category: 'utilities', component: markRaw( @@ -139,6 +157,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.map.name', route: '/apps/map', }, + { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/BankingApp.vue')), + ), + dockOrder: null, + gridOrder: 5, + icon: markRaw(Landmark), + iconClass: 'app-icon--banking', + iconImage: bankingIcon, + id: 'banking', + labelKey: 'Apps.banking.name', + route: '/apps/banking', + }, { category: 'social', component: markRaw( @@ -379,6 +411,17 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ }, ] +export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet = + new Set([ + 'app-store', + 'settings', + 'camera', + 'photos', + 'phone', + 'messages', + 'mail', + ]) + export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id) export function getPhoneApp( diff --git a/frontend/src/config/ifruit.ts b/frontend/src/config/ifruit.ts deleted file mode 100644 index 34c54de..0000000 --- a/frontend/src/config/ifruit.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const IFRUIT_AUTH_INPUT_COLORS = { - outlineLabelBgIos: 'bg-black', -} diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index b4739ff..c09d8da 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -1,7 +1,9 @@ import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps' import { useAppStoreStore } from '@/stores/app-store' +import { removeHomeApp } from '@/utils/homeLayout' const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() })) vi.mock('@/stores/phone', () => ({ @@ -32,6 +34,7 @@ describe('app store', () => { expect(apps.launchCounts).toEqual({ mail: 4 }) expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', { claimedApps: ['snake'], + homeLayout: apps.homeLayout, launchCounts: { mail: 4 }, }) }) @@ -53,6 +56,7 @@ describe('app store', () => { expect(apps.claimedApps).toEqual(['snake']) expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', { claimedApps: ['snake'], + homeLayout: apps.homeLayout, launchCounts: {}, }) }) @@ -69,4 +73,84 @@ describe('app store', () => { expect(apps.claimedApps).toEqual(['memory', 'snake']) expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1) }) + + it('reinstalls core and claimed apps removed from the Home Screen', () => { + vi.useFakeTimers() + const apps = useAppStoreStore() + + apps.hydrate({ claimedApps: ['memory'] }) + apps.removeHomeApp('notes') + apps.removeHomeApp('memory') + mocks.saveDeviceNamespace.mockClear() + + apps.installApp('notes') + apps.installApp('memory') + + expect(apps.installingApps).toEqual({ notes: true, memory: true }) + expect(apps.homeLayout.hidden).toEqual(['notes', 'memory']) + + vi.advanceTimersByTime(3000) + + expect(apps.installingApps).toEqual({}) + expect(apps.homeLayout.hidden).toEqual([]) + expect(apps.homeLayout.grid).toContain('notes') + expect(apps.homeLayout.grid).toContain('memory') + expect(apps.claimedApps).toEqual(['memory']) + expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(2) + }) + + it('prevents protected apps from being removed from the Home Screen', () => { + const apps = useAppStoreStore() + apps.hydrate(null) + mocks.saveDeviceNamespace.mockClear() + + expect([...NON_REMOVABLE_PHONE_APP_IDS]).toEqual([ + 'app-store', + 'settings', + 'camera', + 'photos', + 'phone', + 'messages', + 'mail', + ]) + for (const appId of NON_REMOVABLE_PHONE_APP_IDS) { + apps.removeHomeApp(appId) + expect(apps.homeLayout.hidden).not.toContain(appId) + } + + expect(mocks.saveDeviceNamespace).not.toHaveBeenCalled() + }) + + it('restores protected apps hidden by older persisted layouts', () => { + const apps = useAppStoreStore() + const legacyLayout = removeHomeApp(apps.homeLayout, 'mail') + + apps.hydrate({ homeLayout: legacyLayout }) + + expect(apps.homeLayout.hidden).not.toContain('mail') + expect(apps.homeLayout.grid).toContain('mail') + expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', { + claimedApps: [], + homeLayout: apps.homeLayout, + launchCounts: {}, + }) + }) + + it('persists home reordering and removal independently from installation', () => { + const apps = useAppStoreStore() + apps.hydrate(null) + + const notesIndex = apps.homeLayout.grid.indexOf('notes') + apps.moveHomeApp('grid', notesIndex, 'grid', 0) + expect(apps.homeLayout.grid[0]).toBe('notes') + + apps.removeHomeApp('notes') + expect(apps.homeLayout.grid).not.toContain('notes') + expect(apps.homeLayout.hidden).toContain('notes') + expect(mocks.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', { + claimedApps: [], + homeLayout: apps.homeLayout, + launchCounts: {}, + }) + }) }) diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index dc90b15..4814cfd 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -1,14 +1,40 @@ import { defineStore } from 'pinia' -import { isPhoneAppId } from '@/config/apps' +import { + isPhoneAppId, + NON_REMOVABLE_PHONE_APP_IDS, + PHONE_APPS, +} from '@/config/apps' import { usePhoneStore } from '@/stores/phone' import type { LaunchablePhoneAppId } from '@/types/apps' +import { + createDefaultHomeLayout, + moveHomeApp, + parseHomeLayout, + removeHomeApp, + restoreHomeApp, + type HomeArea, +} from '@/utils/homeLayout' const INSTALL_DURATION_MS = 3000 +const DEFAULT_GRID_IDS = [...PHONE_APPS] + .sort((a, b) => a.gridOrder - b.gridOrder) + .map((app) => app.id) +const DEFAULT_DOCK_IDS = PHONE_APPS.filter((app) => app.dockOrder !== null) + .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) + .map((app) => app.id) +const CORE_APP_IDS = PHONE_APPS.filter((app) => app.category !== 'games').map( + (app) => app.id, +) export const useAppStoreStore = defineStore('app-store', { state: () => ({ claimedApps: [] as LaunchablePhoneAppId[], + homeLayout: createDefaultHomeLayout( + CORE_APP_IDS, + DEFAULT_GRID_IDS, + DEFAULT_DOCK_IDS, + ), installingApps: {} as Partial>, launchCounts: {} as Partial>, }), @@ -16,21 +42,34 @@ export const useAppStoreStore = defineStore('app-store', { claimApp(id: LaunchablePhoneAppId): void { if (!this.claimedApps.includes(id)) { this.claimedApps.push(id) + this.homeLayout = restoreHomeApp(this.homeLayout, id) this.persist() } }, installApp(id: LaunchablePhoneAppId): void { - if (this.claimedApps.includes(id) || this.installingApps[id]) return + const installed = + CORE_APP_IDS.includes(id) || this.claimedApps.includes(id) + if ( + this.installingApps[id] || + (installed && !this.homeLayout.hidden.includes(id)) + ) { + return + } this.installingApps[id] = true globalThis.setTimeout(() => { - this.claimApp(id) + if (CORE_APP_IDS.includes(id) || this.claimedApps.includes(id)) { + this.restoreHomeApp(id) + } else { + this.claimApp(id) + } delete this.installingApps[id] }, INSTALL_DURATION_MS) }, hydrate(payload: unknown): void { const data = payload as { claimedApps?: unknown + homeLayout?: unknown launchCounts?: unknown } | null this.claimedApps = Array.isArray(data?.claimedApps) @@ -39,6 +78,23 @@ export const useAppStoreStore = defineStore('app-store', { typeof id === 'string' && isPhoneAppId(id), ) : [] + const installedIds = [...CORE_APP_IDS, ...this.claimedApps] + const defaults = createDefaultHomeLayout( + installedIds, + DEFAULT_GRID_IDS, + DEFAULT_DOCK_IDS, + ) + this.homeLayout = parseHomeLayout( + data?.homeLayout, + defaults, + installedIds, + ) + const protectedHiddenAppIds = this.homeLayout.hidden.filter((id) => + NON_REMOVABLE_PHONE_APP_IDS.has(id), + ) + for (const appId of protectedHiddenAppIds) { + this.homeLayout = restoreHomeApp(this.homeLayout, appId) + } this.installingApps = {} this.launchCounts = {} if (data?.launchCounts && typeof data.launchCounts === 'object') { @@ -53,14 +109,41 @@ export const useAppStoreStore = defineStore('app-store', { } } } + if (protectedHiddenAppIds.length) this.persist() }, recordLaunch(appId: LaunchablePhoneAppId): void { this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1 this.persist() }, + moveHomeApp( + from: HomeArea, + sourceIndex: number, + to: HomeArea, + targetIndex: number, + ): void { + this.homeLayout = moveHomeApp( + this.homeLayout, + from, + sourceIndex, + to, + targetIndex, + ) + this.persist() + }, + removeHomeApp(appId: LaunchablePhoneAppId): void { + if (NON_REMOVABLE_PHONE_APP_IDS.has(appId)) return + + this.homeLayout = removeHomeApp(this.homeLayout, appId) + this.persist() + }, + restoreHomeApp(appId: LaunchablePhoneAppId): void { + this.homeLayout = restoreHomeApp(this.homeLayout, appId) + this.persist() + }, persist(): void { usePhoneStore().saveDeviceNamespace('apps', { claimedApps: this.claimedApps, + homeLayout: this.homeLayout, launchCounts: this.launchCounts, }) }, diff --git a/frontend/src/stores/banking.test.ts b/frontend/src/stores/banking.test.ts new file mode 100644 index 0000000..db3d45a --- /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('transfer', 50000, 17) + + 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/darkchat.test.ts b/frontend/src/stores/darkchat.test.ts new file mode 100644 index 0000000..2cedfd7 --- /dev/null +++ b/frontend/src/stores/darkchat.test.ts @@ -0,0 +1,108 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useDarkChatStore } from '@/stores/darkchat' +import type { DarkChatMessage, DarkChatThread } from '@/types/darkchat' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) +const mockNuiCall = vi.mocked(nuiCall) + +const conversation: DarkChatThread['conversation'] = { + blockedByPeer: false, + createdAt: '2026-08-06 20:00:00', + disappearingSeconds: 3600, + id: 'conversation-id', + notificationsEnabled: true, + peer: { alias: 'Nova', avatarSeed: 42, darkId: 'dark:N0VA-41KQ', id: 2 }, + readReceipts: true, +} + +function message(id: string): DarkChatMessage { + return { + body: 'Quiet channel', + conversationId: conversation.id, + createdAt: '2026-08-06 20:01:00', + direction: 'sent', + id, + messageType: 'text', + reactions: {}, + } +} + +describe('darkchat store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('optimistically sends and confirms a private message', async () => { + let resolveSend: ((value: { data: DarkChatMessage; success: true }) => void) | undefined + const pending = new Promise<{ data: DarkChatMessage; success: true }>((resolve) => (resolveSend = resolve)) + mockNuiCall + .mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true }) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + .mockImplementationOnce(() => pending) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + + const store = useDarkChatStore() + await store.openThread(conversation.id) + const sending = store.send({ body: 'Quiet channel', messageType: 'text' }) + expect(store.messages[0]).toMatchObject({ deliveryStatus: 'sending', body: 'Quiet channel' }) + + resolveSend?.({ data: message('server-message'), success: true }) + await sending + expect(store.messages[0]).toMatchObject({ deliveryStatus: 'delivered', id: 'server-message' }) + }) + + it('keeps failed messages visible for delivery feedback', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true }) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + .mockResolvedValueOnce({ error: 'blocked', success: false }) + + const store = useDarkChatStore() + await store.openThread(conversation.id) + await store.send({ body: 'Quiet channel', messageType: 'text' }) + expect(store.messages[0].deliveryStatus).toBe('failed') + }) + + it('uses a local media preview without sending that URL to the server', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true }) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + .mockResolvedValueOnce({ data: { ...message('media-id'), messageType: 'image' }, success: true }) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + + const store = useDarkChatStore() + await store.openThread(conversation.id) + const sending = store.send({ + mediaAssetId: '17', + mediaPreviewUrl: 'https://media.example/photo.jpg', + messageType: 'image', + }) + + expect(store.messages[0]).toMatchObject({ + mediaPayload: 'https://media.example/photo.jpg', + messageType: 'image', + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'darkchat:send', { + conversationId: conversation.id, + mediaAssetId: '17', + messageType: 'image', + }) + await sending + }) + + it('loads protected voice data once', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { mime: 'audio/webm;codecs=opus', payload: 'ZmFrZQ==' }, + success: true, + }) + const store = useDarkChatStore() + expect(await store.loadMedia('voice-id')).toBe(true) + expect(await store.loadMedia('voice-id')).toBe(true) + expect(store.mediaSources['voice-id']).toBe('data:audio/webm;codecs=opus;base64,ZmFrZQ==') + expect(mockNuiCall).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/stores/darkchat.ts b/frontend/src/stores/darkchat.ts new file mode 100644 index 0000000..47a3b9b --- /dev/null +++ b/frontend/src/stores/darkchat.ts @@ -0,0 +1,160 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import type { + DarkChatBootstrap, + DarkChatContact, + DarkChatConversation, + DarkChatConversationSummary, + DarkChatMessage, + DarkChatOutgoing, + DarkChatProfile, + DarkChatThread, +} from '@/types/darkchat' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useDarkChatStore = defineStore('darkchat', () => { + const profile = ref(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 { mediaPreviewUrl, ...request } = outgoing + const optimistic: DarkChatMessage = { + body: outgoing.body ?? '', + clientId, + conversationId: activeConversation.value.id, + createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '), + deliveryStatus: 'sending', + direction: 'sent', + id: clientId, + mediaDurationMs: outgoing.mediaDurationMs, + mediaMime: outgoing.mediaMime, + mediaPayload: + outgoing.messageType === 'image' || outgoing.messageType === 'video' + ? mediaPreviewUrl + : outgoing.mediaPayload, + mediaWaveform: outgoing.mediaWaveform, + messageType: outgoing.messageType, + reactions: {}, + replyToId: outgoing.replyToId, + } + messages.value.push(optimistic) + if (outgoing.messageType === 'voice' && outgoing.mediaPayload) { + mediaSources.value[clientId] = `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` + } + const response = await nuiCall('darkchat:send', { + ...request, + conversationId: activeConversation.value.id, + }) + const index = messages.value.findIndex((message) => message.clientId === clientId) + if (!response.success || !response.data) { + if (index >= 0) messages.value[index].deliveryStatus = 'failed' + return response + } + const source = mediaSources.value[clientId] + delete mediaSources.value[clientId] + if (source) mediaSources.value[response.data.id] = source + if (index >= 0) { + messages.value[index] = { ...response.data, clientId, deliveryStatus: 'delivered' } + } + await refreshInbox() + return response + } + + async function loadMedia(messageId: string): Promise { + 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/messageMedia.test.ts b/frontend/src/stores/messageMedia.test.ts new file mode 100644 index 0000000..d972bd5 --- /dev/null +++ b/frontend/src/stores/messageMedia.test.ts @@ -0,0 +1,34 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useMessageMediaStore } from '@/stores/messageMedia' +import type { PhoneMedia } from '@/types/media' + +const photo: PhoneMedia = { + createdAt: 1_786_035_600, + id: 17, + mediaType: 'photo', + url: 'https://media.example/photo.jpg', +} + +describe('message media handoff', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('returns captured media to the requesting DarkChat conversation', () => { + const store = useMessageMediaStore() + store.begin('darkchat:conversation-id', 'photo', '/apps/darkchat') + + expect(store.complete(photo)).toBe('/apps/darkchat') + expect(store.consume('darkchat:another-conversation')).toBeNull() + expect(store.consume('darkchat:conversation-id')).toEqual(photo) + }) + + it('keeps the request active when the selected media type does not match', () => { + const store = useMessageMediaStore() + store.begin('4205550196', 'video') + + expect(store.complete(photo)).toBeNull() + expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' }) + expect(store.cancel()).toBe('/apps/messages') + }) +}) diff --git a/frontend/src/stores/messageMedia.ts b/frontend/src/stores/messageMedia.ts index 3ef4661..083a461 100644 --- a/frontend/src/stores/messageMedia.ts +++ b/frontend/src/stores/messageMedia.ts @@ -4,7 +4,8 @@ import type { MediaType, PhoneMedia } from '@/types/media' type MessageMediaRequest = { mediaType: MediaType - phoneNumber: string + returnPath: string + target: string } type MessageMediaResult = MessageMediaRequest & { @@ -17,20 +18,28 @@ export const useMessageMediaStore = defineStore('message-media', { result: null as MessageMediaResult | null, }), actions: { - begin(phoneNumber: string, mediaType: MediaType): void { - this.request = { mediaType, phoneNumber } + begin( + target: string, + mediaType: MediaType, + returnPath = '/apps/messages', + ): void { + this.request = { mediaType, returnPath, target } this.result = null }, - cancel(): void { + cancel(): string { + const returnPath = this.request?.returnPath ?? '/apps/messages' this.request = null + return returnPath }, - complete(media: PhoneMedia): void { - if (!this.request || this.request.mediaType !== media.mediaType) return + complete(media: PhoneMedia): string | null { + if (!this.request || this.request.mediaType !== media.mediaType) return null + const returnPath = this.request.returnPath this.result = { ...this.request, media } this.request = null + return returnPath }, - consume(phoneNumber: string): PhoneMedia | null { - if (!this.result || this.result.phoneNumber !== phoneNumber) return null + consume(target: string): PhoneMedia | null { + if (!this.result || this.result.target !== target) return null const media = this.result.media this.result = null return media diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 4cbcf44..86b674f 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', photo: 'Photo', video: 'Video', attachPhoto: 'Attach Photo', takePhoto: 'Take Photo', attachGif: 'Attach GIF', attachVideo: 'Attach Video', searchGifs: 'Search GIFs', loadMore: 'Load More', + voiceMessage: 'Voice message', sending: 'Sending', failed: 'Not delivered', delivered: 'Delivered', read: 'Read', replying: 'Replying to', + messageDeleted: 'Message deleted', securityUpdate: 'Security settings updated', timerChanged: 'Disappearing messages: {timer}', + timerOff: 'Off', timerAfterRead: 'After reading', timerMinute: '1 minute', timerFiveMinutes: '5 minutes', timerHour: '1 hour', timerDay: '24 hours', timerWeek: '7 days', + copied: 'Copied', reply: 'Reply', copy: 'Copy', deleteForMe: 'Delete for me', deleteForBoth: 'Delete for both', report: 'Report', + contactSecurity: 'Contact & Security', chatSince: 'Private chat since {date}', notifications: 'Notifications', readReceipts: 'Read receipts', disappearing: 'Disappearing messages', contactAlias: 'Contact alias', saveContact: 'Save Contact', addContact: 'Add Contact', contactSaved: 'Contact saved', removeContact: 'Remove Contact', block: 'Block User', unblock: 'Unblock User', clearChat: 'Clear Chat', chatCleared: 'Chat cleared', + myIdentity: 'My Dark Identity', alias: 'Alias', notificationPrivacy: 'Notification privacy', notificationFull: 'Full · alias and message', notificationPrivate: 'Private · generic message', notificationHidden: 'Invisible · badge only', shareActivity: 'Share activity status', inviteCode: 'Private invitation code', copyInvite: 'Copy Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.', + unknownIdentity: 'Unknown Identity', unknownIdentityBody: 'Only continue if you expected this identity. The account is not discoverable through public search.', openSecureChat: 'Open Private Chat', + reportUser: 'Report User', reportSpam: 'Spam', reportHarassment: 'Harassment', reportThreats: 'Threats', reportIllegal: 'Illegal content', reportOther: 'Other', reportDetails: 'Optional details', submitReport: 'Submit Report', reported: 'Report submitted', + microphoneUnavailable: 'The microphone is unavailable.', recordingTooLarge: 'The voice message is too large.', + errors: { + not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_attachment: 'This photo or video is unavailable.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.', + }, + }, messages: { name: 'Messages', newMessage: 'New message from {sender}', @@ -244,6 +265,58 @@ const defaultLocales: LocaleTree = { default: 'The radio request failed.', }, }, + banking: { + name: 'Banking', + welcome: 'Welcome back', + totalBalance: 'Total Balance', + recentPeriod: 'in recent activity', + actions: 'Banking actions', + send: 'Send', + accounts: 'Accounts', + bankAccount: 'Bank account', + cash: 'Cash', + latestTransactions: 'Latest Transactions', + allTransactions: 'All Transactions', + viewAll: 'View all', + noTransactions: 'Your banking activity will appear here.', + home: 'Home', + activity: 'Activity', + chartDaySummary: '{day}: {incoming} incoming, {outgoing} outgoing', + navigation: 'Banking navigation', + incoming: 'Incoming', + outgoing: 'Outgoing', + refresh: 'Refresh banking data', + unavailable: 'Banking unavailable', + tryAgain: 'Try Again', + playerId: 'Player ID', + playerIdPlaceholder: 'Enter the recipient ID', + amount: 'Amount', + amountPlaceholder: 'Enter an amount', + transactions: { + deposit: 'Cash deposit', + withdrawal: 'Cash withdrawal', + transfer_in: 'Incoming transfer', + transfer_out: 'Outgoing transfer', + }, + forms: { + transfer: { + title: 'Send money', + body: 'Transfer money from your bank account to an online player.', + submit: 'Send transfer', + }, + }, + errors: { + invalid_request: 'Enter a valid whole amount and player ID.', + insufficient_funds: 'There is not enough money in this account.', + target_not_found: 'The recipient is not online.', + self_transfer: 'You cannot send money to yourself.', + rate_limited: 'Please wait before making another transaction.', + transfer_failed: 'The transfer could not be completed.', + banking_unavailable: 'Banking is currently unavailable.', + request_failed: 'The banking request failed.', + default: 'The banking request failed.', + }, + }, calculator: { name: 'Calculator' }, snake: { name: 'Snake', @@ -1048,6 +1121,7 @@ const defaultLocales: LocaleTree = { apps: 'Apps', dock: 'Dock', noApps: 'No apps found', + removeApp: 'Remove {app} from Home Screen', page: 'Page', pages: 'Home screen pages', groups: { diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index a26a838..193c46a 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -3,11 +3,13 @@ import type { Component } from 'vue' export type PhoneAppId = | 'phone' | 'messages' + | 'darkchat' | 'calculator' | 'camera' | 'clock' | 'calendar' | '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..f91e89c --- /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 = 'transfer' diff --git a/frontend/src/types/darkchat.ts b/frontend/src/types/darkchat.ts new file mode 100644 index 0000000..05dcf0a --- /dev/null +++ b/frontend/src/types/darkchat.ts @@ -0,0 +1,99 @@ +import type { DatabaseDateValue } from '@/utils/date' + +export type DarkChatMessageType = + | 'text' + | 'emoji' + | 'gif' + | 'voice' + | 'image' + | 'video' + | 'system' +export type DarkChatNotificationMode = 'full' | 'private' | 'hidden' + +export type DarkChatIdentity = { + id: number + darkId: string + alias: string + originalAlias?: string + avatarSeed: number + activityVisible?: boolean + isContact?: boolean + blocked?: boolean +} + +export type DarkChatProfile = DarkChatIdentity & { + inviteCode: string + notificationMode: DarkChatNotificationMode + activityVisible: boolean + createdAt: DatabaseDateValue +} + +export type DarkChatContact = DarkChatIdentity & { + createdAt: DatabaseDateValue +} + +export type DarkChatConversationSummary = { + id: string + peer: DarkChatIdentity + disappearingSeconds: number + blocked: boolean + lastMessage: string + lastMessageType: DarkChatMessageType + lastMessageAt: DatabaseDateValue + unread: number +} + +export type DarkChatConversation = { + id: string + peer: DarkChatIdentity + disappearingSeconds: number + notificationsEnabled: boolean + readReceipts: boolean + blockedByPeer: boolean + createdAt: DatabaseDateValue +} + +export type DarkChatMessage = { + id: string + clientId?: string + conversationId: string + direction: 'sent' | 'received' + senderProfileId?: number + messageType: DarkChatMessageType + body: string + mediaMime?: string | null + mediaPayload?: string | null + mediaDurationMs?: number | null + mediaWaveform?: number[] | null + replyToId?: string | null + replyBody?: string | null + reactions: Record + expiresAt?: DatabaseDateValue | null + createdAt: DatabaseDateValue + readAt?: DatabaseDateValue | null + deletedForEveryone?: boolean + deliveryStatus?: 'sending' | 'delivered' | 'failed' +} + +export type DarkChatBootstrap = { + profile: DarkChatProfile + contacts: DarkChatContact[] + conversations: DarkChatConversationSummary[] +} + +export type DarkChatThread = { + conversation: DarkChatConversation + messages: DarkChatMessage[] +} + +export type DarkChatOutgoing = { + body?: string + messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video' + mediaAssetId?: string + mediaPayload?: string + mediaPreviewUrl?: string + mediaMime?: string + mediaDurationMs?: number + mediaWaveform?: number[] + replyToId?: string +} diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..c577c29 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,23 @@ +export function copyText(value: string): boolean { + const activeElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : undefined + const textarea = document.createElement('textarea') + + textarea.value = value + textarea.readOnly = true + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + textarea.setSelectionRange(0, value.length) + + try { + return document.execCommand('copy') + } finally { + textarea.remove() + activeElement?.focus({ preventScroll: true }) + } +} diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts new file mode 100644 index 0000000..6fa813a --- /dev/null +++ b/frontend/src/utils/homeLayout.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' + +import { + createDefaultHomeLayout, + HOME_GRID_PAGE_SIZE, + moveHomeApp, + parseHomeLayout, + removeHomeApp, + restoreHomeApp, + type HomeLayout, +} from '@/utils/homeLayout' + +const installed = ['phone', 'messages', 'mail', 'clock', 'notes'] as const +const defaults = createDefaultHomeLayout( + [...installed], + ['phone', 'messages', 'mail', 'clock', 'notes'], + ['phone', 'messages', 'clock'], +) + +describe('home layout', () => { + it('uses fixed grid and dock slots for the registry arrangement', () => { + const layout = parseHomeLayout(undefined, defaults, [...installed]) + + expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE) + expect(layout.grid.slice(0, 6)).toEqual([ + 'phone', + 'messages', + 'mail', + 'clock', + 'notes', + null, + ]) + expect(layout.dock).toEqual(['phone', 'messages', 'clock', null]) + expect(layout.version).toBe(2) + }) + + it('migrates compact persisted arrays and appends newly installed apps', () => { + const layout = parseHomeLayout( + { + dock: ['messages', 'invalid', 'messages'], + grid: ['mail'], + hidden: ['phone'], + }, + defaults, + [...installed], + ) + + expect(layout.dock).toEqual(['messages', null, null, null]) + expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null]) + expect(layout.hidden).toEqual(['phone']) + expect(layout.version).toBe(2) + }) + + it('preserves explicit gaps in versioned layouts', () => { + const grid: HomeLayout['grid'] = Array.from( + { length: HOME_GRID_PAGE_SIZE }, + () => null, + ) + grid[0] = 'phone' + grid[7] = 'mail' + + const layout = parseHomeLayout( + { + dock: ['messages', null, 'clock', null], + grid, + hidden: ['notes'], + version: 2, + }, + defaults, + [...installed], + ) + + expect(layout.grid[0]).toBe('phone') + expect(layout.grid[1]).toBeNull() + expect(layout.grid[7]).toBe('mail') + expect(layout.dock).toEqual(['messages', null, 'clock', null]) + }) + + it('preserves independently positioned shortcuts for the same app', () => { + const grid: HomeLayout['grid'] = Array.from( + { length: HOME_GRID_PAGE_SIZE }, + () => null, + ) + grid[0] = 'phone' + grid[5] = 'phone' + + const layout = parseHomeLayout( + { + dock: ['phone', null, null, null], + grid, + hidden: [], + version: 2, + }, + defaults, + [...installed], + ) + + expect(layout.grid[0]).toBe('phone') + expect(layout.grid[5]).toBe('phone') + expect(layout.dock[0]).toBe('phone') + }) + + it('moves to an exact empty slot without compacting other apps', () => { + const moved = moveHomeApp(defaults, 'grid', 2, 'grid', 12) + + expect(moved.grid[2]).toBeNull() + expect(moved.grid[12]).toBe('mail') + expect(moved.grid[0]).toBe('phone') + expect(moved.grid[4]).toBe('notes') + }) + + it('shifts occupied grid slots instead of replacing their apps', () => { + const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0) + expect(reordered.grid.slice(0, 5)).toEqual([ + 'mail', + 'phone', + 'messages', + 'clock', + 'notes', + ]) + }) + + it('shifts an occupied dock slot toward its gap', () => { + const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2) + + expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock']) + expect(docked.grid[2]).toBeNull() + }) + + it('moves an app displaced from a full dock into the source slot', () => { + const layout: HomeLayout = { + ...defaults, + dock: ['phone', 'messages', 'clock', 'notes'], + } + const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1) + + expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock']) + expect(docked.grid[2]).toBe('notes') + }) + + it('moves shortcuts between the dock and grid independently', () => { + const movedToGrid = moveHomeApp(defaults, 'dock', 0, 'grid', 5) + + expect(movedToGrid.dock[0]).toBeNull() + expect(movedToGrid.grid[0]).toBe('phone') + expect(movedToGrid.grid[5]).toBe('phone') + + const movedToDock = moveHomeApp(movedToGrid, 'grid', 1, 'dock', 3) + expect(movedToDock.grid[1]).toBeNull() + expect(movedToDock.dock[1]).toBe('messages') + expect(movedToDock.dock[3]).toBe('messages') + }) + + it('removes shortcuts without closing gaps and restores the first gap', () => { + const layout: HomeLayout = moveHomeApp(defaults, 'grid', 0, 'grid', 10) + const removed = removeHomeApp(layout, 'phone') + expect(removed.grid[0]).toBeNull() + expect(removed.grid[10]).toBeNull() + expect(removed.dock[0]).toBeNull() + expect(removed.hidden).toContain('phone') + + const restored = restoreHomeApp(removed, 'phone') + expect(restored.grid[0]).toBe('phone') + expect(restored.hidden).not.toContain('phone') + }) +}) diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts new file mode 100644 index 0000000..adf62e1 --- /dev/null +++ b/frontend/src/utils/homeLayout.ts @@ -0,0 +1,259 @@ +import type { LaunchablePhoneAppId } from '@/types/apps' + +export const HOME_DOCK_CAPACITY = 4 +export const HOME_GRID_PAGE_SIZE = 20 +const MAX_HOME_GRID_PAGES = 5 + +export type HomeArea = 'dock' | 'grid' +export type HomeSlot = LaunchablePhoneAppId | null + +export type HomeLayout = { + dock: HomeSlot[] + grid: HomeSlot[] + hidden: LaunchablePhoneAppId[] + version: 2 +} + +function readAppIds( + value: unknown, + availableIds: Set, +): LaunchablePhoneAppId[] { + if (!Array.isArray(value)) return [] + + const ids: LaunchablePhoneAppId[] = [] + for (const valueId of value) { + if ( + typeof valueId === 'string' && + availableIds.has(valueId as LaunchablePhoneAppId) && + !ids.includes(valueId as LaunchablePhoneAppId) + ) { + ids.push(valueId as LaunchablePhoneAppId) + } + } + return ids +} + +function createSlots(length: number): HomeSlot[] { + return Array.from({ length }, () => null) +} + +function getGridCapacity(itemCount: number): number { + return Math.max( + HOME_GRID_PAGE_SIZE, + Math.ceil(itemCount / HOME_GRID_PAGE_SIZE) * HOME_GRID_PAGE_SIZE, + ) +} + +function readSlots( + value: unknown, + availableIds: Set, + length: number, +): HomeSlot[] { + const slots = createSlots(length) + if (!Array.isArray(value)) return slots + + for (let index = 0; index < Math.min(value.length, length); index += 1) { + const valueId = value[index] + if ( + typeof valueId === 'string' && + availableIds.has(valueId as LaunchablePhoneAppId) + ) { + slots[index] = valueId as LaunchablePhoneAppId + } + } + return slots +} + +function placeInFirstEmptySlot( + slots: HomeSlot[], + appId: LaunchablePhoneAppId, +): void { + const emptyIndex = slots.indexOf(null) + if (emptyIndex !== -1) { + slots[emptyIndex] = appId + return + } + + slots.push(...createSlots(HOME_GRID_PAGE_SIZE)) + slots[slots.length - HOME_GRID_PAGE_SIZE] = appId +} + +function insertIntoSlot( + slots: HomeSlot[], + targetIndex: number, + appId: LaunchablePhoneAppId, +): HomeSlot { + if (slots[targetIndex] === null) { + slots[targetIndex] = appId + return null + } + + const emptyAfter = slots.indexOf(null, targetIndex + 1) + if (emptyAfter !== -1) { + for (let index = emptyAfter; index > targetIndex; index -= 1) { + slots[index] = slots[index - 1] + } + slots[targetIndex] = appId + return null + } + + const emptyBefore = slots.lastIndexOf(null, targetIndex - 1) + if (emptyBefore !== -1) { + for (let index = emptyBefore; index < targetIndex; index += 1) { + slots[index] = slots[index + 1] + } + slots[targetIndex] = appId + return null + } + + const displacedApp = slots.at(-1) ?? null + for (let index = slots.length - 1; index > targetIndex; index -= 1) { + slots[index] = slots[index - 1] + } + slots[targetIndex] = appId + return displacedApp +} + +export function createDefaultHomeLayout( + installedIds: LaunchablePhoneAppId[], + defaultGridIds: LaunchablePhoneAppId[], + defaultDockIds: LaunchablePhoneAppId[], +): HomeLayout { + const installed = new Set(installedIds) + const gridIds = defaultGridIds.filter((id) => installed.has(id)) + const grid = createSlots(getGridCapacity(gridIds.length)) + for (let index = 0; index < gridIds.length; index += 1) { + grid[index] = gridIds[index] + } + + const dock = createSlots(HOME_DOCK_CAPACITY) + for (const [index, id] of defaultDockIds + .filter((id) => installed.has(id)) + .slice(0, HOME_DOCK_CAPACITY) + .entries()) { + dock[index] = id + } + + return { dock, grid, hidden: [], version: 2 } +} + +export function parseHomeLayout( + value: unknown, + defaults: HomeLayout, + installedIds: LaunchablePhoneAppId[], +): HomeLayout { + if (!value || typeof value !== 'object') return defaults + + const source = value as Partial> + const availableIds = new Set(installedIds) + const hidden = readAppIds(source.hidden, availableIds) + const hiddenIds = new Set(hidden) + const persistedGridLength = Array.isArray(source.grid) + ? Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) + : 0 + const gridLength = Math.max( + defaults.grid.length, + getGridCapacity(persistedGridLength), + ) + let grid: HomeSlot[] + let dock: HomeSlot[] + + if (source.version === 2) { + grid = readSlots(source.grid, availableIds, gridLength) + dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY) + } else { + grid = createSlots(gridLength) + for (const id of readAppIds(source.grid, availableIds)) { + placeInFirstEmptySlot(grid, id) + } + dock = createSlots(HOME_DOCK_CAPACITY) + for (const [index, id] of readAppIds(source.dock, availableIds) + .slice(0, HOME_DOCK_CAPACITY) + .entries()) { + dock[index] = id + } + } + + grid = grid.map((id) => (id && !hiddenIds.has(id) ? id : null)) + dock = dock.map((id) => (id && !hiddenIds.has(id) ? id : null)) + const placedIds = new Set( + [...grid, ...dock, ...hidden].filter( + (id): id is LaunchablePhoneAppId => id !== null, + ), + ) + + for (const id of defaults.grid) { + if (id && !placedIds.has(id)) { + placeInFirstEmptySlot(grid, id) + placedIds.add(id) + } + } + + return { dock, grid, hidden, version: 2 } +} + +export function removeHomeApp( + layout: HomeLayout, + appId: LaunchablePhoneAppId, +): HomeLayout { + return { + dock: layout.dock.map((id) => (id === appId ? null : id)), + grid: layout.grid.map((id) => (id === appId ? null : id)), + hidden: layout.hidden.includes(appId) + ? [...layout.hidden] + : [...layout.hidden, appId], + version: 2, + } +} + +export function restoreHomeApp( + layout: HomeLayout, + appId: LaunchablePhoneAppId, +): HomeLayout { + if (layout.grid.includes(appId) || layout.dock.includes(appId)) return layout + const grid = [...layout.grid] + placeInFirstEmptySlot(grid, appId) + return { + dock: [...layout.dock], + grid, + hidden: layout.hidden.filter((id) => id !== appId), + version: 2, + } +} + +export function moveHomeApp( + layout: HomeLayout, + from: HomeArea, + sourceIndex: number, + to: HomeArea, + targetIndex: number, +): HomeLayout { + const next: HomeLayout = { + dock: [...layout.dock], + grid: [...layout.grid], + hidden: [...layout.hidden], + version: 2, + } + const source = next[from] + const target = next[to] + const appId = source[sourceIndex] + if ( + !appId || + sourceIndex < 0 || + sourceIndex >= source.length || + targetIndex < 0 || + targetIndex >= target.length + ) { + return layout + } + + if (from === to) { + if (sourceIndex === targetIndex) return layout + source[sourceIndex] = null + insertIntoSlot(source, targetIndex, appId) + return next + } + + source[sourceIndex] = insertIntoSlot(target, targetIndex, appId) + return next +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 73a23d9..a33588e 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 }, @@ -65,6 +66,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< clock: { enabled: true, sounds: true }, calendar: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, + banking: { enabled: true, sounds: true }, mail: { enabled: true, sounds: true }, map: { enabled: true, sounds: true }, notes: { enabled: true, sounds: true }, diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index 6b03bab..bb0ef1a 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -1,6 +1,7 @@