diff --git a/README.md b/README.md index 428972a..22f9e6a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,42 @@ # sky_phone +## FlipTok verification + +FlipTok verification is server-authoritative and limited to the framework groups configured in +`Config.FlipTok.AdminGroups`; no command ACE is required. Use +`/fliptokverify <@handle> [on|off]`. Without `on` or `off`, the current blue-check state is toggled. +The command name is configurable through `Config.FlipTok.VerifyCommand`. +Verification command access uses `Config.FlipTok.AdminGroups`. The report moderation overview is +server-authoritative and independently restricted through `Config.FlipTok.ReportAdminGroups`. + +## FlipTok music + +Licensed music can be exposed in the composer through `Config.FlipTok.MusicTracks`. Keep the IDs +stable because published videos store the selected ID; URLs must be directly playable by the NUI. + +```lua +MusicTracks = { + { Id = "night-drive", Title = "Night Drive", Artist = "Sky Radio", Url = "https://cdn.example.com/night-drive.ogg" }, +} +``` + +## FlipTok accounts + +FlipTok profiles use their own username and password login. Registration requires a linked iFruit +account once so an existing creator profile, videos, followers, and verification can be claimed +without data loss. Login sessions are stored per phone IMEI and survive resource or server restarts; +signing out removes only that device session. + +Set a private, stable password pepper in `server.cfg` before players register. Changing it later +invalidates every existing FlipTok password: + +```cfg +set sky_phone_fliptok_password_pepper "replace-with-a-long-random-secret" +``` + +Passwords are stored as salted hashes. The pepper is read server-side from the convar and is never +included in the NUI bundle. + Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8918086..9c40798 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -14,6 +14,7 @@ import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue' import PhoneControlCenter from '@/components/PhoneControlCenter.vue' import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue' import PhoneLockScreen from '@/components/PhoneLockScreen.vue' +import PhonePasscode from '@/components/PhonePasscode.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' @@ -30,6 +31,7 @@ import { useMailStore } from '@/stores/mail' import { useMessagesStore } from '@/stores/messages' import { useDarkChatStore } from '@/stores/darkchat' import { useFlareStore } from '@/stores/flare' +import { useFlipTokStore } from '@/stores/fliptok' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' @@ -60,6 +62,8 @@ type AppMessage = { | MessagesEventData | DarkChatEventData | FlareEventData + | FlipTokVerificationData + | FlipTokNotificationData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -124,6 +128,20 @@ type CalendarReminderData = { text?: string title?: string } + +type FlipTokVerificationData = { + profileId: number + verified: boolean +} + +type FlipTokNotificationData = { + actor?: string + device?: PhoneNotificationDevicePayload + kind?: 'like' | 'comment' | 'follow' | 'verified' + text?: string + title?: string + videoId?: string +} const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -140,6 +158,7 @@ const mail = useMailStore() const messages = useMessagesStore() const darkchat = useDarkChatStore() const flare = useFlareStore() +const fliptok = useFlipTokStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -155,6 +174,13 @@ const appTransitionName = computed(() => ) const isLocked = ref(false) const isUnlocking = ref(false) +const passcodeBusy = ref(false) +const passcodeError = ref('') +const passcodeResetKey = ref(0) +const passcodeRetrySeconds = ref(0) +const passcodeVisible = ref(false) +const pendingUnlockRoute = ref(null) +const unlockedServicesLoaded = ref(false) const controlCenterOpened = ref(false) const simPicker = ref(null) const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') @@ -180,6 +206,7 @@ const phoneFrameImage = computed( ) let clockTicker: ReturnType | undefined let unlockTimer: number | undefined +let passcodeLockTimer: number | undefined function getViewportScale(): number { const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT @@ -197,12 +224,17 @@ function hydratePhone(payload: PhoneOpenPayload): void { media.hydrate(payload.device?.data.media?.payload) appStore.hydrate(payload.device?.data.apps?.payload) widgets.hydrate(payload.device?.data.widgets?.payload) - void mail.bootstrap(payload.account?.email ?? '') - if (payload.account?.email) void marketplace.loadCounts() +} + +function loadUnlockedPhoneData(): void { + if (unlockedServicesLoaded.value) return + unlockedServicesLoaded.value = true + void mail.bootstrap(account.email) + if (account.email) void marketplace.loadCounts() else marketplace.setCounts({ active: 0, unread: 0 }) void calls.bootstrap() void messages.loadConversations() - if (payload.account?.email) void darkchat.bootstrap() + if (account.email) void darkchat.bootstrap() } async function hydrateDevelopmentPhone(): Promise { @@ -274,6 +306,32 @@ function onMessage(event: MessageEvent): void { } else if (event.data?.type === 'marketplace:changed' && event.data.data) { const data = event.data.data as MarketplaceEventData if (data.counts) marketplace.setCounts(data.counts) + } else if ( + event.data?.type === 'fliptok:verification-changed' && + event.data.data + ) { + const data = event.data.data as FlipTokVerificationData + fliptok.applyVerification(Number(data.profileId), data.verified === true) + } else if (event.data?.type === 'fliptok:new' && event.data.data) { + const data = event.data.data as FlipTokNotificationData + const notification: PhoneNotificationInput = { + appId: 'fliptok', + subtitle: data.actor, + text: data.text ?? phone.t('Apps.fliptok.notifications.default'), + title: data.title ?? phone.t('Apps.fliptok.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) + if (phone.isOpen) void fliptok.loadActivities() } else if ( event.data?.type === 'marketplace:new-message' && event.data.data @@ -442,8 +500,11 @@ function onMessage(event: MessageEvent): void { ) { calls.applyCallState(event.data.data as PhoneCall) controlCenterOpened.value = false - isLocked.value = false - isUnlocking.value = false + if (!phone.security.enabled) { + isLocked.value = false + isUnlocking.value = false + loadUnlockedPhoneData() + } window.setTimeout(() => void router.push('/apps/phone'), 0) } else if (event.data?.type === 'sim:picker' && event.data.data) { simPicker.value = event.data.data as unknown as SimPickerPayload @@ -470,14 +531,78 @@ function updateViewportScale(): void { viewportScale.value = getViewportScale() } -function unlockPhone(): void { +function finishUnlock(): void { if (!isLocked.value) return isUnlocking.value = true isLocked.value = false + passcodeVisible.value = false + passcodeError.value = '' unlockTimer = window.setTimeout(() => { isUnlocking.value = false }, 720) + + if (pendingUnlockRoute.value) { + const routePath = pendingUnlockRoute.value + pendingUnlockRoute.value = null + window.setTimeout(() => void router.push(routePath), 0) + } + loadUnlockedPhoneData() +} + +function unlockPhone(): void { + if (!isLocked.value) return + if (phone.security.enabled) { + passcodeError.value = '' + passcodeVisible.value = true + return + } + finishUnlock() +} + +function cancelPasscode(): void { + if (passcodeBusy.value) return + passcodeVisible.value = false + passcodeError.value = '' + pendingUnlockRoute.value = null +} + +function startPasscodeLock(seconds: number): void { + if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer) + passcodeRetrySeconds.value = Math.max(1, Math.ceil(seconds)) + passcodeLockTimer = window.setInterval(() => { + passcodeRetrySeconds.value = Math.max(0, passcodeRetrySeconds.value - 1) + if (passcodeRetrySeconds.value === 0 && passcodeLockTimer !== undefined) { + window.clearInterval(passcodeLockTimer) + passcodeLockTimer = undefined + passcodeError.value = '' + } + }, 1000) +} + +async function submitUnlockPasscode(passcode: string): Promise { + if (passcodeBusy.value || passcodeRetrySeconds.value > 0) return + passcodeBusy.value = true + const response = await phone.unlockWithPasscode(passcode) + passcodeBusy.value = false + if (response.success) { + finishUnlock() + return + } + + passcodeResetKey.value += 1 + if (response.error === 'passcode_locked') { + startPasscodeLock(response.data?.retryAfter ?? 30) + passcodeError.value = phone.t('LockScreen.passcode.locked', { + seconds: String(response.data?.retryAfter ?? 30), + }) + return + } + if (response.error === 'rate_limited') { + passcodeError.value = phone.t('LockScreen.passcode.rateLimited') + return + } + passcodeError.value = phone.t('LockScreen.passcode.incorrect') } function toggleControlCenter(): void { @@ -486,6 +611,11 @@ function toggleControlCenter(): void { } function unlockCamera(): void { + if (phone.security.enabled) { + pendingUnlockRoute.value = '/apps/camera' + unlockPhone() + return + } unlockPhone() window.setTimeout(() => void router.push('/apps/camera'), 0) } @@ -574,12 +704,33 @@ watch( controlCenterOpened.value = false isLocked.value = false isUnlocking.value = false + passcodeVisible.value = false + passcodeBusy.value = false + passcodeError.value = '' + pendingUnlockRoute.value = null + unlockedServicesLoaded.value = false + if (passcodeLockTimer !== undefined) { + window.clearInterval(passcodeLockTimer) + passcodeLockTimer = undefined + } return } isLocked.value = true + unlockedServicesLoaded.value = false controlCenterOpened.value = false weather.start() isUnlocking.value = false + passcodeVisible.value = false + passcodeBusy.value = false + passcodeError.value = '' + passcodeResetKey.value += 1 + passcodeRetrySeconds.value = Math.max( + 0, + (phone.security.lockedUntil ?? 0) - Math.floor(Date.now() / 1000), + ) + if (passcodeRetrySeconds.value > 0) { + startPasscodeLock(passcodeRetrySeconds.value) + } phone.setLaunchOrigin(null) void router.replace('/') }, @@ -596,6 +747,7 @@ onBeforeUnmount(() => { weather.stop() if (clockTicker) clearInterval(clockTicker) if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) + if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer) window.removeEventListener('message', onMessage) window.removeEventListener('keydown', onKeydown) window.removeEventListener('resize', updateViewportScale) @@ -687,6 +839,26 @@ onBeforeUnmount(() => { @unlock="unlockPhone" /> + + + - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/img/app-icons/banking.webp b/frontend/src/assets/img/app-icons/banking.webp new file mode 100644 index 0000000..31eadf7 Binary files /dev/null and b/frontend/src/assets/img/app-icons/banking.webp differ diff --git a/frontend/src/assets/img/app-icons/darkchat.svg b/frontend/src/assets/img/app-icons/darkchat.svg deleted file mode 100644 index de63f3d..0000000 --- a/frontend/src/assets/img/app-icons/darkchat.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/src/assets/img/app-icons/darkchat.webp b/frontend/src/assets/img/app-icons/darkchat.webp new file mode 100644 index 0000000..cb6de7e Binary files /dev/null and b/frontend/src/assets/img/app-icons/darkchat.webp differ diff --git a/frontend/src/assets/img/app-icons/fliptok.webp b/frontend/src/assets/img/app-icons/fliptok.webp new file mode 100644 index 0000000..511bc27 Binary files /dev/null and b/frontend/src/assets/img/app-icons/fliptok.webp differ diff --git a/frontend/src/assets/img/app-icons/garage.svg b/frontend/src/assets/img/app-icons/garage.svg deleted file mode 100644 index 85e2abb..0000000 --- a/frontend/src/assets/img/app-icons/garage.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/img/app-icons/garage.webp b/frontend/src/assets/img/app-icons/garage.webp new file mode 100644 index 0000000..b65ce44 Binary files /dev/null and b/frontend/src/assets/img/app-icons/garage.webp differ diff --git a/frontend/src/assets/img/app-icons/settings.svg b/frontend/src/assets/img/app-icons/settings.svg new file mode 100644 index 0000000..f4a3d0e --- /dev/null +++ b/frontend/src/assets/img/app-icons/settings.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 1bd1679..069009e 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -4153,29 +4153,23 @@ button { 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 { +.messages-inbox-search { + position: relative; min-width: 0; + height: 40px; flex: 1; - border: 0; - outline: 0; - background: transparent; - color: #111; - font-size: 13px; +} +.messages-inbox-search__voice { + position: absolute; + z-index: 45; + top: 50%; + right: 8px; + width: 32px; + height: 32px; + display: grid; + place-items: center; + color: currentColor; + transform: translateY(-50%); } .messages-inbox-toolbar > button { width: 40px; @@ -4380,11 +4374,9 @@ button { } .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 { @@ -5166,7 +5158,6 @@ button { 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 { @@ -5533,7 +5524,6 @@ button { .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 { @@ -5543,7 +5533,6 @@ button { 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 { @@ -5636,6 +5625,11 @@ button { } .messages-thread-page .messages-chat-header__contact { top: 4px; + left: 50%; + width: 280px; + min-width: 280px; + margin-left: -140px; + transform: none; } .messages-thread-page .messages-avatar--header { width: 64px; @@ -5644,7 +5638,8 @@ button { } .messages-thread-page .messages-chat-header__name { min-width: 0; - max-width: 230px; + width: max-content; + max-width: calc(100% - 8px); height: 32px; padding: 0 11px; justify-content: center; @@ -6047,29 +6042,13 @@ button { height: 50px; gap: 10px; } -.messages-inbox-page .messages-inbox-toolbar label { +.messages-inbox-page .messages-inbox-search { 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; +.messages-inbox-page .messages-inbox-search__voice { 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; @@ -6100,8 +6079,7 @@ button { --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-toolbar label { +.phone-app.dark .messages-inbox-page .messages-inbox-header { background: var(--messages-inbox-control); box-shadow: none; } diff --git a/frontend/src/components/PhoneMediaCapture.vue b/frontend/src/components/PhoneMediaCapture.vue index cbf6c2c..cf6f48d 100644 --- a/frontend/src/components/PhoneMediaCapture.vue +++ b/frontend/src/components/PhoneMediaCapture.vue @@ -23,6 +23,7 @@ let renderFrameId: number | undefined let lastRenderAt = 0 let recorder: MediaRecorder | null = null let stream: MediaStream | null = null +let microphoneStream: MediaStream | null = null let chunks: RecordingChunk[] = [] let lastChunkAt = 0 let lastChunkTimecode: number | null = null @@ -95,7 +96,9 @@ function resetRecording(): void { function stopTracks(): void { stream?.getTracks().forEach((track) => track.stop()) + microphoneStream?.getTracks().forEach((track) => track.stop()) stream = null + microphoneStream = null } function cleanupRecording(): void { @@ -109,7 +112,7 @@ function cleanupRecording(): void { postRecordState(false) } -function startRecording(data: Record): void { +async function startRecording(data: Record): Promise { if (recorder) return if (typeof MediaRecorder === 'undefined') { window.postMessage( @@ -127,13 +130,45 @@ function startRecording(data: Record): void { } startRenderLoop() resetRecording() - stream = canvasRef.value?.captureStream(captureFps) ?? null - if (!stream) { + const videoStream = canvasRef.value?.captureStream(captureFps) ?? null + if (!videoStream) { cleanupRecording() return } + if (data.microphoneEnabled === true) { + try { + microphoneStream = await navigator.mediaDevices.getUserMedia({ + audio: { + autoGainControl: true, + echoCancellation: true, + noiseSuppression: true, + }, + }) + } catch { + videoStream.getTracks().forEach((track) => track.stop()) + cleanupRecording() + window.postMessage( + { + data: { error: 'microphone_unavailable', success: false }, + type: 'camera:recordError', + }, + '*', + ) + return + } + } + stream = new MediaStream([ + ...videoStream.getVideoTracks(), + ...(microphoneStream?.getAudioTracks() ?? []), + ]) + const mimeType = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp8', + 'video/webm', + ].find((type) => MediaRecorder.isTypeSupported(type)) recorder = new MediaRecorder(stream, { - mimeType: 'video/webm', + ...(mimeType ? { mimeType } : {}), + audioBitsPerSecond: 128_000, videoBitsPerSecond: bitrateBps, }) recorder.ondataavailable = (event) => { @@ -311,7 +346,7 @@ function onMessage(event: MessageEvent): void { type?: string } if (message.type === 'camera:recordStart') { - startRecording(message.data ?? {}) + void startRecording(message.data ?? {}) } else if (message.type === 'camera:recordStop') { void stopRecording(message.data ?? {}) } else if (message.type === 'camera:recordCancel') { diff --git a/frontend/src/components/PhonePasscode.vue b/frontend/src/components/PhonePasscode.vue new file mode 100644 index 0000000..3f09d7c --- /dev/null +++ b/frontend/src/components/PhonePasscode.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/frontend/src/components/SpringboardWidget.vue b/frontend/src/components/SpringboardWidget.vue index 9461fc0..738a1e2 100644 --- a/frontend/src/components/SpringboardWidget.vue +++ b/frontend/src/components/SpringboardWidget.vue @@ -111,6 +111,9 @@ const forecastLow = computed(() => const weatherIcon = computed( () => weatherIcons[weather.forecast.value?.condition ?? 'partly_cloudy'], ) +const visibleHourlyWeather = computed( + () => weather.forecast.value?.hourly.slice(0, 5) ?? [], +) const balance = computed(() => props.instance.settings.balanceSource === 'cash' ? bank.overview.value.cash @@ -148,6 +151,13 @@ function avatar(name: string): string { return name.trim().charAt(0).toLocaleUpperCase(phone.lang) || '?' } +function formatForecastHour(timestamp: number): string { + return new Intl.DateTimeFormat(phone.lang, { + hour: '2-digit', + minute: '2-digit', + }).format(timestamp) +} + function clearHold(): void { if (holdTimer !== undefined) window.clearTimeout(holdTimer) holdTimer = undefined @@ -326,6 +336,13 @@ onBeforeUnmount(() => { H: {{ forecastHigh ?? '--' }}°   L: {{ forecastLow ?? '--' }}° +
+
+ + + {{ hour.temperature }}° +
+
+ + + { + +
+ {{ phone.t('Apps.map.placeMarker') }} + {{ phone.t('Apps.map.placeMarkerHint') }} +
+ + + {{ phone.t('Common.cancel') }} + + + + {{ phone.t('Apps.map.addHere') }} + +
+
+ + + + + + + + + {{ toastText }} + @@ -428,6 +793,84 @@ onBeforeUnmount(() => { box-shadow: 0 1px 4px rgb(0 0 0 / 35%); } +.custom-map-marker { + position: absolute; + z-index: 2; + display: flex; + padding: 0; + align-items: center; + flex-direction: column; + border: 0; + color: #fff; + background: transparent; + filter: drop-shadow(0 2px 3px rgb(0 0 0 / 45%)); + transform-origin: 50% 100%; + white-space: nowrap; +} + +.custom-map-marker svg { + stroke: #fff; + stroke-width: 1.8; +} + +.custom-map-marker span { + max-width: 112px; + padding: 3px 7px; + overflow: hidden; + border: 0.5px solid rgb(255 255 255 / 18%); + border-radius: 8px; + background: rgb(20 20 22 / 84%); + box-shadow: 0 2px 7px rgb(0 0 0 / 30%); + backdrop-filter: blur(12px); + font-size: 10px; + font-weight: 650; + text-overflow: ellipsis; +} + +.map-placement-crosshair { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + width: 42px; + height: 42px; + border: 1px solid rgb(255 255 255 / 80%); + border-radius: 50%; + box-shadow: + 0 2px 12px rgb(0 0 0 / 40%), + inset 0 0 0 1px rgb(0 0 0 / 16%); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.map-placement-crosshair::before, +.map-placement-crosshair::after, +.map-placement-crosshair span { + position: absolute; + top: 50%; + left: 50%; + background: #fff; + content: ''; + transform: translate(-50%, -50%); +} + +.map-placement-crosshair::before { + width: 16px; + height: 1px; +} + +.map-placement-crosshair::after { + width: 1px; + height: 16px; +} + +.map-placement-crosshair span { + width: 5px; + height: 5px; + border: 1px solid rgb(0 0 0 / 35%); + border-radius: 50%; +} + .map-controls { position: absolute; z-index: 3; @@ -448,6 +891,150 @@ onBeforeUnmount(() => { height: 21px; } +.map-control--marker { + --color-primary: #0a84ff; +} + +.map-placement-panel { + position: absolute; + z-index: 4; + right: 66px; + bottom: 28px; + left: 12px; + display: flex; + min-height: 84px; + padding: 12px; + flex-direction: column; + border: 0.5px solid rgb(255 255 255 / 22%); + border-radius: 18px; + color: #fff; + background: rgb(24 24 27 / 86%); + box-shadow: 0 8px 24px rgb(0 0 0 / 32%); + backdrop-filter: blur(22px) saturate(145%); +} + +.map-placement-panel strong { + font-size: 13px; +} + +.map-placement-panel > span { + margin-top: 2px; + color: rgb(255 255 255 / 64%); + font-size: 10px; + line-height: 1.25; +} + +.map-placement-panel > div { + display: grid; + margin-top: 10px; + grid-template-columns: 1fr 1fr; + gap: 7px; +} + +.map-placement-panel :deep(button) { + min-height: 32px; + font-size: 11px; +} + +.map-marker-sheet__content { + display: flex; + min-height: 350px; + padding: 22px 16px calc(18px + env(safe-area-inset-bottom)); + flex-direction: column; + color: #111; +} + +.map-marker-sheet__content--dark { + color: #fff; +} + +.map-marker-sheet__content h2 { + margin: 0; + font-size: 20px; + font-weight: 750; + letter-spacing: -0.4px; + text-align: center; +} + +.map-marker-sheet__content > p { + margin: 5px 0 14px; + color: rgb(60 60 67 / 60%); + font-size: 12px; + line-height: 1.4; + text-align: center; +} + +.map-marker-sheet__content--dark > p { + color: rgb(235 235 245 / 60%); +} + +.map-marker-sheet__label { + margin: 13px 5px 8px; + color: rgb(60 60 67 / 60%); + font-size: 12px; +} + +.map-marker-sheet__content--dark .map-marker-sheet__label { + color: rgb(235 235 245 / 60%); +} + +.map-marker-colors { + display: flex; + margin-bottom: 18px; + justify-content: center; + gap: 14px; +} + +.map-marker-colors button { + width: 31px; + height: 31px; + padding: 0; + border: 3px solid transparent; + border-radius: 50%; + box-shadow: 0 1px 4px rgb(0 0 0 / 25%); +} + +.map-marker-colors .map-marker-color--active { + border-color: #fff; + outline: 2px solid #0a84ff; +} + +.map-marker-error { + color: #ff3b30 !important; + font-size: 11px !important; +} + +.map-marker-sheet__content--details { + min-height: 250px; + align-items: center; +} + +.map-marker-sheet__pin { + display: grid; + width: 58px; + height: 58px; + margin-bottom: 10px; + place-items: center; + border-radius: 18px; + background: rgb(120 120 128 / 12%); +} + +.map-marker-sheet__pin svg { + stroke: #fff; +} + +.map-marker-sheet__content .map-marker-delete { + width: 100%; + margin-top: 10px; + color: #fff; + background: #ff3b30; +} + +.map-marker-sheet__content .map-marker-waypoint { + width: 100%; + margin-top: auto; +} + .map-error { position: absolute; top: 50%; diff --git a/frontend/src/views/apps/MessagesApp.vue b/frontend/src/views/apps/MessagesApp.vue index 1d37ce2..c3a3170 100644 --- a/frontend/src/views/apps/MessagesApp.vue +++ b/frontend/src/views/apps/MessagesApp.vue @@ -14,6 +14,7 @@ import { kNavbarBackLink, kPage, kPreloader, + kSearchbar, kToast, kToolbarPane, } from 'konsta/vue' @@ -394,7 +395,8 @@ async function saveContactDetails(): Promise { return } contactEditing.value = false - contactNumberDraft.value = response.data?.phone_number ?? contactNumberDraft.value + contactNumberDraft.value = + response.data?.phone_number ?? contactNumberDraft.value } async function deleteActiveContact(): Promise { @@ -432,7 +434,10 @@ function openEmojiPicker(): void { emojiOpen.value = true } -function openMediaApp(app: 'camera' | 'photos', mediaType: 'photo' | 'video'): void { +function openMediaApp( + app: 'camera' | 'photos', + mediaType: 'photo' | 'video', +): void { if (!messages.activeNumber) return attachmentMenuOpen.value = false messageMedia.begin(messages.activeNumber, mediaType) @@ -536,7 +541,10 @@ function sampleMicrophone(): void { async function startVoiceRecording(): Promise { emojiOpen.value = false - if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { + if ( + !navigator.mediaDevices?.getUserMedia || + typeof MediaRecorder === 'undefined' + ) { showToast(phone.t('Apps.messages.microphoneUnavailable')) return } @@ -618,7 +626,13 @@ function compressedWaveform(): number[] { const end = Math.max(start + 1, Math.floor((index + 1) * bucketSize)) const bucket = recordingSamples.slice(start, end) result.push( - Math.max(0.08, Math.min(1, bucket.reduce((sum, value) => sum + value, 0) / bucket.length)), + Math.max( + 0.08, + Math.min( + 1, + bucket.reduce((sum, value) => sum + value, 0) / bucket.length, + ), + ), ) } return result @@ -694,7 +708,9 @@ onBeforeUnmount(() => { >
- +

{{ phone.t('Apps.messages.noSim') }}

{{ phone.t('Apps.messages.noSimBody') }}

@@ -746,10 +762,7 @@ onBeforeUnmount(() => { -
+
- +

{{ phone.t( @@ -823,16 +840,31 @@ onBeforeUnmount(() => {

- + + + +
{