Merge branch 'dev' into weather-app

This commit is contained in:
Type
2026-08-13 11:47:07 +02:00
288 changed files with 58857 additions and 4439 deletions
+72 -23
View File
@@ -1,16 +1,21 @@
<script setup lang="ts">
import { kBadge } from 'konsta/vue'
import { Minus } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
import { getPhoneAppLabel, isPhoneAppRemovable } from '@/config/apps'
import { useMailStore } from '@/stores/mail'
import { useBillingStore } from '@/stores/billing'
import { useCompaniesStore } from '@/stores/companies'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useDarkChatStore } from '@/stores/darkchat'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
import {
reorderDirectionFromKeyboard,
type ReorderDirection,
} from '@/utils/keyboard'
const props = withDefaults(
defineProps<{
@@ -32,11 +37,13 @@ const emit = defineEmits<{
dragstart: [event: PointerEvent]
edit: []
remove: []
reorder: [direction: ReorderDirection]
}>()
const phone = usePhoneStore()
const mail = useMailStore()
const billing = useBillingStore()
const companies = useCompaniesStore()
const marketplace = useMarketplaceStore()
const darkchat = useDarkChatStore()
const router = useRouter()
@@ -54,15 +61,31 @@ const dragStyle = computed(() =>
}
: undefined,
)
const iconStyle = computed(() =>
props.app.kind === 'external' && props.app.iconBackground
? { backgroundColor: props.app.iconBackground }
: undefined,
)
const suppressClick = ref(false)
let holdTimer: number | undefined
let calendarTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let pointerTarget: HTMLElement | null = null
let pointerId: number | null = null
watch(
() => props.app.iconImage,
() => {
iconFailed.value = false
},
)
const unreadCount = computed(() => {
if (props.app.id === 'mail') return mail.counts.unread
if (props.app.id === 'citymarkt') return marketplace.counts.unread
if (props.app.id === 'darkchat') return darkchat.unreadCount
if (props.app.id === 'billing') return billing.overview?.unreadCount ?? 0
if (props.app.id === 'companies') return companies.unreadCount
return 0
})
const notificationBadgeColors = {
@@ -119,6 +142,9 @@ function clearHold(): void {
function onPointerDown(event: PointerEvent): void {
if (props.compact || event.button !== 0) return
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
@@ -157,35 +183,48 @@ function beginPointerDrag(event: PointerEvent): void {
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
isDragging.value = true
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', cancelPointerDrag)
emit('dragstart', event)
}
function onPointerUp(event: PointerEvent): void {
clearHold()
if (!isDragging.value) return
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
if (isDragging.value) {
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
}
releasePointerCapture()
}
function cancelPointerDrag(): void {
clearHold()
if (!isDragging.value) return
const wasDragging = isDragging.value
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
emit('dragcancel')
releasePointerCapture()
if (wasDragging) emit('dragcancel')
}
function removeDragListeners(): void {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', cancelPointerDrag)
function releasePointerCapture(): void {
if (
pointerTarget &&
pointerId !== null &&
pointerTarget.hasPointerCapture(pointerId)
) {
pointerTarget.releasePointerCapture(pointerId)
}
pointerTarget = null
pointerId = null
}
function onKeydown(event: KeyboardEvent): void {
if (!props.editMode) return
const direction = reorderDirectionFromKeyboard(event)
if (!direction) return
event.preventDefault()
event.stopPropagation()
emit('reorder', direction)
}
onMounted(() => {
@@ -198,7 +237,7 @@ onMounted(() => {
onBeforeUnmount(() => {
clearHold()
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
removeDragListeners()
releasePointerCapture()
})
</script>
@@ -216,12 +255,17 @@ onBeforeUnmount(() => {
class="app-icon-button"
:class="{ 'app-icon-button--compact': compact }"
type="button"
:aria-label="phone.t(app.labelKey)"
:aria-label="getPhoneAppLabel(app, phone.t)"
:aria-disabled="!app.route"
:aria-keyshortcuts="
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
"
@click="launch"
@contextmenu.prevent
@keydown="onKeydown"
@pointercancel="cancelPointerDrag"
@pointerdown="onPointerDown"
@lostpointercapture="cancelPointerDrag"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@@ -233,6 +277,7 @@ onBeforeUnmount(() => {
app.iconClass,
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
]"
:style="iconStyle"
>
<span v-if="app.id === 'calendar'" class="app-icon-calendar">
<strong>{{ calendarWeekday }}</strong>
@@ -262,14 +307,18 @@ onBeforeUnmount(() => {
</k-badge>
</span>
<span v-if="showLabel" class="app-icon-label">{{
phone.t(app.labelKey)
getPhoneAppLabel(app, phone.t)
}}</span>
</button>
<button
v-if="editMode && !NON_REMOVABLE_PHONE_APP_IDS.has(app.id)"
v-if="editMode && isPhoneAppRemovable(app)"
class="app-icon-remove"
type="button"
:aria-label="phone.t('Home.removeApp', { app: phone.t(app.labelKey) })"
:aria-label="
phone.t('Home.removeApp', {
app: getPhoneAppLabel(app, phone.t),
})
"
@click.stop="emit('remove')"
@pointerdown.stop
>
+497
View File
@@ -0,0 +1,497 @@
<script setup lang="ts">
import { kBlock, kButton, kPage, kPreloader } from 'konsta/vue'
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
import { useAppCatalogStore } from '@/stores/app-catalog'
import { useNotificationsStore } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
import type {
CustomAppOpenRequest,
ExternalPhoneAppDefinition,
LaunchablePhoneAppId,
SkyPhoneAppBridgeRequest,
SkyPhoneAppBridgeResponse,
SkyPhoneAppContextV1,
} from '@/types/apps'
import {
createCustomAppBridgeRequestHandler,
getCustomAppFrameBootstrapMessages,
getSkyPhoneAppCapabilities,
shouldReportCustomAppReady,
} from '@/utils/customAppBridge'
import {
createCustomAppLifecycleReporter,
customAppOrientationCoordinator,
customAppLifecycleScheduler,
getCustomAppSafeArea,
} from '@/utils/customAppLifecycle'
import {
createLbPhoneFrameDocument,
createLbPhoneHostSettings,
getLbPhoneCallbackResource,
usesLbPhoneHostRuntime,
} from '@/utils/lbPhoneAppBridge'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
const props = defineProps<{
app: ExternalPhoneAppDefinition
}>()
const PROTOCOL_VERSION = 1
const catalog = useAppCatalogStore()
const notifications = useNotificationsStore()
const phone = usePhoneStore()
const router = useRouter()
const frame = ref<HTMLIFrameElement | null>(null)
const frameLoaded = ref(false)
const frameUnavailable = ref(false)
const skyBridgeReady = ref(false)
const lbFrameDocument = ref<string | null>(null)
let loadTimeout: ReturnType<typeof setTimeout> | undefined
let frameDocumentController: AbortController | undefined
const lifecycleAppId = props.app.id
const initialOpenRequest: CustomAppOpenRequest | undefined =
catalog.openRequests[lifecycleAppId]
const orientation = customAppOrientationCoordinator.createSession((landscape) =>
phone.setCameraLandscape(landscape),
)
const lifecycle = createCustomAppLifecycleReporter({
onFailure(event, error) {
console.error(
`[Custom apps] ${event} lifecycle failed for ${lifecycleAppId}: ${error}`,
)
},
scheduler: customAppLifecycleScheduler,
send: (event, data) =>
nuiCall('custom-app:lifecycle', {
appId: lifecycleAppId,
...(data === undefined ? {} : { data }),
event,
}),
})
const bridgeRequests = createCustomAppBridgeRequestHandler({
createNotification(notification) {
return notifications.show({
...notification,
appId: notification.appId as LaunchablePhoneAppId,
})
},
getSourceApp: () => props.app,
prepareExternalOpen: (appId, data) => catalog.requestOpen(appId, data),
resolveTarget(appId) {
const target = getPhoneApp(appId)
return target
? {
external: isExternalPhoneApp(target),
id: target.id,
route: target.route,
}
: null
},
storageCall: (endpoint, payload) => nuiCall(endpoint, payload),
})
const frameUrl = computed(() => {
const url = new URL(props.app.ui)
url.searchParams.set('skyPhoneAppId', props.app.id)
return url.href
})
const frameOrigin = computed(() => new URL(frameUrl.value).origin)
const lbHostRuntime = computed(() => usesLbPhoneHostRuntime(props.app))
const frameMountable = computed(
() => !lbHostRuntime.value || lbFrameDocument.value !== null,
)
const frameSource = computed(() =>
lbHostRuntime.value ? undefined : frameUrl.value,
)
const postMessageOrigin = computed(() =>
props.app.bundled || lbHostRuntime.value ? '*' : frameOrigin.value,
)
const sandbox = computed(() =>
props.app.bundled || lbHostRuntime.value
? 'allow-downloads allow-forms allow-modals allow-scripts'
: 'allow-downloads allow-forms allow-modals allow-same-origin allow-scripts',
)
const frameReady = computed(
() =>
frameLoaded.value &&
(props.app.bridgeMode === 'legacy' || skyBridgeReady.value),
)
const context = computed<SkyPhoneAppContextV1>(() => ({
appId: props.app.id,
capabilities: getSkyPhoneAppCapabilities(props.app.capabilities),
colorScheme: phone.isDarkMode ? 'dark' : 'light',
language: phone.lang,
locale: {
description: props.app.description,
name: props.app.name,
},
phoneScale: phone.preferences.settings.phoneScale / 100,
protocolVersion: PROTOCOL_VERSION,
safeArea: getCustomAppSafeArea(props.app.orientation),
}))
const lbSettings = computed(() =>
createLbPhoneHostSettings({
deviceName: phone.device?.name ?? '',
isDarkMode: phone.isDarkMode,
language: phone.lang,
preferences: phone.preferences,
securityEnabled: phone.security.enabled,
}),
)
function postToFrame(payload: unknown): boolean {
const target = frame.value?.contentWindow
if (!target) return false
try {
target.postMessage(cloneJsonData(payload), postMessageOrigin.value)
return true
} catch (error) {
console.error(`[Custom apps] Could not message ${props.app.id}.`, error)
return false
}
}
function queueReadyLifecycle(): void {
void lifecycle.report('open', initialOpenRequest?.data)
void lifecycle.report('ready')
}
function sendContext(): void {
if (!skyBridgeReady.value) return
postToFrame({
appId: props.app.id,
context: context.value,
protocolVersion: PROTOCOL_VERSION,
type: 'sky-phone-app:context',
})
}
function sendLbSettings(): void {
if (!lbHostRuntime.value || !frameLoaded.value) return
postToFrame({
settings: lbSettings.value,
type: 'sky-phone:lb-settings',
})
}
async function prepareLbFrameDocument(): Promise<void> {
const controller = new AbortController()
frameDocumentController = controller
try {
const response = await fetch(frameUrl.value, {
credentials: 'omit',
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`)
}
const html = await response.text()
lbFrameDocument.value = createLbPhoneFrameDocument(html, {
appName: props.app.id,
resourceName: getLbPhoneCallbackResource(props.app),
settings: lbSettings.value,
ui: props.app.ui,
})
} catch (error) {
if (controller.signal.aborted) return
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
loadTimeout = undefined
frameUnavailable.value = true
console.error(
`[Custom apps] Could not prepare LB Phone frame ${props.app.id}.`,
error,
)
}
}
function flushOpenRequest(): void {
const request = catalog.openRequests[props.app.id]
if (!request || !frameLoaded.value) return
if (props.app.bridgeMode === 'sky' && !skyBridgeReady.value) return
if (
props.app.bridgeMode === 'legacy' ||
postToFrame({
appId: props.app.id,
data: request.data,
protocolVersion: PROTOCOL_VERSION,
type: 'sky-phone-app:open',
})
) {
catalog.consumeOpenRequest(props.app.id, request.sequence)
}
}
function flushHostMessages(): void {
if (!frameLoaded.value) return
if (props.app.bridgeMode === 'sky' && !skyBridgeReady.value) return
let lastDeliveredSequence = 0
for (const message of catalog.hostMessages[props.app.id] ?? []) {
const delivered = postToFrame(
props.app.bridgeMode === 'sky'
? {
appId: props.app.id,
payload: message.payload,
protocolVersion: PROTOCOL_VERSION,
type: 'sky-phone-app:message',
}
: message.payload,
)
if (!delivered) break
lastDeliveredSequence = message.sequence
}
if (lastDeliveredSequence) {
catalog.consumeHostMessages(props.app.id, lastDeliveredSequence)
}
}
function sendResponse(response: SkyPhoneAppBridgeResponse): void {
postToFrame({
appId: props.app.id,
...response,
protocolVersion: PROTOCOL_VERSION,
type: 'sky-phone-app:response',
})
}
async function handleBridgeRequest(
request: SkyPhoneAppBridgeRequest,
): Promise<void> {
const result = await bridgeRequests.handle(request)
if (!result) return
sendResponse(result.response)
if (result.effect?.type === 'close') {
void router.push('/')
} else if (result.effect?.type === 'open') {
void router.push(result.effect.route)
}
}
function isTrustedFrameMessage(event: MessageEvent): boolean {
if (event.source !== frame.value?.contentWindow) return false
if (props.app.bundled || lbHostRuntime.value) {
return event.origin === 'null' || event.origin === frameOrigin.value
}
return event.origin === frameOrigin.value
}
function onFrameMessage(event: MessageEvent): void {
if (!isTrustedFrameMessage(event)) return
if (
!event.data ||
typeof event.data !== 'object' ||
Array.isArray(event.data)
) {
return
}
const message = event.data as Record<string, unknown>
if (
message.appId !== props.app.id ||
message.protocolVersion !== PROTOCOL_VERSION
) {
return
}
if (message.type === 'sky-phone-app:ready') {
if (!skyBridgeReady.value) {
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
loadTimeout = undefined
skyBridgeReady.value = true
frameUnavailable.value = false
sendContext()
flushOpenRequest()
flushHostMessages()
}
if (shouldReportCustomAppReady(props.app.bridgeMode, 'bridge-ready')) {
queueReadyLifecycle()
}
return
}
if (message.type === 'sky-phone-app:request') {
void handleBridgeRequest(message as unknown as SkyPhoneAppBridgeRequest)
}
}
function onFrameLoad(): void {
if (props.app.bridgeMode === 'legacy') {
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
loadTimeout = undefined
}
frameLoaded.value = true
for (const message of getCustomAppFrameBootstrapMessages(
props.app.compatibility,
)) {
postToFrame(message)
}
sendLbSettings()
if (props.app.bridgeMode === 'legacy' || skyBridgeReady.value) {
frameUnavailable.value = false
}
if (shouldReportCustomAppReady(props.app.bridgeMode, 'frame-load')) {
queueReadyLifecycle()
}
flushOpenRequest()
flushHostMessages()
}
function closeApp(): void {
void router.push('/')
}
onBeforeMount(() => {
window.addEventListener('message', onFrameMessage)
orientation.apply(props.app.orientation)
void lifecycle.report('open', initialOpenRequest?.data)
if (props.app.bridgeMode === 'legacy' && initialOpenRequest) {
catalog.consumeOpenRequest(props.app.id, initialOpenRequest.sequence)
}
loadTimeout = setTimeout(() => {
loadTimeout = undefined
frameUnavailable.value = true
console.error(
`[Custom apps] Frame readiness timed out for ${props.app.id}.`,
)
}, props.app.readyTimeoutMs)
if (lbHostRuntime.value) void prepareLbFrameDocument()
})
onBeforeUnmount(() => {
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
frameDocumentController?.abort()
window.removeEventListener('message', onFrameMessage)
orientation.release()
void lifecycle.report('close')
})
watch(context, sendContext, { deep: true })
watch(lbSettings, sendLbSettings, { deep: true })
watch(
() => props.app.orientation,
(nextOrientation) => orientation.apply(nextOrientation),
)
watch(() => catalog.hostMessages[props.app.id], flushHostMessages, {
deep: true,
})
watch(() => catalog.openRequests[props.app.id], flushOpenRequest, {
deep: true,
})
</script>
<template>
<k-page
component="main"
class="custom-app-page"
:class="{
'custom-app-page--landscape': app.orientation === 'landscape',
}"
>
<iframe
v-if="frameMountable"
ref="frame"
v-show="frameReady && !frameUnavailable"
class="custom-app-frame"
:class="{
'custom-app-frame--fix-blur': app.compatibility.fixBlur === true,
}"
:sandbox="sandbox"
:src="frameSource"
:srcdoc="lbFrameDocument ?? undefined"
:title="app.name"
referrerpolicy="no-referrer"
@load="onFrameLoad"
/>
<div
v-if="!frameReady && !frameUnavailable"
class="custom-app-state"
role="status"
>
<k-preloader />
<k-block>{{ phone.t('Apps.customApps.loading') }}</k-block>
</div>
<div v-else-if="frameUnavailable" class="custom-app-state" role="alert">
<k-block>
<strong>{{ phone.t('Apps.customApps.unavailableTitle') }}</strong>
<p>{{ phone.t('Apps.customApps.unavailableBody') }}</p>
</k-block>
<k-button rounded @click="closeApp">
{{ phone.t('Apps.customApps.close') }}
</k-button>
</div>
</k-page>
</template>
<style scoped>
.custom-app-page {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--phone-app-background, #000);
}
.custom-app-page--landscape {
top: 50%;
right: auto;
bottom: auto;
left: 50%;
width: 827px;
height: 368px;
width: 100cqh;
height: 100cqw;
transform: translate(-50%, -50%) rotate(90deg);
}
.custom-app-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
.custom-app-frame--fix-blur {
transform: translateZ(0);
}
.custom-app-state {
position: absolute;
inset: 44px 20px 25px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
text-align: center;
}
.custom-app-state :deep(.k-block) {
margin: 0;
}
.custom-app-state strong {
display: block;
margin-bottom: 7px;
font-size: 17px;
}
.custom-app-state p {
margin: 0;
color: rgb(142 142 147);
font-size: 13px;
line-height: 1.35;
}
</style>
+4 -1
View File
@@ -35,7 +35,10 @@ function closeFromOutside(event: PointerEvent): void {
}
function closeFromEscape(event: KeyboardEvent): void {
if (event.key === 'Escape') opened.value = false
if (event.key !== 'Escape' || !opened.value) return
event.preventDefault()
event.stopPropagation()
opened.value = false
}
onMounted(() => {
File diff suppressed because one or more lines are too long
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Camera, Pause, Play } from 'lucide-vue-next'
import { Camera, Pause, Play, TriangleAlert } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import type { SmsMessageType } from '@/types/messages'
type MessageAttachment = {
@@ -11,7 +12,9 @@ type MessageAttachment = {
}
const props = defineProps<{ message: MessageAttachment }>()
const phone = usePhoneStore()
const playing = ref(false)
const playbackFailed = ref(false)
const video = ref<HTMLVideoElement>()
const imageStyles: Record<string, string> = {
@@ -55,9 +58,18 @@ async function toggleVideo(): Promise<void> {
playing.value = !playing.value
return
}
if (video.value.paused) await video.value.play()
else video.value.pause()
playing.value = !video.value.paused
if (!video.value.paused) {
video.value.pause()
return
}
playbackFailed.value = false
try {
await video.value.play()
} catch (error) {
playing.value = false
playbackFailed.value = true
console.error('[Messages] Could not play the attached video.', error)
}
}
function durationLabel(milliseconds: number | null): string {
@@ -85,8 +97,13 @@ function durationLabel(milliseconds: number | null): string {
v-else-if="message.message_type === 'video'"
type="button"
class="messages-attachment messages-attachment--video"
:class="{ playing }"
:class="{ playing, 'messages-attachment--failed': playbackFailed }"
:style="{ background }"
:aria-label="
playbackFailed
? phone.t('Apps.photos.errors.unsupported')
: phone.t('Apps.photos.videoAlt')
"
@click="toggleVideo"
>
<video
@@ -95,15 +112,25 @@ function durationLabel(milliseconds: number | null): string {
:src="mediaUrl"
playsinline
preload="metadata"
@play="playing = true"
@pause="playing = false"
@ended="playing = false"
@error="playbackFailed = true"
/>
<span
><Pause v-if="playing" :size="22" fill="currentColor" /><Play
><TriangleAlert v-if="playbackFailed" :size="22" /><Pause
v-else-if="playing"
:size="22"
fill="currentColor"
/><Play
v-else
:size="22"
fill="currentColor"
/></span>
<small>{{ durationLabel(message.media_duration_ms) }}</small>
<small v-if="playbackFailed">{{
phone.t('Apps.photos.errors.unsupported')
}}</small>
<small v-else>{{ durationLabel(message.media_duration_ms) }}</small>
</button>
<div v-else class="messages-attachment messages-attachment--gif">
<img
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { kButton } from 'konsta/vue'
import {
Check,
ChevronRight,
MessageCircle,
UserRound,
UserPlus,
} from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import type { SmsSharedContact } from '@/types/messages'
const props = defineProps<{
addLabel: string
contact: SmsSharedContact
messageLabel: string
saved: boolean
savedLabel: string
}>()
const emit = defineEmits<{ message: []; save: [] }>()
const imageFailed = ref(false)
watch(
() => props.contact.avatar_url,
() => {
imageFailed.value = false
},
)
const displayName = computed(
() => props.contact.name.trim() || props.contact.phone_number,
)
const showNumber = computed(() => Boolean(props.contact.name.trim()))
const initials = computed(() =>
displayName.value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join(''),
)
</script>
<template>
<div class="message-contact-card">
<div class="message-contact-card__identity">
<span class="message-contact-card__avatar">
<img
v-if="contact.avatar_url && !imageFailed"
:src="contact.avatar_url"
alt=""
@error="imageFailed = true"
/>
<b v-else-if="initials">{{ initials }}</b>
<UserRound v-else :size="28" />
</span>
<div>
<strong>{{ displayName }}</strong>
<small v-if="contact.organization">{{ contact.organization }}</small>
<small v-if="showNumber">({{ contact.phone_number }})</small>
</div>
<ChevronRight :size="20" class="message-contact-card__chevron" />
</div>
<div class="message-contact-card__actions">
<k-button rounded tonal @click.stop="emit('message')">
<MessageCircle :size="17" />
{{ messageLabel }}
</k-button>
<k-button
rounded
tonal
:disabled="saved"
@click.stop="emit('save')"
>
<Check v-if="saved" :size="17" />
<UserPlus v-else :size="17" />
{{ saved ? savedLabel : addLabel }}
</k-button>
</div>
</div>
</template>
<style scoped>
.message-contact-card {
width: min(258px, 100%);
width: min(258px, 72cqw);
overflow: hidden;
border: 1px solid rgb(255 255 255 / 48%);
border-radius: 21px;
color: #151517;
background: linear-gradient(155deg, rgb(255 255 255 / 96%), #edf5ff);
box-shadow: 0 8px 24px rgb(22 63 112 / 14%);
}
.message-contact-card__identity {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) 20px;
align-items: center;
gap: 11px;
min-height: 82px;
padding: 13px 12px;
}
.message-contact-card__avatar {
width: 58px;
height: 58px;
overflow: hidden;
border-radius: 50%;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #64d2ff, #0a84ff);
box-shadow: 0 4px 14px rgb(10 132 255 / 24%);
}
.message-contact-card__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.message-contact-card__identity b {
font-size: 18px;
}
.message-contact-card__identity div,
.message-contact-card__identity strong,
.message-contact-card__identity small {
min-width: 0;
}
.message-contact-card__identity strong,
.message-contact-card__identity small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-contact-card__identity strong {
font-size: 16px;
line-height: 1.2;
}
.message-contact-card__identity small {
margin-top: 3px;
color: #6e6e73;
font-size: 11.5px;
}
.message-contact-card__chevron {
color: #8e8e93;
}
.message-contact-card__actions {
display: grid;
gap: 1px;
padding: 8px;
border-top: 1px solid rgb(60 60 67 / 12%);
background: rgb(255 255 255 / 58%);
}
.message-contact-card__actions :deep(.button) {
width: 100%;
margin: 0;
min-height: 36px;
justify-content: flex-start;
padding-inline: 14px;
font-size: 13px;
}
:global(.phone-app.dark) .message-contact-card {
color: #f7f7f7;
border-color: rgb(255 255 255 / 10%);
background: linear-gradient(155deg, #34363b, #242a33);
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
}
:global(.phone-app.dark) .message-contact-card__identity small {
color: #aeaeb2;
}
:global(.phone-app.dark) .message-contact-card__actions {
border-top-color: rgb(255 255 255 / 10%);
background: rgb(0 0 0 / 10%);
}
</style>
@@ -14,6 +14,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
close: []
open: [notification: PhoneNotification]
}>()
const phone = usePhoneStore()
const device = computed(() => props.notification.device!)
@@ -69,6 +70,7 @@ const wrapperStyle = computed<CSSProperties>(() => ({ zoom: props.zoom }))
<PhoneNotifications
:notification="notification"
@close="emit('close')"
@open="emit('open', $event)"
/>
</k-app>
</div>
+6 -2
View File
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type PayphoneState =
| 'idle'
@@ -214,6 +215,7 @@ async function close(): Promise<void> {
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'payphone:open' && event.data.data) {
const payload = event.data.data as PayphoneOpenPayload
currency.value = payload.currency
@@ -237,6 +239,7 @@ function onKeydown(event: KeyboardEvent): void {
if (!visible.value) return
if (event.key === 'Escape') {
event.preventDefault()
event.stopImmediatePropagation()
void close()
return
}
@@ -254,7 +257,7 @@ function onKeydown(event: KeyboardEvent): void {
onMounted(() => {
prepareButtonSounds()
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
window.addEventListener('keydown', onKeydown, true)
ticker = window.setInterval(() => {
now.value = Date.now()
}, 250)
@@ -262,7 +265,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('keydown', onKeydown, true)
if (ticker !== undefined) window.clearInterval(ticker)
for (const sound of buttonSounds) {
sound.pause()
@@ -397,6 +400,7 @@ onBeforeUnmount(() => {
rgb(0 0 0 / 84%) 72%
);
font-family: 'Segoe UI', Arial, sans-serif;
pointer-events: auto;
user-select: none;
}
+37 -7
View File
@@ -10,6 +10,7 @@ import {
Pause,
Plane,
Play,
RadioTower,
Signal,
SkipBack,
SkipForward,
@@ -30,6 +31,8 @@ import { useRouter } from 'vue-router'
import { usePhoneStore } from '@/stores/phone'
import { useMusicStore } from '@/stores/music'
import { useEasyShareStore } from '@/stores/easyshare'
import type { EasySharePayload } from '@/types/easyshare'
import { nuiCall } from '@/utils/nui'
const props = defineProps<{ opened: boolean }>()
@@ -44,6 +47,7 @@ type ConnectivityPreference =
const phone = usePhoneStore()
const music = useMusicStore()
const easyShare = useEasyShareStore()
const router = useRouter()
const panel = ref<HTMLElement | null>(null)
const brightness = ref(phone.preferences.settings.screenBrightness)
@@ -56,6 +60,7 @@ const volume = ref(
)
const flashlightActive = ref(false)
const flashlightPending = ref(false)
const easySharePending = ref(false)
const previousAlertVolume = ref(volume.value || 75)
const inactiveGlassColors = {
@@ -202,6 +207,23 @@ async function toggleFlashlight(): Promise<void> {
flashlightPending.value = false
}
async function shareOwnContact(): Promise<void> {
if (easySharePending.value) return
easySharePending.value = true
const response = await nuiCall<EasySharePayload>('easyshare:own-contact')
easySharePending.value = false
if (!response.success || !response.data) {
console.error(
'[ControlCenter] Could not load own EasyShare contact.',
response.error,
)
return
}
emit('close')
easyShare.open(response.data)
await easyShare.showNearby()
}
function openApp(path: string): void {
emit('close')
void router.push(path)
@@ -360,9 +382,7 @@ onBeforeUnmount(() => {
</div>
<div class="control-center__middle-grid">
<div
class="control-center__round-control control-center__round-control--wide"
>
<div class="control-center__round-control">
<k-glass
component="button"
type="button"
@@ -385,6 +405,20 @@ onBeforeUnmount(() => {
</k-glass>
</div>
<div class="control-center__round-control">
<k-glass
component="button"
type="button"
class="control-center__round-button"
:colors="inactiveGlassColors"
:disabled="easySharePending || !phone.device?.sim"
:aria-label="phone.t('ControlCenter.easyShareContact')"
@click="shareOwnContact"
>
<RadioTower aria-hidden="true" />
</k-glass>
</div>
<k-glass
class="control-center__slider control-center__slider--brightness"
:colors="inactiveGlassColors"
@@ -712,10 +746,6 @@ onBeforeUnmount(() => {
justify-content: center;
}
.control-center__round-control--wide {
grid-column: 1 / span 2;
}
.control-center__round-control > span,
.control-center__quick-action > span {
width: 100%;
+195 -26
View File
@@ -1,12 +1,19 @@
<script setup lang="ts">
import { kFab, kGlass } from 'konsta/vue'
import { Camera, Flashlight, LockKeyhole, X } from 'lucide-vue-next'
import { Camera, Flashlight, LockKeyhole, Trash2 } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { getPhoneApp } from '@/config/apps'
import type { PhoneNotification } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
import {
clampNotificationSwipeOffset,
NOTIFICATION_SWIPE_ACTION_WIDTH,
resolveNotificationSwipeAxis,
shouldRevealNotificationAction,
type NotificationSwipeAxis,
} from '@/utils/notificationSwipe'
import { nuiCall } from '@/utils/nui'
import type { PhonePreferencesV1 } from '@/utils/preferences'
@@ -19,6 +26,7 @@ const emit = defineEmits<{
camera: []
clearNotifications: []
dismissNotification: [id: string]
openNotification: [notification: PhoneNotification]
unlock: []
}>()
@@ -28,6 +36,9 @@ const now = ref(new Date())
const dragOffset = ref(0)
const dragging = ref(false)
const flashlightActive = ref(false)
const revealedNotificationId = ref<string | null>(null)
const swipingNotificationId = ref<string | null>(null)
const notificationSwipeOffset = ref(0)
const shortcutColors = {
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
@@ -36,6 +47,12 @@ const shortcutColors = {
let pointerStart = 0
let pointerStartedAt = 0
let clockTicker: number | undefined
let notificationPointerId: number | null = null
let notificationPointerStartX = 0
let notificationPointerStartY = 0
let notificationPointerStartedAt = 0
let notificationSwipeAxis: NotificationSwipeAxis | null = null
let suppressNotificationClickUntil = 0
const date = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
@@ -72,9 +89,7 @@ const flashlightShortcutColors = computed(() =>
function onPointerDown(event: PointerEvent): void {
if (props.preview) return
if (
(event.target as HTMLElement).closest(
'button, .lock-screen__notifications',
)
(event.target as HTMLElement).closest('button, .lock-screen__notifications')
)
return
pointerStart = event.clientY
@@ -83,6 +98,131 @@ function onPointerDown(event: PointerEvent): void {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function openNotification(notification: PhoneNotification): void {
if (performance.now() <= suppressNotificationClickUntil) return
if (revealedNotificationId.value === notification.id) {
closeNotificationAction()
return
}
if (notification.route) emit('openNotification', notification)
}
function notificationOffset(notificationId: string): number {
if (swipingNotificationId.value === notificationId)
return notificationSwipeOffset.value
return revealedNotificationId.value === notificationId
? -NOTIFICATION_SWIPE_ACTION_WIDTH
: 0
}
function notificationStyle(notificationId: string): Record<string, string> {
return {
'--notification-swipe': `${notificationOffset(notificationId)}px`,
}
}
function closeNotificationAction(): void {
revealedNotificationId.value = null
swipingNotificationId.value = null
notificationSwipeOffset.value = 0
}
function beginNotificationSwipe(
notificationId: string,
event: PointerEvent,
): void {
if (props.preview || event.button !== 0) return
if (
revealedNotificationId.value &&
revealedNotificationId.value !== notificationId
) {
revealedNotificationId.value = null
}
notificationPointerId = event.pointerId
notificationPointerStartX = event.clientX
notificationPointerStartY = event.clientY
notificationPointerStartedAt = performance.now()
notificationSwipeAxis = null
swipingNotificationId.value = notificationId
notificationSwipeOffset.value =
revealedNotificationId.value === notificationId
? -NOTIFICATION_SWIPE_ACTION_WIDTH
: 0
}
function moveNotificationSwipe(
notificationId: string,
event: PointerEvent,
): void {
if (
swipingNotificationId.value !== notificationId ||
notificationPointerId !== event.pointerId
) {
return
}
const deltaX = event.clientX - notificationPointerStartX
const deltaY = event.clientY - notificationPointerStartY
notificationSwipeAxis ??= resolveNotificationSwipeAxis(deltaX, deltaY)
if (notificationSwipeAxis === 'vertical') {
swipingNotificationId.value = null
notificationSwipeOffset.value = 0
return
}
if (notificationSwipeAxis !== 'horizontal') return
const target = event.currentTarget as HTMLElement
if (!target.hasPointerCapture(event.pointerId))
target.setPointerCapture(event.pointerId)
event.preventDefault()
const startingOffset =
revealedNotificationId.value === notificationId
? -NOTIFICATION_SWIPE_ACTION_WIDTH
: 0
notificationSwipeOffset.value = clampNotificationSwipeOffset(
startingOffset + deltaX,
)
}
function finishNotificationSwipe(
notificationId: string,
event: PointerEvent,
): void {
if (
swipingNotificationId.value !== notificationId ||
notificationPointerId !== event.pointerId
) {
return
}
const elapsed = Math.max(1, performance.now() - notificationPointerStartedAt)
const velocityX = (event.clientX - notificationPointerStartX) / elapsed
const wasHorizontal = notificationSwipeAxis === 'horizontal'
if (wasHorizontal) {
suppressNotificationClickUntil = performance.now() + 120
revealedNotificationId.value = shouldRevealNotificationAction(
notificationSwipeOffset.value,
velocityX,
)
? notificationId
: null
}
swipingNotificationId.value = null
notificationSwipeOffset.value = 0
notificationPointerId = null
notificationSwipeAxis = null
}
function dismissNotification(notificationId: string): void {
closeNotificationAction()
emit('dismissNotification', notificationId)
}
function onPointerMove(event: PointerEvent): void {
if (!dragging.value) return
dragOffset.value = Math.min(0, event.clientY - pointerStart)
@@ -190,35 +330,64 @@ onBeforeUnmount(() => {
>
{{ phone.t('Notifications.clearAll') }}
</button>
<k-glass
<div
v-for="notification in props.notifications"
:key="notification.id"
class="lock-screen__notification"
:highlight="false"
class="lock-screen__notification-row"
:class="{
'is-revealed': revealedNotificationId === notification.id,
'is-swiping': swipingNotificationId === notification.id,
'is-action-visible': notificationOffset(notification.id) < -0.5,
}"
:style="notificationStyle(notification.id)"
>
<img
v-if="getPhoneApp(notification.appId)?.iconImage"
:src="getPhoneApp(notification.appId)?.iconImage"
alt=""
class="lock-screen__notification-icon"
/>
<div class="lock-screen__notification-copy">
<div class="lock-screen__notification-heading">
<strong>{{ notification.title }}</strong>
<span>{{ phone.t('Notifications.now') }}</span>
</div>
<small v-if="notification.subtitle">{{ notification.subtitle }}</small>
<p>{{ notification.text }}</p>
</div>
<button
class="lock-screen__notification-close"
class="lock-screen__notification-clear"
type="button"
:aria-label="phone.t('Common.close')"
@click.stop="emit('dismissNotification', notification.id)"
:tabindex="revealedNotificationId === notification.id ? 0 : -1"
:aria-hidden="revealedNotificationId !== notification.id"
:aria-label="phone.t('Notifications.clear')"
@click.stop="dismissNotification(notification.id)"
>
<X :size="14" aria-hidden="true" />
<Trash2 :size="18" :stroke-width="1.8" aria-hidden="true" />
<span>{{ phone.t('Notifications.clear') }}</span>
</button>
</k-glass>
<k-glass
class="lock-screen__notification"
:class="{ 'is-actionable': !!notification.route }"
:highlight="false"
:role="notification.route ? 'button' : undefined"
:tabindex="notification.route ? 0 : undefined"
:aria-label="
notification.route ? phone.t('Notifications.open') : undefined
"
@click="openNotification(notification)"
@keydown.enter.prevent="openNotification(notification)"
@keydown.space.prevent="openNotification(notification)"
@keydown.delete.prevent="dismissNotification(notification.id)"
@pointerdown.stop="beginNotificationSwipe(notification.id, $event)"
@pointermove.stop="moveNotificationSwipe(notification.id, $event)"
@pointerup.stop="finishNotificationSwipe(notification.id, $event)"
@pointercancel.stop="finishNotificationSwipe(notification.id, $event)"
>
<img
v-if="getPhoneApp(notification.appId)?.iconImage"
:src="getPhoneApp(notification.appId)?.iconImage"
alt=""
class="lock-screen__notification-icon"
/>
<div class="lock-screen__notification-copy">
<div class="lock-screen__notification-heading">
<strong>{{ notification.title }}</strong>
<span>{{ phone.t('Notifications.now') }}</span>
</div>
<small v-if="notification.subtitle">{{
notification.subtitle
}}</small>
<p>{{ notification.text }}</p>
</div>
</k-glass>
</div>
</section>
<div v-if="!preview" class="lock-screen__footer">
+193 -45
View File
@@ -4,13 +4,20 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
import type { UploadReady } from '@/types/media'
import { createGameView, type GameView } from '@/utils/gameView'
import {
bindMediaRecorderError,
setBoundedMapEntry,
stopMediaRecorder,
} from '@/utils/mediaRecorder'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type RecordingChunk = { blob: Blob; durationMs: number }
type PendingVideo = { blob: Blob; fileName: string }
const canvasRef = ref<HTMLCanvasElement | null>(null)
const pendingVideos = new Map<string, PendingVideo>()
const maxPendingVideos = 3
const captureFps = 30
const maxCaptureEdge = 720
const portraitAspect = 3 / 4
@@ -22,12 +29,16 @@ let gameView: GameView | null = null
let renderFrameId: number | undefined
let lastRenderAt = 0
let recorder: MediaRecorder | null = null
let recordingStarting = false
let stream: MediaStream | null = null
let microphoneStream: MediaStream | null = null
let chunks: RecordingChunk[] = []
let lastChunkAt = 0
let lastChunkTimecode: number | null = null
let recordingStartedAt = 0
let recordingGeneration = 0
let flushTimer: number | undefined
let removeRecorderErrorListener: (() => void) | null = null
function captureDimensions(): { height: number; width: number } {
return landscape
@@ -53,7 +64,11 @@ function ensureGameView(): GameView {
if (gameView && !gameView.isLost()) return gameView
gameView?.dispose()
const dimensions = captureDimensions()
gameView = createGameView(canvasRef.value)
gameView = createGameView(canvasRef.value, {
onContextRestored: () => {
if (recorder?.state === 'recording') startRenderLoop()
},
})
gameView.resize(
dimensions.width,
dimensions.height,
@@ -92,6 +107,7 @@ function resetRecording(): void {
chunks = []
lastChunkAt = 0
lastChunkTimecode = null
recordingStartedAt = 0
}
function stopTracks(): void {
@@ -102,8 +118,22 @@ function stopTracks(): void {
}
function cleanupRecording(): void {
if (recorder && recorder.state !== 'inactive') recorder.stop()
recordingGeneration += 1
recordingStarting = false
const activeRecorder = recorder
recorder = null
removeRecorderErrorListener?.()
removeRecorderErrorListener = null
if (activeRecorder) {
activeRecorder.ondataavailable = null
if (activeRecorder.state !== 'inactive') {
try {
activeRecorder.stop()
} catch (error) {
console.error('[Camera] Could not stop the failed media recorder.', error)
}
}
}
stopTracks()
if (flushTimer !== undefined) window.clearInterval(flushTimer)
flushTimer = undefined
@@ -113,7 +143,7 @@ function cleanupRecording(): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (recorder) return
if (recorder || recordingStarting) return
if (typeof MediaRecorder === 'undefined') {
window.postMessage(
{
@@ -128,7 +158,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
if (Number.isFinite(configuredBitrate) && configuredBitrate > 0) {
bitrateBps = Math.round(configuredBitrate * 1000)
}
startRenderLoop()
try {
startRenderLoop()
} catch (error) {
console.error('[Camera] Could not start game-view recording.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
recordingStarting = true
const generation = ++recordingGeneration
resetRecording()
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
if (!videoStream) {
@@ -137,15 +182,23 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
}
if (data.microphoneEnabled === true) {
try {
microphoneStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
const acquiredMicrophoneStream =
await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
if (generation !== recordingGeneration) {
acquiredMicrophoneStream.getTracks().forEach((track) => track.stop())
videoStream.getTracks().forEach((track) => track.stop())
return
}
microphoneStream = acquiredMicrophoneStream
} catch {
videoStream.getTracks().forEach((track) => track.stop())
if (generation !== recordingGeneration) return
cleanupRecording()
window.postMessage(
{
@@ -161,16 +214,51 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
...videoStream.getVideoTracks(),
...(microphoneStream?.getAudioTracks() ?? []),
])
if (generation !== recordingGeneration) {
stopTracks()
stopRenderLoop()
return
}
const mimeType = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp8',
'video/webm',
].find((type) => MediaRecorder.isTypeSupported(type))
recorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
try {
recorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
} catch (error) {
console.error('[Camera] Could not create the media recorder.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'unsupported', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
const activeRecorder = recorder
removeRecorderErrorListener = bindMediaRecorderError(
activeRecorder,
() =>
generation === recordingGeneration && recorder === activeRecorder,
(event) => {
console.error('[Camera] Media recorder failed while recording.', event)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
},
)
recorder.ondataavailable = (event) => {
if (!event.data.size) return
const now = Date.now()
@@ -185,7 +273,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
lastChunkAt = now
chunks.push({ blob: event.data, durationMs })
}
recorder.start()
try {
recorder.start()
} catch (error) {
console.error('[Camera] Could not start the media recorder.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
recordingStartedAt = Date.now()
recordingStarting = false
flushTimer = window.setInterval(() => {
if (recorder?.state === 'recording') recorder.requestData()
}, 1000)
@@ -195,37 +298,78 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
async function stopRecording(data: Record<string, unknown>): Promise<void> {
const correlationId = String(data.correlationId ?? '')
if (!recorder || recorder.state === 'inactive' || !correlationId) return
const generation = recordingGeneration
const activeRecorder = recorder
postRecordState(false, true)
recorder.requestData()
await new Promise((resolve) => window.setTimeout(resolve, 120))
recorder.stop()
await new Promise((resolve) => window.setTimeout(resolve, 120))
if (flushTimer !== undefined) window.clearInterval(flushTimer)
flushTimer = undefined
stopTracks()
recorder = null
stopRenderLoop()
const durationMs = chunks.reduce((sum, entry) => sum + entry.durationMs, 0)
let blob = new Blob(
chunks.map((entry) => entry.blob),
{ type: 'video/webm' },
)
blob = await (
fixWebmDuration as unknown as (
source: Blob,
duration: number,
options: { logger: boolean },
) => Promise<Blob>
)(blob, durationMs, { logger: false })
resetRecording()
pendingVideos.set(correlationId, {
blob,
fileName: `camera-${correlationId}.webm`,
})
await nuiCall('media:requestUpload', {
correlationId,
mediaType: 'video',
})
const stopErrorListener = removeRecorderErrorListener
stopErrorListener?.()
if (removeRecorderErrorListener === stopErrorListener) {
removeRecorderErrorListener = null
}
try {
await stopMediaRecorder(activeRecorder)
if (generation !== recordingGeneration) return
const durationMs = Math.max(
chunks.reduce((sum, entry) => sum + entry.durationMs, 0),
recordingStartedAt ? Date.now() - recordingStartedAt : 0,
)
let blob = new Blob(
chunks.map((entry) => entry.blob),
{ type: 'video/webm' },
)
blob = await (
fixWebmDuration as unknown as (
source: Blob,
duration: number,
options: { logger: boolean },
) => Promise<Blob>
)(blob, durationMs, { logger: false })
if (generation !== recordingGeneration) return
setBoundedMapEntry(
pendingVideos,
correlationId,
{ blob, fileName: `camera-${correlationId}.webm` },
maxPendingVideos,
)
const response = await nuiCall('media:requestUpload', {
correlationId,
mediaType: 'video',
})
if (generation !== recordingGeneration) return
if (!response.success) {
pendingVideos.delete(correlationId)
window.postMessage(
{
data: { correlationId, error: 'request_failed', success: false },
type: 'media:uploadResult',
},
'*',
)
}
} catch (error) {
if (generation === recordingGeneration) {
console.error('[Camera] Could not finalize the video recording.', error)
pendingVideos.delete(correlationId)
window.postMessage(
{
data: { correlationId, error: 'capture_failed', success: false },
type: 'media:uploadResult',
},
'*',
)
}
} finally {
if (generation === recordingGeneration || recorder === activeRecorder) {
if (recorder === activeRecorder) {
recorder = null
}
stopTracks()
stopRenderLoop()
resetRecording()
}
}
}
async function renderFrames(view: GameView, count: number): Promise<void> {
@@ -341,6 +485,7 @@ async function uploadReady(ready: UploadReady): Promise<void> {
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
const message = event.data as {
data?: Record<string, unknown>
type?: string
@@ -379,6 +524,9 @@ function onMessage(event: MessageEvent): void {
}
} else if (message.type === 'media:uploadReady') {
void uploadReady(message.data as UploadReady)
} else if (message.type === 'media:uploadResult') {
const correlationId = String(message.data?.correlationId ?? '')
if (correlationId) pendingVideos.delete(correlationId)
}
}
@@ -11,6 +11,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
close: []
open: [notification: PhoneNotification]
}>()
const phone = usePhoneStore()
const icon = computed(() =>
@@ -18,6 +19,16 @@ const icon = computed(() =>
? getPhoneApp(props.notification.appId)?.iconImage
: undefined,
)
function openNotification(event: MouseEvent): void {
if (
!props.notification?.route ||
(event.target as HTMLElement).closest('button')
) {
return
}
emit('open', props.notification)
}
</script>
<template>
@@ -29,7 +40,9 @@ const icon = computed(() =>
:title-right-text="phone.t('Notifications.now')"
button="close"
class="phone-notification"
:class="{ 'is-actionable': !!notification?.route }"
@close="emit('close')"
@click="openNotification"
>
<template v-if="icon" #icon>
<img :src="icon" alt="" class="phone-notification__icon" />
+31 -5
View File
@@ -1,17 +1,23 @@
<script setup lang="ts">
import { PhoneCall } from 'lucide-vue-next'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
withDefaults(
defineProps<{ controlCenterOpened?: boolean; lockable?: boolean }>(),
const props = withDefaults(
defineProps<{
activeCallReturn?: boolean
controlCenterOpened?: boolean
lockable?: boolean
}>(),
{
activeCallReturn: false,
controlCenterOpened: false,
lockable: false,
},
)
const emit = defineEmits<{ controlCenter: []; lock: [] }>()
const emit = defineEmits<{ activeCall: []; controlCenter: []; lock: [] }>()
const time = ref('')
let intervalId: number | undefined
@@ -23,6 +29,14 @@ function updateTime(): void {
}).format(new Date())
}
function handleTimeClick(): void {
if (props.activeCallReturn) {
emit('activeCall')
return
}
emit('lock')
}
onMounted(() => {
updateTime()
intervalId = window.setInterval(updateTime, 30_000)
@@ -38,10 +52,22 @@ onBeforeUnmount(() => {
<button
v-if="lockable"
class="phone-status-bar__time phone-status-bar__time-button"
:class="{
'phone-status-bar__time-button--active-call': activeCallReturn,
}"
type="button"
:aria-label="phone.t('LockScreen.label')"
@click.stop="emit('lock')"
:aria-label="
phone.t(
activeCallReturn ? 'Apps.phone.returnToCall' : 'LockScreen.label',
)
"
@click.stop="handleTimeClick"
>
<PhoneCall
v-if="activeCallReturn"
class="phone-status-bar__active-call-icon"
aria-hidden="true"
/>
<time>{{ time }}</time>
</button>
<time v-else class="phone-status-bar__time">{{ time }}</time>
+2
View File
@@ -3,6 +3,7 @@ import { Headphones } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { RadioHudConfig, RadioHudMember } from '@/types/radio'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type RadioHudEntry = RadioHudMember & {
state: 'recent' | 'talking'
@@ -122,6 +123,7 @@ function updateConfig(value: Partial<RadioHudConfig>): void {
}
function onMessage(event: MessageEvent<RadioHudMessage>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'radio:hud-config' && event.data.data) {
updateConfig(event.data.data as Partial<RadioHudConfig>)
} else if (event.data?.type === 'radio:hud-update' && event.data.data) {
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { Image, MapPin, Music2, Play, UserRound } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getPhoneApp, getPhoneAppLabel } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { EasySharePayload } from '@/types/easyshare'
import { openEasySharePayload } from '@/utils/easyshare'
const props = withDefaults(
defineProps<{
compact?: boolean
payload: EasySharePayload
variant?: 'darkchat' | 'flare' | 'messages'
}>(),
{ compact: false, variant: 'messages' },
)
const phone = usePhoneStore()
const router = useRouter()
const imageFailed = ref(false)
const sourceApp = computed(() => getPhoneApp(props.payload.appId))
const isVideo = computed(
() =>
props.payload.kind === 'video' ||
(props.payload.appId === 'fliptok' && props.payload.kind === 'post'),
)
const fallbackIcon = computed(() => {
if (props.payload.kind === 'location') return MapPin
if (props.payload.kind === 'profile') return UserRound
if (props.payload.kind === 'track' || props.payload.kind === 'playlist') {
return Music2
}
if (props.payload.kind === 'video') return Play
return Image
})
watch(
() => props.payload.imageUrl,
() => {
imageFailed.value = false
},
)
</script>
<template>
<article
class="shared-content-card"
:class="[
`shared-content-card--${variant}`,
{ 'shared-content-card--compact': compact },
]"
role="button"
tabindex="0"
@click="openEasySharePayload(router, payload)"
@keydown.enter.prevent="openEasySharePayload(router, payload)"
@keydown.space.prevent="openEasySharePayload(router, payload)"
>
<div class="shared-content-card__source">
<img v-if="sourceApp?.iconImage" :src="sourceApp.iconImage" alt="" />
<span>
<b>{{
sourceApp ? getPhoneAppLabel(sourceApp, phone.t) : payload.appId
}}</b>
<small>{{ phone.t(`Apps.easyShare.kinds.${payload.kind}`) }}</small>
</span>
</div>
<div class="shared-content-card__media">
<video
v-if="payload.imageUrl && !imageFailed && isVideo"
:src="payload.imageUrl"
muted
playsinline
preload="metadata"
@error="imageFailed = true"
/>
<img
v-else-if="payload.imageUrl && !imageFailed"
:src="payload.imageUrl"
alt=""
loading="lazy"
@error="imageFailed = true"
/>
<span v-else><component :is="fallbackIcon" :size="30" /></span>
</div>
<div class="shared-content-card__copy">
<small v-if="payload.subtitle">{{ payload.subtitle }}</small>
<strong>{{ payload.title }}</strong>
<p v-if="payload.copyText !== payload.title">{{ payload.copyText }}</p>
</div>
</article>
</template>
<style scoped>
.shared-content-card {
width: min(262px, 100%);
width: min(262px, 73cqw);
overflow: hidden;
border: 1px solid rgb(60 60 67 / 13%);
border-radius: 20px;
color: #171719;
background: rgb(255 255 255 / 96%);
box-shadow: 0 8px 24px rgb(19 45 78 / 13%);
cursor: pointer;
}
.shared-content-card:focus-visible {
outline: 3px solid #0a84ff;
outline-offset: 2px;
}
.shared-content-card__source {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 11px;
}
.shared-content-card__source > img {
width: 27px;
height: 27px;
border-radius: 7px;
object-fit: cover;
}
.shared-content-card__source b,
.shared-content-card__source small {
display: block;
line-height: 1.1;
}
.shared-content-card__source b {
font-size: 12px;
}
.shared-content-card__source small {
margin-top: 2px;
color: #8e8e93;
font-size: 10px;
}
.shared-content-card__media {
height: 142px;
overflow: hidden;
background: linear-gradient(145deg, #dcecff, #b9d5ff);
}
.shared-content-card__media > img,
.shared-content-card__media > video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.shared-content-card__media > span {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: #0a84ff;
}
.shared-content-card__copy {
padding: 10px 12px 12px;
}
.shared-content-card__copy small,
.shared-content-card__copy strong,
.shared-content-card__copy p {
display: block;
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
}
.shared-content-card__copy small {
margin-bottom: 3px;
color: #0a84ff;
font-size: 10px;
font-weight: 650;
}
.shared-content-card__copy strong {
display: -webkit-box;
font-size: 14px;
line-height: 1.25;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.shared-content-card__copy p {
display: -webkit-box;
margin-top: 5px;
color: #636366;
font-size: 11px;
line-height: 1.3;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.shared-content-card--compact {
width: 100%;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
border-radius: 16px;
box-shadow: none;
}
.shared-content-card--compact .shared-content-card__source {
grid-column: 2;
padding: 7px 9px 0;
}
.shared-content-card--compact .shared-content-card__media {
grid-row: 1 / span 2;
height: 78px;
}
.shared-content-card--compact .shared-content-card__copy {
grid-column: 2;
padding: 5px 9px 8px;
}
.shared-content-card--compact .shared-content-card__copy p,
.shared-content-card--compact .shared-content-card__source small {
display: none;
}
.shared-content-card--darkchat {
border-color: rgb(255 255 255 / 10%);
color: #f5f5f7;
background: linear-gradient(155deg, #302c3d, #1b1922);
box-shadow: 0 9px 25px rgb(0 0 0 / 28%);
}
.shared-content-card--darkchat .shared-content-card__media {
background: linear-gradient(145deg, #392f5c, #201c31);
}
.shared-content-card--darkchat .shared-content-card__media > span,
.shared-content-card--darkchat .shared-content-card__copy small {
color: #bf9cff;
}
.shared-content-card--darkchat .shared-content-card__copy p {
color: #beb9c8;
}
.shared-content-card--flare {
border-color: rgb(255 255 255 / 58%);
background: linear-gradient(155deg, #fff, #fff0f6);
box-shadow: 0 8px 24px rgb(245 71 132 / 15%);
}
.shared-content-card--flare .shared-content-card__media {
background: linear-gradient(145deg, #ffd4e4, #ffc1d5);
}
.shared-content-card--flare .shared-content-card__media > span,
.shared-content-card--flare .shared-content-card__copy small {
color: #ef3f7c;
}
:global(.phone-app.dark) .shared-content-card--messages {
border-color: rgb(255 255 255 / 10%);
color: #f5f5f7;
background: linear-gradient(155deg, #34363b, #242a33);
}
:global(.phone-app.dark) .shared-content-card--messages .shared-content-card__copy p {
color: #b8b8bd;
}
</style>
@@ -42,7 +42,6 @@ async function insert(
}
function close(): void {
void nuiCall('sim:picker-close')
emit('close')
}
</script>
+45 -17
View File
@@ -31,6 +31,10 @@ import { useCallsStore } from '@/stores/calls'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
import type { WidgetInstance } from '@/types/widgets'
import {
reorderDirectionFromKeyboard,
type ReorderDirection,
} from '@/utils/keyboard'
import type { WeatherConditionId } from '@/types/weather'
import { WIDGET_SPANS } from '@/utils/widgetLayout'
@@ -50,6 +54,7 @@ const emit = defineEmits<{
dragstart: [event: PointerEvent]
menu: []
remove: []
reorder: [direction: ReorderDirection]
}>()
const phone = usePhoneStore()
@@ -75,6 +80,8 @@ let holdTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let dragStartPage = 0
let dragPageWidth = 0
let pointerTarget: HTMLElement | null = null
let pointerId: number | null = null
const weatherIcons: Record<WeatherConditionId, Component> = {
sunny: Sun,
@@ -180,6 +187,9 @@ function onPointerDown(event: PointerEvent): void {
) {
return
}
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
@@ -217,35 +227,48 @@ function beginDrag(event: PointerEvent): void {
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
isDragging.value = true
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', cancelDrag)
emit('dragstart', event)
}
function onPointerUp(event: PointerEvent): void {
clearHold()
if (!isDragging.value) return
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
if (isDragging.value) {
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
}
releasePointerCapture()
}
function cancelDrag(): void {
clearHold()
if (!isDragging.value) return
const wasDragging = isDragging.value
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
emit('dragcancel')
releasePointerCapture()
if (wasDragging) emit('dragcancel')
}
function removeDragListeners(): void {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', cancelDrag)
function releasePointerCapture(): void {
if (
pointerTarget &&
pointerId !== null &&
pointerTarget.hasPointerCapture(pointerId)
) {
pointerTarget.releasePointerCapture(pointerId)
}
pointerTarget = null
pointerId = null
}
function onKeydown(event: KeyboardEvent): void {
if (!props.editMode) return
const direction = reorderDirectionFromKeyboard(event)
if (!direction) return
event.preventDefault()
event.stopPropagation()
emit('reorder', direction)
}
function openWidget(): void {
@@ -283,7 +306,7 @@ async function messageContact(phoneNumber: string): Promise<void> {
onBeforeUnmount(() => {
clearHold()
removeDragListeners()
releasePointerCapture()
})
</script>
@@ -307,9 +330,14 @@ onBeforeUnmount(() => {
:class="`home-widget--${instance.kind}`"
role="button"
tabindex="0"
:aria-keyshortcuts="
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
"
@click="openWidget"
@contextmenu.prevent
@keydown="onKeydown"
@keydown.enter="openWidget"
@lostpointercapture="cancelDrag"
@pointercancel="cancelDrag"
@pointerdown="onPointerDown"
@pointerleave="isDragging || clearHold()"
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import SpringboardWidget from '@/components/SpringboardWidget.vue'
import { useWidgetsStore } from '@/stores/widgets'
import type { ReorderDirection } from '@/utils/keyboard'
import { WIDGET_HOME_ROWS, WIDGET_PAGE_ROWS } from '@/utils/widgetLayout'
const props = defineProps<{
@@ -17,6 +18,7 @@ const emit = defineEmits<{
dragstart: [id: string, event: PointerEvent]
menu: [id: string]
remove: [id: string]
reorder: [id: string, direction: ReorderDirection]
}>()
const widgets = useWidgetsStore()
const rows = computed(() =>
@@ -51,6 +53,7 @@ const instances = computed(() =>
@dragstart="emit('dragstart', instance.id, $event)"
@menu="emit('menu', instance.id)"
@remove="emit('remove', instance.id)"
@reorder="emit('reorder', instance.id, $event)"
/>
</div>
</template>
@@ -71,12 +71,12 @@ watch(
</script>
<template>
<k-sheet
:opened="opened"
class="widget-config-sheet"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<div class="widget-config-sheet">
<k-sheet
:opened="opened"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<k-page
v-if="instance"
class="widget-config-page"
@@ -185,11 +185,12 @@ watch(
</section>
</div>
</k-page>
</k-sheet>
</k-sheet>
</div>
</template>
<style scoped>
:global(.widget-config-sheet) {
.widget-config-sheet :deep(.k-sheet) {
z-index: 115;
height: calc(100% - 22px);
overflow: hidden;
@@ -117,12 +117,12 @@ watch(
</script>
<template>
<k-sheet
:opened="opened"
class="widget-picker-sheet"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<div class="widget-picker-sheet">
<k-sheet
:opened="opened"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<k-page
class="widget-picker-page"
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
@@ -206,11 +206,12 @@ watch(
</p>
</div>
</k-page>
</k-sheet>
</k-sheet>
</div>
</template>
<style scoped>
:global(.widget-picker-sheet) {
.widget-picker-sheet :deep(.k-sheet) {
z-index: 110;
height: calc(100% - 22px);
overflow: hidden;
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { kDialog, kDialogButton } from 'konsta/vue'
import { useAppAuthStore, type AppAuthId } from '@/stores/app-auth'
import { useCrewLinkStore } from '@/stores/crewlink'
import { useFeatherStore } from '@/stores/feather'
import { useMarketplaceStore } from '@/stores/marketplace'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
const opened = defineModel<boolean>('opened', { default: false })
const emit = defineEmits<{ loggedOut: [] }>()
const props = defineProps<{ appId: AppAuthId; appName: string }>()
const appAuth = useAppAuthStore()
const crewLink = useCrewLinkStore()
const feather = useFeatherStore()
const marketplace = useMarketplaceStore()
const pages = usePagesStore()
const phone = usePhoneStore()
function close(): void {
opened.value = false
}
function confirmLogout(): void {
appAuth.signOut(props.appId)
if (props.appId === 'citymarkt') marketplace.$reset()
if (props.appId === 'local-pages') pages.$reset()
if (props.appId === 'feather') feather.$reset()
if (props.appId === 'crewlink') {
crewLink.$reset()
crewLink.error = 'not_authenticated'
}
opened.value = false
emit('loggedOut')
}
</script>
<template>
<k-dialog :opened="opened" @backdropclick="close">
<template #title>{{ phone.t('Common.signOutTitle', { app: appName }) }}</template>
<p>{{ phone.t('Common.signOutBody', { app: appName }) }}</p>
<template #buttons>
<k-dialog-button @click="close">
{{ phone.t('Common.cancel') }}
</k-dialog-button>
<k-dialog-button
strong
class="account-logout-confirm"
@click="confirmLogout"
>
{{ phone.t('Common.signOut') }}
</k-dialog-button>
</template>
</k-dialog>
</template>
<style scoped>
.account-logout-confirm {
background: #e44760 !important;
color: #fff !important;
}
</style>
@@ -0,0 +1,414 @@
<script setup lang="ts">
import {
ArrowRight,
Camera,
Images,
LockKeyhole,
Mail,
UserRound,
} from 'lucide-vue-next'
import {
kButton,
kGlass,
kList,
kListInput,
kPreloader,
kSegmented,
kSegmentedButton,
} from 'konsta/vue'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
avatarUrl: string | null
body: string
cameraLabel: string
email: string
emailLabel: string
error: string
eyebrow: string
galleryLabel: string
loginLabel: string
maxUsernameLength?: number
minUsernameLength?: number
mode: 'login' | 'register'
pending: boolean
registerLabel: string
title: string
username: string
usernameLabel: string
usernamePlaceholder?: string
}>(),
{
maxUsernameLength: 40,
minUsernameLength: 2,
usernamePlaceholder: '',
},
)
const emit = defineEmits<{
camera: []
gallery: []
submit: []
'update:mode': [value: 'login' | 'register']
'update:username': [value: string]
}>()
const canSubmit = computed(() => {
const length = props.username.trim().length
return Boolean(
props.email &&
length >= props.minUsernameLength &&
length <= props.maxUsernameLength,
)
})
</script>
<template>
<section class="app-profile-auth">
<header class="app-profile-auth__hero">
<span class="app-profile-auth__mark"><UserRound :size="23" /></span>
<div>
<small>{{ eyebrow }}</small>
<h2>{{ title }}</h2>
</div>
<p>{{ body }}</p>
</header>
<k-glass class="app-profile-auth__card">
<k-segmented raised class="app-profile-auth__mode">
<k-segmented-button
class="app-profile-auth__mode-button app-profile-auth__mode-button--login"
:class="{
'app-profile-auth__mode-button--active': mode === 'login',
}"
:active="mode === 'login'"
@click="emit('update:mode', 'login')"
>
{{ loginLabel }}
</k-segmented-button>
<k-segmented-button
class="app-profile-auth__mode-button app-profile-auth__mode-button--register"
:class="{
'app-profile-auth__mode-button--active': mode === 'register',
}"
:active="mode === 'register'"
@click="emit('update:mode', 'register')"
>
{{ registerLabel }}
</k-segmented-button>
</k-segmented>
<div v-if="mode === 'register'" class="app-profile-auth__photo">
<span class="app-profile-auth__avatar">
<img v-if="avatarUrl" :src="avatarUrl" alt="" />
<UserRound v-else :size="28" />
<i><Camera :size="11" /></i>
</span>
<div>
<k-button rounded outline @click="emit('gallery')">
<Images :size="15" />{{ galleryLabel }}
</k-button>
<k-button rounded outline @click="emit('camera')">
<Camera :size="15" />{{ cameraLabel }}
</k-button>
</div>
</div>
<div class="app-profile-auth__identity">
<span><Mail :size="17" /></span>
<div>
<small>{{ emailLabel }}</small>
<strong>{{ email }}</strong>
</div>
<LockKeyhole :size="15" />
</div>
<k-list inset strong class="app-profile-auth__fields">
<k-list-input
input-id="app-profile-auth-username"
:label="usernameLabel"
:value="username"
:maxlength="maxUsernameLength"
:placeholder="usernamePlaceholder"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
outline
@input="
emit('update:username', ($event.target as HTMLInputElement).value)
"
@keydown.enter="emit('submit')"
/>
</k-list>
<div v-if="error" class="app-profile-auth__error" role="alert">
{{ error }}
</div>
<k-button
large
rounded
class="app-profile-auth__submit"
:disabled="!canSubmit || pending"
@click="emit('submit')"
>
<k-preloader v-if="pending" />
<template v-else>
<span>{{ mode === 'login' ? loginLabel : registerLabel }}</span>
<ArrowRight :size="18" />
</template>
</k-button>
</k-glass>
</section>
</template>
<style scoped>
.app-profile-auth {
width: 100%;
max-width: 320px;
margin: 0 auto;
padding: 12px 4px 18px;
color: inherit;
text-align: center;
}
.app-profile-auth__hero {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
gap: 0 11px;
margin: 0 12px 14px;
text-align: left;
}
.app-profile-auth__mark {
display: grid;
width: 46px;
height: 46px;
grid-row: 1 / span 2;
place-items: center;
border: 1px solid
color-mix(in srgb, var(--auth-accent, #ffd63e) 46%, transparent);
border-radius: 15px;
color: var(--auth-accent, var(--yellow, #ffd63e));
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 14%, transparent);
box-shadow: 0 10px 28px
color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
}
.app-profile-auth__hero small {
color: var(--auth-accent, var(--yellow, #ffd63e));
font-size: 9px;
font-weight: 850;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.app-profile-auth__hero h2 {
margin: 3px 0 0;
color: inherit;
font-size: 20px;
line-height: 1.08;
}
.app-profile-auth__hero p {
grid-column: 2;
margin: 6px 0 0;
color: var(--muted, #9ba4aa);
font-size: 11px;
line-height: 1.35;
}
.app-profile-auth__card {
position: relative;
display: block;
padding: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
background: color-mix(in srgb, var(--panel, #20262c) 90%, transparent);
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(22px) saturate(1.15);
}
.app-profile-auth__card::before {
position: absolute;
top: -80px;
right: -55px;
width: 170px;
height: 150px;
border-radius: 50%;
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
filter: blur(38px);
content: '';
pointer-events: none;
}
.app-profile-auth__mode {
position: relative;
z-index: 1;
margin: 0 0 12px;
padding: 3px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(0, 0, 0, 0.16);
}
.app-profile-auth__mode :deep(.app-profile-auth__mode-button) {
border-radius: 3px;
}
.app-profile-auth__mode
:deep(
.app-profile-auth__mode-button--login.app-profile-auth__mode-button--active
) {
border-radius: 10px 3px 3px 10px;
}
.app-profile-auth__mode
:deep(
.app-profile-auth__mode-button--register.app-profile-auth__mode-button--active
) {
border-radius: 3px 10px 10px 3px;
}
.app-profile-auth__photo {
display: flex;
align-items: center;
gap: 12px;
margin: 0 2px 11px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
text-align: left;
}
.app-profile-auth__avatar {
position: relative;
display: grid;
width: 66px;
height: 66px;
flex: none;
place-items: center;
border: 2px solid
color-mix(in srgb, var(--auth-accent, #ffd63e) 58%, transparent);
border-radius: 50%;
color: var(--auth-accent, var(--yellow, #ffd63e));
background: color-mix(
in srgb,
var(--auth-accent, #ffd63e) 10%,
var(--panel, #20262c)
);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
}
.app-profile-auth__photo img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.app-profile-auth__avatar i {
position: absolute;
right: -2px;
bottom: -1px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 2px solid var(--panel, #20262c);
border-radius: 50%;
color: #fff;
background: var(--auth-accent, #ffd63e);
}
.app-profile-auth__photo > div {
display: grid;
min-width: 0;
flex: 1;
gap: 6px;
}
.app-profile-auth__photo :deep(.k-button) {
min-height: 32px;
justify-content: flex-start;
gap: 6px;
border-color: rgba(255, 255, 255, 0.12);
color: inherit;
background: rgba(255, 255, 255, 0.04);
font-size: 11px;
}
.app-profile-auth__identity {
position: relative;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 18px;
align-items: center;
gap: 9px;
margin-bottom: 9px;
padding: 9px 11px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 15px;
background: rgba(255, 255, 255, 0.045);
text-align: left;
}
.app-profile-auth__identity > span {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 11px;
color: var(--auth-accent, #ffd63e);
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
}
.app-profile-auth__identity div {
min-width: 0;
}
.app-profile-auth__identity small {
display: block;
margin-bottom: 1px;
color: var(--muted, #9ba4aa);
font-size: 9px;
}
.app-profile-auth__identity strong {
display: block;
overflow: hidden;
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-profile-auth__identity > svg {
color: var(--muted, #9ba4aa);
}
.app-profile-auth__fields {
margin-top: 0;
margin-right: 0;
margin-bottom: 11px;
margin-left: 0;
color: inherit;
background: rgba(255, 255, 255, 0.045) !important;
text-align: left;
}
.app-profile-auth__fields :deep(.text-black) {
color: inherit !important;
}
.app-profile-auth__fields :deep(.text-xs > div) {
background: color-mix(
in srgb,
var(--panel, #20262c) 94%,
transparent
) !important;
}
.app-profile-auth__error {
margin: -2px 1px 10px;
padding: 8px 10px;
border: 1px solid rgba(255, 105, 97, 0.22);
border-radius: 11px;
color: #ff6961;
background: rgba(255, 105, 97, 0.08);
font-size: 11px;
}
.app-profile-auth__submit {
--k-button-bg-color: var(--auth-accent, var(--yellow, #ffd63e));
--k-button-text-color: #fff;
width: 100%;
min-height: 44px;
display: flex;
justify-content: space-between;
padding: 0 17px;
color: #fff !important;
background: var(--auth-accent, var(--yellow, #ffd63e)) !important;
box-shadow: 0 10px 26px
color-mix(in srgb, var(--auth-accent, #ffd63e) 25%, transparent);
font-weight: 750;
}
.app-profile-auth__submit:disabled {
opacity: 0.46;
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import {
kButton,
kList,
kListInput,
kPreloader,
kSegmented,
kSegmentedButton,
} from 'konsta/vue'
import { computed, ref } from 'vue'
import { useAccountStore } from '@/stores/account'
import { useAppAuthStore, type AppAuthId } from '@/stores/app-auth'
import { usePhoneStore } from '@/stores/phone'
import {
filterMailAddressInput,
MAIL_ADDRESS_INPUT_MAX_LENGTH,
normalizeMailAddress,
} from '@/utils/mail'
const props = defineProps<{
appId: AppAuthId
appName: string
}>()
const emit = defineEmits<{ signedIn: [] }>()
const account = useAccountStore()
const appAuth = useAppAuthStore()
const phone = usePhoneStore()
const mode = ref<'login' | 'register'>('login')
const email = ref(account.email)
const password = ref('')
const confirm = ref('')
const pending = ref(false)
const error = ref('')
const canSubmit = computed(() => {
const normalized = normalizeMailAddress(email.value)
const passwordValid = password.value.length >= 6 && password.value.length <= 64
return Boolean(
normalized &&
passwordValid &&
(mode.value === 'login' || (confirm.value && confirm.value === password.value)),
)
})
function setMode(next: 'login' | 'register'): void {
mode.value = next
confirm.value = ''
error.value = ''
}
function updateEmail(event: Event): void {
email.value = filterMailAddressInput((event.target as HTMLInputElement).value)
}
function errorMessage(key?: string): string {
const known = [
'invalid_email',
'invalid_password',
'invalid_credentials',
'email_taken',
'rate_limited',
]
return phone.t(
`Common.appAuth.errors.${key && known.includes(key) ? key : 'default'}`,
)
}
async function submit(): Promise<void> {
if (!canSubmit.value || pending.value) return
const normalized = normalizeMailAddress(email.value)
if (!normalized) return
pending.value = true
error.value = ''
const response =
mode.value === 'login'
? await account.login(normalized, password.value)
: await account.register(normalized, password.value)
pending.value = false
if (!response.success || !response.data) {
error.value = errorMessage(response.error)
return
}
appAuth.signIn(props.appId, response.data.email)
password.value = ''
confirm.value = ''
emit('signedIn')
}
</script>
<template>
<section class="ifruit-app-auth">
<small>{{ phone.t('Common.appAuth.eyebrow') }}</small>
<h2>{{ phone.t('Common.appAuth.title', { app: appName }) }}</h2>
<p>{{ phone.t('Common.appAuth.body', { app: appName }) }}</p>
<k-segmented raised class="ifruit-app-auth__mode">
<k-segmented-button :active="mode === 'login'" @click="setMode('login')">
{{ phone.t('Common.appAuth.login') }}
</k-segmented-button>
<k-segmented-button :active="mode === 'register'" @click="setMode('register')">
{{ phone.t('Common.appAuth.register') }}
</k-segmented-button>
</k-segmented>
<k-list inset strong class="ifruit-app-auth__fields">
<k-list-input
input-id="ifruit-app-auth-email"
:label="phone.t('Common.appAuth.email')"
:value="email"
:maxlength="MAIL_ADDRESS_INPUT_MAX_LENGTH"
autocomplete="username"
inputmode="email"
outline
@input="updateEmail"
/>
<k-list-input
input-id="ifruit-app-auth-password"
:label="phone.t('Common.appAuth.password')"
:value="password"
type="password"
maxlength="64"
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
outline
@input="password = ($event.target as HTMLInputElement).value"
/>
<k-list-input
v-if="mode === 'register'"
input-id="ifruit-app-auth-confirm"
:label="phone.t('Common.appAuth.confirm')"
:value="confirm"
type="password"
maxlength="64"
autocomplete="new-password"
outline
@input="confirm = ($event.target as HTMLInputElement).value"
/>
</k-list>
<p v-if="error" class="ifruit-app-auth__error" role="alert">{{ error }}</p>
<k-button large rounded :disabled="!canSubmit || pending" @click="submit">
<k-preloader v-if="pending" />
<template v-else>
{{ phone.t(mode === 'login' ? 'Common.appAuth.loginAction' : 'Common.appAuth.registerAction') }}
</template>
</k-button>
</section>
</template>
<style scoped>
.ifruit-app-auth {
width: 100%;
max-width: 310px;
margin: auto;
padding: 18px 10px;
text-align: center;
}
.ifruit-app-auth > small {
color: var(--k-color-primary);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ifruit-app-auth h2 {
margin: 6px 0 4px;
font-size: 21px;
}
.ifruit-app-auth > p {
margin: 0 auto 14px;
color: #8e8e93;
font-size: 12px;
line-height: 1.4;
}
.ifruit-app-auth__mode {
margin: 0 8px 12px;
}
.ifruit-app-auth__fields {
margin-top: 0;
margin-bottom: 12px;
text-align: left;
}
.ifruit-app-auth .ifruit-app-auth__error {
margin: -4px 12px 10px;
color: #ff453a;
font-size: 11px;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
import { usePhoneStore } from '@/stores/phone'
defineProps<{
avatarUrl: string | null
email: string
error: string
mode: 'login' | 'register'
pending: boolean
username: string
}>()
const emit = defineEmits<{
camera: []
gallery: []
submit: []
'update:mode': [value: 'login' | 'register']
'update:username': [value: string]
}>()
const phone = usePhoneStore()
</script>
<template>
<AppProfileAuth
:avatar-url="avatarUrl"
:body="phone.t('Apps.citymarkt.authBody')"
:camera-label="phone.t('Apps.citymarkt.takePhoto')"
:email="email"
:email-label="phone.t('Apps.citymarkt.profileEmail')"
:error="error"
:eyebrow="phone.t('Apps.citymarkt.authEyebrow')"
:gallery-label="phone.t('Apps.citymarkt.chooseGallery')"
:login-label="phone.t('Apps.citymarkt.login')"
:mode="mode"
:pending="pending"
:register-label="phone.t('Apps.citymarkt.register')"
:title="phone.t('Apps.citymarkt.authTitle')"
:username="username"
:username-label="phone.t('Apps.citymarkt.authUsername')"
@camera="emit('camera')"
@gallery="emit('gallery')"
@submit="emit('submit')"
@update:mode="emit('update:mode', $event)"
@update:username="emit('update:username', $event)"
/>
</template>
@@ -41,6 +41,9 @@ function select(value: string): void {
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
if (!isOpen.value) return
event.preventDefault()
event.stopPropagation()
isOpen.value = false
return
}
@@ -367,4 +367,15 @@ function relativeTime(timestamp: number): string {
.feather-actions .is-bookmarked {
color: #438cf5;
}
@supports not (color: color-mix(in srgb, white, black)) {
.feather-post:active {
background: rgb(127 127 127 / 4%);
}
.feather-follow {
border-color: var(--feather-blue, #1d9bf0);
}
.feather-media {
border-color: rgb(127 127 127 / 18%);
}
}
</style>