diff --git a/README.md b/README.md index bc2bd27..65e3b77 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,19 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m sent to NUI because clients receive temporary presigned upload URLs instead. - `yaca-voice`, `pma-voice`, or `saltychat` when the Radio app is enabled. `Config.Radio.VoiceProvider = "auto"` selects the first running provider in that order. +## Messages GIF provider + +Configure GIF search in `sky_phone/config/config.lua`: + +```lua +Config.Media.GiphyApiKey = "YOUR_GIPHY_API_KEY" +``` + +GIPHY provides trending and searched GIFs through a paginated server-side proxy. The shared +`config.lua` is loaded by both FiveM runtimes, so its values are available to clients even though +only the server uses the GIPHY key. Photo and video actions in Messages are intentionally inactive +until their dedicated implementation is available. + Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password. Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current diff --git a/frontend/package.json b/frontend/package.json index 8b933eb..7c86e8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "test": "vitest run" }, "dependencies": { + "emoji-picker-element-data": "^1.8.0", "@tiptap/core": "^3.29.2", "@tiptap/extension-placeholder": "^3.29.2", "@tiptap/markdown": "^3.29.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5ad6d78..1f4dd36 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: dompurify: specifier: ^3.4.13 version: 3.4.13 + emoji-picker-element-data: + specifier: ^1.8.0 + version: 1.8.0 fix-webm-duration: specifier: ^1.0.6 version: 1.0.6 @@ -1161,6 +1164,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-picker-element-data@1.8.0: + resolution: {integrity: sha512-VfRuRJNEDLS1JKlNS4olaqhjX5S1nnZ+ZHG73b/dV8QeZyi0yPruTPEE72EmF6XO3k/9hj3lybMIYMOYXb/57A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3199,6 +3205,8 @@ snapshots: ee-first@1.1.1: {} + emoji-picker-element-data@1.8.0: {} + emoji-regex@8.0.0: {} encodeurl@2.0.0: {} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 78c48ec..18baae9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -26,9 +26,11 @@ import { useGamesStore } from '@/features/games/store' import { useCallsStore } from '@/stores/calls' import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' +import { useMessagesStore } from '@/stores/messages' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' +import { isPhoneAppId } from '@/config/apps' import { useNotesStore } from '@/stores/notes' import { useWeatherStore } from '@/stores/weather' import { @@ -51,6 +53,7 @@ type AppMessage = { | CalendarReminderData | MailEventData | MarketplaceEventData + | MessagesEventData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -70,6 +73,14 @@ type MailEventData = { title?: string } +type MessagesEventData = { + device?: PhoneNotificationDevicePayload + phoneNumber?: string + sender?: string + text?: string + title?: string +} + type MarketplaceEventData = { counts?: MarketplaceCounts device?: PhoneNotificationDevicePayload @@ -88,7 +99,6 @@ type CalendarReminderData = { text?: string title?: string } - const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -100,6 +110,7 @@ const clock = useClockStore() const games = useGamesStore() const calls = useCallsStore() const mail = useMailStore() +const messages = useMessagesStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -149,6 +160,7 @@ function hydratePhone(payload: PhoneOpenPayload): void { if (payload.account?.email) void marketplace.loadCounts() else marketplace.setCounts({ active: 0, unread: 0 }) void calls.bootstrap() + void messages.loadConversations() } async function hydrateDevelopmentPhone(): Promise { @@ -274,6 +286,35 @@ function onMessage(event: MessageEvent): void { notifications.show(notification) } else if (event.data?.type === 'contacts:changed') { void calls.loadContacts() + } else if (event.data?.type === 'messages:changed') { + void messages.loadConversations() + if (messages.activeNumber) void messages.openThread(messages.activeNumber) + } else if (event.data?.type === 'messages:new' && event.data.data) { + const data = event.data.data as MessagesEventData + void messages.loadConversations() + if (messages.activeNumber === data.phoneNumber) { + void messages.openThread(messages.activeNumber) + } + + const notification: PhoneNotificationInput = { + appId: 'messages', + subtitle: data.phoneNumber, + text: + data.text ?? + phone.t('Apps.messages.newMessage', { sender: data.sender ?? '' }), + title: data.title ?? phone.t('Apps.messages.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 ( @@ -378,6 +419,15 @@ onMounted(() => { } }) +watch( + () => route.params.appId, + (appId) => { + if (typeof appId === 'string' && isPhoneAppId(appId)) { + appStore.recordLaunch(appId) + } + }, +) + watch( [() => notifications.requiresAttention, () => calls.activeCall], ([requiresAttention, activeCall]) => { diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index d2510f5..e43a0e1 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -344,13 +344,13 @@ button { left: 0; width: 100%; height: 45px; - padding: 11px 26px 0; + padding: 11px 42px 0; color: #fff; display: flex; justify-content: space-between; align-items: flex-start; font-size: 12px; - font-weight: 650; + font-weight: 500; text-shadow: 0 1px 3px #0009; pointer-events: none; } @@ -583,13 +583,10 @@ button { } } .springboard-track { - width: 300%; height: 100%; display: flex; touch-action: pan-y; - transform: translateX( - calc(var(--springboard-page) * -33.333333% + var(--drag-offset)) - ); + transform: translateX(calc(var(--springboard-offset) + var(--drag-offset))); transition: transform 0.46s cubic-bezier(0.22, 0.9, 0.24, 1); } .springboard--dragging .springboard-track { @@ -597,7 +594,6 @@ button { } .springboard-page { position: relative; - width: 33.333333%; flex: none; padding: 61px 22px 138px; } @@ -858,6 +854,9 @@ button { } .springboard-page--library { padding-top: 62px; + padding-bottom: 25px; + display: flex; + flex-direction: column; } .app-library-search, .app-search { @@ -886,14 +885,22 @@ button { color: white; } .app-library-groups { + flex: 1; + min-height: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 13px 12px; margin-top: 16px; + padding-bottom: 24px; + overflow-y: auto; + scrollbar-width: none; transition: transform 0.35s ease, opacity 0.35s ease; } +.app-library-groups::-webkit-scrollbar { + display: none; +} .app-library-groups--behind { transform: scale(0.95); opacity: 0.45; @@ -1801,11 +1808,9 @@ button { .store-icon { width: 49px; height: 49px; - border-radius: 12px; - display: grid; - place-items: center; - font-size: 24px; - font-weight: 700; + flex: 0 0 49px; + border-radius: 11px; + object-fit: cover; } .store-list article > div:nth-child(2) { display: flex; @@ -1816,13 +1821,31 @@ button { color: #888; } .store-list button { + width: 67px; + height: 28px; + display: grid; + place-items: center; border: 0; border-radius: 20px; - padding: 5px 15px; + padding: 0 12px; background: #222; color: #0a84ff; font-weight: 700; } +.store-list button:disabled { + cursor: wait; +} +.store-installing { + width: 14px; + height: 14px; + color: #0a84ff; +} +.store-empty { + margin: 32px 0; + color: #777; + text-align: center; + font-size: 13px; +} /* Reference-accurate app surfaces. These remain scoped to the phone resource. */ .calculator-app { padding: 54px 16px 27px; @@ -1901,32 +1924,12 @@ button { overflow-y: auto; } .store-title { - display: flex; - align-items: center; margin-bottom: 20px; } -.store-title > div { - flex: 1; - display: flex; - align-items: baseline; - gap: 11px; -} .store-title h1 { margin: 0; font-size: 34px; } -.store-title strong { - color: #777; - font-size: 18px; -} -.store-title > span { - width: 37px; - height: 37px; - display: grid; - place-items: center; - border-radius: 50%; - background: linear-gradient(145deg, #3a3c46, #1b1c22); -} .editorial-card { position: relative; height: 390px; @@ -2044,6 +2047,1881 @@ button { *::-webkit-scrollbar { display: none; } +.messages-page { + background: #fff !important; + color: #111; + color-scheme: light; +} +.dark .messages-page { + background: #fff !important; + color: #111; +} +.phone-app:has(.messages-page) .phone-status-bar { + color: #080808; + text-shadow: none; +} +.phone-app:has(.messages-page)::before { + content: ''; + position: absolute; + z-index: 60; + top: 0; + right: 0; + left: 0; + height: 44px; + background: #fff; + pointer-events: none; +} +.messages-inbox-page { + padding: 96px 0 86px !important; + overflow-y: auto; +} +.messages-inbox-header { + position: fixed; + z-index: 34; + top: 44px; + right: 0; + left: 0; + height: 52px; + padding: 0 16px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + background: rgb(255 255 255 / 92%); + backdrop-filter: saturate(180%) blur(22px); + -webkit-backdrop-filter: saturate(180%) blur(22px); +} +.messages-inbox-header > span { + color: #191919; + font-size: 14px; +} +.messages-inbox-header > strong { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.025em; +} +.messages-inbox-header > button { + width: 34px; + height: 34px; + margin-left: auto; + display: grid; + place-items: center; + border: 0; + border-radius: 50%; + background: transparent; + color: #181818; + transition: background 0.18s ease, color 0.18s ease; +} +.messages-inbox-header > button.active { + background: #e8f4ff; + color: #007aff; +} +.messages-inbox-header__edit { + width: auto !important; + height: 34px !important; + margin: 0 !important; + justify-self: start; + border-radius: 9px !important; + color: #007aff !important; + font-size: 13px; +} +.messages-inbox-header__delete { + color: #ff3b30 !important; +} +.messages-inbox-header__delete:disabled { + color: #aaa !important; + opacity: 0.45; +} +.messages-conversation-list { + padding: 0 14px 12px; +} +.messages-conversation { + position: relative; + width: 100%; + min-height: 72px; + padding: 8px 0 8px 7px; + display: flex; + align-items: center; + gap: 11px; + border: 0; + background: transparent; + color: #111; + text-align: left; +} +.messages-conversation::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 66px; + height: 1px; + background: #e6e6e8; +} +.messages-conversation:active { + border-radius: 13px; + background: #f0f0f3; +} +.messages-conversation__dot { + position: absolute; + left: -8px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #0a84ff; +} +.messages-conversation__selection { + position: absolute; + left: -8px; + width: 19px; + height: 19px; + display: grid; + place-items: center; + border: 1.5px solid #c7c7cc; + border-radius: 50%; + background: #fff; + color: #fff; +} +.messages-conversation--selected .messages-conversation__selection { + border-color: #0a84ff; + background: #0a84ff; +} +.messages-conversation--selected { + border-radius: 13px; + background: #f3f8ff; +} +.messages-conversation__body { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; +} +.messages-conversation__headline { + display: grid; + grid-template-columns: minmax(0, 1fr) auto 13px; + align-items: center; + gap: 4px; +} +.messages-conversation__headline strong { + overflow: hidden; + font-size: 14px; + font-weight: 600; + letter-spacing: -0.012em; + text-overflow: ellipsis; + white-space: nowrap; +} +.messages-conversation--unread .messages-conversation__headline strong { + font-weight: 750; +} +.messages-conversation__headline time, +.messages-conversation__headline svg { + color: #aaa; +} +.messages-conversation__headline time { + font-size: 10px; + white-space: nowrap; +} +.messages-conversation__preview { + min-height: 30px; + overflow: hidden; + color: #8b8b91; + display: -webkit-box; + font-size: 11px; + line-height: 1.3; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.messages-inbox-toolbar { + position: fixed; + z-index: 42; + right: 12px; + bottom: 30px; + left: 12px; + height: 46px; + display: flex; + align-items: center; + gap: 9px; +} +.messages-inbox-toolbar label { + height: 40px; + padding: 0 12px; + display: flex; + flex: 1; + align-items: center; + gap: 7px; + border: 1px solid #ededf0; + border-radius: 22px; + background: rgb(255 255 255 / 94%); + box-shadow: 0 7px 25px rgb(0 0 0 / 8%); + color: #9b9ba0; + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); +} +.messages-inbox-toolbar input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: #111; + font-size: 13px; +} +.messages-inbox-toolbar > button { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border: 1px solid #ededf0; + border-radius: 50%; + background: rgb(255 255 255 / 96%); + box-shadow: 0 7px 25px rgb(0 0 0 / 9%); + color: #111; +} +.messages-edit-toolbar { + position: fixed; + z-index: 42; + right: 12px; + bottom: 30px; + left: 12px; + height: 46px; + padding: 0 7px 0 15px; + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid #ededf0; + border-radius: 23px; + background: rgb(255 255 255 / 96%); + box-shadow: 0 7px 25px rgb(0 0 0 / 8%); + color: #8e8e93; + font-size: 12px; +} +.messages-edit-toolbar button { + height: 34px; + padding: 0 12px; + display: flex; + align-items: center; + gap: 6px; + border: 0; + border-radius: 17px; + background: #ff3b30; + color: #fff; + font-weight: 650; +} +.messages-edit-toolbar button:disabled { + background: #e5e5ea; + color: #aaa; +} +.messages-avatar { + width: 50px; + height: 50px; + display: grid; + flex: none; + place-items: center; + border: 1px solid rgb(255 255 255 / 25%); + border-radius: 50%; + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 30%), + 0 3px 12px rgb(0 0 0 / 12%); + overflow: hidden; + color: #fff; +} +.messages-avatar__glyph { + padding-top: 6px; + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; + font-size: 34px; + line-height: 1; + filter: drop-shadow(0 2px 2px rgb(0 0 0 / 15%)); +} +.messages-avatar--small { + width: 38px; + height: 38px; +} +.messages-avatar--small .messages-avatar__glyph { + padding-top: 5px; + font-size: 27px; +} +.messages-empty-state { + min-height: 72%; + padding: 42px 42px 80px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} +.messages-empty-state--list { + min-height: 58%; +} +.messages-empty-state__icon { + width: 74px; + height: 74px; + display: grid; + place-items: center; + border-radius: 24px; + background: linear-gradient(145deg, #55e875, #20b74a); + box-shadow: 0 16px 35px rgb(37 196 79 / 28%); + color: #fff; +} +.messages-empty-state h2 { + margin: 20px 0 6px; + font-size: 22px; + letter-spacing: -0.025em; +} +.messages-empty-state p { + max-width: 250px; + margin: 0; + color: #8e8e93; + font-size: 14px; + line-height: 1.4; +} +.messages-empty-state button, +.messages-number-action { + margin-top: 18px; + padding: 8px 17px; + border: 0; + border-radius: 20px; + background: rgb(52 199 89 / 13%); + color: #20a647; + font-size: 14px; + font-weight: 650; +} +.messages-recipient-field { + margin-top: 8px; +} +.messages-contact-list { + margin-top: 10px; +} +.messages-number-action { + margin-left: 16px; + display: flex; + align-items: center; + gap: 8px; +} +.messages-thread-page { + padding: 218px 0 84px !important; + overflow-y: auto; + scroll-padding-top: 218px; + transition: padding-bottom 0.28s cubic-bezier(0.32, 0.72, 0, 1); +} +.messages-thread-page--emoji { + padding-bottom: 288px !important; +} +.messages-chat-header { + position: fixed; + z-index: 35; + top: 44px; + right: 0; + left: 0; + height: 140px; + background: linear-gradient(180deg, #fff 76%, rgb(255 255 255 / 94%) 100%); +} +.dark .messages-chat-header { + background: linear-gradient(180deg, #fff 76%, rgb(255 255 255 / 94%) 100%); +} +.messages-chat-header__back { + position: absolute; + top: 14px; + left: 14px; + width: 43px; + height: 43px; + display: grid; + place-items: center; + border: 1px solid #e7e7e9; + border-radius: 50%; + background: rgb(255 255 255 / 94%); + box-shadow: 0 5px 18px rgb(0 0 0 / 5%); + color: #111; +} +.messages-chat-header__contact { + position: absolute; + top: 6px; + left: 50%; + min-width: 190px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transform: translateX(-50%); +} +.messages-avatar--header { + width: 58px; + height: 58px; + margin-bottom: 7px; + border: 2px solid #fff; + box-shadow: 0 0 0 1px rgb(0 0 0 / 5%), 0 8px 22px rgb(0 0 0 / 12%); +} +.messages-avatar--header .messages-avatar__glyph { + padding-top: 7px; + font-size: 42px; +} +.messages-chat-header__name { + max-width: 205px; + height: 29px; + padding: 0 9px 0 12px; + display: flex; + align-items: center; + gap: 2px; + overflow: hidden; + border: 1px solid #e5e5e7; + border-radius: 16px; + background: rgb(255 255 255 / 88%); + box-shadow: 0 3px 10px rgb(0 0 0 / 4%); + color: #111; +} +.messages-chat-header__name strong { + min-width: 0; + overflow: hidden; + font-size: 15px; + font-weight: 700; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} +.messages-chat-header__name svg { + flex: none; + color: #aaa; +} +.messages-bubbles { + min-height: calc(100% - 12px); + padding: 10px 12px 20px; +} +.messages-bubbles [class*='message-bubble'] { + max-width: 248px; + border-radius: 21px; + box-shadow: none; + font-size: 15px; + line-height: 1.26; +} +.messages-bubbles .k-message { + max-width: 82% !important; +} +.messages-bubbles .k-message.self-end > div > div { + background: #34c759 !important; + color: #fff !important; +} +.messages-bubbles .k-message:not(.self-end) > div > div { + background: #e9e9eb !important; + color: #111 !important; +} +.messages-bubbles .k-message.self-end > div > div::after { + content: ''; + position: absolute; + right: -4px; + bottom: 0; + width: 12px; + height: 15px; + border-bottom-left-radius: 12px 10px; + background: #34c759; + clip-path: polygon(0 0, 100% 100%, 0 100%); +} +.messages-bubbles .k-message:not(.self-end) > div > div::after { + content: ''; + position: absolute; + bottom: 0; + left: -4px; + width: 12px; + height: 15px; + border-bottom-right-radius: 12px 10px; + background: #e9e9eb; + clip-path: polygon(100% 0, 100% 100%, 0 100%); +} +.messages-thread-timestamp { + min-height: 34px; + margin: 0 auto 12px; + padding: 3px 12px; + display: flex; + flex-direction: column; + align-items: center; + color: #89898e; + font-size: 12px; + font-weight: 500; + line-height: 1.35; +} +.messages-thread-timestamp b { + font-weight: 550; +} +.messages-message--sending { + animation: messages-send-in 0.44s cubic-bezier(0.22, 1.35, 0.36, 1) both; +} +.messages-message--sending [class*='message-footer'] { + opacity: 0.62; +} +.messages-message--failed [class*='message-bubble'] { + box-shadow: 0 0 0 1px #ff453a; +} +.messages-messagebar { + position: fixed; + z-index: 42; + right: 10px; + bottom: 31px; + left: 10px; + width: auto; + min-height: 45px; + padding: 2px 3px !important; + border: 1px solid #ededf0; + border-radius: 25px; + background: rgb(255 255 255 / 94%); + box-shadow: 0 8px 28px rgb(0 0 0 / 9%); + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); +} +.dark .messages-messagebar { + background: rgb(255 255 255 / 94%); +} +.messages-messagebar textarea { + max-height: 88px; + color: #111 !important; + font-size: 14px; +} +.messages-messagebar > div { + padding: 0 !important; +} +.messages-messagebar > div > div:first-child { + display: none; +} +.messages-messagebar > div > div:nth-child(2) { + height: 39px !important; + gap: 6px !important; +} +.messages-messagebar .k-glass { + border: 0 !important; + background: transparent !important; + box-shadow: none !important; +} +.messages-messagebar button { + color: #34c759; +} +.messages-messagebar button:disabled { + color: #8e8e93; + opacity: 0.5; +} +.messages-messagebar__tools button { + width: 35px; + height: 35px; + border: 1px solid #e5e5e7; + border-radius: 50%; + color: #111; + transition: + color 0.2s ease, + transform 0.2s ease; +} +.messages-messagebar__tools button.active { + background: #e8f4ff; + color: #007aff; + transform: rotate(45deg) scale(1.05); +} +.messages-attachment-menu { + position: fixed; + z-index: 48; + bottom: 82px; + left: 12px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 7px; + animation: messages-action-menu-in 0.28s cubic-bezier(0.22, 1.25, 0.36, 1) both; +} +.messages-attachment-menu > button { + height: 39px; + padding: 0 14px 0 6px; + display: flex; + align-items: center; + gap: 9px; + border: 1px solid #e6e6e8; + border-radius: 21px; + background: rgb(255 255 255 / 97%); + box-shadow: 0 7px 22px rgb(0 0 0 / 12%); + color: #111; + font-size: 12px; + font-weight: 600; + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); +} +.messages-attachment-menu > button > span { + width: 29px; + height: 29px; + display: grid; + place-items: center; + border-radius: 50%; + background: #e8f4ff; + color: #007aff; +} +.messages-attachment-menu > button:nth-child(2) > span { + background: #e9f9ed; + color: #28a745; +} +.messages-attachment-menu > button:nth-child(3) > span { + background: #fff6dd; +} +.messages-attachment-menu > button:nth-child(4) > span { + background: #f6eefe; + color: #af52de; +} +.messages-attachment-menu > button:nth-child(5) > span { + background: #fff0ef; + color: #ff3b30; +} +.messages-action-emoji { + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; + font-size: 17px; +} +.messages-media-picker { + position: fixed; + z-index: 47; + right: 0; + bottom: 78px; + left: 0; + height: 244px; + overflow: hidden; + border-top: 1px solid #e1e1e4; + background: rgb(247 247 249 / 98%); + box-shadow: 0 -8px 28px rgb(0 0 0 / 10%); + animation: messages-panel-in 0.3s cubic-bezier(0.32, 0.72, 0, 1) both; +} +.messages-media-picker > header { + height: 40px; + padding: 0 13px 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e4e4e7; +} +.messages-media-picker > header strong { + font-size: 14px; +} +.messages-media-picker > header small { margin-left: auto; color: #8e8e93; font-size: 9px; font-weight: 650; } +.messages-media-picker > header button { + border: 0; + background: transparent; + color: #007aff; + font-size: 12px; +} +.messages-media-picker__photos { + height: 204px; + padding: 7px; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 82px; + gap: 5px; + overflow-y: auto; +} +.messages-media-picker__photos button { + border: 0; + border-radius: 10px; + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 18%); +} +.messages-media-picker__gifs { + height: 204px; + padding: 8px; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 7px; + overflow-y: auto; +} +.messages-media-picker__gifs button { + min-height: 83px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + border: 0; + border-radius: 13px; + background: linear-gradient(145deg, #fff, #ececf2); + box-shadow: 0 5px 14px rgb(0 0 0 / 7%); +} +.messages-media-picker__gifs span { + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; + font-size: 35px; + animation: messages-gif-bounce 1.3s ease-in-out infinite alternate; +} +.messages-media-picker__gifs strong { + font-size: 11px; +} +.messages-media-picker__videos { + height: 204px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 7px; + overflow-y: auto; +} +.messages-media-picker__videos button { + min-height: 53px; + padding: 0 11px; + display: grid; + grid-template-columns: 36px 1fr auto; + align-items: center; + gap: 9px; + border: 0; + border-radius: 13px; + background: linear-gradient(135deg, #302b63, #a6c1ee); + color: #fff; + text-align: left; +} +.messages-video-option--sunset-loop { + background: linear-gradient(135deg, #141e30, #ff9a62) !important; +} +.messages-video-option--ocean-loop { + background: linear-gradient(135deg, #0b132b, #67d5b5) !important; +} +.messages-media-picker__videos button > span { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 50%; + background: rgb(255 255 255 / 20%); +} +.messages-media-picker__videos strong { font-size: 12px; } +.messages-media-picker__videos small { font-size: 10px; opacity: 0.8; } +.messages-emoji-picker { + position: fixed; + z-index: 41; + right: 0; + bottom: 74px; + left: 0; + height: 214px; + overflow: hidden; + border-top: 1px solid rgb(60 60 67 / 20%); + background: rgb(244 244 246 / 96%); + box-shadow: 0 -8px 30px rgb(0 0 0 / 12%); + animation: messages-panel-in 0.32s cubic-bezier(0.32, 0.72, 0, 1) both; + backdrop-filter: blur(28px); + -webkit-backdrop-filter: blur(28px); +} +.dark .messages-emoji-picker { + border-color: rgb(60 60 67 / 20%); + background: rgb(244 244 246 / 96%); +} +.messages-emoji-picker__categories { + height: 39px; + padding: 4px 10px; + display: flex; + align-items: center; + justify-content: space-around; + border-bottom: 1px solid rgb(60 60 67 / 12%); +} +.messages-emoji-picker__categories button { + width: 34px; + height: 29px; + border: 0; + border-radius: 9px; + background: transparent; + font-size: 17px; + filter: grayscale(0.65); + transition: all 0.18s ease; +} +.messages-emoji-picker__categories button.active { + background: rgb(118 118 128 / 17%); + filter: none; + transform: scale(1.08); +} +.messages-emoji-picker__grid { + height: 175px; + padding: 8px 10px 14px; + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-auto-rows: 35px; + overflow-y: auto; +} +.messages-emoji-picker__grid button { + display: grid; + place-items: center; + border: 0; + background: transparent; + font-size: 23px; + transition: transform 0.12s ease; +} +.messages-emoji-picker__grid button:active { + transform: scale(1.32); +} +.messages-recorder { + position: fixed; + z-index: 43; + right: 7px; + bottom: 28px; + left: 7px; + height: 46px; + padding: 0 7px; + display: flex; + align-items: center; + gap: 7px; + border: 1px solid rgb(120 120 128 / 27%); + border-radius: 24px; + background: rgb(246 246 248 / 94%); + box-shadow: 0 7px 28px rgb(0 0 0 / 22%); + animation: messages-recorder-in 0.3s cubic-bezier(0.22, 1.3, 0.36, 1) both; + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); +} +.dark .messages-recorder { + background: rgb(246 246 248 / 94%); + color: #111; +} +.messages-recorder button { + width: 32px; + height: 32px; + display: grid; + flex: none; + place-items: center; + border: 0; + border-radius: 50%; +} +.messages-recorder__cancel { + background: rgb(120 120 128 / 16%); + color: #8e8e93; +} +.messages-recorder__send { + background: transparent; + color: #0a84ff; +} +.messages-recorder__dot { + width: 7px; + height: 7px; + flex: none; + border-radius: 50%; + background: #ff453a; + box-shadow: 0 0 0 4px rgb(255 69 58 / 15%); + animation: messages-record-pulse 1.2s ease-in-out infinite; +} +.messages-recorder time { + width: 31px; + flex: none; + font-size: 10px; + font-variant-numeric: tabular-nums; +} +.messages-recorder__wave { + height: 28px; + display: flex; + flex: 1; + align-items: center; + justify-content: flex-end; + gap: 1px; + overflow: hidden; +} +.messages-recorder__wave i { + width: 2px; + flex: none; + border-radius: 2px; + background: #ff453a; + transition: height 0.09s linear; +} +.voice-message { + min-width: 165px; + display: flex; + align-items: center; + gap: 8px; +} +.voice-message__play { + width: 29px; + height: 29px; + display: grid; + flex: none; + place-items: center; + border: 0; + border-radius: 50%; + background: rgb(255 255 255 / 22%); + color: inherit; +} +.voice-message__content { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; + gap: 3px; +} +.voice-message__content > span { + font-size: 9px; + font-variant-numeric: tabular-nums; + opacity: 0.7; +} +.voice-message__waveform { + height: 24px; + display: flex; + align-items: center; + gap: 1.5px; +} +.voice-message__waveform i { + width: 2px; + border-radius: 2px; + background: currentColor; + opacity: 0.35; + transition: opacity 0.12s ease; +} +.voice-message__waveform i.active { + opacity: 1; +} +.voice-message audio { + display: none; +} +.voice-message--failed { + opacity: 0.6; +} +.voice-message__loader { + width: 13px; + height: 13px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: messages-spin 0.7s linear infinite; +} +.messages-attachment { + position: relative; + width: 205px; + height: 132px; + overflow: hidden; + border: 0; + border-radius: 17px; + color: #fff; +} +.messages-attachment--image { + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding: 9px; + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 14%); +} +.messages-attachment--image svg { + padding: 4px; + border-radius: 50%; + background: rgb(0 0 0 / 25%); +} +.messages-attachment--video { + padding: 10px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.messages-attachment--video::before { + content: ''; + position: absolute; + inset: -20%; + background: linear-gradient(115deg, transparent 30%, rgb(255 255 255 / 20%), transparent 70%); + transform: translateX(-70%); +} +.messages-attachment--video.playing::before { + animation: messages-video-play 1.8s linear infinite; +} +.messages-attachment--video > span { + width: 47px; + height: 47px; + display: grid; + place-items: center; + border-radius: 50%; + background: rgb(0 0 0 / 28%); + backdrop-filter: blur(8px); +} +.messages-attachment--video small { + position: absolute; + right: 9px; + bottom: 8px; + padding: 2px 6px; + border-radius: 8px; + background: rgb(0 0 0 / 35%); + font-size: 9px; +} +.messages-attachment--gif { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(145deg, #f6eefe, #e8f4ff); + color: #111; +} +.messages-attachment--gif span { + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; + font-size: 60px; + animation: messages-gif-bounce 0.9s ease-in-out infinite alternate; +} +.messages-attachment--gif strong { + margin-top: 4px; + font-size: 13px; +} +.messages-contact-details { + position: fixed; + z-index: 58; + top: 44px; + right: 0; + bottom: 25px; + left: 0; + overflow-y: auto; + background: #f2f2f7; + color: #111; + animation: messages-contact-in 0.32s cubic-bezier(0.32, 0.72, 0, 1) both; +} +.messages-contact-details > header { + height: 53px; + padding: 0 10px; + display: grid; + grid-template-columns: 40px 1fr 40px; + align-items: center; + border-bottom: 1px solid #dedee2; + background: rgb(255 255 255 / 92%); +} +.messages-contact-details > header strong { + text-align: center; + font-size: 15px; +} +.messages-contact-details > header button { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border: 0; + border-radius: 50%; + background: transparent; + color: #007aff; +} +.messages-contact-details__hero { + padding: 22px 16px 14px; + display: flex; + flex-direction: column; + align-items: center; +} +.messages-avatar--contact { + width: 78px; + height: 78px; +} +.messages-avatar--contact .messages-avatar__glyph { + padding-top: 10px; + font-size: 57px; +} +.messages-contact-details__hero h2 { + margin: 10px 0 2px; + font-size: 20px; +} +.messages-contact-details__hero small { color: #8e8e93; } +.messages-contact-details__actions { + padding: 0 45px 16px; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 11px; +} +.messages-contact-details__actions button { + height: 55px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + border: 0; + border-radius: 14px; + background: #fff; + color: #007aff; + font-size: 10px; +} +.messages-contact-details__fields { + margin: 0 11px; + overflow: hidden; + border-radius: 13px; + background: #fff; +} +.messages-contact-details__fields label { + min-height: 54px; + padding: 7px 13px; + display: flex; + flex-direction: column; + gap: 2px; + border-bottom: 1px solid #e5e5e8; +} +.messages-contact-details__fields label:last-child { border-bottom: 0; } +.messages-contact-details__fields span { + color: #8e8e93; + font-size: 9px; +} +.messages-contact-details__fields input { + border: 0; + outline: 0; + background: transparent; + color: #111; + font-size: 13px; +} +.messages-contact-details__fields input:not([readonly]) { color: #007aff; } +.messages-contact-details__primary, +.messages-contact-details__delete { + width: calc(100% - 22px); + height: 44px; + margin: 12px 11px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 0; + border-radius: 13px; + background: #fff; + color: #007aff; + font-size: 13px; +} +.messages-contact-details__delete { color: #ff3b30; } +@keyframes messages-send-in { + from { + opacity: 0; + transform: translate(24px, 18px) scale(0.72); + } + 72% { + transform: translate(-2px, -1px) scale(1.025); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes messages-action-menu-in { + from { opacity: 0; transform: translate(-8px, 18px) scale(0.84); } + to { opacity: 1; transform: none; } +} +@keyframes messages-gif-bounce { + to { transform: translateY(-5px) rotate(4deg) scale(1.05); } +} +@keyframes messages-video-play { + to { transform: translateX(70%); } +} +@keyframes messages-contact-in { + from { opacity: 0; transform: translateX(35%); } + to { opacity: 1; transform: none; } +} +/* iOS 26-style Liquid Glass refinements and complete media pickers. */ +.phone-app:has(.messages-page) { + --ios-glass: rgb(252 252 255 / 70%); + --ios-glass-strong: rgb(255 255 255 / 86%); + --ios-glass-border: rgb(255 255 255 / 72%); + --ios-blue: #007aff; +} +.messages-inbox-header, +.messages-chat-header, +.messages-contact-details > header { + border-bottom: 1px solid rgb(60 60 67 / 10%); + background: linear-gradient(180deg, rgb(255 255 255 / 82%), rgb(248 248 252 / 62%)); + box-shadow: inset 0 1px 0 var(--ios-glass-border); + backdrop-filter: blur(30px) saturate(190%); + -webkit-backdrop-filter: blur(30px) saturate(190%); +} +.messages-inbox-header > button:not(.messages-inbox-header__edit), +.messages-chat-header__back, +.messages-inbox-toolbar > button, +.messages-messagebar__tools button { + border: 1px solid rgb(255 255 255 / 78%); + background: linear-gradient(145deg, rgb(255 255 255 / 90%), rgb(236 238 244 / 68%)); + box-shadow: inset 0 1px 0 #fff, 0 5px 16px rgb(32 40 60 / 11%); + backdrop-filter: blur(22px) saturate(175%); + -webkit-backdrop-filter: blur(22px) saturate(175%); +} +.messages-inbox-toolbar label, +.messages-edit-toolbar, +.messages-messagebar, +.messages-chat-header__name { + border-color: rgb(255 255 255 / 74%); + background: linear-gradient(145deg, rgb(255 255 255 / 88%), rgb(242 243 248 / 69%)); + box-shadow: inset 0 1px 0 #fff, 0 7px 24px rgb(24 31 50 / 10%); + backdrop-filter: blur(30px) saturate(185%); + -webkit-backdrop-filter: blur(30px) saturate(185%); +} +.messages-conversation { + transition: transform 160ms cubic-bezier(.2,.8,.2,1), background 160ms ease; +} +.messages-conversation:active { transform: scale(.985); } +.messages-attachment-menu > button { + border-color: rgb(255 255 255 / 78%); + background: linear-gradient(145deg, rgb(255 255 255 / 91%), rgb(239 241 247 / 73%)); + box-shadow: inset 0 1px 0 #fff, 0 8px 24px rgb(22 30 48 / 15%); + backdrop-filter: blur(28px) saturate(190%); + -webkit-backdrop-filter: blur(28px) saturate(190%); +} +.messages-attachment-menu > button:active { transform: scale(.96); } +.messages-attachment-menu > button[aria-disabled='true'] { + opacity: .48; + cursor: default; +} +.messages-attachment-menu > button[aria-disabled='true']:active { transform: none; } +.messages-thread-page--emoji { padding-bottom: 392px !important; } +.messages-media-picker { height: 310px; background: rgb(247 247 251 / 82%); backdrop-filter: blur(32px) saturate(180%); -webkit-backdrop-filter: blur(32px) saturate(180%); } +.messages-media-picker__photos, +.messages-media-picker__gifs, +.messages-media-picker__videos { height: 270px; } +.messages-media-picker__photos button { background-position: center !important; background-size: cover !important; } +.messages-media-picker__empty { grid-column: 1 / -1; margin: auto; padding: 25px; color: #8e8e93; font-size: 12px; text-align: center; } +.messages-gif-search { grid-column: 1 / -1; position: sticky; z-index: 2; top: 0; height: 34px; padding: 0 11px; display: flex; align-items: center; gap: 6px; border-radius: 17px; background: rgb(118 118 128 / 12%); color: #8e8e93; } +.messages-gif-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 12px; } +.messages-media-picker__gifs button { min-height: 92px; overflow: hidden; padding: 0; background: #e9e9ed; } +.messages-media-picker__gifs button img { width: 100%; height: 100%; object-fit: cover; } +.messages-media-picker__gifs .messages-gif-more { min-height: 36px; grid-column: 1 / -1; color: var(--ios-blue); font-size: 12px; font-weight: 650; } +.messages-gif-loading { grid-column: 1 / -1; justify-self: center; } +.messages-gif-error { + grid-column: 1 / -1; + min-height: 150px; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: #8e8e93; + text-align: center; +} +.messages-gif-error strong { max-width: 220px; font-size: 12px; font-weight: 550; } +.messages-media-picker__gifs .messages-gif-error button { + min-height: 32px; + padding: 0 14px; + border-radius: 16px; + background: rgb(0 122 255 / 13%); + box-shadow: none; + color: var(--ios-blue); + font-size: 12px; + font-weight: 650; +} +.messages-media-picker__videos button { position: relative; overflow: hidden; min-height: 92px; padding: 0; grid-template-columns: 1fr; } +.messages-media-picker__videos button video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.messages-media-picker__videos button > span { position: relative; z-index: 1; justify-self: center; } +.messages-media-picker__videos button small { position: absolute; z-index: 1; right: 7px; bottom: 6px; padding: 2px 6px; border-radius: 9px; background: rgb(0 0 0 / 48%); } +.messages-full-emoji-picker { + position: fixed; + z-index: 47; + right: 0; + bottom: 78px; + left: 0; + height: 318px; + overflow: hidden; + border-top: 1px solid rgb(255 255 255 / 76%); + background: rgb(247 247 251 / 84%); + box-shadow: 0 -10px 34px rgb(20 26 43 / 13%); + animation: messages-panel-in .32s cubic-bezier(.32,.72,0,1) both; + backdrop-filter: blur(34px) saturate(190%); + -webkit-backdrop-filter: blur(34px) saturate(190%); +} +.messages-full-emoji-picker > header { height: 38px; padding: 0 12px 0 16px; display: flex; align-items: center; justify-content: space-between; } +.messages-full-emoji-picker > header strong { font-size: 14px; } +.messages-full-emoji-picker > header button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgb(118 118 128 / 13%); color: #5d5d62; } +.messages-full-emoji-picker__search { height: 31px; margin: 0 10px 4px; padding: 0 10px; display: flex; align-items: center; gap: 6px; border-radius: 16px; background: rgb(118 118 128 / 13%); color: #8e8e93; } +.messages-full-emoji-picker__search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 12px; } +.messages-full-emoji-picker nav { height: 34px; padding: 1px 7px 4px; display: flex; gap: 1px; overflow-x: auto; border-bottom: 1px solid rgb(60 60 67 / 10%); } +.messages-full-emoji-picker nav i { width: 1px; height: 21px; margin: 4px 4px; flex: none; background: rgb(60 60 67 / 16%); } +.messages-full-emoji-picker nav button { min-width: 32px; height: 29px; border: 0; border-radius: 9px; background: transparent; filter: grayscale(.55); } +.messages-full-emoji-picker nav button.active { background: rgb(118 118 128 / 14%); filter: none; } +.messages-full-emoji-picker__grid { height: 211px; padding: 7px 9px 18px; display: grid; grid-template-columns: repeat(7,1fr); grid-auto-rows: 38px; overflow-y: auto; } +.messages-full-emoji-picker__grid button { display: grid; place-items: center; border: 0; background: transparent; font-family: 'Apple Color Emoji','Segoe UI Emoji',sans-serif; font-size: 24px; transition: transform .12s ease; } +.messages-full-emoji-picker__grid button:active { transform: scale(1.35); } +.messages-attachment--image { padding: 0; } +.messages-attachment--image img, +.messages-attachment--gif img, +.messages-attachment--video video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.messages-attachment--video > span, +.messages-attachment--video small { z-index: 2; } +.messages-attachment--gif:has(img) { background: #e9e9ed; } + +/* Konsta's k-app dark state drives the complete iOS Messages palette. */ +.phone-app.dark:has(.messages-page) { + --ios-glass: rgb(28 28 30 / 76%); + --ios-glass-strong: rgb(36 36 38 / 90%); + --ios-glass-border: rgb(255 255 255 / 12%); + color-scheme: dark; +} +.phone-app.dark:has(.messages-page)::before { background: #000; } +.phone-app.dark:has(.messages-page) .phone-status-bar { + color: #fff; + text-shadow: none; +} +.phone-app.dark .messages-page { + background: #000 !important; + color: #f5f5f7; + color-scheme: dark; +} +.phone-app.dark .messages-inbox-header, +.phone-app.dark .messages-chat-header, +.phone-app.dark .messages-contact-details > header { + border-color: rgb(84 84 88 / 55%); + background: linear-gradient(180deg, rgb(28 28 30 / 90%), rgb(0 0 0 / 78%)); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 9%); +} +.phone-app.dark .messages-inbox-header > span, +.phone-app.dark .messages-inbox-header > strong, +.phone-app.dark .messages-conversation, +.phone-app.dark .messages-chat-header__name, +.phone-app.dark .messages-empty-state h2, +.phone-app.dark .messages-contact-details, +.phone-app.dark .messages-contact-details__fields input { + color: #f5f5f7; +} +.phone-app.dark .messages-inbox-header > button:not(.messages-inbox-header__edit), +.phone-app.dark .messages-chat-header__back, +.phone-app.dark .messages-inbox-toolbar > button, +.phone-app.dark .messages-messagebar__tools button, +.phone-app.dark .messages-attachment-menu > button { + border-color: rgb(255 255 255 / 10%); + background: linear-gradient(145deg, rgb(58 58 60 / 92%), rgb(28 28 30 / 78%)); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 10%), 0 7px 22px rgb(0 0 0 / 32%); + color: #f5f5f7; +} +.phone-app.dark .messages-conversation::after, +.phone-app.dark .messages-contact-details__fields label, +.phone-app.dark .messages-media-picker > header, +.phone-app.dark .messages-full-emoji-picker nav { + border-color: rgb(84 84 88 / 55%); +} +.phone-app.dark .messages-conversation:active, +.phone-app.dark .messages-conversation--selected { background: #1c1c1e; } +.phone-app.dark .messages-conversation__selection { + border-color: #636366; + background: #1c1c1e; +} +.phone-app.dark .messages-conversation--selected .messages-conversation__selection { + border-color: #0a84ff; + background: #0a84ff; +} +.phone-app.dark .messages-conversation__headline time, +.phone-app.dark .messages-conversation__headline svg, +.phone-app.dark .messages-conversation__preview, +.phone-app.dark .messages-thread-timestamp, +.phone-app.dark .messages-empty-state p, +.phone-app.dark .messages-contact-details__hero small, +.phone-app.dark .messages-contact-details__fields span { + color: #98989f; +} +.phone-app.dark .messages-inbox-toolbar label, +.phone-app.dark .messages-edit-toolbar, +.phone-app.dark .messages-messagebar, +.phone-app.dark .messages-chat-header__name { + border-color: rgb(255 255 255 / 11%); + background: linear-gradient(145deg, rgb(48 48 50 / 92%), rgb(28 28 30 / 80%)); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 10%), 0 8px 26px rgb(0 0 0 / 32%); +} +.phone-app.dark .messages-inbox-toolbar input, +.phone-app.dark .messages-messagebar textarea, +.phone-app.dark .messages-gif-search input, +.phone-app.dark .messages-full-emoji-picker__search input { + color: #f5f5f7 !important; +} +.phone-app.dark .messages-bubbles .k-message:not(.self-end) > div > div { + background: #2c2c2e !important; + color: #fff !important; +} +.phone-app.dark .messages-bubbles .k-message:not(.self-end) > div > div::after { background: #2c2c2e; } +.phone-app.dark .messages-media-picker, +.phone-app.dark .messages-full-emoji-picker, +.phone-app.dark .messages-emoji-picker { + border-color: rgb(255 255 255 / 10%); + background: rgb(28 28 30 / 91%); + box-shadow: 0 -10px 34px rgb(0 0 0 / 38%); + color: #f5f5f7; +} +.phone-app.dark .messages-gif-search, +.phone-app.dark .messages-full-emoji-picker__search, +.phone-app.dark .messages-full-emoji-picker > header button, +.phone-app.dark .messages-full-emoji-picker nav button.active { + background: rgb(118 118 128 / 28%); +} +.phone-app.dark .messages-media-picker__gifs button { background: #2c2c2e; } +.phone-app.dark .messages-media-picker__gifs .messages-gif-more, +.phone-app.dark .messages-media-picker__gifs .messages-gif-error button { + background: rgb(10 132 255 / 20%); + color: #0a84ff; +} +.phone-app.dark .messages-recorder { + border-color: rgb(255 255 255 / 11%); + background: rgb(36 36 38 / 94%); + color: #f5f5f7; +} +.phone-app.dark .messages-contact-details { background: #000; } +.phone-app.dark .messages-contact-details__actions button, +.phone-app.dark .messages-contact-details__fields, +.phone-app.dark .messages-contact-details__primary, +.phone-app.dark .messages-contact-details__delete { + background: #1c1c1e; +} +.phone-app.dark .messages-avatar--header { border-color: #2c2c2e; } + +/* Native iOS Messages thread treatment shared by light and dark appearances. */ +.messages-thread-page { + --messages-thread-background: #fff; + --messages-thread-control: #fff; + --messages-thread-control-border: rgb(60 60 67 / 9%); + --messages-thread-control-shadow: 0 8px 24px rgb(0 0 0 / 8%); + --messages-thread-field: #fff; + --messages-thread-field-border: #e5e5ea; + --messages-thread-received: #e9e9eb; + --messages-thread-primary: #111; + --messages-thread-secondary: #8e8e93; + background: var(--messages-thread-background) !important; +} +.messages-thread-page .messages-chat-header { + height: 148px; + border-bottom: 0; + background: var(--messages-thread-background); + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} +.messages-thread-page .messages-chat-header__back { + top: 16px; + width: 46px; + height: 46px; + padding: 0; + display: grid; + place-items: center; + border-color: var(--messages-thread-control-border); + background: var(--messages-thread-control); + box-shadow: var(--messages-thread-control-shadow); + color: var(--messages-thread-primary); + line-height: 0; +} +.messages-thread-page .messages-chat-header__back svg { + display: block; + margin: 0; +} +.messages-thread-page .messages-chat-header__contact { top: 4px; } +.messages-thread-page .messages-avatar--header { + width: 64px; + height: 64px; + margin-bottom: 6px; +} +.messages-thread-page .messages-chat-header__name { + min-width: 0; + max-width: 230px; + height: 32px; + padding: 0 11px; + justify-content: center; + border-color: var(--messages-thread-control-border); + border-radius: 18px; + background: var(--messages-thread-control); + box-shadow: var(--messages-thread-control-shadow); + color: var(--messages-thread-primary); + line-height: 1; +} +.messages-thread-page .messages-chat-header__name strong { + font-size: 16px; + line-height: 1; +} +.messages-thread-page .messages-chat-header__name svg { + display: block; + margin: 0; +} +.messages-avatar--unknown { + background: linear-gradient(145deg, #a8bee4, #7e98ca) !important; +} +.messages-avatar__placeholder { + position: relative; + width: 100%; + height: 100%; + display: block; +} +.messages-avatar__placeholder i { + position: absolute; + top: 19%; + left: 50%; + width: 35%; + aspect-ratio: 1; + border-radius: 50%; + background: #fff; + transform: translateX(-50%); +} +.messages-avatar__placeholder b { + position: absolute; + right: 17%; + bottom: 8%; + left: 17%; + height: 37%; + border-radius: 50% 50% 44% 44%; + background: #fff; +} +.messages-thread-page .messages-bubbles { + padding-right: 16px; + padding-left: 16px; +} +.messages-thread-page .messages-bubbles [class*='message-bubble'] { + max-width: 270px; + padding: 9px 13px; + border-radius: 22px; + font-size: 16px; + line-height: 1.28; +} +.messages-thread-page .messages-bubbles .k-message { max-width: 86% !important; } +.messages-thread-page .messages-bubbles .k-message:not(.self-end) > div > div { + background: var(--messages-thread-received) !important; + color: var(--messages-thread-primary) !important; +} +.messages-thread-page .messages-bubbles .k-message:not(.self-end) > div > div::after { + background: var(--messages-thread-received); +} +.messages-thread-page .messages-thread-timestamp { color: var(--messages-thread-secondary); } +.messages-thread-page .messages-message--failed { position: relative; } +.messages-thread-page .messages-message--failed.self-end::after { + content: '!'; + position: absolute; + top: 50%; + right: -29px; + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 2px solid #ff3b30; + border-radius: 50%; + color: #ff3b30; + font-size: 15px; + font-weight: 750; + line-height: 1; + transform: translateY(-50%); +} +.messages-thread-page .messages-message--failed [class*='message-footer'] { + color: #ff3b30 !important; + font-weight: 650; +} +.messages-thread-page .messages-messagebar { + border-color: var(--messages-thread-field-border); + background: var(--messages-thread-field); + box-shadow: var(--messages-thread-control-shadow); +} +.messages-thread-page .messages-messagebar textarea { color: var(--messages-thread-primary) !important; } +.messages-thread-page .messages-messagebar__tools button { + padding: 0; + display: grid; + place-items: center; + border-color: var(--messages-thread-control-border); + background: var(--messages-thread-control); + box-shadow: var(--messages-thread-control-shadow); + color: var(--messages-thread-primary); +} +.phone-app.dark .messages-thread-page { + --messages-thread-background: #000; + --messages-thread-control: #1c1c1e; + --messages-thread-control-border: #38383a; + --messages-thread-control-shadow: none; + --messages-thread-field: #1c1c1e; + --messages-thread-field-border: #38383a; + --messages-thread-received: #29292c; + --messages-thread-primary: #f5f5f7; + --messages-thread-secondary: #8e8e93; +} +.phone-app.dark .messages-thread-page .messages-chat-header, +.phone-app.dark .messages-thread-page .messages-chat-header__name, +.phone-app.dark .messages-thread-page .messages-chat-header__back, +.phone-app.dark .messages-thread-page .messages-messagebar, +.phone-app.dark .messages-thread-page .messages-messagebar__tools button { + background: var(--messages-thread-control); + box-shadow: none; +} +.phone-app.dark .messages-thread-page .messages-chat-header { background: #000; } +.phone-app.dark .messages-thread-page .messages-avatar--unknown { + border-color: #000; + background: linear-gradient(145deg, #625c79, #3f3a55) !important; +} +.messages-thread-page .messages-messagebar, +.phone-app.dark .messages-thread-page .messages-messagebar { + min-height: 46px; + padding: 0 !important; + border: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} +.messages-thread-page .messages-messagebar > .k-toolbar { background: transparent; } +.messages-thread-page .messages-messagebar > .k-toolbar > div:nth-child(2) { + position: relative; + width: 100%; + height: 46px !important; + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + align-items: center; + gap: 10px !important; +} +.messages-thread-page .messages-messagebar > .k-toolbar > div:nth-child(2) > div:first-child, +.messages-thread-page .messages-messagebar__tools, +.messages-thread-page .messages-messagebar__tools button { + width: 46px; + height: 46px; +} +.messages-thread-page .messages-messagebar__tools button { + border-color: var(--messages-thread-control-border); + border-radius: 50%; + background: var(--messages-thread-control); + box-shadow: var(--messages-thread-control-shadow); + line-height: 0; +} +.messages-thread-page .messages-messagebar__tools button svg { + display: block; + margin: 0; +} +.messages-thread-page .messages-attachment-menu > button { + padding-right: 8px; + padding-left: 8px; + justify-content: center; + line-height: 1; +} +.messages-thread-page .messages-attachment-menu > button > span { + flex: none; + line-height: 1; +} +.messages-thread-page .messages-attachment-menu > button > span svg { + display: block; + margin: 0; +} +.messages-thread-page .messages-messagebar > .k-toolbar > div:nth-child(2) > .k-glass { + width: 100%; + height: 46px; + grid-column: 2; + border: 1px solid var(--messages-thread-field-border) !important; + border-radius: 24px; + background: var(--messages-thread-field) !important; + box-shadow: var(--messages-thread-control-shadow) !important; +} +.messages-thread-page .messages-messagebar > .k-toolbar > div:nth-child(2) > div:last-child { + position: absolute; + z-index: 2; + top: 50%; + right: 6px; + width: 34px; + height: 34px; + display: grid; + place-items: center; + transform: translateY(-50%); +} +.messages-thread-page .messages-messagebar > .k-toolbar > div:nth-child(2) > div:last-child .k-toolbar-pane, +.messages-thread-page .messages-messagebar > .k-toolbar > div:nth-child(2) > div:last-child .k-link { + width: 34px !important; + height: 34px !important; + padding: 0 !important; + display: grid !important; + place-items: center !important; + line-height: 0; +} +.messages-thread-page .messages-messagebar > .k-toolbar > div:nth-child(2) > div:last-child svg { + display: block; + margin: 0; + transform: none; +} +.messages-thread-page .messages-messagebar textarea { + height: 44px !important; + padding: 13px 42px 7px 15px !important; + border: 0 !important; + background: transparent !important; +} + +/* Native iOS Messages conversation list in matching light and dark palettes. */ +.messages-inbox-page { + --messages-inbox-background: #fff; + --messages-inbox-control: #fff; + --messages-inbox-control-border: rgb(60 60 67 / 9%); + --messages-inbox-control-shadow: 0 8px 24px rgb(0 0 0 / 9%); + --messages-inbox-primary: #111; + --messages-inbox-secondary: #8e8e93; + --messages-inbox-separator: #e5e5ea; + padding: 112px 0 92px !important; + background: var(--messages-inbox-background) !important; + color: var(--messages-inbox-primary); +} +.messages-inbox-page .messages-inbox-header { + height: 68px; + padding: 0 14px; + border: 0; + background: var(--messages-inbox-background); + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} +.messages-inbox-page .messages-inbox-header > strong { + color: var(--messages-inbox-primary); + font-size: 19px; + font-weight: 750; + letter-spacing: -0.025em; +} +.messages-inbox-page .messages-inbox-header > button { + width: 46px; + height: 46px; + padding: 0; + display: grid; + place-items: center; + border: 1px solid var(--messages-inbox-control-border); + background: var(--messages-inbox-control); + box-shadow: var(--messages-inbox-control-shadow); + color: var(--messages-inbox-primary); + line-height: 1; +} +.messages-inbox-page .messages-inbox-header > button svg { + display: block; + flex: none; +} +.messages-inbox-page .messages-inbox-header > button.active { + border-color: rgb(10 132 255 / 30%); + background: rgb(10 132 255 / 16%); + color: #0a84ff; +} +.messages-inbox-page .messages-inbox-header__edit { + width: 76px !important; + height: 44px !important; + padding: 0 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 22px !important; + color: var(--messages-inbox-primary) !important; + font-size: 15px; + font-weight: 600; + line-height: 1; +} +.messages-inbox-page .messages-inbox-header__edit > span { + display: block; + transform: translateY(1px); +} +.messages-inbox-page .messages-conversation-list { padding: 4px 14px 18px; } +.messages-inbox-page .messages-conversation { + min-height: 88px; + padding: 10px 0 10px 8px; + gap: 13px; + color: var(--messages-inbox-primary); +} +.messages-inbox-page .messages-conversation::after { + left: 65px; + background: var(--messages-inbox-separator); +} +.messages-inbox-page .messages-conversation:active, +.messages-inbox-page .messages-conversation--selected { + background: color-mix(in srgb, var(--messages-inbox-control) 86%, #0a84ff 14%); +} +.messages-inbox-page .messages-conversation__dot { + left: -9px; + width: 9px; + height: 9px; + background: #0a84ff; +} +.messages-inbox-page .messages-avatar { + width: 50px; + height: 50px; +} +.messages-inbox-page .messages-avatar__glyph { + padding-top: 6px; + font-size: 34px; +} +.messages-inbox-page .messages-conversation__body { gap: 3px; } +.messages-inbox-page .messages-conversation__headline { + grid-template-columns: minmax(0, 1fr) auto 16px; + gap: 5px; +} +.messages-inbox-page .messages-conversation__headline strong { + color: var(--messages-inbox-primary); + font-size: 17px; + font-weight: 650; +} +.messages-inbox-page .messages-conversation--unread .messages-conversation__headline strong { + font-weight: 780; +} +.messages-inbox-page .messages-conversation__headline time, +.messages-inbox-page .messages-conversation__headline svg, +.messages-inbox-page .messages-conversation__preview { + color: var(--messages-inbox-secondary); +} +.messages-inbox-page .messages-conversation__headline time { font-size: 13px; } +.messages-inbox-page .messages-conversation__headline svg { + width: 16px; + height: 16px; +} +.messages-inbox-page .messages-conversation__preview { + min-height: 36px; + font-size: 14px; + line-height: 1.32; +} +.messages-inbox-page .messages-inbox-toolbar { + right: 13px; + bottom: 29px; + left: 13px; + height: 50px; + gap: 10px; +} +.messages-inbox-page .messages-inbox-toolbar label { + height: 48px; + padding: 0 14px; + border-color: var(--messages-inbox-control-border); + border-radius: 25px; + background: var(--messages-inbox-control); + box-shadow: var(--messages-inbox-control-shadow); + color: var(--messages-inbox-secondary); +} +.messages-inbox-page .messages-inbox-toolbar label > svg:first-child { + width: 22px; + height: 22px; + color: var(--messages-inbox-primary); +} +.messages-inbox-page .messages-inbox-toolbar label > svg:last-child { + width: 21px; + height: 21px; + color: var(--messages-inbox-primary); +} +.messages-inbox-page .messages-inbox-toolbar input { + color: var(--messages-inbox-primary); + font-size: 16px; +} +.messages-inbox-page .messages-inbox-toolbar > button { + width: 48px; + height: 48px; + padding: 0; + display: grid; + place-items: center; + border-color: var(--messages-inbox-control-border); + background: var(--messages-inbox-control); + box-shadow: var(--messages-inbox-control-shadow); + color: var(--messages-inbox-primary); +} +.messages-inbox-page .messages-inbox-toolbar > button svg { + width: 24px; + height: 24px; + display: block; + flex: none; +} +.messages-inbox-page .messages-inbox-header > button:not(.messages-inbox-header__edit) svg, +.messages-inbox-page .messages-inbox-toolbar > button svg { + transform: translateY(1px); +} +.phone-app.dark .messages-inbox-page { + --messages-inbox-background: #000; + --messages-inbox-control: #1c1c1e; + --messages-inbox-control-border: #38383a; + --messages-inbox-control-shadow: none; + --messages-inbox-primary: #f5f5f7; + --messages-inbox-secondary: #8e8e93; + --messages-inbox-separator: #2c2c2e; +} +.phone-app.dark .messages-inbox-page .messages-inbox-header, +.phone-app.dark .messages-inbox-page .messages-inbox-header > button, +.phone-app.dark .messages-inbox-page .messages-inbox-toolbar label, +.phone-app.dark .messages-inbox-page .messages-inbox-toolbar > button { + background: var(--messages-inbox-control); + box-shadow: none; +} +.phone-app.dark .messages-inbox-page .messages-inbox-header { background: #000; } +.phone-app.dark .messages-inbox-page .messages-avatar--unknown { + border-color: #000; + background: linear-gradient(145deg, #625c79, #3f3a55) !important; +} +@keyframes messages-panel-in { + from { + opacity: 0; + transform: translateY(36px); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes messages-recorder-in { + from { + opacity: 0; + transform: translateY(14px) scale(0.92); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes messages-record-pulse { + 50% { + opacity: 0.45; + transform: scale(0.82); + } +} +@keyframes messages-spin { + to { + transform: rotate(360deg); + } +} @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/frontend/src/components/FullEmojiPicker.vue b/frontend/src/components/FullEmojiPicker.vue new file mode 100644 index 0000000..725130a --- /dev/null +++ b/frontend/src/components/FullEmojiPicker.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/components/MessageAttachmentBubble.vue b/frontend/src/components/MessageAttachmentBubble.vue new file mode 100644 index 0000000..a8372f1 --- /dev/null +++ b/frontend/src/components/MessageAttachmentBubble.vue @@ -0,0 +1,98 @@ + + + diff --git a/frontend/src/components/VoiceMessageBubble.vue b/frontend/src/components/VoiceMessageBubble.vue new file mode 100644 index 0000000..11fff5c --- /dev/null +++ b/frontend/src/components/VoiceMessageBubble.vue @@ -0,0 +1,103 @@ + + + diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 0a60dac..68d8968 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -21,17 +21,17 @@ describe('app registry', () => { route: '/apps/phone', }) expect(PHONE_APPS.find((app) => app.id === 'mail')).toMatchObject({ - gridOrder: 5, + gridOrder: 6, labelKey: 'Apps.mail.name', route: '/apps/mail', }) expect(PHONE_APPS.find((app) => app.id === 'weather')).toMatchObject({ - gridOrder: 4, + gridOrder: 5, labelKey: 'Apps.weather.name', route: '/apps/weather', }) expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({ - gridOrder: 20, + gridOrder: 21, labelKey: 'Apps.calendar.name', route: '/apps/calendar', }) @@ -43,49 +43,49 @@ describe('app registry', () => { }) expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({ dockOrder: null, - gridOrder: 11, + gridOrder: 12, labelKey: 'Apps.snake.name', route: '/apps/snake', }) expect(PHONE_APPS.find((app) => app.id === 'memory')).toMatchObject({ dockOrder: null, - gridOrder: 12, + gridOrder: 13, labelKey: 'Apps.memory.name', route: '/apps/memory', }) expect(PHONE_APPS.find((app) => app.id === 'number-merge')).toMatchObject({ dockOrder: null, - gridOrder: 13, + gridOrder: 14, labelKey: 'Apps.numberMerge.name', route: '/apps/number-merge', }) expect(PHONE_APPS.find((app) => app.id === 'minesweeper')).toMatchObject({ dockOrder: null, - gridOrder: 14, + gridOrder: 15, labelKey: 'Apps.minesweeper.name', route: '/apps/minesweeper', }) expect(PHONE_APPS.find((app) => app.id === 'tower-stack')).toMatchObject({ dockOrder: null, - gridOrder: 15, + gridOrder: 16, labelKey: 'Apps.towerStack.name', route: '/apps/tower-stack', }) expect(PHONE_APPS.find((app) => app.id === 'sky-flappy')).toMatchObject({ dockOrder: null, - gridOrder: 16, + gridOrder: 17, labelKey: 'Apps.skyFlappy.name', route: '/apps/sky-flappy', }) expect(PHONE_APPS.find((app) => app.id === 'neon-drop')).toMatchObject({ dockOrder: null, - gridOrder: 17, + gridOrder: 18, labelKey: 'Apps.neonDrop.name', route: '/apps/neon-drop', }) expect(PHONE_APPS.find((app) => app.id === 'citymarkt')).toMatchObject({ dockOrder: null, - gridOrder: 18, + gridOrder: 19, labelKey: 'Apps.citymarkt.name', route: '/apps/citymarkt', }) @@ -98,10 +98,26 @@ describe('app registry', () => { expect(isPhoneAppId('camera')).toBe(true) expect(isPhoneAppId('photos')).toBe(true) expect(isPhoneAppId('clock')).toBe(true) + expect( + PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id), + ).toEqual([ + 'snake', + 'memory', + 'number-merge', + 'minesweeper', + 'tower-stack', + 'sky-flappy', + 'neon-drop', + ]) + expect( + PHONE_APPS.filter((app) => app.category === 'social').map( + (app) => app.id, + ), + ).toEqual(['local-pages', 'radio', 'phone', 'mail']) expect( PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) .map((app) => app.id), - ).toEqual(['phone', 'calculator', 'camera', 'clock']) + ).toEqual(['phone', 'messages', 'camera', 'clock']) }) }) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 1b048d3..247e2e6 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -12,6 +12,7 @@ import { Layers3, Mail, MapPinned, + MessageCircle, NotebookPen, Phone, RadioTower, @@ -31,6 +32,7 @@ import clockIcon from '@/assets/img/app-icons/clock.webp' 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 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' @@ -54,11 +56,12 @@ import type { export const PHONE_APPS: PhoneAppDefinition[] = [ { + category: 'productivity', component: markRaw( defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')), ), dockOrder: null, - gridOrder: 20, + gridOrder: 21, icon: markRaw(CalendarDays), iconClass: 'app-icon--calendar', iconImage: calendarIcon, @@ -67,11 +70,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/calendar', }, { + category: 'social', component: markRaw( defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')), ), dockOrder: null, - gridOrder: 19, + gridOrder: 20, icon: markRaw(MapPinHouse), iconClass: 'app-icon--local-pages', iconImage: localPagesIcon, @@ -80,6 +84,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/local-pages', }, { + category: 'social', component: markRaw( defineAsyncComponent(() => import('@/views/apps/RadioApp.vue')), ), @@ -93,6 +98,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/radio', }, { + category: 'social', component: markRaw( defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')), ), @@ -106,11 +112,26 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/phone', }, { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/MessagesApp.vue')), + ), + dockOrder: 1, + gridOrder: 1, + icon: markRaw(MessageCircle), + iconClass: '', + iconImage: messagesIcon, + id: 'messages', + labelKey: 'Apps.messages.name', + route: '/apps/messages', + }, + { + category: 'utilities', component: markRaw( defineAsyncComponent(() => import('@/views/apps/MapApp.vue')), ), dockOrder: null, - gridOrder: 10, + gridOrder: 11, icon: markRaw(MapPinned), iconClass: '', iconImage: mapIcon, @@ -119,11 +140,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/map', }, { + category: 'social', component: markRaw( defineAsyncComponent(() => import('@/views/apps/MailApp.vue')), ), dockOrder: null, - gridOrder: 5, + gridOrder: 6, icon: markRaw(Mail), iconClass: '', iconImage: mailIcon, @@ -132,11 +154,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/mail', }, { + category: 'productivity', component: markRaw( defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')), ), dockOrder: null, - gridOrder: 6, + gridOrder: 7, icon: markRaw(NotebookPen), iconClass: '', iconImage: notesIcon, @@ -145,11 +168,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/notes', }, { + category: 'productivity', component: markRaw( defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')), ), - dockOrder: 1, - gridOrder: 1, + dockOrder: null, + gridOrder: 2, icon: markRaw(Calculator), iconClass: 'app-icon--calculator', iconImage: calculatorIcon, @@ -158,11 +182,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/calculator', }, { + category: 'utilities', component: markRaw( defineAsyncComponent(() => import('@/views/apps/CameraApp.vue')), ), dockOrder: 2, - gridOrder: 2, + gridOrder: 3, icon: markRaw(Camera), iconClass: 'app-icon--camera', iconImage: cameraIcon, @@ -171,11 +196,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/camera', }, { + category: 'productivity', component: markRaw( defineAsyncComponent(() => import('@/views/apps/ClockApp.vue')), ), dockOrder: 3, - gridOrder: 3, + gridOrder: 4, icon: markRaw(Clock3), iconClass: 'app-icon--clock', iconImage: clockIcon, @@ -184,11 +210,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/clock', }, { + category: 'utilities', component: markRaw( defineAsyncComponent(() => import('@/views/apps/WeatherApp.vue')), ), dockOrder: null, - gridOrder: 4, + gridOrder: 5, icon: markRaw(CloudSun), iconClass: 'app-icon--weather', iconImage: weatherIcon, @@ -197,11 +224,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/weather', }, { + category: 'utilities', component: markRaw( defineAsyncComponent(() => import('@/views/apps/GalleryApp.vue')), ), dockOrder: null, - gridOrder: 7, + gridOrder: 8, icon: markRaw(Images), iconClass: 'app-icon--photos', iconImage: photosIcon, @@ -210,11 +238,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/photos', }, { + category: 'utilities', component: markRaw( defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')), ), dockOrder: null, - gridOrder: 8, + gridOrder: 9, icon: markRaw(ShoppingBag), iconClass: 'app-icon--store', iconImage: appStoreIcon, @@ -223,11 +252,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/app-store', }, { + category: 'utilities', component: markRaw( defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')), ), dockOrder: null, - gridOrder: 9, + gridOrder: 10, icon: markRaw(Settings), iconClass: 'app-icon--settings', iconImage: settingsIcon, @@ -236,11 +266,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/settings', }, { + category: 'games', component: markRaw( defineAsyncComponent(() => import('@/views/apps/SnakeApp.vue')), ), dockOrder: null, - gridOrder: 11, + gridOrder: 12, icon: markRaw(Gamepad2), iconClass: 'app-icon--snake', iconImage: snakeIcon, @@ -249,11 +280,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/snake', }, { + category: 'games', component: markRaw( defineAsyncComponent(() => import('@/views/apps/MemoryApp.vue')), ), dockOrder: null, - gridOrder: 12, + gridOrder: 13, icon: markRaw(Brain), iconClass: 'app-icon--memory', iconImage: memoryIcon, @@ -262,11 +294,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/memory', }, { + category: 'games', component: markRaw( defineAsyncComponent(() => import('@/views/apps/NumberMergeApp.vue')), ), dockOrder: null, - gridOrder: 13, + gridOrder: 14, icon: markRaw(Grid2X2), iconClass: 'app-icon--number-merge', iconImage: numberMergeIcon, @@ -275,11 +308,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/number-merge', }, { + category: 'games', component: markRaw( defineAsyncComponent(() => import('@/views/apps/MinesweeperApp.vue')), ), dockOrder: null, - gridOrder: 14, + gridOrder: 15, icon: markRaw(Bomb), iconClass: 'app-icon--minesweeper', iconImage: minesweeperIcon, @@ -288,11 +322,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/minesweeper', }, { + category: 'games', component: markRaw( defineAsyncComponent(() => import('@/views/apps/TowerStackApp.vue')), ), dockOrder: null, - gridOrder: 15, + gridOrder: 16, icon: markRaw(Layers3), iconClass: 'app-icon--tower-stack', iconImage: towerStackIcon, @@ -301,11 +336,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/tower-stack', }, { + category: 'games', component: markRaw( defineAsyncComponent(() => import('@/views/apps/SkyFlappyApp.vue')), ), dockOrder: null, - gridOrder: 16, + gridOrder: 17, icon: markRaw(Wind), iconClass: 'app-icon--sky-flappy', iconImage: skyFlappyIcon, @@ -314,11 +350,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/sky-flappy', }, { + category: 'shopping', component: markRaw( defineAsyncComponent(() => import('@/views/apps/CityMarktApp.vue')), ), dockOrder: null, - gridOrder: 18, + gridOrder: 19, icon: markRaw(Tag), iconClass: 'app-icon--citymarkt', iconImage: citymarktIcon, @@ -327,11 +364,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ route: '/apps/citymarkt', }, { + category: 'games', component: markRaw( defineAsyncComponent(() => import('@/views/apps/NeonDropApp.vue')), ), dockOrder: null, - gridOrder: 17, + gridOrder: 18, icon: markRaw(Blocks), iconClass: 'app-icon--neon-drop', iconImage: neonDropIcon, diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts new file mode 100644 index 0000000..b4739ff --- /dev/null +++ b/frontend/src/stores/app-store.test.ts @@ -0,0 +1,72 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useAppStoreStore } from '@/stores/app-store' + +const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() })) +vi.mock('@/stores/phone', () => ({ + usePhoneStore: () => ({ + saveDeviceNamespace: mocks.saveDeviceNamespace, + }), +})) + +describe('app store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mocks.saveDeviceNamespace.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('hydrates valid launch counts and persists launches with claimed apps', () => { + const apps = useAppStoreStore() + + apps.hydrate({ + claimedApps: ['snake'], + launchCounts: { mail: 3, invalid: 20, phone: -1 }, + }) + apps.recordLaunch('mail') + + expect(apps.launchCounts).toEqual({ mail: 4 }) + expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', { + claimedApps: ['snake'], + launchCounts: { mail: 4 }, + }) + }) + + it('installs an app after showing a three second loading state', () => { + vi.useFakeTimers() + const apps = useAppStoreStore() + + apps.installApp('snake') + + expect(apps.installingApps.snake).toBe(true) + expect(apps.claimedApps).toEqual([]) + + vi.advanceTimersByTime(2999) + expect(apps.claimedApps).toEqual([]) + + vi.advanceTimersByTime(1) + expect(apps.installingApps.snake).toBeUndefined() + expect(apps.claimedApps).toEqual(['snake']) + expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', { + claimedApps: ['snake'], + launchCounts: {}, + }) + }) + + it('ignores duplicate installation requests and invalid persisted ids', () => { + vi.useFakeTimers() + const apps = useAppStoreStore() + + apps.hydrate({ claimedApps: ['memory', 'not-an-app'] }) + apps.installApp('snake') + apps.installApp('snake') + vi.advanceTimersByTime(3000) + + expect(apps.claimedApps).toEqual(['memory', 'snake']) + expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index fce198d..dc90b15 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -1,27 +1,67 @@ import { defineStore } from 'pinia' +import { isPhoneAppId } from '@/config/apps' import { usePhoneStore } from '@/stores/phone' +import type { LaunchablePhoneAppId } from '@/types/apps' + +const INSTALL_DURATION_MS = 3000 export const useAppStoreStore = defineStore('app-store', { state: () => ({ - claimedApps: [] as string[], + claimedApps: [] as LaunchablePhoneAppId[], + installingApps: {} as Partial>, + launchCounts: {} as Partial>, }), actions: { - claimApp(id: string): void { + claimApp(id: LaunchablePhoneAppId): void { if (!this.claimedApps.includes(id)) { this.claimedApps.push(id) this.persist() } }, + installApp(id: LaunchablePhoneAppId): void { + if (this.claimedApps.includes(id) || this.installingApps[id]) return + + this.installingApps[id] = true + globalThis.setTimeout(() => { + this.claimApp(id) + delete this.installingApps[id] + }, INSTALL_DURATION_MS) + }, hydrate(payload: unknown): void { - const data = payload as { claimedApps?: unknown } | null + const data = payload as { + claimedApps?: unknown + launchCounts?: unknown + } | null this.claimedApps = Array.isArray(data?.claimedApps) - ? data.claimedApps.filter((id): id is string => typeof id === 'string') + ? data.claimedApps.filter( + (id): id is LaunchablePhoneAppId => + typeof id === 'string' && isPhoneAppId(id), + ) : [] + this.installingApps = {} + this.launchCounts = {} + if (data?.launchCounts && typeof data.launchCounts === 'object') { + for (const [appId, count] of Object.entries(data.launchCounts)) { + if ( + isPhoneAppId(appId) && + typeof count === 'number' && + Number.isFinite(count) && + count > 0 + ) { + this.launchCounts[appId] = Math.floor(count) + } + } + } + }, + recordLaunch(appId: LaunchablePhoneAppId): void { + this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1 + this.persist() }, persist(): void { usePhoneStore().saveDeviceNamespace('apps', { claimedApps: this.claimedApps, + launchCounts: this.launchCounts, }) }, }, diff --git a/frontend/src/stores/media.ts b/frontend/src/stores/media.ts index f99c52f..4e5c5b3 100644 --- a/frontend/src/stores/media.ts +++ b/frontend/src/stores/media.ts @@ -3,32 +3,38 @@ import { defineStore } from 'pinia' import { usePhoneStore } from '@/stores/phone' export type PhonePhoto = { + attachmentId: string capturedAt: number gradient: string id: string titleKey: string + url?: string } const samplePhotos: PhonePhoto[] = [ { + attachmentId: 'sunset-drive', capturedAt: Date.now() - 86_400_000, gradient: 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)', id: 'sunset-drive', titleKey: 'Apps.photos.samples.sunset', }, { + attachmentId: 'ocean-air', capturedAt: Date.now() - 172_800_000, gradient: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)', id: 'ocean-air', titleKey: 'Apps.photos.samples.ocean', }, { + attachmentId: 'city-lights', capturedAt: Date.now() - 259_200_000, gradient: 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)', id: 'city-lights', titleKey: 'Apps.photos.samples.city', }, { + attachmentId: 'desert-road', capturedAt: Date.now() - 345_600_000, gradient: 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)', id: 'desert-road', @@ -55,10 +61,12 @@ export const useMediaStore = defineStore('media', { 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)', ] const captureNumber = this.captures.length + const id = `capture-${Date.now()}-${captureNumber + 1}` const photo: PhonePhoto = { + attachmentId: id, capturedAt: Date.now(), gradient: gradients[captureNumber % gradients.length], - id: `capture-${Date.now()}-${captureNumber + 1}`, + id, titleKey: 'Apps.photos.samples.capture', } this.captures.unshift(photo) @@ -76,7 +84,14 @@ export const useMediaStore = defineStore('media', { captures: PhonePhoto[] claimedApps: string[] }> | null - this.captures = Array.isArray(data?.captures) ? data.captures : [] + this.captures = Array.isArray(data?.captures) + ? data.captures.filter( + (photo) => + typeof photo.id === 'string' && + typeof photo.url === 'string' && + photo.url.startsWith('https://'), + ) + : [] this.claimedApps = Array.isArray(data?.claimedApps) ? data.claimedApps.filter((id): id is string => typeof id === 'string') : [] diff --git a/frontend/src/stores/messageMedia.ts b/frontend/src/stores/messageMedia.ts new file mode 100644 index 0000000..3ef4661 --- /dev/null +++ b/frontend/src/stores/messageMedia.ts @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia' + +import type { MediaType, PhoneMedia } from '@/types/media' + +type MessageMediaRequest = { + mediaType: MediaType + phoneNumber: string +} + +type MessageMediaResult = MessageMediaRequest & { + media: PhoneMedia +} + +export const useMessageMediaStore = defineStore('message-media', { + state: () => ({ + request: null as MessageMediaRequest | null, + result: null as MessageMediaResult | null, + }), + actions: { + begin(phoneNumber: string, mediaType: MediaType): void { + this.request = { mediaType, phoneNumber } + this.result = null + }, + cancel(): void { + this.request = null + }, + complete(media: PhoneMedia): void { + if (!this.request || this.request.mediaType !== media.mediaType) return + this.result = { ...this.request, media } + this.request = null + }, + consume(phoneNumber: string): PhoneMedia | null { + if (!this.result || this.result.phoneNumber !== phoneNumber) return null + const media = this.result.media + this.result = null + return media + }, + }, +}) diff --git a/frontend/src/stores/messages.test.ts b/frontend/src/stores/messages.test.ts new file mode 100644 index 0000000..4074b47 --- /dev/null +++ b/frontend/src/stores/messages.test.ts @@ -0,0 +1,116 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useMessagesStore } from '@/stores/messages' +import type { SmsMessage } from '@/types/messages' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const mockNuiCall = vi.mocked(nuiCall) + +function sentMessage(id: string): SmsMessage { + return { + body: 'Hello', + created_at: '2026-08-06 15:00:00', + direction: 'sent', + id, + media_duration_ms: null, + media_asset_id: null, + media_mime: null, + media_waveform: null, + message_type: 'text', + read_at: null, + recipient_number: '4205550196', + sender_number: '4205550100', + } +} + +describe('messages store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('shows an outgoing message immediately and marks it delivered', async () => { + let resolveSend: ((value: { data: SmsMessage; success: true }) => void) | undefined + const response = new Promise<{ data: SmsMessage; success: true }>((resolve) => { + resolveSend = resolve + }) + mockNuiCall + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ data: [], success: true }) + .mockImplementationOnce(() => response) + .mockResolvedValueOnce({ data: [], success: true }) + + const messages = useMessagesStore() + await messages.openThread('4205550196') + const sending = messages.send({ body: 'Hello', messageType: 'text' }) + + expect(messages.messages).toHaveLength(1) + expect(messages.messages[0]).toMatchObject({ + body: 'Hello', + delivery_status: 'sending', + message_type: 'text', + }) + + resolveSend?.({ data: sentMessage('server-id'), success: true }) + await sending + + expect(messages.messages[0]).toMatchObject({ + delivery_status: 'delivered', + id: 'server-id', + }) + }) + + it('keeps a failed optimistic message visible', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ error: 'recipient_unavailable', success: false }) + + const messages = useMessagesStore() + await messages.openThread('4205550196') + await messages.send({ body: 'Hello', messageType: 'text' }) + + expect(messages.messages[0].delivery_status).toBe('failed') + }) + + it('normalizes numeric phone data from the NUI boundary', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: [ + { + lastMessage: 'Hello', + lastMessageAt: 1_786_034_600, + lastMessageType: 'text', + phoneNumber: 4_205_550_196, + unread: 0, + }, + ], + success: true, + }) + + const messages = useMessagesStore() + await messages.loadConversations() + + expect(messages.conversations[0].phoneNumber).toBe('4205550196') + }) + + it('loads protected voice media once and caches the data URI', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { mime: 'audio/webm;codecs=opus', payload: 'ZmFrZQ==' }, + success: true, + }) + + const messages = useMessagesStore() + expect(await messages.loadMedia('voice-id')).toBe(true) + expect(await messages.loadMedia('voice-id')).toBe(true) + + expect(messages.mediaSources['voice-id']).toBe( + 'data:audio/webm;codecs=opus;base64,ZmFrZQ==', + ) + expect(mockNuiCall).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/stores/messages.ts b/frontend/src/stores/messages.ts new file mode 100644 index 0000000..fd18982 --- /dev/null +++ b/frontend/src/stores/messages.ts @@ -0,0 +1,165 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import type { + GifSearchPage, + SmsConversation, + SmsMedia, + SmsMessage, + SmsOutgoingMessage, +} from '@/types/messages' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useMessagesStore = defineStore('messages', () => { + const conversations = ref([]) + const messages = ref([]) + const mediaSources = ref>({}) + const activeNumber = ref(null) + const loading = ref(false) + const unreadCount = computed(() => + conversations.value.reduce((total, item) => total + item.unread, 0), + ) + + async function loadConversations(): Promise { + const response = await nuiCall('messages:conversations') + if (response.success && response.data) { + conversations.value = response.data.map((conversation) => ({ + ...conversation, + phoneNumber: String(conversation.phoneNumber), + })) + } + else if (!response.success) conversations.value = [] + return response.success + } + + async function openThread(phoneNumber: string): Promise { + loading.value = true + const response = await nuiCall('messages:thread', { + phoneNumber, + }) + loading.value = false + if (!response.success || !response.data) return false + activeNumber.value = phoneNumber + messages.value = response.data.map((message) => ({ + ...message, + delivery_status: + message.direction === 'sent' ? 'delivered' : undefined, + recipient_number: String(message.recipient_number), + sender_number: String(message.sender_number), + })) + await loadConversations() + return true + } + + async function send( + outgoing: SmsOutgoingMessage, + ): Promise> { + if (!activeNumber.value) return { success: false, error: 'invalid_number' } + const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const optimistic: SmsMessage = { + body: outgoing.messageType === 'text' ? outgoing.body.trim() : '', + client_id: clientId, + created_at: new Date().toISOString().slice(0, 19).replace('T', ' '), + delivery_status: 'sending', + direction: 'sent', + id: clientId, + media_asset_id: + outgoing.messageType === 'image' || + outgoing.messageType === 'gif' || + outgoing.messageType === 'video' + ? outgoing.mediaAssetId + : null, + media_duration_ms: + outgoing.messageType === 'voice' || outgoing.messageType === 'video' + ? outgoing.mediaDurationMs ?? null + : null, + media_mime: + outgoing.messageType === 'voice' ? outgoing.mediaMime : null, + media_waveform: + outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null, + message_type: outgoing.messageType, + read_at: null, + recipient_number: activeNumber.value, + sender_number: '', + } + messages.value.push(optimistic) + if (outgoing.messageType === 'voice') { + mediaSources.value[clientId] = + `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` + } + + const response = await nuiCall('messages:send', { + ...outgoing, + phoneNumber: activeNumber.value, + }) + const index = messages.value.findIndex( + (message) => message.client_id === clientId, + ) + if (!response.success || !response.data) { + if (index >= 0) messages.value[index].delivery_status = '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, + client_id: clientId, + delivery_status: 'delivered', + } + } + await loadConversations() + return response + } + + async function loadMedia(id: string): Promise { + if (mediaSources.value[id]) return true + const response = await nuiCall('messages:media', { id }) + if (!response.success || !response.data) return false + mediaSources.value[id] = + `data:${response.data.mime};base64,${response.data.payload}` + return true + } + + async function deleteConversations(phoneNumbers: string[]): Promise { + if (!phoneNumbers.length) return false + const response = await nuiCall('messages:delete', { phoneNumbers }) + if (!response.success) return false + if (activeNumber.value && phoneNumbers.includes(activeNumber.value)) { + closeThread() + } + await loadConversations() + return true + } + + async function searchGifs( + query: string, + offset = 0, + ): Promise> { + return nuiCall('messages:gifs', { offset, query }) + } + + function closeThread(): void { + activeNumber.value = null + messages.value = [] + mediaSources.value = {} + } + + return { + activeNumber, + closeThread, + conversations, + deleteConversations, + loadConversations, + loadMedia, + loading, + mediaSources, + messages, + openThread, + searchGifs, + send, + unreadCount, + } +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 8b96583..4cbcf44 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -29,44 +29,103 @@ const namespaceQueues = new Map>() const defaultLocales: LocaleTree = { Apps: { + messages: { + name: 'Messages', + newMessage: 'New message from {sender}', + compose: 'New Message', + search: 'Search', + to: 'To:', + message: 'Message', + send: 'Send', + details: 'Details', + filterUnread: 'Show Unread Messages', + smsLabel: 'Text Message Β· SMS', + photo: 'Photo', + gif: 'GIF', + video: 'Video', + attachPhoto: 'Attach Photo', + takePhoto: 'Take Photo', + attachGif: 'Attach GIF', + attachVideo: 'Attach Video', + photos: 'Photos', + gifs: 'GIFs', + videos: 'Videos', + noPhotos: 'Take a photo first to attach it here.', + noVideos: 'Record a video in Camera first.', + searchGifs: 'Search GIPHY', + loadMore: 'Load More', + retryGifs: 'Try Again', + moreActions: 'More Actions', + contactDetails: 'Contact Details', + contactName: 'Name', + phoneNumber: 'Phone Number', + call: 'Call', + messageAction: 'Message', + addContact: 'Add Contact', + deleteContact: 'Delete Contact', + selectedCount: '{count} Selected', + deleteSelected: 'Delete', + contactSaveFailed: 'The contact could not be saved.', + contactDeleteFailed: 'The contact could not be deleted.', + callFailed: 'The call could not be started.', + emoji: 'Emoji', + voiceMessage: 'Audio Message', + recordVoice: 'Record Audio', + playAudio: 'Play Audio', + pauseAudio: 'Pause Audio', + recording: 'Recording', + stopAndSend: 'Stop and Send', + cancelRecording: 'Cancel Recording', + sending: 'Sending...', + delivered: 'Delivered', + notDelivered: 'Not Delivered', + microphoneUnavailable: 'The microphone is unavailable.', + recordingTooLarge: 'The recording is too large.', + noSim: 'No SIM', + noSimBody: 'Insert a SIM card in Settings to send and receive messages.', + noMessages: 'No Messages', + noMessagesBody: 'Start a conversation with a phone number.', + noResults: 'No Results', + today: 'Today', + yesterday: 'Yesterday', + errors: { + invalid_number: 'Enter a valid phone number.', + invalid_message: 'Enter a message.', + invalid_voice: 'The audio message is invalid.', + invalid_attachment: 'The attachment is invalid.', + media_provider_unconfigured: 'Photo uploads are not configured.', + capture_provider_unavailable: 'The screenshot resource is unavailable.', + capture_failed: 'The photo could not be captured.', + video_provider_unavailable: 'Video capture requires the screencapture resource.', + video_capture_failed: 'The video could not be recorded.', + recording_in_progress: 'A video is already being recorded.', + recording_not_found: 'No active video recording was found.', + gif_provider_unconfigured: 'GIF search is not configured.', + gif_provider_unauthorized: 'The configured GIPHY API key is invalid.', + gif_provider_rate_limited: 'GIPHY is busy. Try again in a moment.', + gif_provider_failed: 'GIF search is temporarily unavailable.', + self_message: 'You cannot message your own number.', + recipient_not_found: 'That number is unavailable.', + no_sim: 'This phone has no SIM card.', + rate_limited: 'Too many messages. Try again in a minute.', + request_failed: 'Messages are temporarily unavailable.', + default: 'The message could not be sent.', + }, + }, appStore: { name: 'App Store', - eyebrow: 'Discover', - featured: 'Featured', - heroTitle: 'Apps for every day', - heroBody: 'Fresh ideas, built for your life in the city.', get: 'GET', open: 'OPEN', - searchPlaceholder: 'Games, Apps, Stories and More', - communityTitle: 'Black Voices and Creators', - communityBody: 'Apps and games from the community', - playing: "What We're Playing", - recommended: 'Recommended for You', - selected: 'Great apps selected by our editors', + installing: 'Installing', + searchPlaceholder: 'Search apps and games', + appsTitle: 'Built-in Apps', + gamesTitle: 'Games', + selected: 'Apps available for your Sky Phone', tabs: { - today: 'Today', apps: 'Apps', games: 'Games', - arcade: 'Arcade', search: 'Search', }, - catalog: { - orbit: 'Plan your day', - studio: 'Create something new', - trail: 'Explore nearby', - prism: 'A colorful puzzle', - }, - card: { - oneEyebrow: 'App of the Day', - oneTitle: 'A universe in your pocket', - oneBody: 'Explore something extraordinary today.', - twoEyebrow: 'Now Trending', - twoTitle: 'Turn up your afternoon', - twoBody: 'Fresh sounds and stories picked for you.', - threeEyebrow: "Editors' Choice", - threeTitle: 'Play without limits', - threeBody: 'A new world is waiting.', - }, }, phone: { name: 'Phone', @@ -991,7 +1050,15 @@ const defaultLocales: LocaleTree = { noApps: 'No apps found', page: 'Page', pages: 'Home screen pages', - groups: { 0: 'Suggestions', 1: 'Recently Added', other: 'Other' }, + groups: { + suggestions: 'Suggestions', + recentlyAdded: 'Recently Added', + games: 'Games', + productivity: 'Productivity', + shopping: 'Shopping', + social: 'Social Networks', + utilities: 'Utilities', + }, widgets: { label: 'Widgets', weather: { city: 'Los Santos', condition: 'Partly Cloudy' }, @@ -1080,8 +1147,8 @@ export const usePhoneStore = defineStore('phone', { }) namespaceQueues.set(namespace, tracked) }, - setCurrentPage(page: number): void { - this.currentPage = clampPage(page) + setCurrentPage(page: number, pageCount?: number): void { + this.currentPage = clampPage(page, pageCount) }, setCameraLandscape(landscape: boolean): void { this.cameraLandscape = landscape diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 7e87d5d..a26a838 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -2,6 +2,7 @@ import type { Component } from 'vue' export type PhoneAppId = | 'phone' + | 'messages' | 'calculator' | 'camera' | 'clock' @@ -26,6 +27,13 @@ export type PhoneAppId = export type LaunchablePhoneAppId = PhoneAppId +export type PhoneAppCategory = + | 'games' + | 'productivity' + | 'shopping' + | 'social' + | 'utilities' + export type AppLaunchOrigin = { borderRadius: number scaleX: number @@ -35,6 +43,7 @@ export type AppLaunchOrigin = { } export type PhoneAppDefinition = { + category: PhoneAppCategory component: Component | null dockOrder: number | null gridOrder: number diff --git a/frontend/src/types/messages.ts b/frontend/src/types/messages.ts new file mode 100644 index 0000000..02748d8 --- /dev/null +++ b/frontend/src/types/messages.ts @@ -0,0 +1,66 @@ +import type { DatabaseDateValue } from '@/utils/date' + +export type SmsDirection = 'sent' | 'received' +export type SmsAttachmentType = 'image' | 'gif' | 'video' +export type SmsMessageType = 'text' | 'voice' | SmsAttachmentType +export type SmsDeliveryStatus = 'sending' | 'delivered' | 'failed' + +export type SmsConversation = { + lastMessage: string + lastMessageAt: DatabaseDateValue + lastMessageType: SmsMessageType + phoneNumber: string + unread: number +} + +export type SmsMessage = { + body: string + client_id?: string + created_at: DatabaseDateValue + delivery_status?: SmsDeliveryStatus + direction: SmsDirection + id: string + media_asset_id: string | null + media_duration_ms: number | null + media_mime: string | null + media_waveform: number[] | null + message_type: SmsMessageType + read_at: string | null + recipient_number: string + sender_number: string +} + +export type SmsOutgoingMessage = + | { body: string; messageType: 'text' } + | { + mediaDurationMs: number + mediaMime: string + mediaPayload: string + mediaWaveform: number[] + messageType: 'voice' + } + | { + mediaAssetId: string + mediaDurationMs?: number + messageType: SmsAttachmentType + } + +export type SmsMedia = { + mime: string + payload: string +} + +export type GifSearchResult = { + height: number + id: string + previewUrl: string + title: string + url: string + width: number +} + +export type GifSearchPage = { + hasMore: boolean + nextOffset: number + results: GifSearchResult[] +} diff --git a/frontend/src/utils/date.test.ts b/frontend/src/utils/date.test.ts new file mode 100644 index 0000000..782bf4b --- /dev/null +++ b/frontend/src/utils/date.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { parseDatabaseDate } from './date' + +describe('database dates', () => { + it('parses SQL date strings', () => { + expect(parseDatabaseDate('2026-08-06 17:30:00').getTime()).toBe( + new Date('2026-08-06T17:30:00').getTime(), + ) + }) + + it('parses Unix timestamps in seconds and milliseconds', () => { + expect(parseDatabaseDate(1_786_034_600).getTime()).toBe(1_786_034_600_000) + expect(parseDatabaseDate(1_786_034_600_000).getTime()).toBe( + 1_786_034_600_000, + ) + }) +}) diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 0000000..38a49b9 --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,10 @@ +export type DatabaseDateValue = string | number + +export function parseDatabaseDate(value: DatabaseDateValue): Date { + if (typeof value === 'number') { + const timestamp = Math.abs(value) < 1_000_000_000_000 ? value * 1000 : value + return new Date(timestamp) + } + + return new Date(value.replace(' ', 'T')) +} diff --git a/frontend/src/utils/pages.test.ts b/frontend/src/utils/pages.test.ts index fc7f7c2..341c115 100644 --- a/frontend/src/utils/pages.test.ts +++ b/frontend/src/utils/pages.test.ts @@ -1,9 +1,21 @@ import { describe, expect, it } from 'vitest' -import { clampPage } from './pages' +import { clampPage, paginateItems } from './pages' describe('page clamping', () => { it('keeps pages in range', () => { expect(clampPage(-4)).toBe(0) expect(clampPage(1)).toBe(1) expect(clampPage(9)).toBe(2) }) + + it('splits overflowing app grids into additional pages', () => { + expect( + paginateItems( + Array.from({ length: 21 }, (_, index) => index), + 20, + ), + ).toEqual([ + Array.from({ length: 20 }, (_, index) => index), + [20], + ]) + }) }) diff --git a/frontend/src/utils/pages.ts b/frontend/src/utils/pages.ts index 820dde2..d8e3583 100644 --- a/frontend/src/utils/pages.ts +++ b/frontend/src/utils/pages.ts @@ -1,5 +1,14 @@ export const SPRINGBOARD_PAGE_COUNT = 3 +export function paginateItems(items: readonly T[], pageSize: number): T[][] { + if (!Number.isInteger(pageSize) || pageSize <= 0) return [] + const pages: T[][] = [] + for (let index = 0; index < items.length; index += pageSize) { + pages.push(items.slice(index, index + pageSize)) + } + return pages +} + export function clampPage( page: number, pageCount = SPRINGBOARD_PAGE_COUNT, diff --git a/frontend/src/utils/phone.test.ts b/frontend/src/utils/phone.test.ts index b6647ee..9e93124 100644 --- a/frontend/src/utils/phone.test.ts +++ b/frontend/src/utils/phone.test.ts @@ -11,5 +11,6 @@ describe('phone numbers', () => { it('formats keypad input incrementally', () => { expect(formatPhoneNumber('5551234567')).toBe('555 123 4567') expect(formatPhoneNumber('5551')).toBe('555 1') + expect(formatPhoneNumber(5551234567)).toBe('555 123 4567') }) }) diff --git a/frontend/src/utils/phone.ts b/frontend/src/utils/phone.ts index 1ef0fe3..358a5b5 100644 --- a/frontend/src/utils/phone.ts +++ b/frontend/src/utils/phone.ts @@ -5,8 +5,8 @@ export function normalizePhoneNumber(value: string): string | null { return digits.length === PHONE_NUMBER_LENGTH ? digits : null } -export function formatPhoneNumber(value: string): string { - const digits = value.replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH) +export function formatPhoneNumber(value: string | number): string { + const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH) const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)] return groups.filter(Boolean).join(' ') } diff --git a/frontend/src/utils/preferences.test.ts b/frontend/src/utils/preferences.test.ts index bdfc931..d720ddf 100644 --- a/frontend/src/utils/preferences.test.ts +++ b/frontend/src/utils/preferences.test.ts @@ -16,6 +16,7 @@ describe('preferences', () => { notificationVolume: 45, notificationDurationSeconds: 14, notifications: { + messages: { enabled: false, sounds: false }, clock: { enabled: false, sounds: false }, }, phoneScale: 110, @@ -26,6 +27,10 @@ describe('preferences', () => { expect(value.settings.appearanceMode).toBe('light') expect(value.settings.notificationVolume).toBe(45) expect(value.settings.notificationDurationSeconds).toBe(14) + expect(value.settings.notifications.messages).toEqual({ + enabled: false, + sounds: false, + }) expect(value.settings.notifications.clock).toEqual({ enabled: false, sounds: false, diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 2e1f5a7..73a23d9 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -49,6 +49,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< AppNotificationPreferences > = { phone: { enabled: true, sounds: true }, + messages: { enabled: true, sounds: true }, 'app-store': { enabled: true, sounds: true }, calculator: { enabled: true, sounds: true }, snake: { enabled: true, sounds: true }, diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index 739dd4d..6b03bab 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -5,11 +5,21 @@ import { computed, ref } from 'vue' import AppIcon from '@/components/AppIcon.vue' import SpringboardWidgets from '@/components/SpringboardWidgets.vue' import { PHONE_APPS } from '@/config/apps' +import { useAppStoreStore } from '@/stores/app-store' import { usePhoneStore } from '@/stores/phone' -import type { PhoneAppDefinition } from '@/types/apps' -import { clampPage, SPRINGBOARD_PAGE_COUNT } from '@/utils/pages' +import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps' +import { paginateItems } from '@/utils/pages' +const APPS_PER_HOME_PAGE = 20 +const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [ + 'games', + 'productivity', + 'social', + 'utilities', + 'shopping', +] const phone = usePhoneStore() +const appStore = useAppStoreStore() const searchQuery = ref('') const searchFocused = ref(false) const showAllApps = ref(false) @@ -19,7 +29,17 @@ let pointerStart = 0 let pointerStartedAt = 0 const gridApps = computed(() => - [...PHONE_APPS].sort((a, b) => a.gridOrder - b.gridOrder), + PHONE_APPS.filter( + (app) => app.category !== 'games' || appStore.claimedApps.includes(app.id), + ).sort((a, b) => a.gridOrder - b.gridOrder), +) +const appPages = computed(() => + paginateItems(gridApps.value, APPS_PER_HOME_PAGE), +) +const pageCount = computed(() => appPages.value.length + 2) +const libraryPage = computed(() => pageCount.value - 1) +const isAppPage = computed( + () => phone.currentPage > 0 && phone.currentPage < libraryPage.value, ) const dockApps = computed(() => PHONE_APPS.filter((app) => app.dockOrder !== null).sort( @@ -34,14 +54,31 @@ const filteredApps = computed(() => { ) }) const appGroups = computed(() => { - const groups: PhoneAppDefinition[][] = [] - for (let index = 0; index < gridApps.value.length; index += 3) { - groups.push(gridApps.value.slice(index, index + 3)) - } - return groups.map((apps, index) => ({ - apps, - moreApps: groups[(index + 1) % groups.length] ?? [], - })) + const suggestions = [...gridApps.value] + .sort( + (a, b) => + (appStore.launchCounts[b.id] ?? 0) - + (appStore.launchCounts[a.id] ?? 0) || a.gridOrder - b.gridOrder, + ) + .slice(0, 7) + const recentlyAdded = [...gridApps.value] + .sort((a, b) => b.gridOrder - a.gridOrder) + .slice(0, 7) + const groups = [ + { apps: suggestions, key: 'suggestions' }, + { apps: recentlyAdded, key: 'recentlyAdded' }, + ...APP_LIBRARY_CATEGORIES.map((category) => ({ + apps: gridApps.value.filter((app) => app.category === category), + key: category, + })), + ] + return groups + .filter((group) => group.apps.length > 0) + .map((group) => ({ + ...group, + apps: group.apps.slice(0, 3), + moreApps: group.apps.slice(3), + })) }) const alphabeticalGroups = computed(() => { const groups: Array<{ apps: PhoneAppDefinition[]; letter: string }> = [] @@ -57,8 +94,10 @@ const alphabeticalGroups = computed(() => { }) const trackStyle = computed(() => ({ '--drag-offset': `${dragOffset.value}px`, - '--springboard-page': phone.currentPage, + '--springboard-offset': `${(-phone.currentPage * 100) / pageCount.value}%`, + width: `${pageCount.value * 100}%`, })) +const pageStyle = computed(() => ({ width: `${100 / pageCount.value}%` })) function onPointerDown(event: PointerEvent): void { const target = event.target as HTMLElement @@ -80,7 +119,10 @@ function finishPointer(event: PointerEvent): void { const elapsed = Math.max(1, Date.now() - pointerStartedAt) const velocity = Math.abs(distance) / elapsed if (Math.abs(distance) > 48 || velocity > 0.45) { - phone.setCurrentPage(phone.currentPage + (distance < 0 ? 1 : -1)) + phone.setCurrentPage( + phone.currentPage + (distance < 0 ? 1 : -1), + pageCount.value, + ) } dragging.value = false dragOffset.value = 0 @@ -116,22 +158,27 @@ function openAllApps(): void { >
- +
@@ -173,6 +220,7 @@ function openAllApps(): void { :show-label="false" />
- {{ - phone.t(index < 2 ? `Home.groups.${index}` : 'Home.groups.other') - }} + {{ phone.t(`Home.groups.${group.key}`) }}
@@ -223,11 +269,7 @@ function openAllApps(): void { -
diff --git a/frontend/src/views/apps/AppStoreApp.vue b/frontend/src/views/apps/AppStoreApp.vue index 0496694..36dff1b 100644 --- a/frontend/src/views/apps/AppStoreApp.vue +++ b/frontend/src/views/apps/AppStoreApp.vue @@ -1,144 +1,127 @@