ENH - merge latest dev into weather app

Integrates the current dev branch while preserving the weather-app Dynamic Island alongside the new hardware volume HUD and homescreen editing state. Makes the new Companies contract tests line-ending agnostic so the merged suite passes on Windows checkouts.
This commit is contained in:
Type
2026-08-17 11:38:13 +02:00
152 changed files with 15805 additions and 4315 deletions
@@ -6,6 +6,7 @@ const viewSource = readFileSync(
new URL('./SpringboardView.vue', import.meta.url),
'utf8',
)
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
const appIconSource = readFileSync(
new URL('../components/AppIcon.vue', import.meta.url),
'utf8',
@@ -19,6 +20,26 @@ const mainCss = readFileSync(
'utf8',
)
describe('Springboard page swipe contract', () => {
it('replaces the home status row with the edit controls', () => {
expect(viewSource).toContain("emit('editModeChange', editing)")
expect(viewSource).toContain("emit('editModeChange', false)")
expect(appSource).toContain(
'@edit-mode-change="springboardEditing = $event"',
)
expect(appSource).toMatch(
/v-if="\s*!isLocked && !\(isHomeRoute && springboardEditing\)\s*"/,
)
expect(mainCss).toMatch(
/\.springboard-edit-add\s*\{[^}]*top:\s*14px;[^}]*left:\s*28px;[^}]*width:\s*58px;[^}]*height:\s*30px;/s,
)
expect(mainCss).toMatch(
/\.springboard-edit-done\s*\{[^}]*top:\s*14px;[^}]*right:\s*28px;[^}]*width:\s*58px;[^}]*height:\s*30px;/s,
)
expect(viewSource).toContain(
'<Check :size="20" :stroke-width="3" aria-hidden="true" />',
)
})
it('allows page swipes to start on home apps and widget surfaces', () => {
expect(viewSource).toContain(
'.springboard-page--apps .app-icon-button, .home-widget',
@@ -29,6 +50,15 @@ describe('Springboard page swipe contract', () => {
)
})
it('keeps blank-surface page swipes available while editing', () => {
expect(viewSource).not.toMatch(
/if \(editMode\.value\) return\s+dragging\.value/,
)
expect(viewSource).toContain(
'if (editMode.value || pageSwipeSurface) return',
)
})
it('suppresses app and folder activation after pointer movement', () => {
expect(appIconSource).toContain('springboardSwipeIntent')
expect(folderIconSource).toContain('springboardSwipeIntent')
@@ -36,42 +66,40 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).toContain('suppressClick.value = true')
})
it('separates animated page compensation from immediate pointer movement', () => {
it('keeps app and folder drags alive independently of element capture', () => {
expect(appIconSource).toContain('bindPointerDragSession(window, pointerId')
expect(folderIconSource).toContain(
'bindPointerDragSession(window, pointerId',
)
expect(appIconSource).not.toContain('@lostpointercapture')
expect(folderIconSource).not.toContain('@lostpointercapture')
})
it('converts pointer movement from viewport pixels using measured page geometry', () => {
expect(appIconSource).toContain('springboardPageDragCompensation')
expect(folderIconSource).toContain('springboardPageDragCompensation')
expect(appIconSource).toContain('readSpringboardDragMetrics')
expect(folderIconSource).toContain('readSpringboardDragMetrics')
expect(appIconSource).toContain(':style="dragPointerStyle"')
expect(folderIconSource).toContain(':style="dragPointerStyle"')
})
it('keeps a phone-wide drag ghost outside the moving page track', () => {
const trackStart = viewSource.indexOf('class="springboard-track"')
const dragLayerStart = viewSource.indexOf('ref="homeDragLayer"')
expect(trackStart).toBeGreaterThan(-1)
expect(dragLayerStart).toBeGreaterThan(trackStart)
expect(viewSource.slice(trackStart, dragLayerStart)).toContain('</div>')
expect(viewSource).toContain('source.cloneNode(true)')
expect(viewSource).toContain("ghost.classList.add('home-drag-ghost')")
expect(viewSource).toContain('springboardViewportToLocal')
expect(viewSource).toContain('springboardViewportDeltaToLocal')
expect(viewSource).toContain('event.target instanceof Element')
expect(viewSource).toContain('dropGhost?.getBoundingClientRect()')
expect(viewSource.match(/:external-drag-visual/g)).toHaveLength(4)
expect(viewSource.match(/startHomeDrag\([^\n]+\$event\)/g)).toHaveLength(4)
expect(appIconSource).toContain('externalDragVisual?: boolean')
expect(folderIconSource).toContain('externalDragVisual?: boolean')
expect(appIconSource).toContain("'app-icon-item--drag-source'")
expect(folderIconSource).toContain("'app-icon-item--drag-source'")
expect(mainCss).toMatch(
/\.home-drag-layer\s*\{[^}]*z-index:\s*70;[^}]*pointer-events:\s*none;/s,
it('uses the same scale-aware in-phone drag path as widgets', () => {
expect(viewSource).not.toContain('source.cloneNode(true)')
expect(viewSource).not.toContain('<Teleport to="body">')
expect(viewSource).not.toContain(':external-drag-visual')
expect(viewSource).toContain(
"'springboard--home-dragging': draggingHomeApp !== null",
)
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
expect(appIconSource).not.toContain('externalDragVisual')
expect(folderIconSource).not.toContain('externalDragVisual')
expect(mainCss).not.toContain('.home-drag-layer')
expect(mainCss).not.toContain('.home-drag-ghost')
expect(mainCss).not.toContain('.app-icon-item--drag-source')
expect(mainCss).toMatch(
/\.home-drag-ghost\s*\{[^}]*position:\s*absolute;[^}]*will-change:\s*transform;/s,
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
)
expect(mainCss).toMatch(
/\.app-icon-item--drag-source\s*\{[^}]*opacity:\s*0;/s,
)
expect(mainCss).not.toContain('.springboard--home-dragging')
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
expect(mainCss).toMatch(
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
@@ -84,8 +112,11 @@ describe('Springboard page swipe contract', () => {
expect(viewSource).toContain('nearestGridDropTarget')
expect(viewSource).toContain('moveHomeAppToGridPage')
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
expect(viewSource).toContain('event.clientX < springboardBounds.left')
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
expect(viewSource).toContain('event.clientX < pageBounds.left')
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
expect(viewSource).toContain('nearestSpringboardRectIndex')
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
})
it('resolves occupied dock slots from the dock bounds rather than the event target', () => {
@@ -102,4 +133,15 @@ describe('Springboard page swipe contract', () => {
expect(viewSource).toContain('resolveSpringboardHomeEdgeTurn')
expect(viewSource).toContain('onBeforeUnmount')
})
it('materializes page-local app positions before widget layout changes', () => {
expect(viewSource).toContain('function applyWidgetLayout')
expect(viewSource).toContain('appStore.applyWidgetGridCapacities')
expect(viewSource).toContain('homeGridCapacitiesFor(widgets.layout)')
expect(viewSource).toContain('homeGridCapacitiesFor(nextLayout)')
expect(viewSource).toContain('previewWidgetAdd')
expect(viewSource).toContain('previewWidgetMove')
expect(viewSource).toContain('previewWidgetRemove')
expect(viewSource).toContain('previewWidgetResize')
})
})
+236 -297
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue'
import { Check, Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
import { kButton, kGlass } from 'konsta/vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -15,7 +15,19 @@ import { usePhoneStore } from '@/stores/phone'
import { useWidgetsStore } from '@/stores/widgets'
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
import type { LaunchablePhoneAppId } from '@/types/apps'
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
import type {
WidgetKind,
WidgetLayout,
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
import {
SkyActionButton,
SkyActionGroup,
SkyActionSheet,
SkyActionsLabel,
SkyProvider,
} from '@/ui'
import {
deleteHomePage as previewHomePageDelete,
getHomeFolder,
@@ -32,15 +44,18 @@ import type { ReorderDirection } from '@/utils/keyboard'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
import {
maximumRenderedWidgetPage,
nearestSpringboardRectIndex,
resolveSpringboardEdgeTurn,
resolveSpringboardHomeEdgeTurn,
springboardSwipeIntent,
springboardViewportDeltaToLocal,
springboardViewportToLocal,
} from '@/utils/springboardDrag'
import {
addWidget as previewWidgetAdd,
deleteWidgetPage as previewWidgetPageDelete,
moveWidget as previewWidgetMove,
removeWidget as previewWidgetRemove,
resizeWidget as previewWidgetResize,
WIDGET_GRID_COLUMNS,
widgetKeyboardTarget,
widgetOccupiedCells,
@@ -54,6 +69,10 @@ const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
'shopping',
]
const HOME_FOLDER_HOVER_DURATION = 900
const HOME_EDGE_PAGE_TURN_DELAY = 420
const emit = defineEmits<{
editModeChange: [editing: boolean]
}>()
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const widgets = useWidgetsStore()
@@ -87,10 +106,9 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null)
const folderDraggingOutside = ref(false)
const renameFolderOnOpenId = ref<string | null>(null)
const folderHoverTarget = ref('')
let pointerStart = 0
@@ -107,8 +125,6 @@ let edgePageDirection = 0
let edgePageLocked = false
let folderHoverTimer: number | undefined
let lastHomePointer: { clientX: number; clientY: number } | null = null
let homeDragGhost: HTMLElement | null = null
let homeDragGrip: { x: number; y: number } | null = null
const installedApps = computed(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -121,6 +137,17 @@ const hiddenApps = computed(() =>
appStore.homeLayout.hidden.includes(app.id),
),
)
function homeGridCapacitiesFor(layout: WidgetLayout): number[] {
return Array.from({ length: MAX_HOME_GRID_PAGES }, (_, pageIndex) =>
Math.max(
0,
HOME_GRID_PAGE_SIZE -
widgetOccupiedCells(layout.instances, pageIndex + 1).size,
),
)
}
const previewWidgetLayout = computed(() => {
const preview = widgetDragPreview.value
return preview
@@ -133,24 +160,13 @@ const previewWidgetLayout = computed(() => {
)
: widgets.layout
})
const homeGridCapacities = computed(() =>
Array.from({ length: MAX_HOME_GRID_PAGES }, (_, pageIndex) =>
Math.max(
0,
HOME_GRID_PAGE_SIZE -
widgetOccupiedCells(widgets.layout.instances, pageIndex + 1).size,
),
),
)
const homeGridCapacities = computed(() => homeGridCapacitiesFor(widgets.layout))
const appPages = computed(() => {
const maxWidgetPage = maximumRenderedWidgetPage(
widgets.layout.instances.map((instance) => instance.page),
previewWidgetLayout.value.instances.map((instance) => instance.page),
)
const persistedHomePages = Math.max(
1,
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
)
const persistedHomePages = appStore.homeLayout.pageCount
const lastHomePage = Math.max(
maxWidgetPage,
persistedHomePages,
@@ -173,6 +189,7 @@ const appPages = computed(() => {
appStore.homeLayout.grid,
occupiedByPage,
lastHomePage,
(item) => isHomeFolder(item) || installedAppsById.value.has(item),
).map((page) => ({
...page,
cells: page.cells.map((cell, cellIndex) => {
@@ -209,13 +226,10 @@ const isEditablePage = computed(
() => phone.currentPage >= 0 && phone.currentPage < libraryPage.value,
)
const canAddHomePage = computed(
() =>
appStore.homeLayout.grid.length < HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
() => appStore.homeLayout.pageCount < MAX_HOME_GRID_PAGES,
)
const addingHomePage = ref(false)
const persistedHomePageCount = computed(() =>
Math.max(1, Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE)),
)
const persistedHomePageCount = computed(() => appStore.homeLayout.pageCount)
const canDeleteCurrentPage = computed(() => {
if (phone.currentPage < 1 || persistedHomePageCount.value <= 1) return false
const remainingPages = persistedHomePageCount.value - 1
@@ -244,6 +258,9 @@ const openedFolder = computed(() =>
? getHomeFolder(appStore.homeLayout, openedFolderId.value)
: null,
)
const folderOverlayVisible = computed(
() => openedFolder.value !== null && !folderDraggingOutside.value,
)
const openedFolderApps = computed(() =>
openedFolder.value
? openedFolder.value.apps.flatMap((appId, index) => {
@@ -364,13 +381,12 @@ function onPointerDown(event: PointerEvent): void {
pointerStartedAt = Date.now()
pointerPageStart = phone.currentPage
blankTapCandidate = editMode.value
if (editMode.value) return
dragging.value = true
if (!pageSwipeSurface) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
clearBackgroundHold()
if (pageSwipeSurface) return
if (editMode.value || pageSwipeSurface) return
backgroundHoldTimer = window.setTimeout(() => {
enterEditMode()
backgroundHoldTimer = undefined
@@ -457,22 +473,22 @@ function updateWidgetDragPreview(event: {
`[data-widget-page="${page}"]`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return
if (!grid || !pageElement) return
const renderedGridBounds = grid.getBoundingClientRect()
const pageBounds = pageElement.getBoundingClientRect()
const springboardBounds = springboard.getBoundingClientRect()
const gridLeft =
springboardBounds.left + (renderedGridBounds.left - pageBounds.left)
const gridStyle = getComputedStyle(grid)
const columnGap = Number.parseFloat(gridStyle.columnGap) || 0
const rowGap = Number.parseFloat(gridStyle.rowGap) || 0
const scaleX =
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
const scaleY =
grid.offsetHeight > 0 ? renderedGridBounds.height / grid.offsetHeight : 1
const columnGap = (Number.parseFloat(gridStyle.columnGap) || 0) * scaleX
const rowGap = (Number.parseFloat(gridStyle.rowGap) || 0) * scaleY
const columnWidth =
(renderedGridBounds.width - columnGap * (WIDGET_GRID_COLUMNS - 1)) /
WIDGET_GRID_COLUMNS
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows)
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows) * scaleY
const column = Math.round(
(event.clientX - grip.x - gridLeft) / (columnWidth + columnGap),
(event.clientX - grip.x - renderedGridBounds.left) /
(columnWidth + columnGap),
)
const row = Math.round(
(event.clientY - grip.y - renderedGridBounds.top) / (rowHeight + rowGap),
@@ -504,7 +520,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
if (keepCurrentPage) return
const lastPage = Math.max(
1,
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
appStore.homeLayout.pageCount,
...widgets.layout.instances.map((instance) => instance.page),
)
if (phone.currentPage > lastPage) changePage(lastPage)
@@ -527,7 +543,7 @@ function resolveHomeEdgeTurn(event: { clientX: number }) {
}
function queueEdgePageTurn(
event: PointerEvent,
event: { clientX: number; clientY: number },
dragType: 'app' | 'widget',
): void {
const springboard = document.querySelector<HTMLElement>('.springboard')
@@ -581,8 +597,11 @@ function queueEdgePageTurn(
}
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = true
}, 520)
edgePageLocked = dragType === 'widget'
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
queueEdgePageTurn(lastHomePointer, 'app')
}
}, HOME_EDGE_PAGE_TURN_DELAY)
}
function queueWidgetDragPreview(event: PointerEvent): void {
@@ -610,6 +629,19 @@ function clearWidgetDragPreview(): void {
widgetDragPreview.value = null
}
function applyWidgetLayout(nextLayout: WidgetLayout): boolean {
if (nextLayout === widgets.layout) return true
if (
!appStore.applyWidgetGridCapacities(
homeGridCapacitiesFor(widgets.layout),
homeGridCapacitiesFor(nextLayout),
)
) {
return false
}
return widgets.applyLayout(nextLayout)
}
function finishWidgetDrag(event: PointerEvent): void {
const id = draggingWidgetId.value
if (!id) return
@@ -617,7 +649,15 @@ function finishWidgetDrag(event: PointerEvent): void {
updateWidgetDragPreview(lastWidgetPointer)
const preview = widgetDragPreview.value
if (preview?.id === id) {
widgets.move(id, preview.page, preview.column, preview.row)
applyWidgetLayout(
previewWidgetMove(
widgets.layout,
id,
preview.page,
preview.column,
preview.row,
),
)
}
draggingWidgetId.value = null
clearWidgetDragPreview()
@@ -633,18 +673,33 @@ function reorderWidget(id: string, direction: ReorderDirection): void {
if (!instance) return
const target = widgetKeyboardTarget(instance, direction)
if (!target) return
widgets.move(id, instance.page, target.column, target.row)
applyWidgetLayout(
previewWidgetMove(
widgets.layout,
id,
instance.page,
target.column,
target.row,
),
)
}
function removeWidget(id: string): void {
widgets.remove(id)
if (!applyWidgetLayout(previewWidgetRemove(widgets.layout, id))) return
if (widgetActionId.value === id) widgetActionId.value = null
if (widgetConfigId.value === id) widgetConfigId.value = null
}
async function addWidget(kind: WidgetKind, size: WidgetSize): Promise<void> {
const targetPage = isEditablePage.value ? phone.currentPage : 1
const addedId = widgets.add(kind, size, targetPage)
const previousIds = new Set(
widgets.layout.instances.map((instance) => instance.id),
)
const nextLayout = previewWidgetAdd(widgets.layout, kind, size, targetPage)
if (nextLayout === widgets.layout || !applyWidgetLayout(nextLayout)) return
const addedId = widgets.layout.instances.find(
(instance) => !previousIds.has(instance.id),
)?.id
if (!addedId) return
widgetPickerOpened.value = false
editMode.value = true
@@ -666,7 +721,8 @@ async function saveWidgetConfig(
): Promise<void> {
if (!widgetConfigId.value) return
const id = widgetConfigId.value
widgets.resize(id, size)
const resized = previewWidgetResize(widgets.layout, id, size)
if (!applyWidgetLayout(resized)) return
widgets.updateSettings(id, settings)
widgetConfigId.value = null
editMode.value = true
@@ -677,122 +733,10 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page)
}
function homeDragLocalPoint(
clientX: number,
clientY: number,
): {
x: number
y: number
} | null {
const layer = homeDragLayer.value
if (!layer) return null
const bounds = layer.getBoundingClientRect()
return springboardViewportToLocal(
clientX,
clientY,
bounds.left,
bounds.top,
bounds.width,
bounds.height,
layer.offsetWidth,
layer.offsetHeight,
)
}
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
if (expectedGhost && homeDragGhost !== expectedGhost) return
homeDragGhost?.remove()
homeDragGhost = null
homeDragGrip = null
homeDragVisualActive.value = false
}
function updateHomeDragGhost(event: {
clientX: number
clientY: number
}): void {
const point = homeDragLocalPoint(event.clientX, event.clientY)
if (!homeDragGhost || !homeDragGrip || !point) return
homeDragGhost.style.transform = `translate3d(${point.x - homeDragGrip.x}px, ${point.y - homeDragGrip.y}px, 0)`
}
function createHomeDragGhost(event: PointerEvent): void {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
? event.currentTarget
: event.target instanceof Element
? event.target
: null
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
const layer = homeDragLayer.value
if (!source || !layer) return
const layerBounds = layer.getBoundingClientRect()
const sourceBounds = source.getBoundingClientRect()
const sourceOrigin = springboardViewportToLocal(
sourceBounds.left,
sourceBounds.top,
layerBounds.left,
layerBounds.top,
layerBounds.width,
layerBounds.height,
layer.offsetWidth,
layer.offsetHeight,
)
const pointer = homeDragLocalPoint(event.clientX, event.clientY)
if (!pointer) return
const ghost = source.cloneNode(true) as HTMLElement
ghost.classList.remove(
'app-icon-item--dragging',
'app-icon-item--editing',
'app-icon-item--drag-source',
'app-icon-item--drop-settling',
'home-folder-item--dragging',
'home-item--folder-hover',
)
ghost.classList.add('home-drag-ghost')
ghost.removeAttribute('style')
ghost.setAttribute('aria-hidden', 'true')
ghost.querySelector('.app-icon-remove')?.remove()
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
'a, button, input, select, textarea, [tabindex]',
)) {
control.tabIndex = -1
}
const localScaleX =
layerBounds.width > 0 ? layer.offsetWidth / layerBounds.width : 1
const localScaleY =
layerBounds.height > 0 ? layer.offsetHeight / layerBounds.height : 1
ghost.style.width = `${sourceBounds.width * localScaleX}px`
ghost.style.height = `${sourceBounds.height * localScaleY}px`
homeDragGhost = ghost
homeDragGrip = {
x: pointer.x - sourceOrigin.x,
y: pointer.y - sourceOrigin.y,
}
layer.appendChild(ghost)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
}
function startHomeDrag(
area: HomeArea,
index: number,
event: PointerEvent,
): void {
function startHomeDrag(area: HomeArea, index: number): void {
clearTemporaryHomePage()
lastHomePointer = null
draggingHomeApp.value = { area, index }
createHomeDragGhost(event)
}
function clearFolderHover(): void {
@@ -878,7 +822,6 @@ function queueFolderHover(event: PointerEvent): void {
if (folderId) {
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
openedFolderId.value = folderId
}
@@ -889,7 +832,6 @@ function queueFolderHover(event: PointerEvent): void {
function moveHomeDrag(event: PointerEvent): void {
if (!draggingHomeApp.value) return
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (resolveHomeEdgeTurn(event)) {
clearFolderHover()
queueEdgePageTurn(event, 'app')
@@ -982,35 +924,25 @@ function nearestGridDropTarget(event: {
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
if (!grid || !pageElement) return null
const pageBounds = pageElement.getBoundingClientRect()
const springboardBounds = springboard.getBoundingClientRect()
if (
event.clientX < springboardBounds.left ||
event.clientX > springboardBounds.right ||
event.clientY < springboardBounds.top ||
event.clientY > springboardBounds.bottom
event.clientX < pageBounds.left ||
event.clientX > pageBounds.right ||
event.clientY < pageBounds.top ||
event.clientY > pageBounds.bottom
) {
return null
}
const slots = Array.from(
grid.querySelectorAll<HTMLElement>('[data-home-target-offset]'),
)
const distanceToSlot = (slot: HTMLElement): number => {
const bounds = slot.getBoundingClientRect()
const centerX =
springboardBounds.left +
(bounds.left - pageBounds.left) +
bounds.width / 2
const centerY =
springboardBounds.top + (bounds.top - pageBounds.top) + bounds.height / 2
return Math.hypot(event.clientX - centerX, event.clientY - centerY)
}
const closest = slots.reduce<HTMLElement | null>((nearest, slot) => {
if (!nearest) return slot
return distanceToSlot(slot) < distanceToSlot(nearest) ? slot : nearest
}, null)
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => slot.getBoundingClientRect()),
)
const closest = closestIndex === null ? null : slots[closestIndex]
const targetOffset = Number(closest?.dataset.homeTargetOffset)
return Number.isInteger(targetOffset) ? { page, targetOffset } : null
}
@@ -1030,37 +962,29 @@ function nearestDockDropTarget(event: {
) {
return null
}
return Array.from(
const slots = Array.from(
dock.querySelectorAll<HTMLElement>(
'[data-home-area="dock"][data-home-index]',
),
).reduce<HTMLElement | null>((nearest, slot) => {
if (!nearest) return slot
const bounds = slot.getBoundingClientRect()
const nearestBounds = nearest.getBoundingClientRect()
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
)
const nearestDistance = Math.hypot(
event.clientX - (nearestBounds.left + nearestBounds.width / 2),
event.clientY - (nearestBounds.top + nearestBounds.height / 2),
)
return distance < nearestDistance ? slot : nearest
}, null)
)
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => slot.getBoundingClientRect()),
)
return closestIndex === null ? null : slots[closestIndex]
}
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
const dragged = draggingHomeApp.value
if (!dragged) {
clearHomeDragGhost()
return
}
if (!dragged) return
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
const dropGhost = homeDragGhost
const dropOrigin = dropGhost?.getBoundingClientRect()
const draggedElement = document.querySelector<HTMLElement>(
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area
@@ -1098,14 +1022,7 @@ function finishHomeDrag(event: PointerEvent): void {
lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(
draggedItem,
dropArea,
dropIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
} else {
clearHomeDragGhost()
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
}
}
@@ -1114,7 +1031,6 @@ function stopHomeDrag(): void {
clearFolderHover()
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
@@ -1183,10 +1099,12 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
function openFolder(folderId: string): void {
pageTransitioning.value = false
folderDraggingOutside.value = false
openedFolderId.value = folderId
}
function closeFolder(): void {
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
}
@@ -1208,9 +1126,11 @@ function extractOpenedFolderApp(
const folder = openedFolder.value
if (!folder) return
const appId = folder.apps[sourceIndex]
const sourceElement = (event.currentTarget as HTMLElement | null)?.closest(
'.app-icon-item',
)
const sourceElement = document
.querySelector<HTMLElement>(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const sourceBounds = sourceElement?.getBoundingClientRect()
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
@@ -1237,15 +1157,20 @@ function extractOpenedFolderApp(
)
return distance < closestDistance ? candidate : closest
}, null)
if (!target) return
if (!target) {
closeFolder()
return
}
const area = target.dataset.homeArea as HomeArea
const targetIndex = Number(target.dataset.homeIndex)
if (
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
) {
closeFolder()
return
}
closeFolder()
if (sourceBounds) {
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
if (extractedIndex !== null) {
@@ -1269,6 +1194,7 @@ watch(isEditablePage, (visible) => {
if (!visible) editMode.value = false
})
watch(editMode, (editing) => {
emit('editModeChange', editing)
if (editing) return
stopHomeDrag()
stopWidgetDrag()
@@ -1277,6 +1203,7 @@ watch(openedFolder, (folder) => {
if (!folder) closeFolder()
})
onBeforeUnmount(() => {
emit('editModeChange', false)
clearBackgroundHold()
clearEdgePageTurn()
clearFolderHover()
@@ -1285,7 +1212,6 @@ onBeforeUnmount(() => {
pendingWidgetPointer = null
lastWidgetPointer = null
lastHomePointer = null
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingWidgetId.value = null
@@ -1300,7 +1226,8 @@ onBeforeUnmount(() => {
{
'springboard--dragging': dragging,
'springboard--editing': editMode,
'springboard--folder-open': openedFolder !== null,
'springboard--folder-open': folderOverlayVisible,
'springboard--home-dragging': draggingHomeApp !== null,
'springboard--widget-dragging': draggingWidgetId !== null,
},
]"
@@ -1376,7 +1303,6 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:class="{
'home-item--folder-hover':
folderHoverTarget === `grid:${cell.sourceIndex}`,
@@ -1384,7 +1310,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1399,7 +1325,6 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="cell.folder"
:class="{
'home-item--folder-hover':
@@ -1408,7 +1333,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@open="openFolder(cell.folder.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1559,11 +1484,9 @@ onBeforeUnmount(() => {
</section>
</div>
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage && !openedFolder"
v-if="editMode && isEditablePage && !folderOverlayVisible"
component="button"
class="springboard-edit-add"
type="button"
@@ -1576,13 +1499,14 @@ onBeforeUnmount(() => {
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage && !openedFolder"
v-if="editMode && isEditablePage && !folderOverlayVisible"
component="button"
class="springboard-edit-done"
type="button"
:aria-label="phone.t('Common.done')"
@click="editMode = false"
>
{{ phone.t('Common.done') }}
<Check :size="20" :stroke-width="3" aria-hidden="true" />
</k-glass>
</Transition>
@@ -1606,7 +1530,6 @@ onBeforeUnmount(() => {
:data-home-item-key="`app-${slot.app.id}`"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:show-label="false"
:class="{
'home-item--folder-hover':
@@ -1615,7 +1538,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@dragstart="startHomeDrag('dock', slot.index)"
@edit="enterEditMode"
@remove="removeHomeApp(slot.app.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1629,7 +1552,6 @@ onBeforeUnmount(() => {
:data-home-item-key="slot.folder.id"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="slot.folder"
:show-label="false"
:class="{
@@ -1639,7 +1561,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@dragstart="startHomeDrag('dock', slot.index)"
@edit="enterEditMode"
@open="openFolder(slot.folder.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1701,6 +1623,7 @@ onBeforeUnmount(() => {
:folder="openedFolder"
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
@close="closeFolder"
@drag-outside-change="folderDraggingOutside = $event"
@edit="enterEditMode"
@extract="extractOpenedFolderApp"
@move="moveOpenedFolderApp"
@@ -1709,50 +1632,56 @@ onBeforeUnmount(() => {
/>
</Transition>
<div class="widget-action-sheet">
<k-sheet
<SkyProvider
class="widget-action-provider"
:dark="phone.isDarkMode"
safe-areas
>
<SkyActionSheet
class="widget-action-sheet"
:opened="widgetActionId !== null"
:aria-label="
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
"
@backdropclick="widgetActionId = null"
@escape="widgetActionId = null"
>
<div class="widget-sheet-handle" />
<h3>
{{
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
}}
</h3>
<k-list inset strong class="widget-action-list">
<k-list-item
link
link-component="button"
content-class="w-full"
:title="phone.t('Home.widgetSystem.editWidget')"
<SkyActionGroup>
<SkyActionsLabel>
{{
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
}}
</SkyActionsLabel>
<SkyActionButton
class="widget-action-button"
@click="openWidgetConfig"
>
<template #media><Pencil :size="20" /></template>
</k-list-item>
<k-list-item
link
link-component="button"
content-class="w-full"
class="widget-action-remove"
:title="phone.t('Home.widgetSystem.removeWidget')"
<Pencil :size="20" />
<span>{{ phone.t('Home.widgetSystem.editWidget') }}</span>
</SkyActionButton>
<SkyActionButton
class="widget-action-button widget-action-remove"
@click="activeWidget && removeWidget(activeWidget.id)"
>
<template #media><Trash2 :size="20" /></template>
</k-list-item>
</k-list>
<k-button
large
rounded
class="widget-action-cancel"
@click="widgetActionId = null"
>
{{ phone.t('Common.cancel') }}
</k-button>
</k-sheet>
</div>
<Trash2 :size="20" />
<span>{{ phone.t('Home.widgetSystem.removeWidget') }}</span>
</SkyActionButton>
</SkyActionGroup>
<SkyActionGroup>
<SkyActionButton
bold
class="widget-action-cancel"
@click="widgetActionId = null"
>
{{ phone.t('Common.cancel') }}
</SkyActionButton>
</SkyActionGroup>
</SkyActionSheet>
</SkyProvider>
<WidgetPickerSheet
:opened="widgetPickerOpened"
@@ -1769,44 +1698,54 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.widget-action-sheet {
display: contents;
}
.widget-action-sheet :deep(.k-sheet) {
.widget-action-provider {
position: absolute;
z-index: 105;
padding: 9px 0 30px;
border-radius: 25px 25px 0 0;
inset: 0;
pointer-events: none;
}
.widget-sheet-handle {
width: 36px;
height: 5px;
margin: 0 auto 9px;
border-radius: 999px;
background: rgb(142 142 147 / 45%);
.widget-action-sheet {
--sky-overlay-layer: 105;
}
.widget-action-sheet h3 {
margin: 3px 18px 9px;
color: inherit;
font-size: 16px;
font-weight: 650;
text-align: center;
.widget-action-sheet :deep(.sky-overlay-backdrop) {
background: rgb(0 0 0 / 58%);
}
.widget-action-list {
margin-top: 0;
margin-bottom: 10px;
.widget-action-sheet :deep(.sky-action-sheet__panel) {
max-width: 100%;
}
.widget-action-remove :deep([class*='title']),
.widget-action-remove :deep(svg) {
color: #ff3b30 !important;
.widget-action-sheet :deep(.sky-action-group) {
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
}
.widget-action-cancel {
width: calc(100% - 32px);
margin: 0 16px;
.widget-action-sheet :deep(.sky-actions-label) {
color: var(--sky-muted);
background: transparent;
font-size: 14px;
font-weight: 600;
}
.widget-action-sheet :deep(.sky-action-button) {
color: #fff;
font-size: 17px;
}
.widget-action-sheet :deep(.widget-action-button) {
justify-content: flex-start;
gap: var(--sky-space-3);
text-align: left;
}
.widget-action-sheet :deep(.widget-action-button svg) {
width: 24px;
flex: none;
}
.widget-action-sheet :deep(.widget-action-remove) {
color: var(--sky-danger);
}
</style>
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./SpringboardView.vue', import.meta.url),
'utf8',
)
describe('Springboard widget actions Sky UI contract', () => {
it('uses the first-party action sheet without legacy Konsta sheet markup', () => {
expect(source).not.toContain('<k-sheet')
expect(source).not.toContain('<k-list')
expect(source).not.toContain('<k-list-item')
expect(source).toContain('<SkyProvider')
expect(source).toContain('<SkyActionSheet')
expect(source).toContain('<SkyActionGroup')
expect(source).toContain('<SkyActionsLabel')
expect(source).toContain('<SkyActionButton')
})
it('anchors the action sheet to the phone-owned springboard', () => {
expect(source).toMatch(
/\.widget-action-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
)
expect(source).toMatch(
/\.widget-action-sheet\s*\{[^}]*--sky-overlay-layer:\s*105;/s,
)
})
it('keeps ordinary actions white and removal destructive', () => {
expect(source).toMatch(
/\.widget-action-sheet\s+:deep\(\.sky-action-button\)\s*\{[^}]*color:\s*#fff;/s,
)
expect(source).toMatch(
/\.widget-action-sheet\s+:deep\(\.widget-action-remove\)\s*\{[^}]*color:\s*var\(--sky-danger\);/s,
)
})
})
@@ -6,6 +6,22 @@ const source = readFileSync(
new URL('./BankingApp.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
const appSource = readFileSync(
new URL('../../App.vue', import.meta.url),
'utf8',
)
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
const serverSource = readFileSync(
new URL('../../../../sky_phone/source/server/banking.lua', import.meta.url),
'utf8',
)
describe('Banking app Sky UI migration', () => {
it('uses Sky UI instead of Konsta components', () => {
@@ -37,4 +53,60 @@ describe('Banking app Sky UI migration', () => {
expect(source).not.toContain('handleSheetKeydown')
expect(source).not.toContain('handleWindowKeydown')
})
it('uses a comma-first text amount field without native stepper arrows', () => {
expect(source).toContain('class="banking-amount-field"')
expect(source).toContain('inputmode="decimal"')
expect(source).toContain('type="text"')
expect(source).not.toMatch(
/input-id="banking-transfer-amount"[\s\S]*?type="number"/,
)
expect(source).toContain('normalizeBankingAmountInput')
expect(source).toContain('parseBankingAmount')
})
it('turns received phone transfers into routed Banking notifications', () => {
expect(serverSource).toContain('kind = "transfer_in"')
expect(serverSource).toContain('currency = Config.Banking.Currency')
expect(clientSource).toContain(
'SendNUIMessage({ type = "banking:changed", data = data })',
)
expect(appSource).toContain("data?.kind === 'transfer_in'")
expect(appSource).toContain("appId: 'banking'")
expect(appSource).toContain("route: '/apps/banking'")
expect(appSource).toContain("phone.t('Apps.banking.notifications.received'")
})
it('resets tab scroll and locks the background behind sheets', () => {
expect(source).toContain('async function selectTab(nextTab: BankingTab)')
expect(source).toContain('bankingScroll.value.scrollTop = 0')
expect(source).toContain(':class="{ \'is-locked\': overlayOpened }"')
expect(source).toContain('@click="selectTab(\'activity\')"')
expect(source).toContain('@click="selectTab(\'home\')"')
expect(styles).toMatch(
/\.banking-scroll\s*\{[^}]*overscroll-behavior-y:\s*contain;[^}]*touch-action:\s*pan-y;/s,
)
expect(styles).toMatch(
/\.banking-scroll\.is-locked\s*\{[^}]*overflow-y:\s*hidden;[^}]*touch-action:\s*none;/s,
)
})
it('opens every transaction in an accessible Sky detail sheet', () => {
expect(source).toContain(
'const selectedTransaction = ref<BankingTransaction | null>(null)',
)
expect(source).toContain(
'function openTransaction(transaction: BankingTransaction)',
)
expect(source).toContain('@click="openTransaction(transaction)"')
expect(source).toContain('link-component="button"')
expect(source).toContain(':opened="overlayOpened"')
expect(source).toContain('swipe-to-close')
expect(source).toContain('grabber-clickable')
expect(source).toContain('@swipeclose="closeAction"')
expect(source).not.toContain('banking-modal__close')
expect(source).toContain('id="banking-transaction-detail-title"')
expect(source).toContain('banking-transaction-detail__amount')
expect(source).toContain("phone.t('Apps.banking.transactionReference')")
})
})
+139 -97
View File
@@ -10,10 +10,10 @@ import {
Landmark,
Send,
WalletCards,
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { usePullToRefresh } from '@/composables/usePullToRefresh'
import { useBankingStore } from '@/stores/banking'
import { useCallsStore } from '@/stores/calls'
import { usePhoneStore } from '@/stores/phone'
@@ -23,6 +23,10 @@ import type {
BankingTransactionKind,
} from '@/types/banking'
import type { PhoneContact } from '@/types/phone'
import {
normalizeBankingAmountInput,
parseBankingAmount,
} from '@/utils/bankingAmount'
import { handleEnterAction } from '@/utils/keyboard'
import { formatPhoneNumber, normalizePhoneNumber } from '@/utils/phone'
import {
@@ -50,20 +54,30 @@ const banking = useBankingStore()
const calls = useCallsStore()
const activeTab = ref<BankingTab>('home')
const action = ref<BankingAction | null>(null)
const selectedTransaction = ref<BankingTransaction | null>(null)
const amount = ref('')
const target = ref('')
const formError = ref('')
const bankingScroll = ref<HTMLElement | null>(null)
const isRefreshing = ref(false)
const pullDistance = ref(0)
const cooldownToastOpened = ref(false)
const overlayOpened = computed(() =>
Boolean(action.value || selectedTransaction.value),
)
const pullThreshold = 56
let pullStartY = 0
let isPulling = false
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
let cooldownToastTimer: ReturnType<typeof setTimeout> | undefined
const {
finishPull,
movePull,
pullDistance,
pullThreshold,
pullWithWheel,
startPull,
} = usePullToRefresh({
isAtTop: () => (bankingScroll.value?.scrollTop ?? 0) <= 0,
refresh: () => banking.load(true),
})
function closeCooldownToast(): void {
cooldownToastOpened.value = false
if (banking.error === 'reload_cooldown') banking.error = ''
@@ -145,11 +159,15 @@ function transactionTitle(transaction: BankingTransaction): string {
return phone.t(`Apps.banking.transactions.${transaction.kind}`)
}
async function selectTab(nextTab: BankingTab): Promise<void> {
if (activeTab.value === nextTab) return
activeTab.value = nextTab
await nextTick()
if (bankingScroll.value) bankingScroll.value.scrollTop = 0
}
function openAction(nextAction: BankingAction): void {
if (banking.cooldownUntil > Date.now()) {
banking.error = 'reload_cooldown'
return
}
selectedTransaction.value = null
action.value = nextAction
amount.value = ''
target.value = ''
@@ -158,8 +176,14 @@ function openAction(nextAction: BankingAction): void {
}
function closeAction(): void {
if (banking.isLoading) return
if (action.value && banking.isLoading) return
action.value = null
selectedTransaction.value = null
}
function openTransaction(transaction: BankingTransaction): void {
action.value = null
selectedTransaction.value = transaction
}
function updateTarget(event: Event): void {
@@ -186,59 +210,14 @@ function updateAmount(event: Event): void {
console.error('[banking] Amount input emitted without an input target.')
return
}
amount.value = event.target.value
const normalizedAmount = normalizeBankingAmountInput(event.target.value)
if (event.target.value !== normalizedAmount) {
event.target.value = normalizedAmount
}
amount.value = normalizedAmount
formError.value = ''
}
async function refresh(): Promise<void> {
if (isRefreshing.value) return
isRefreshing.value = true
pullDistance.value = pullThreshold
await banking.load(true)
isRefreshing.value = false
pullDistance.value = 0
}
function atTop(): boolean {
return (bankingScroll.value?.scrollTop ?? 0) <= 0
}
function startPull(event: TouchEvent): void {
if (!atTop() || isRefreshing.value) return
pullStartY = event.touches[0]?.clientY ?? 0
isPulling = true
}
function movePull(event: TouchEvent): void {
if (!isPulling || isRefreshing.value) return
const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY
if (distance <= 0) {
pullDistance.value = 0
return
}
pullDistance.value = Math.min(pullThreshold + 20, distance * 0.45)
}
function finishPull(): void {
if (!isPulling && pullDistance.value === 0) return
isPulling = false
if (pullDistance.value >= pullThreshold) {
void refresh()
return
}
pullDistance.value = 0
}
function pullWithWheel(event: WheelEvent): void {
if (!atTop() || isRefreshing.value || event.deltaY >= 0) return
pullDistance.value = Math.min(
pullThreshold + 20,
pullDistance.value + Math.abs(event.deltaY) * 0.18,
)
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
wheelRefreshTimeout = setTimeout(finishPull, 130)
}
function errorMessage(code: string): string {
return phone.t(`Apps.banking.errors.${code}`) ===
`Apps.banking.errors.${code}`
@@ -248,14 +227,10 @@ function errorMessage(code: string): string {
async function submitAction(): Promise<void> {
if (!action.value) return
const parsedAmount = Number(amount.value)
const parsedAmount = parseBankingAmount(amount.value)
const phoneNumber =
action.value === 'transfer' ? normalizePhoneNumber(target.value) : undefined
if (
!Number.isSafeInteger(parsedAmount) ||
parsedAmount <= 0 ||
(action.value === 'transfer' && !phoneNumber)
) {
if (parsedAmount === null || (action.value === 'transfer' && !phoneNumber)) {
formError.value = phone.t('Apps.banking.errors.invalid_request')
return
}
@@ -293,7 +268,6 @@ watch(action, async (currentAction) => {
})
onBeforeUnmount(() => {
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
if (cooldownToastTimer) clearTimeout(cooldownToastTimer)
})
</script>
@@ -310,8 +284,8 @@ onBeforeUnmount(() => {
<SkyNavbar
class="banking-navbar"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:aria-hidden="overlayOpened"
:inert="overlayOpened"
:subtitle="phone.t('Apps.banking.welcome')"
:title="banking.overview?.playerName ?? phone.t('Common.loading')"
/>
@@ -319,8 +293,8 @@ onBeforeUnmount(() => {
<div
v-if="!banking.overview && banking.isLoading"
class="banking-loading"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:aria-hidden="overlayOpened"
:inert="overlayOpened"
>
<SkySpinner :label="phone.t('Common.loading')" />
<span>{{ phone.t('Common.loading') }}</span>
@@ -329,9 +303,9 @@ onBeforeUnmount(() => {
<SkyEmptyState
v-else-if="!banking.overview"
class="banking-empty"
:aria-hidden="Boolean(action)"
:aria-hidden="overlayOpened"
:body="errorMessage(banking.error)"
:inert="Boolean(action)"
:inert="overlayOpened"
:title="phone.t('Apps.banking.unavailable')"
>
<template #icon><Landmark :size="34" /></template>
@@ -346,8 +320,9 @@ onBeforeUnmount(() => {
v-else
ref="bankingScroll"
class="banking-scroll"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:class="{ 'is-locked': overlayOpened }"
:aria-hidden="overlayOpened"
:inert="overlayOpened"
@touchend="finishPull"
@touchmove.passive="movePull"
@touchstart.passive="startPull"
@@ -418,7 +393,7 @@ onBeforeUnmount(() => {
<SkyLink
component="button"
type="button"
@click="activeTab = 'activity'"
@click="selectTab('activity')"
>
{{ phone.t('Apps.banking.viewAll') }}
</SkyLink>
@@ -434,6 +409,9 @@ onBeforeUnmount(() => {
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
link
link-component="button"
@click="openTransaction(transaction)"
>
<template #media>
<component
@@ -519,6 +497,9 @@ onBeforeUnmount(() => {
:key="transaction.id"
:subtitle="formatDate(transaction.createdAt)"
:title="transactionTitle(transaction)"
link
link-component="button"
@click="openTransaction(transaction)"
>
<template #media>
<component
@@ -550,43 +531,44 @@ onBeforeUnmount(() => {
icons
labels
class="banking-tabbar"
:aria-hidden="Boolean(action)"
:inert="Boolean(action)"
:aria-hidden="overlayOpened"
:inert="overlayOpened"
:label="phone.t('Apps.banking.navigation')"
>
<SkyTabButton
:active="activeTab === 'home'"
:label="phone.t('Apps.banking.home')"
@click="activeTab = 'home'"
@click="selectTab('home')"
>
<template #icon><House :size="25" /></template>
</SkyTabButton>
<SkyTabButton
:active="activeTab === 'activity'"
:label="phone.t('Apps.banking.activity')"
@click="activeTab = 'activity'"
@click="selectTab('activity')"
>
<template #icon><BarChart3 :size="25" /></template>
</SkyTabButton>
</SkyTabBar>
<SkySheet
:opened="Boolean(action)"
:ariaLabelledby="action ? `banking-${action}-title` : undefined"
:opened="overlayOpened"
:ariaLabelledby="
action
? `banking-${action}-title`
: selectedTransaction
? 'banking-transaction-detail-title'
: undefined
"
swipe-to-close
grabber-clickable
:grabber-label="phone.t('Common.close')"
@backdropclick="closeAction"
@escape="closeAction"
@grabberclick="closeAction"
@swipeclose="closeAction"
>
<section v-if="action" class="banking-sheet__content">
<SkyLink
component="button"
class="banking-modal__close"
:aria-label="phone.t('Common.close')"
icon-only
type="button"
@click="closeAction"
>
<X :size="17" />
</SkyLink>
<span class="banking-modal__icon">
<Send :size="23" />
</span>
@@ -606,14 +588,16 @@ onBeforeUnmount(() => {
@input="updateTarget"
/>
<SkyField
autocomplete="off"
class="banking-amount-field"
:label="phone.t('Apps.banking.amount')"
:error="formError || false"
input-id="banking-transfer-amount"
inputmode="numeric"
min="1"
inputmode="decimal"
outline
pattern="[0-9]+([,][0-9]{0,2})?"
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
type="number"
type="text"
:value="amount"
@input="updateAmount"
@keydown.enter="handleEnterAction($event, submitAction)"
@@ -652,6 +636,64 @@ onBeforeUnmount(() => {
</template>
</SkyButton>
</section>
<section
v-else-if="selectedTransaction"
class="banking-sheet__content banking-transaction-detail"
>
<span
class="banking-modal__icon banking-transaction-detail__icon"
:class="{
'is-incoming': isIncoming(selectedTransaction.kind),
}"
>
<component
:is="transactionIcons[selectedTransaction.kind]"
:size="23"
/>
</span>
<p class="banking-transaction-detail__eyebrow">
{{ phone.t('Apps.banking.transactionDetails') }}
</p>
<h2 id="banking-transaction-detail-title">
{{ transactionTitle(selectedTransaction) }}
</h2>
<strong
class="banking-transaction-detail__amount"
:class="{
'is-incoming': isIncoming(selectedTransaction.kind),
}"
>
{{
formatMoney(
isIncoming(selectedTransaction.kind)
? selectedTransaction.amount
: -selectedTransaction.amount,
true,
)
}}
</strong>
<SkyList inset strong class="banking-transaction-detail__list">
<SkyListItem
:title="phone.t('Apps.banking.transactionDate')"
:after="formatDate(selectedTransaction.createdAt)"
/>
<SkyListItem
:title="phone.t('Apps.banking.transactionDirection')"
:after="
phone.t(
isIncoming(selectedTransaction.kind)
? 'Apps.banking.incoming'
: 'Apps.banking.outgoing',
)
"
/>
<SkyListItem
v-if="selectedTransaction.reference"
:title="phone.t('Apps.banking.transactionReference')"
:subtitle="selectedTransaction.reference"
/>
</SkyList>
</section>
</SkySheet>
<SkyToast
@@ -14,6 +14,10 @@ const cameraClient = readFileSync(
new URL('../../../../sky_phone/source/client/camera.lua', import.meta.url),
'utf8',
)
const focusClient = readFileSync(
new URL('../../../../sky_phone/source/client/focus.lua', import.meta.url),
'utf8',
)
const cameraConfig = readFileSync(
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
'utf8',
@@ -39,7 +43,9 @@ describe('Camera app controls', () => {
expect(cameraView).not.toContain('camera-zoom-slider')
expect(cameraView).not.toContain('type="range"')
expect(cameraView).toContain('@wheel.prevent.stop="zoomWithWheel"')
expect(cameraView).toContain('Math.min(120, event.deltaY * deltaMultiplier)')
expect(cameraView).toContain(
'Math.min(120, event.deltaY * deltaMultiplier)',
)
expect(cameraView).toContain('wheelDelta * 0.00075')
expect(cameraView).toContain("message.type === 'camera:zoom'")
expect(cameraClient).toContain('mouse_wheel_zoom_step = 0.08')
@@ -69,11 +75,13 @@ describe('Camera app controls', () => {
expect(cameraClient).toContain('INPUT_LOOK_UD')
expect(cameraClient).toContain('first_person_view_mode = 4')
expect(cameraClient).toContain(
'SetFollowPedCamViewMode(first_person_view_mode)',
'local view_mode = camera_state.front_camera and front_camera_view_mode or first_person_view_mode',
)
expect(cameraClient).toContain('SetFollowVehicleCamViewMode(view_mode)')
expect(cameraClient).toContain('SetFollowPedCamViewMode(view_mode)')
expect(cameraClient).toMatch(/while camera_state\.active do[\s\S]*apply_camera_view\(\)/)
expect(cameraClient).toMatch(
/while camera_state\.active do[\s\S]*apply_camera_view\(\)/,
)
expect(cameraClient).not.toContain('next_view_apply')
expect(cameraClient).not.toContain('ultrawide_camera_handle')
expect(cameraClient).not.toContain('ensure_ultrawide_camera')
@@ -91,9 +99,11 @@ describe('Camera app controls', () => {
expect(cameraClient).toContain('local front_camera_fov = 32.0')
expect(cameraClient).toContain('local front_camera_distance = 1.05')
expect(cameraClient).toContain('local head_position = GetPedBoneCoords')
expect(cameraClient).toContain('local dot = (to_camera.x * forward_vector.x)')
expect(cameraClient).toContain('front_camera_target_height')
expect(cameraClient).toContain(
'local dot = (to_camera.x * forward_vector.x)',
)
expect(cameraClient).toContain('front_camera_target_height')
expect(focusClient).toContain(
'return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }',
)
expect(cameraClient).toContain('SetCamCoord(')
+7 -2
View File
@@ -18,7 +18,12 @@ import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
import type {
MediaConfig,
MediaType,
PhoneMedia,
UploadResult,
} from '@/types/media'
import { SkySegmented, SkySegmentedButton } from '@/ui'
import { createGameView, type GameView } from '@/utils/gameView'
import { formatRecordingDuration, mediaErrorKey } from '@/utils/media'
@@ -416,7 +421,7 @@ onMounted(() => {
window.addEventListener('keyup', onKeyup)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
void nuiCall<{ videoBitrateKbps?: number }>('media:config').then(
void nuiCall<MediaConfig>('media:config').then(
(response) => {
if (response.success && response.data?.videoBitrateKbps) {
videoBitrateKbps.value = response.data.videoBitrateKbps
+112 -11
View File
@@ -6,28 +6,42 @@ const clockView = readFileSync(
new URL('./ClockApp.vue', import.meta.url),
'utf8',
)
const alarmEditorView = readFileSync(
new URL('../../components/AlarmEditor.vue', import.meta.url),
'utf8',
)
const mainCss = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
describe('Clock app controls', () => {
it('removes the empty navigation row from world clock and timer titles', () => {
expect(clockView).toContain('class="clock-navbar"')
it('only renders a title navbar for the alarm tab', () => {
expect(clockView.match(/<sky-navbar/g)).toHaveLength(1)
expect(clockView).toContain('v-if="tab === \'alarm\'"')
expect(clockView).toContain('class="clock-navbar clock-navbar--alarm"')
expect(clockView).toContain('variant="large"')
expect(clockView).not.toContain('clock-navbar__empty-inner')
expect(clockView).not.toContain('v-else-if="tab !== \'stopwatch\'"')
expect(mainCss).toMatch(
/\.clock-navbar\.sky-navbar--no-navigation\s*\{[^}]*--sky-navbar-large-title-height[^}]*padding-top:\s*var\(--sky-navbar-safe-area-top\)/s,
/\.clock-navbar\.sky-navbar\s*\{[^}]*--sky-navbar-safe-area-top:\s*var\(--sky-space-2\);/s,
)
expect(mainCss).not.toContain('.clock-navbar.sky-navbar--no-navigation')
})
it('keeps the single world clock centered with readable line boxes', () => {
expect(clockView).toContain('class="clock-world-now"')
expect(clockView).not.toContain('clock-world-list')
expect(mainCss).toMatch(
/\.clock-world-time\s*\{[^}]*font-size:\s*74px;[^}]*line-height:\s*1;/s,
)
})
it('uses the danger color while stopwatch and timer controls are running', () => {
expect(clockView).toMatch(
/clock\.stopwatchStartedAt === null\s*\? 'primary'\s*:\s*'danger'/,
)
expect(clockView).toMatch(
/:variant="clock\.timerStartedAt === null \? 'primary' : 'danger'"/,
)
it('uses the orange clock accent and state-specific circular actions', () => {
expect(clockView).toContain('accent="#ff9f0a"')
expect(clockView).toContain('clock-action-button--start')
expect(clockView).toContain('clock-action-button--stop')
expect(clockView).toContain('<Play')
expect(clockView).toContain('<Pause')
})
it('uses the shared full-width Sky tab bar', () => {
@@ -35,4 +49,91 @@ describe('Clock app controls', () => {
expect(clockView).toContain('<sky-tab-button')
expect(clockView).not.toContain('<sky-segmented')
})
it('does not nest a second SkyBlock around the timer layout', () => {
expect(clockView).toContain(
'<section v-else class="clock-tool clock-timer">',
)
expect(clockView).not.toContain(
'<sky-block v-else nested class="clock-tool clock-timer">',
)
expect(mainCss).toMatch(
/\.clock-timer\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*align-items:\s*center;/s,
)
expect(mainCss).toMatch(/\.clock-timer-actions\s*\{[^}]*width:\s*100%;/s)
expect(mainCss).toMatch(/\.clock-timer-settings\s*\{[^}]*width:\s*100%;/s)
})
it('keeps the stopwatch single-page and separates alarm actions', () => {
expect(clockView).not.toContain('clock-stopwatch-pages')
expect(clockView).not.toContain('clock-stopwatch-page')
expect(clockView).toMatch(
/<template #left>[\s\S]*?clock-navbar-action--icon[\s\S]*?<\/template>\s*<template #right>[\s\S]*?clock-navbar-action--edit/,
)
expect(clockView).not.toContain('clock-navbar-actions')
expect(clockView).toMatch(
/<sky-button\s+v-if="alarmsEditing"[\s\S]*?icon-only[\s\S]*?class="clock-alarm-remove"/,
)
expect(mainCss).toMatch(
/\.clock-navbar--alarm \.sky-navbar__inner\s*\{[^}]*margin-bottom:\s*0;/s,
)
expect(mainCss).toMatch(
/\.clock-navbar--alarm \.sky-navbar__left\.sky-glass-surface,\s*\.clock-navbar--alarm \.sky-navbar__right\.sky-glass-surface\s*\{/s,
)
expect(mainCss).not.toMatch(/\.clock-navbar--alarm\s*\{[^}]*margin-top:/s)
expect(mainCss).toMatch(
/\.clock-content\s*\{[^}]*--sky-block-gutter-left:\s*0px;[^}]*--sky-block-gutter-right:\s*0px;/s,
)
expect(mainCss).toMatch(
/\.clock-stopwatch\s*\{[^}]*--sky-block-gutter-left:\s*calc\(\s*var\(--sky-page-gutter\) \+ var\(--sky-safe-area-left\)\s*\);[^}]*--sky-block-gutter-right:\s*calc\(\s*var\(--sky-page-gutter\) \+ var\(--sky-safe-area-right\)\s*\);/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-item > \.sky-toggle\s*\{[^}]*grid-column:\s*3;[^}]*justify-self:\s*end;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-remove\.sky-button--icon-only\s*\{[^}]*width:\s*28px;[^}]*height:\s*28px;[^}]*min-height:\s*28px;[^}]*padding:\s*0;[^}]*border-radius:\s*50%;/s,
)
})
it('opens the alarm editor as a compact Sky sheet over the alarm page', () => {
expect(clockView).toMatch(/<AlarmEditor\s+v-if="alarmEditor"/)
expect(clockView).not.toContain('v-else-if="alarmEditor"')
expect(alarmEditorView).toContain('<k-sheet')
expect(alarmEditorView).toContain(':show-grabber="false"')
expect(alarmEditorView).toContain('swipe-to-close')
expect(alarmEditorView).toContain('data-sky-sheet-drag-handle')
expect(alarmEditorView).toContain(
'<k-scroll-area padded class="clock-alarm-editor">',
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet \.sky-sheet__panel\s*\{[^}]*height:\s*calc\(\s*100% - var\(--sky-safe-area-top\) - var\(--sky-space-2\)\s*\);[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--sky-radius-sheet\) var\(--sky-radius-sheet\) 0 0;[^}]*background:\s*var\(--sky-sheet-background\);/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet\s*\{[^}]*--sky-sheet-background:\s*#1c1c1d;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet \.sky-sheet__grabber\s*\{[^}]*display:\s*none;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet-surface\s*\{[^}]*height:\s*100%;[^}]*border-radius:\s*var\(--sky-radius-sheet\) var\(--sky-radius-sheet\) 0 0;[^}]*background:\s*var\(--sky-sheet-background\);/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet-navbar\.sky-navbar,[\s\S]*?\.clock-alarm-sheet-surface > \.sky-navbar\s*\{[^}]*--sky-navbar-safe-area-top:\s*var\(--sky-space-3\);/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet-navbar \.sky-navbar__blur,[\s\S]*?\.clock-alarm-sheet-navbar \.sky-navbar__background\s*\{[^}]*background:\s*transparent;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-save\.sky-link\s*\{[^}]*background:\s*#ff9f0a;[^}]*color:\s*#fff;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-editor\.sky-scroll-area--padded\s*\{[^}]*--sky-page-gutter:\s*12px;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-editor \.time-wheel-picker\s*\{[^}]*width:\s*100%;[^}]*margin-right:\s*0;[^}]*margin-left:\s*0;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-editor \.sky-list--strong,[\s\S]*?\.clock-alarm-editor \.sky-list--inset\s*\{[^}]*background:\s*#2c2c2e;/s,
)
})
})
+144 -122
View File
@@ -15,11 +15,14 @@ import {
import {
AlarmClock,
ChevronRight,
Clock3,
Globe2,
Minus,
Pause,
Play,
Plus,
Timer,
TimerReset,
X,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
@@ -95,6 +98,18 @@ const currentTimeZone = computed(
.formatToParts(now.value)
.find((part) => part.type === 'timeZoneName')?.value ?? browserTimeZone,
)
const stopwatchDecimalSeparator = computed(
() =>
new Intl.NumberFormat(phone.lang)
.formatToParts(1.1)
.find((part) => part.type === 'decimal')?.value ?? '.',
)
const stopwatchDisplay = computed(() =>
formatStopwatch(stopwatchValue.value).replace(
'.',
stopwatchDecimalSeparator.value,
),
)
const selectedAlarm = computed(() => {
const editor = alarmEditor.value
return editor?.mode === 'edit'
@@ -102,7 +117,7 @@ const selectedAlarm = computed(() => {
: undefined
})
const tabs = [
{ id: 'world', icon: Clock3 },
{ id: 'world', icon: Globe2 },
{ id: 'alarm', icon: AlarmClock },
{ id: 'stopwatch', icon: Timer },
{ id: 'timer', icon: TimerReset },
@@ -207,7 +222,12 @@ onBeforeUnmount(() => {
</script>
<template>
<sky-app-page component="main" class="native-app clock-app">
<sky-app-page
component="main"
accent="#ff9f0a"
accent-soft="rgba(255, 159, 10, 0.16)"
class="native-app clock-app"
>
<AlarmSoundMenu
v-if="timerSoundMenuOpen"
:back-label="phone.t('Apps.clock.tabs.timer')"
@@ -216,42 +236,35 @@ onBeforeUnmount(() => {
@select="clock.setTimerSound($event)"
/>
<AlarmEditor
v-else-if="alarmEditor"
:alarm="selectedAlarm"
@cancel="alarmEditor = null"
@delete="deleteSelectedAlarm"
@save="saveAlarm"
/>
<template v-else>
<sky-navbar
v-if="tab !== 'stopwatch'"
v-if="tab === 'alarm'"
:title="phone.t(`Apps.clock.tabs.${tab}`)"
variant="large"
transparent
class="clock-navbar"
class="clock-navbar clock-navbar--alarm"
>
<template #left>
<sky-link
v-if="tab === 'alarm'"
component="button"
:link-props="{ type: 'button' }"
@click="toggleAlarmEditing"
>
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
</sky-link>
</template>
<template #right>
<sky-link
v-if="tab === 'alarm'"
component="button"
icon-only
class="clock-navbar-action clock-navbar-action--icon sky-glass-surface"
:link-props="{ type: 'button' }"
:aria-label="phone.t('Apps.clock.add')"
@click="openAlarmEditor()"
>
<Plus />
<Plus aria-hidden="true" />
</sky-link>
</template>
<template #right>
<sky-link
v-if="clock.alarms.length"
component="button"
class="clock-navbar-action clock-navbar-action--edit sky-glass-surface"
:link-props="{ type: 'button' }"
@click="toggleAlarmEditing"
>
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
</sky-link>
</template>
</sky-navbar>
@@ -265,55 +278,70 @@ onBeforeUnmount(() => {
<span class="clock-world-zone">{{ currentTimeZone }}</span>
</sky-block>
<sky-list
<section
v-else-if="tab === 'alarm'"
class="clock-konsta-list clock-alarm-list"
class="clock-alarm-list"
:aria-label="phone.t('Apps.clock.tabs.alarm')"
>
<sky-list-item
<article
v-for="alarm in clock.alarms"
:key="alarm.id"
link
:chevron="alarmsEditing"
:title="alarm.time"
:subtitle="alarmSubtitle(alarm)"
:strong-title="false"
title-font-size-ios="text-[38px] font-light"
class="clock-alarm-item"
@click="openAlarmEditor(alarm.id)"
:class="{ 'clock-alarm-item--disabled': !alarm.enabled }"
>
<template v-if="alarmsEditing" #media>
<sky-button
rounded
small
inline
variant="danger"
class="clock-alarm-remove"
:aria-label="phone.t('Apps.clock.alarm.delete')"
@click.stop="clock.deleteAlarm(alarm.id)"
>
<Minus aria-hidden="true" />
</sky-button>
</template>
<template #after>
<span v-if="!alarmsEditing" @click.stop>
<sky-toggle
:checked="alarm.enabled"
style="--sky-app-accent: #30d158"
:aria-label="`${alarm.time}, ${alarmSubtitle(alarm)}`"
@change="clock.toggleAlarm(alarm.id)"
/>
</span>
</template>
</sky-list-item>
</sky-list>
<sky-button
v-if="alarmsEditing"
rounded
small
icon-only
variant="danger"
class="clock-alarm-remove"
:aria-label="phone.t('Apps.clock.alarm.delete')"
@click="clock.deleteAlarm(alarm.id)"
>
<Minus aria-hidden="true" />
</sky-button>
<sky-block v-else-if="tab === 'stopwatch'" nested class="clock-tool">
<div class="clock-digits">{{ formatStopwatch(stopwatchValue) }}</div>
<div class="clock-actions">
<button
type="button"
class="clock-alarm-copy"
@click="openAlarmEditor(alarm.id)"
>
<time class="clock-alarm-time">{{ alarm.time }}</time>
<span class="clock-alarm-subtitle">{{
alarmSubtitle(alarm)
}}</span>
</button>
<ChevronRight
v-if="alarmsEditing"
class="clock-alarm-chevron"
aria-hidden="true"
/>
<sky-toggle
v-else
:checked="alarm.enabled"
style="--sky-app-accent: #30d158"
:aria-label="`${alarm.time}, ${alarmSubtitle(alarm)}`"
@change="clock.toggleAlarm(alarm.id)"
/>
</article>
</section>
<sky-block
v-else-if="tab === 'stopwatch'"
nested
class="clock-tool clock-stopwatch"
>
<time class="clock-digits">{{ stopwatchDisplay }}</time>
<div class="clock-actions clock-stopwatch-actions">
<sky-button
rounded
variant="secondary"
class="clock-action-button"
class="clock-action-button clock-action-button--secondary"
:disabled="
clock.stopwatchStartedAt === null && stopwatchValue <= 0
"
@click="
clock.stopwatchStartedAt === null
? clock.resetStopwatch()
@@ -330,15 +358,13 @@ onBeforeUnmount(() => {
</sky-button>
<sky-button
rounded
:variant="
clock.stopwatchStartedAt === null ? 'primary' : 'danger'
"
:style="
clock.stopwatchStartedAt === null
? { '--sky-app-accent': '#30d158' }
: undefined
"
variant="secondary"
class="clock-action-button"
:class="
clock.stopwatchStartedAt === null
? 'clock-action-button--start'
: 'clock-action-button--stop'
"
@click="
clock.stopwatchStartedAt === null
? clock.startStopwatch(Date.now())
@@ -354,6 +380,7 @@ onBeforeUnmount(() => {
}}
</sky-button>
</div>
<div class="clock-tool-divider" aria-hidden="true" />
<sky-list dividers class="clock-konsta-list clock-lap-list">
<sky-list-item
v-for="(lap, index) in clock.laps"
@@ -364,7 +391,7 @@ onBeforeUnmount(() => {
</sky-list>
</sky-block>
<sky-block v-else nested class="clock-tool clock-timer">
<section v-else class="clock-tool clock-timer">
<div
v-if="clock.timerStartedAt !== null"
class="timer-ring"
@@ -400,25 +427,47 @@ onBeforeUnmount(() => {
:seconds-unit-label="phone.t('Apps.clock.timer.secondsShort')"
/>
<sky-list
strong
inset
class="clock-konsta-list clock-timer-settings clock-timer-note"
>
<div class="clock-actions clock-timer-actions">
<sky-button
rounded
variant="secondary"
class="clock-action-button clock-action-button--secondary clock-action-button--icon"
:aria-label="phone.t('Common.reset')"
@click="clock.resetTimer()"
>
<X aria-hidden="true" />
</sky-button>
<sky-button
rounded
variant="secondary"
class="clock-action-button clock-action-button--start clock-action-button--icon"
:disabled="clock.timerStartedAt === null && timerValue <= 0"
:aria-label="
phone.t(
clock.timerStartedAt === null
? 'Common.start'
: 'Common.pause',
)
"
@click="
clock.timerStartedAt === null
? clock.startTimer(now)
: clock.pauseTimer(now)
"
>
<Play v-if="clock.timerStartedAt === null" aria-hidden="true" />
<Pause v-else aria-hidden="true" />
</sky-button>
</div>
<sky-list strong inset class="clock-konsta-list clock-timer-settings">
<sky-field
:label="phone.t('Apps.clock.timer.note')"
:label="phone.t('Apps.clock.timer.description')"
:placeholder="phone.t('Apps.clock.timer.notePlaceholder')"
:value="clock.timerNote"
maxlength="80"
@input="setTimerNote"
/>
</sky-list>
<sky-list
strong
inset
class="clock-konsta-list clock-timer-settings clock-timer-sound"
>
<sky-list-item
link
:chevron="false"
@@ -433,42 +482,7 @@ onBeforeUnmount(() => {
</template>
</sky-list-item>
</sky-list>
<div class="clock-actions">
<sky-button
rounded
variant="secondary"
class="clock-action-button"
@click="clock.resetTimer()"
>
{{ phone.t('Common.reset') }}
</sky-button>
<sky-button
rounded
:variant="clock.timerStartedAt === null ? 'primary' : 'danger'"
:style="
clock.timerStartedAt === null
? { '--sky-app-accent': '#30d158' }
: undefined
"
class="clock-action-button"
:disabled="clock.timerStartedAt === null && timerValue <= 0"
@click="
clock.timerStartedAt === null
? clock.startTimer(now)
: clock.pauseTimer(now)
"
>
{{
phone.t(
clock.timerStartedAt === null
? 'Common.start'
: 'Common.pause',
)
}}
</sky-button>
</div>
</sky-block>
</section>
</sky-block>
<sky-tab-bar
@@ -492,6 +506,14 @@ onBeforeUnmount(() => {
</template>
</sky-tab-button>
</sky-tab-bar>
<AlarmEditor
v-if="alarmEditor"
:alarm="selectedAlarm"
@cancel="alarmEditor = null"
@delete="deleteSelectedAlarm"
@save="saveAlarm"
/>
</template>
</sky-app-page>
</template>
@@ -0,0 +1,86 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./CompaniesApp.vue', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const scriptSource = source.slice(0, source.indexOf('</script>'))
const requestBranchStart = source.indexOf(
'<template v-else-if="screen === \'request\'">',
)
const requestBranchEnd = source.indexOf(
"<SkyScrollArea\n v-else-if=\"screen === 'manager'",
requestBranchStart,
)
const requestBranchSource = source.slice(requestBranchStart, requestBranchEnd)
const requestScrollStart = requestBranchSource.indexOf('<SkyScrollArea')
const requestScrollEnd =
requestBranchSource.indexOf('</SkyScrollArea>', requestScrollStart) +
'</SkyScrollArea>'.length
const requestScrollSource = requestBranchSource.slice(
requestScrollStart,
requestScrollEnd,
)
const requestDockStart = requestBranchSource.indexOf(
'<footer',
requestScrollEnd,
)
const requestDockEnd =
requestBranchSource.indexOf('</footer>', requestDockStart) +
'</footer>'.length
const requestDockSource = requestBranchSource.slice(
requestDockStart,
requestDockEnd,
)
const styleSource = source.slice(source.indexOf('<style scoped>'))
describe('CompaniesApp request conversation contract', () => {
it('keeps one registered scroll owner and a separate fixed conversation dock', () => {
expect(requestBranchStart).toBeGreaterThan(-1)
expect(requestBranchEnd).toBeGreaterThan(requestBranchStart)
expect(requestBranchSource.match(/<SkyScrollArea\b/g)).toHaveLength(1)
expect(requestScrollSource).toContain(
'class="companies-content request-thread"',
)
expect(requestDockStart).toBeGreaterThan(requestScrollEnd)
expect(requestScrollSource).not.toContain('request-thread-actions')
expect(requestScrollSource).not.toContain('<SkyMessagebar')
expect(requestDockSource).toContain('class="request-thread-dock"')
expect(requestDockSource).toContain('class="request-thread-actions"')
expect(requestDockSource).toContain('<SkyMessagebar')
expect(requestDockSource.indexOf('request-thread-actions')).toBeLessThan(
requestDockSource.indexOf('<SkyMessagebar'),
)
})
it('does not introduce a nested vertical request scroller', () => {
expect(requestBranchSource).not.toContain('request-thread-scroll')
expect(styleSource).not.toMatch(
/\.request-thread(?:-content|-scroll)?\s*\{[^}]*\boverflow(?:-y)?\s*:\s*(?:auto|scroll)/s,
)
})
it('scrolls the sentinel after initial and newly received messages while respecting reduced motion', () => {
expect(scriptSource).toContain(
'const requestThreadBottom = ref<HTMLElement | null>(null)',
)
expect(requestScrollSource).toContain('ref="requestThreadBottom"')
expect(requestScrollSource).toContain('class="request-thread-bottom"')
expect(scriptSource).toContain(
'requestThreadBottom.value?.scrollIntoView({',
)
expect(scriptSource).toContain(
"window.matchMedia(\n '(prefers-reduced-motion: reduce)',",
)
expect(scriptSource).toContain(
"behavior: animate && !reduceMotion ? 'smooth' : 'auto'",
)
expect(scriptSource).toMatch(
/watch\(\s*\[\s*\(\) => companies\.request\?\.id \?\? ''[\s\S]*?companies\.request\?\.messages\.length \?\? 0[\s\S]*?messages\[messages\.length - 1\]!\.id[\s\S]*?scrollRequestThreadToBottom\(requestId === previousRequestId\)[\s\S]*?\{ flush: 'post' \},\s*\)/,
)
})
})
@@ -17,6 +17,10 @@ const requestFilterSource = source.slice(
source.indexOf('<div class="companies-segment-wrap">'),
) + '</div>'.length,
)
const availabilitySources =
source.match(
/<SkySegmented\s+class=\x22availability-segmented\x22[\s\S]*?<\/SkySegmented>/g,
) ?? []
describe('CompaniesApp Sky pill navigation contract', () => {
it('uses the full-width sliding glass navigation on the root screen', () => {
@@ -53,6 +57,7 @@ describe('CompaniesApp Sky pill navigation contract', () => {
expect(requestFilterSource).toContain('<SkySegmented')
expect(requestFilterSource).toContain('compact')
expect(requestFilterSource).toContain('navigation')
expect(requestFilterSource).toContain('strong')
expect(requestFilterSource).toContain(':item-count="2"')
expect(requestFilterSource).toContain(
':active-index="requestList === \'open\' ? 0 : 1"',
@@ -64,6 +69,12 @@ describe('CompaniesApp Sky pill navigation contract', () => {
})
it('uses compact sliding Glass for both availability controls', () => {
expect(availabilitySources).toHaveLength(2)
for (const availabilitySource of availabilitySources) {
expect(availabilitySource).toContain('compact')
expect(availabilitySource).toContain('navigation')
expect(availabilitySource).toContain('strong')
}
expect(source.match(/class="availability-segmented"/g)).toHaveLength(2)
expect(
source.match(
@@ -0,0 +1,59 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./CompaniesApp.vue', import.meta.url),
'utf8',
)
const testserverSource = readFileSync(
new URL('../../../testserver/index.cjs', import.meta.url),
'utf8',
)
const requestListClassIndex = source.indexOf('company-request-list')
const requestListStart = source.lastIndexOf(
'<SkyListCard',
requestListClassIndex,
)
const requestListSource = source.slice(
requestListStart,
source.indexOf('</SkyListCard>', requestListStart) + '</SkyListCard>'.length,
)
describe('CompaniesApp request card contract', () => {
it('keeps the screenshot hierarchy and request state visible', () => {
expect(requestListStart).toBeGreaterThan(-1)
expect(requestListSource).toMatch(/:header=\x22item\.companyName\x22/)
expect(requestListSource).toMatch(/:title=\x22item\.subject\x22/)
expect(requestListSource).toMatch(
/:subtitle=\x22requestSubtitle\(item\)\x22/,
)
expect(requestListSource).toMatch(/<ClipboardList :size=\x2219\x22/)
expect(requestListSource).toMatch(/v-if=\x22item\.unreadCount\x22/)
expect(requestListSource).toContain('item.unreadCount')
expect(requestListSource).toMatch(
/:class=\x22statusClass\(item\.status\)\x22/,
)
expect(requestListSource).toContain(
'Apps.companies.requestStatuses.${item.status}',
)
expect(requestListSource).toMatch(/openRequest\(item\.id, 'customer'\)/)
})
it('uses separate token-based request surfaces instead of hardcoded colors', () => {
expect(source).toMatch(
/\.company-request-list \{[\s\S]*?display: grid;[\s\S]*?gap: var\(--sky-space-2\);[\s\S]*?background: transparent;[\s\S]*?\}/,
)
expect(source).toMatch(
/\.company-request-list :deep\(\.sky-list-item\) \{[\s\S]*?border: 1px solid var\(--company-border\);[\s\S]*?border-radius: var\(--sky-radius-card\);[\s\S]*?background: var\(--company-surface\);[\s\S]*?\}/,
)
})
it('retains the in-progress reference request in the browser mock', () => {
expect(testserverSource).toMatch(/name: [\x22']Benny's Motor Works[\x22']/)
expect(testserverSource).toMatch(/subject: 'Vehicle will not start'/)
expect(testserverSource).toMatch(/serviceName: 'Roadside Assistance'/)
expect(testserverSource).toMatch(/status: 'in_progress'/)
expect(testserverSource).toContain('unreadCount: 1')
})
})
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./CompaniesApp.vue', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const apostrophe = String.fromCharCode(39)
const quote = String.fromCharCode(34)
function sourceBlock(startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('CompaniesApp outbound service-line dialer contract', () => {
it('gates the work action and its opener by service-line permission', () => {
const openDialer = sourceBlock(
'function openServiceLineDialer()',
'\n\nfunction closeServiceLineDialer()',
)
const workAction = sourceBlock(
`phone.t(${apostrophe}Apps.companies.work.takeCalls${apostrophe})`,
`phone.t(${apostrophe}Apps.companies.manager.title${apostrophe})`,
)
expect(openDialer).toContain(
'if (!companies.workContext?.permissions.canTakeCalls) return',
)
expect(workAction).toContain(
`v-if=${quote}companies.workContext.permissions.canTakeCalls${quote}`,
)
expect(workAction).toContain(`@click=${quote}openServiceLineDialer${quote}`)
expect(workAction).toContain('Apps.companies.work.dialServiceLine')
})
it('uses a Sky sheet and telephone field for the target number', () => {
const sheet = sourceBlock(
`<div class=${quote}companies-sheet companies-service-line-sheet${quote}>`,
`<div class=${quote}companies-sheet companies-assignment-sheet${quote}>`,
)
expect(sheet).toContain('<SkySheet')
expect(sheet).toContain(`:opened=${quote}serviceLineSheetOpened${quote}`)
expect(sheet).toContain(
`<SkyList inset strong class=${quote}service-line-sheet__form${quote}>`,
)
expect(sheet).toContain('<SkyField')
expect(sheet).not.toMatch(/<SkyField\s+outline/)
expect(sheet).toContain(`type=${quote}tel${quote}`)
expect(sheet).toContain(`:value=${quote}serviceLineTarget${quote}`)
expect(sheet).toContain(`:disabled=${quote}!canDialServiceLine${quote}`)
expect(sheet).toContain(`@click=${quote}dialServiceLine${quote}`)
})
it('applies the authoritative call state before opening the Phone app', () => {
const dial = sourceBlock(
'async function dialServiceLine()',
'\n\nfunction syncManagerDraft(',
)
const applyIndex = dial.indexOf('calls.applyCallState(response.data)')
const routeIndex = dial.indexOf(
`await router.push(${apostrophe}/apps/phone${apostrophe})`,
)
expect(dial).toContain('companies.dialServiceLine(')
expect(dial).toContain('serviceLineTarget.value.trim()')
expect(applyIndex).toBeGreaterThanOrEqual(0)
expect(routeIndex).toBeGreaterThan(applyIndex)
})
})
+334 -133
View File
@@ -163,6 +163,8 @@ const threadDraft = ref('')
const workActionsOpened = ref(false)
const assignmentSheetOpened = ref(false)
const selectedMemberId = ref('')
const serviceLineSheetOpened = ref(false)
const serviceLineTarget = ref('')
const cancelDialogOpened = ref(false)
const conflictDialogOpened = ref(false)
const toastOpened = ref(false)
@@ -180,6 +182,7 @@ const announcementDraft = reactive({ body: '', expiresAt: '' })
const profileCoords = ref<CompanyCoordinates | null>(null)
const selectedLogoMedia = ref<PhoneMedia | null>(null)
const selectedCoverMedia = ref<PhoneMedia | null>(null)
const requestThreadBottom = ref<HTMLElement | null>(null)
let searchTimer: ReturnType<typeof setTimeout> | undefined
let toastTimer: ReturnType<typeof setTimeout> | undefined
@@ -224,6 +227,15 @@ const canSendThreadMessage = computed(
Boolean(threadDraft.value.trim()) &&
!companies.mutating,
)
const canDialServiceLine = computed(
() => Boolean(serviceLineTarget.value.trim()) && !companies.mutating,
)
const requestDockVisible = computed(() => {
const actions = companies.request?.actions
return Boolean(
actions && (actions.canCall || actions.canCancel || actions.canReply),
)
})
const navbarTitle = computed(() => {
if (screen.value === 'company') return activeCompany.value?.name ?? ''
if (screen.value === 'request') {
@@ -731,6 +743,30 @@ async function toggleCallAvailability(): Promise<void> {
)
}
function openServiceLineDialer(): void {
if (!companies.workContext?.permissions.canTakeCalls) return
serviceLineTarget.value = ''
serviceLineSheetOpened.value = true
}
function closeServiceLineDialer(): void {
serviceLineSheetOpened.value = false
}
async function dialServiceLine(): Promise<void> {
if (!canDialServiceLine.value) return
const response = await companies.dialServiceLine(
serviceLineTarget.value.trim(),
)
if (!response.success || !response.data) {
showToast(errorText(response.error))
return
}
calls.applyCallState(response.data)
closeServiceLineDialer()
await router.push('/apps/phone')
}
function syncManagerDraft(company: Company): void {
profileDraft.acceptsRequests = company.acceptsRequests
profileDraft.address = company.location?.address ?? ''
@@ -929,6 +965,21 @@ async function reloadConflict(): Promise<void> {
if (companies.request) await companies.loadRequest(companies.request.id)
}
async function scrollRequestThreadToBottom(animate: boolean): Promise<void> {
await nextTick()
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
if (screen.value !== 'request') return
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches
requestThreadBottom.value?.scrollIntoView({
behavior: animate && !reduceMotion ? 'smooth' : 'auto',
block: 'end',
})
}
watch([search, selectedCategory], ([, category], [, previousCategory]) => {
if (category === previousCategory) {
queueDirectoryLoad()
@@ -949,6 +1000,21 @@ watch(requestSheetOpened, async (opened) => {
await nextTick()
requestSheetContent.value?.scrollTo({ top: 0 })
})
watch(
[
() => companies.request?.id ?? '',
() => companies.request?.messages.length ?? 0,
() => {
const messages = companies.request?.messages ?? []
return messages.length ? messages[messages.length - 1]!.id : ''
},
],
([requestId], [previousRequestId]) => {
if (!requestId || screen.value !== 'request') return
void scrollRequestThreadToBottom(requestId === previousRequestId)
},
{ flush: 'post' },
)
watch(
() => route.query.requestId,
@@ -1201,6 +1267,7 @@ onBeforeUnmount(() => {
compact
navigation
rounded
strong
>
<SkySegmentedButton
:active="requestList === 'open'"
@@ -1366,6 +1433,7 @@ onBeforeUnmount(() => {
compact
navigation
rounded
strong
>
<SkySegmentedButton
v-for="availability in availabilityValues"
@@ -1397,6 +1465,23 @@ onBeforeUnmount(() => {
/>
</template>
</SkyListItem>
<SkyListItem
v-if="companies.workContext.permissions.canTakeCalls"
link
link-component="button"
:link-props="{ type: 'button' }"
:title="phone.t('Apps.companies.work.dialServiceLine')"
:subtitle="
phone.t('Apps.companies.work.dialServiceLineBody', {
number:
workCompany.phoneNumber ??
phone.t('Apps.companies.manager.noPhoneNumber'),
})
"
@click="openServiceLineDialer"
>
<template #media><Phone :size="20" /></template>
</SkyListItem>
<SkyListItem
v-if="companies.workContext.role === 'manager'"
link
@@ -1623,119 +1708,136 @@ onBeforeUnmount(() => {
</template>
</SkyScrollArea>
<SkyScrollArea
v-else-if="screen === 'request'"
padded
class="companies-content request-thread"
>
<SkyEmptyState
v-if="companies.requestLoading"
:title="phone.t('Apps.companies.loading.request')"
>
<template #icon><SkySpinner /></template>
</SkyEmptyState>
<SkyEmptyState
v-else-if="companies.requestError || !companies.request"
tone="danger"
:title="phone.t('Apps.companies.states.requestError')"
:body="errorText(companies.requestError)"
>
<template #icon><CircleAlert :size="34" /></template>
<template #actions>
<SkyButton rounded @click="goBack">
{{ phone.t('Apps.companies.back') }}
</SkyButton>
</template>
</SkyEmptyState>
<template v-else>
<div class="request-thread-scroll">
<SkyCard class="request-summary-card">
<span>
<small>{{ companies.request.companyName }}</small>
<strong>{{ companies.request.subject }}</strong>
</span>
<SkyBadge :class="statusClass(companies.request.status)">
{{
phone.t(
`Apps.companies.requestStatuses.${companies.request.status}`,
)
}}
</SkyBadge>
<p>{{ companies.request.description }}</p>
<span class="request-summary-card__meta">
{{
companies.request.serviceName ??
phone.t('Apps.companies.requests.generalService')
}}
· {{ formatDate(companies.request.createdAt) }}
</span>
</SkyCard>
<template v-else-if="screen === 'request'">
<SkyScrollArea padded class="companies-content request-thread">
<SkyEmptyState
v-if="companies.requestLoading"
:title="phone.t('Apps.companies.loading.request')"
>
<template #icon><SkySpinner /></template>
</SkyEmptyState>
<SkyEmptyState
v-else-if="companies.requestError || !companies.request"
tone="danger"
:title="phone.t('Apps.companies.states.requestError')"
:body="errorText(companies.requestError)"
>
<template #icon><CircleAlert :size="34" /></template>
<template #actions>
<SkyButton rounded @click="goBack">
{{ phone.t('Apps.companies.back') }}
</SkyButton>
</template>
</SkyEmptyState>
<template v-else>
<div class="request-thread-content">
<SkyCard class="request-summary-card">
<span>
<small>{{ companies.request.companyName }}</small>
<strong>{{ companies.request.subject }}</strong>
</span>
<SkyBadge :class="statusClass(companies.request.status)">
{{
phone.t(
`Apps.companies.requestStatuses.${companies.request.status}`,
)
}}
</SkyBadge>
<p>{{ companies.request.description }}</p>
<span class="request-summary-card__meta">
{{
companies.request.serviceName ??
phone.t('Apps.companies.requests.generalService')
}}
· {{ formatDate(companies.request.createdAt) }}
</span>
</SkyCard>
<div
v-if="companies.request.media.length"
class="request-media-strip"
:aria-label="phone.t('Apps.companies.requests.attachments')"
>
<img
v-for="media in companies.request.media"
:key="media.id"
:src="media.url"
:alt="phone.t('Apps.companies.requests.attachedPhoto')"
draggable="false"
loading="lazy"
referrerpolicy="no-referrer"
/>
</div>
<SkyBlockTitle>{{
phone.t('Apps.companies.requests.timeline')
}}</SkyBlockTitle>
<div class="request-timeline">
<div v-for="event in companies.request.events" :key="event.id">
<span><Check :size="12" /></span>
<p>
<strong>{{ eventLabel(event) }}</strong>
<small>{{ formatDate(event.createdAt) }}</small>
</p>
<div
v-if="companies.request.media.length"
class="request-media-strip"
:aria-label="phone.t('Apps.companies.requests.attachments')"
>
<img
v-for="media in companies.request.media"
:key="media.id"
:src="media.url"
:alt="phone.t('Apps.companies.requests.attachedPhoto')"
draggable="false"
loading="lazy"
referrerpolicy="no-referrer"
/>
</div>
</div>
<SkyBlockTitle>{{
phone.t('Apps.companies.requests.conversation')
}}</SkyBlockTitle>
<SkyMessages class="company-messages">
<SkyMessagesTitle v-if="!companies.request.messages.length">
{{ phone.t('Apps.companies.requests.noMessages') }}
</SkyMessagesTitle>
<SkyMessage
v-for="message in companies.request.messages"
:key="message.id"
:type="message.isMine ? 'sent' : 'received'"
:name="messageAuthorLabel(message.authorLabel)"
:text="message.body"
:text-footer="formatDate(message.createdAt)"
<SkyBlockTitle>{{
phone.t('Apps.companies.requests.timeline')
}}</SkyBlockTitle>
<div class="request-timeline">
<div v-for="event in companies.request.events" :key="event.id">
<span><Check :size="12" /></span>
<p>
<strong>{{ eventLabel(event) }}</strong>
<small>{{ formatDate(event.createdAt) }}</small>
</p>
</div>
</div>
<SkyBlockTitle>{{
phone.t('Apps.companies.requests.conversation')
}}</SkyBlockTitle>
<SkyMessages class="company-messages">
<SkyMessagesTitle v-if="!companies.request.messages.length">
{{ phone.t('Apps.companies.requests.noMessages') }}
</SkyMessagesTitle>
<SkyMessage
v-for="message in companies.request.messages"
:key="message.id"
:type="message.isMine ? 'sent' : 'received'"
:name="messageAuthorLabel(message.authorLabel)"
:text="message.body"
:text-footer="formatDate(message.createdAt)"
/>
</SkyMessages>
<span
ref="requestThreadBottom"
class="request-thread-bottom"
aria-hidden="true"
/>
</SkyMessages>
<div class="request-thread-actions">
<SkyButton
v-if="companies.request.actions.canCall"
outline
rounded
@click="callRequestParty"
>
<Phone :size="16" />{{ phone.t('Apps.companies.requests.call') }}
</SkyButton>
<SkyButton
v-if="companies.request.actions.canCancel"
outline
rounded
class="is-danger"
@click="cancelDialogOpened = true"
>
<X :size="16" />{{ phone.t('Apps.companies.requests.cancel') }}
</SkyButton>
</div>
</template>
</SkyScrollArea>
<footer
v-if="requestDockVisible && companies.request"
class="request-thread-dock"
:class="{
'request-thread-dock--actions-only':
!companies.request.actions.canReply,
}"
>
<div
v-if="
companies.request.actions.canCall ||
companies.request.actions.canCancel
"
class="request-thread-actions"
>
<SkyButton
v-if="companies.request.actions.canCall"
outline
rounded
@click="callRequestParty"
>
<Phone :size="16" />{{ phone.t('Apps.companies.requests.call') }}
</SkyButton>
<SkyButton
v-if="companies.request.actions.canCancel"
outline
rounded
class="is-danger"
@click="cancelDialogOpened = true"
>
<X :size="16" />{{ phone.t('Apps.companies.requests.cancel') }}
</SkyButton>
</div>
<SkyMessagebar
v-if="companies.request.actions.canReply"
@@ -1761,8 +1863,8 @@ onBeforeUnmount(() => {
</SkyToolbarPane>
</template>
</SkyMessagebar>
</template>
</SkyScrollArea>
</footer>
</template>
<SkyScrollArea
v-else-if="screen === 'manager' && workCompany"
@@ -1792,6 +1894,7 @@ onBeforeUnmount(() => {
compact
navigation
rounded
strong
>
<SkySegmentedButton
v-for="availability in availabilityValues"
@@ -2335,6 +2438,64 @@ onBeforeUnmount(() => {
</SkyActionGroup>
</SkyActionSheet>
<div class="companies-sheet companies-service-line-sheet">
<SkySheet
:opened="serviceLineSheetOpened"
:aria-label="phone.t('Apps.companies.work.dialServiceLine')"
@backdropclick="closeServiceLineDialer"
@escape="closeServiceLineDialer"
>
<section
v-if="serviceLineSheetOpened && workCompany"
class="companies-sheet__content service-line-sheet__content"
>
<header>
<span><PhoneCall :size="23" /></span>
<div>
<small>{{ workCompany.name }}</small>
<h2>{{ phone.t('Apps.companies.work.dialServiceLine') }}</h2>
</div>
<SkyLink
component="button"
icon-only
:aria-label="phone.t('Apps.companies.close')"
type="button"
@click="closeServiceLineDialer"
>
<X :size="19" />
</SkyLink>
</header>
<p class="service-line-sheet__body">
{{
phone.t('Apps.companies.work.dialServiceLineHint', {
number:
workCompany.phoneNumber ??
phone.t('Apps.companies.manager.noPhoneNumber'),
})
}}
</p>
<SkyList inset strong class="service-line-sheet__form">
<SkyField
type="tel"
:label="phone.t('Apps.companies.work.targetNumber')"
:placeholder="phone.t('Apps.companies.work.targetNumberHint')"
:value="serviceLineTarget"
@input="serviceLineTarget = eventValue($event)"
@keydown.enter.exact="handleEnterAction($event, dialServiceLine)"
/>
</SkyList>
<SkyButton
large
rounded
:disabled="!canDialServiceLine"
@click="dialServiceLine"
>
<Phone :size="18" />{{ phone.t('Apps.companies.work.callNow') }}
</SkyButton>
</section>
</SkySheet>
</div>
<div class="companies-sheet companies-assignment-sheet">
<SkySheet
:opened="assignmentSheetOpened"
@@ -2494,9 +2655,24 @@ onBeforeUnmount(() => {
box-shadow: none;
}
.company-list,
.company-list {
margin: 0 !important;
}
.company-request-list {
margin: 0 !important;
display: grid;
gap: var(--sky-space-2);
overflow: visible;
border-radius: 0;
background: transparent;
}
.company-request-list :deep(.sky-list-item) {
overflow: hidden;
border: 1px solid var(--company-border);
border-radius: var(--sky-radius-card);
background: var(--company-surface);
}
.company-list :deep(.sky-list-item__row),
@@ -2831,16 +3007,8 @@ onBeforeUnmount(() => {
border-color: rgba(255, 255, 255, 0.08);
}
.request-thread {
padding: 8px 0 0;
overflow: hidden;
}
.request-thread-scroll {
height: 100%;
padding: 4px 10px 88px;
overflow-y: auto;
scrollbar-width: none;
.request-thread-content {
min-height: 100%;
}
.request-summary-card :deep(> *) {
@@ -2947,11 +3115,30 @@ onBeforeUnmount(() => {
min-height: 100px;
}
.request-thread-bottom {
width: 100%;
height: 1px;
display: block;
}
.request-thread-dock {
position: relative;
z-index: 4;
flex: 0 0 auto;
background: var(--sky-bg);
}
.request-thread-actions {
margin: 14px 5px;
margin: var(--sky-space-2)
calc(var(--sky-page-gutter) + var(--sky-safe-area-right)) var(--sky-space-1)
calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-2);
}
.request-thread-actions > :only-child {
grid-column: 1 / -1;
}
.request-thread-actions .is-danger,
@@ -2959,17 +3146,16 @@ onBeforeUnmount(() => {
color: var(--company-red);
}
.company-messagebar {
position: absolute;
z-index: 4;
right: 0;
bottom: 0;
left: 0;
background: transparent;
.request-thread-dock--actions-only {
padding-bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-2));
}
.companies-app.sky-app-page--dark .company-messagebar {
background: transparent;
.company-messagebar {
position: relative;
z-index: auto;
right: auto;
bottom: auto;
left: auto;
}
.manager-screen {
@@ -3127,6 +3313,21 @@ onBeforeUnmount(() => {
font-size: 9px;
}
.service-line-sheet__body {
margin: 0 0 var(--sky-space-4);
color: var(--company-muted);
font-size: var(--sky-font-body);
line-height: 1.45;
}
.service-line-sheet__form {
margin: 0 0 var(--sky-space-4) !important;
}
.service-line-sheet__content > :deep(.sky-button) {
width: 100%;
}
.composer-service-title {
margin-top: 18px;
margin-bottom: 8px;
+109 -114
View File
@@ -166,6 +166,7 @@ const sheet = ref<CrewLinkSheet>(null)
const username = ref('')
const authMode = ref<'login' | 'register'>('login')
const authUsername = ref('')
const authPassword = ref('')
const authProfilePhoto = ref<PhoneMedia | null>(null)
const authPending = ref(false)
const authError = ref('')
@@ -408,6 +409,7 @@ function showToast(message: string): void {
function switchAuthMode(mode: 'login' | 'register'): void {
authMode.value = mode
authProfilePhoto.value = null
authPassword.value = ''
authUsername.value =
mode === 'register'
? (account.email.split('@')[0] ?? '')
@@ -419,6 +421,8 @@ function switchAuthMode(mode: 'login' | 'register'): void {
function authErrorText(code?: string): string {
const known = [
'invalid_credentials',
'invalid_password',
'invalid_profile_image',
'invalid_username',
'no_ifruit_account',
@@ -438,56 +442,45 @@ async function submitAuthentication(): Promise<void> {
authError.value = authErrorText('no_ifruit_account')
return
}
if (!authUsernameValid.value) {
if (authPassword.value.length < 8 || authPassword.value.length > 72) {
authError.value = authErrorText('invalid_password')
return
}
if (authMode.value === 'register' && !authUsernameValid.value) {
authError.value = authErrorText('invalid_username')
return
}
const submittedUsername = authUsername.value.trim()
authPending.value = true
const loaded = await crew.bootstrap()
if (!loaded) {
authPending.value = false
authError.value = authErrorText(crew.error)
const response =
authMode.value === 'login'
? await crew.login(authPassword.value)
: await crew.register(
submittedUsername,
authPassword.value,
authProfilePhoto.value?.id ?? 0,
)
authPending.value = false
if (!response.success || !crew.profile) {
authError.value = authErrorText(response.error)
return
}
if (authMode.value === 'login') {
authPending.value = false
if (!crew.profile) {
authError.value = authErrorText('profile_not_found')
return
}
if (
crew.profile.username.toLowerCase() !== submittedUsername.toLowerCase()
) {
authError.value = authErrorText('invalid_username')
return
}
} else {
if (crew.profile) {
authPending.value = false
authError.value = authErrorText('profile_exists')
return
}
const response = await crew.createProfile(
submittedUsername,
authProfilePhoto.value?.id ?? 0,
)
authPending.value = false
if (!response.success) {
authError.value = authErrorText(response.error)
return
}
}
appAuth.signIn('crewlink', account.email)
username.value = crew.profile?.username ?? submittedUsername
authUsername.value = ''
authPassword.value = ''
authProfilePhoto.value = null
openSharedInvite()
}
async function handleLoggedOut(): Promise<void> {
await crew.logout()
activeTab.value = 'map'
authPassword.value = ''
}
function openAuthMedia(app: 'camera' | 'photos'): void {
messageMedia.begin(
'crewlink:auth-avatar',
@@ -657,16 +650,6 @@ function removeProfilePhoto(): void {
profileAvatarRemoved.value = true
}
async function createProfile(): Promise<void> {
const response = await crew.createProfile(username.value.trim())
if (!response.success) {
formError.value = errorText(response.error)
return
}
showToast(t('profileCreated'))
openSharedInvite()
}
async function createGroup(): Promise<void> {
const response = await crew.createGroup(
groupName.value.trim(),
@@ -1136,7 +1119,10 @@ onMounted(async () => {
authProfilePhoto.value =
authSelection.media[0] ?? authSelection.context?.selectedPhoto ?? null
}
if (appAuth.isSignedIn('crewlink')) await crew.bootstrap()
if (appAuth.isSignedIn('crewlink')) {
await crew.bootstrap()
if (!crew.authenticated) appAuth.signOut('crewlink')
}
username.value = crew.profile?.username ?? ''
if (profileSelection && crew.profile) {
username.value = profileSelection.context?.username ?? crew.profile.username
@@ -1173,6 +1159,7 @@ onBeforeUnmount(() => {
<div class="crewlink-onboarding crewlink-auth">
<AppProfileAuth
:mode="authMode"
v-model:password="authPassword"
v-model:username="authUsername"
:avatar-url="authProfilePhoto?.url ?? null"
:body="t('authBody')"
@@ -1186,10 +1173,14 @@ onBeforeUnmount(() => {
:max-username-length="20"
:min-username-length="3"
:pending="authPending"
:password-label="t('password')"
:password-placeholder="t('passwordPlaceholder')"
:register-label="t('register')"
require-password
:title="t('authTitle')"
:username-label="t('username')"
:username-placeholder="t('usernamePlaceholder')"
variant="centered"
@camera="openAuthMedia('camera')"
@gallery="openAuthMedia('photos')"
@submit="submitAuthentication"
@@ -1218,57 +1209,11 @@ onBeforeUnmount(() => {
</div>
</template>
<template v-else-if="!crew.profile">
<div class="crewlink-onboarding crewlink-onboarding--profile">
<div class="crewlink-orbits" aria-hidden="true">
<i></i><i></i><i></i><span><Radio /></span>
</div>
<small>{{ t('welcomeEyebrow') }}</small>
<h1>{{ t('welcomeTitle') }}</h1>
<p>{{ t('welcomeBody') }}</p>
<sky-list inset strong class="crewlink-form-list">
<sky-field
input-id="crewlink-username"
:label="t('username')"
:placeholder="t('usernamePlaceholder')"
:value="username"
maxlength="20"
outline
@input="updateValue('username', $event)"
@keydown.enter="handleEnterAction($event, createProfile)"
/>
</sky-list>
<p v-if="formError" class="crewlink-error" role="alert">
{{ formError }}
</p>
<sky-button
large
rounded
:disabled="crew.isLoading"
@click="createProfile"
>
<sky-spinner v-if="crew.isLoading" />
<template v-else>{{ t('createProfile') }}</template>
</sky-button>
<sky-button
large
rounded
outline
class="crewlink-logout-button"
@click="logoutDialogOpen = true"
>
<LogOut />{{ phone.t('Common.signOut') }}
</sky-button>
<small class="crewlink-privacy"
><ShieldCheck />{{ t('privacyNote') }}</small
>
</div>
</template>
<template v-else>
<template v-else-if="crew.profile">
<sky-navbar
v-if="activeGroup"
class="crewlink-navbar"
:class="{ 'crewlink-navbar--map': activeTab === 'map' }"
:subtitle="headerSubtitle"
:title="headerTitle"
/>
@@ -1481,7 +1426,10 @@ onBeforeUnmount(() => {
</div>
</section>
<section v-show="activeTab === 'group'" class="crewlink-scroll-tab">
<section
v-show="activeTab === 'group'"
class="crewlink-scroll-tab crewlink-group-tab"
>
<div class="crewlink-group-hero" :style="activeCrewStyle">
<div class="crewlink-group-hero__signal"><Radio /></div>
<small>{{ t('activeCrew') }}</small>
@@ -1674,7 +1622,10 @@ onBeforeUnmount(() => {
</div>
</section>
<section v-show="activeTab === 'profile'" class="crewlink-scroll-tab">
<section
v-show="activeTab === 'profile'"
class="crewlink-scroll-tab crewlink-profile-tab"
>
<div
class="crewlink-profile-card"
:style="{ '--crew': activeColour }"
@@ -1891,6 +1842,10 @@ onBeforeUnmount(() => {
<section
v-if="sheet"
class="crewlink-sheet__panel__content"
:class="{
'crewlink-sheet__panel__content--manage': sheet === 'edit-group',
'crewlink-sheet__panel__content--ping': sheet === 'ping',
}"
role="dialog"
aria-modal="true"
>
@@ -2372,7 +2327,7 @@ onBeforeUnmount(() => {
v-model:opened="logoutDialogOpen"
app-id="crewlink"
:app-name="t('name')"
@logged-out="activeTab = 'map'"
@logged-out="handleLoggedOut"
/>
<sky-dialog
@@ -2409,6 +2364,7 @@ onBeforeUnmount(() => {
--cl-surface: rgba(255, 255, 255, 0.84);
--cl-text: #102034;
--cl-muted: #6e7c8d;
--cl-map-header-background: linear-gradient(90deg, #061823, #0f2837);
--sky-safe-area-top: 46px;
--sky-safe-area-bottom: 25px;
position: relative;
@@ -2428,22 +2384,13 @@ onBeforeUnmount(() => {
}
.crewlink-navbar {
--sky-safe-area-top: 46px;
--sky-navbar-glass: var(--cl-bg);
position: absolute;
z-index: 20;
top: 0;
right: 0;
left: 0;
}
.crewlink-navbar::after {
position: absolute;
top: 100%;
right: 0;
left: 0;
height: 22px;
background: linear-gradient(var(--cl-bg), transparent);
content: '';
pointer-events: none;
}
.crewlink-content {
position: absolute;
inset: 0;
@@ -2476,8 +2423,11 @@ onBeforeUnmount(() => {
.crewlink-auth {
--auth-accent: #20bde0;
--panel: rgba(18, 39, 53, 0.92);
padding: 60px 18px 30px;
justify-content: flex-start;
box-sizing: border-box;
min-height: 100%;
padding: calc(var(--sky-safe-area-top) + 20px) 18px
calc(var(--sky-safe-area-bottom) + 20px);
justify-content: center;
color: #f3f8fb;
background:
radial-gradient(circle at 82% 8%, rgba(39, 217, 237, 0.2), transparent 34%),
@@ -2485,7 +2435,9 @@ onBeforeUnmount(() => {
overflow-y: auto;
}
.crewlink-auth :deep(.app-profile-auth) {
margin: 0 auto;
width: min(100%, 340px);
flex: none;
margin: auto;
}
.crewlink-logo {
width: 78px;
@@ -2522,6 +2474,15 @@ onBeforeUnmount(() => {
.crewlink-onboarding :deep(.button) {
width: 100%;
}
.crewlink-navbar--map {
--sky-text: #f3f8fb;
--sky-muted: #aab7c1;
background: var(--cl-map-header-background);
}
.crewlink-navbar--map :deep(.sky-navbar__blur),
.crewlink-navbar--map :deep(.sky-navbar__background) {
display: none;
}
.crewlink-orbits {
width: 170px;
height: 170px;
@@ -2703,17 +2664,27 @@ onBeforeUnmount(() => {
padding-bottom: var(--sky-safe-area-bottom);
}
.crewlink-map-summary {
position: relative;
z-index: 1001;
height: 64px;
padding: 8px 13px;
display: flex;
align-items: center;
justify-content: space-between;
color: white;
background: linear-gradient(
90deg,
rgba(6, 24, 35, 0.96),
rgba(15, 41, 56, 0.95)
);
background: var(--cl-map-header-background);
}
.crewlink-map-summary::after {
position: absolute;
top: 100%;
right: 0;
left: 0;
height: 16px;
background: var(--cl-map-header-background);
-webkit-mask-image: linear-gradient(to bottom, #000, transparent);
mask-image: linear-gradient(to bottom, #000, transparent);
content: '';
pointer-events: none;
}
.crewlink-map-summary > div:first-child {
display: grid;
@@ -3020,6 +2991,14 @@ onBeforeUnmount(() => {
linear-gradient(145deg, #0b2433, #0b1825);
box-shadow: 0 15px 35px rgba(6, 20, 31, 0.2);
}
.crewlink-group-tab {
--sky-card-outer-left: 0px;
--sky-card-outer-right: 0px;
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
--sky-title-gutter-left: 0px;
--sky-title-gutter-right: 0px;
}
.crewlink-group-hero__signal {
width: 48px;
height: 48px;
@@ -3311,6 +3290,12 @@ onBeforeUnmount(() => {
color: white;
background: linear-gradient(145deg, var(--crew), #183a5a);
}
.crewlink-profile-tab {
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
--sky-title-gutter-left: 0px;
--sky-title-gutter-right: 0px;
}
.crewlink-profile-card > span {
width: 54px;
height: 54px;
@@ -3413,6 +3398,16 @@ onBeforeUnmount(() => {
background: var(--cl-bg);
border-radius: 24px 24px 0 0;
}
.crewlink-sheet__panel__content--manage {
--sky-card-outer-left: 0px;
--sky-card-outer-right: 0px;
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
}
.crewlink-sheet__panel__content--ping {
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
}
.crewlink-sheet__panel__close {
position: absolute;
right: 14px;
@@ -0,0 +1,151 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./FeatherApp.vue', import.meta.url),
'utf8',
)
const postCard = readFileSync(
new URL('../../components/feather/FeatherPostCard.vue', import.meta.url),
'utf8',
)
describe('FeatherApp Sky UI contract', () => {
it('uses explicit Sky UI components without Konsta-style aliases', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(postCard).not.toContain("from 'konsta/vue'")
expect(postCard).toContain('import { SkyButton, SkyGlass, SkyIcon }')
expect(postCard).not.toMatch(/Sky[A-Za-z]+ as k[A-Za-z]+/)
expect(postCard).not.toMatch(/<\/?k[A-Z]/)
expect(source).not.toContain('SkyTabBar')
expect(source).not.toContain('SkyTabButton')
expect(source).not.toContain('SkyToolbarPane')
expect(source).toContain('<SkyPillNavigation')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('with-tabbar')
})
it('gives likes and bookmarks a reduced-motion-safe pulse animation', () => {
expect(postCard).toContain(
"const reactionPulse = ref<'like' | 'bookmark' | null>(null)",
)
expect(postCard).toContain('@click.stop="react(\'like\')"')
expect(postCard).toContain('@click.stop="react(\'bookmark\')"')
expect(postCard).toContain("'is-pulsing': reactionPulse === 'like'")
expect(postCard).toContain("'is-pulsing': reactionPulse === 'bookmark'")
expect(postCard).toContain('@keyframes feather-reaction-pop')
expect(postCard).toContain('@keyframes feather-reaction-ring')
expect(postCard).toContain('@media (prefers-reduced-motion: reduce)')
})
it('keeps the post header actions visible and compact', () => {
expect(postCard).toContain(
':aria-label="phone.t(\'Apps.feather.moreActions\')"',
)
expect(postCard).toMatch(
/<SkyButton\s+icon-only\s+rounded\s+small\s+tonal\s+class="feather-more"/s,
)
expect(postCard).toMatch(
/\.feather-follow\s*\{[^}]*--sky-app-accent:\s*var\(--feather-blue,[^}]*color:\s*var\(--feather-blue,/s,
)
expect(postCard).not.toMatch(
/\.feather-follow\s*\{[^}]*--sky-app-accent:\s*transparent/s,
)
expect(postCard).toMatch(
/\.feather-more\s*\{[^}]*width:\s*27px[^}]*height:\s*27px[^}]*border:\s*1px/s,
)
})
it('keeps the composer below the iPhone header with compact actions', () => {
expect(source).toContain("'feather-app--composer':")
expect(source).toMatch(
/\.feather-app--active\.feather-app--composer \.feather-navbar\s*\{[^}]*--sky-safe-area-top:\s*54px/s,
)
expect(source).toMatch(
/\.feather-app--active\.feather-app--composer > \.feather-composer\s*\{[^}]*top:\s*112px[^}]*padding-top:\s*18px/s,
)
expect(source).toMatch(
/\.feather-composer-close\s*\{[^}]*width:\s*32px[^}]*height:\s*32px/s,
)
expect(source).toMatch(
/\.feather-composer-publish\s*\{[^}]*min-width:\s*52px[^}]*min-height:\s*28px/s,
)
expect(source).toMatch(
/v-if="screen === 'composer'"[\s\S]*?variant="secondary"[\s\S]*?class="feather-composer-publish"/,
)
expect(source).toMatch(
/v-else-if="screen === 'edit'"[\s\S]*?variant="secondary"[\s\S]*?class="feather-edit__navbar-save"/,
)
})
it('uses the shared phone action sheet for every post more menu', () => {
expect(source).toContain('<SkyActionSheet')
expect(source).toContain('<SkyActionGroup')
expect(source).toContain('<SkyActionButton')
expect(source).toContain('<SkyActionsLabel')
expect(source).not.toContain('<SkySheet :opened="menuPost !== null"')
})
it('renders compact Instagram-style comments with a bottom message bar', () => {
expect(source).toContain('class="feather-comments"')
expect(source).toContain('class="feather-comment"')
expect(source).toContain('class="feather-comment__like"')
expect(source).toContain('class="feather-comment-composer"')
expect(source).toContain('<SkyMessagebar')
expect(source).toContain('@click="reactComment(post)"')
expect(source).toContain('@click="focusThreadReply(post)"')
expect(source).not.toMatch(
/<FeatherPostCard\s+v-for="post in feather\.thread\.replies"/,
)
})
it('returns to the profile tab navigation after closing its connection list', () => {
expect(source).toContain(
"const connectionReturnScreen = ref<'main' | 'profile'>('profile')",
)
expect(source).toContain(
"connectionReturnScreen.value = screen.value === 'main' ? 'main' : 'profile'",
)
expect(source).toContain('screen.value = connectionReturnScreen.value')
})
it('uses a strong Explore filter and a dedicated Network follow control', () => {
expect(source).toMatch(
/<SkySegmented[\s\S]*?class="feather-explore-tabs"[\s\S]*?rounded[\s\S]*?strong/,
)
expect(source).toContain(
'class="feather-follow-button feather-network-person__follow"',
)
expect(source).toContain(':tonal="person.is_following"')
expect(source).toMatch(
/\.feather-network-person__follow\s*\{[^}]*min-width:\s*84px[^}]*min-height:\s*32px/s,
)
expect(source).not.toContain('feather-feed-add')
})
it('keeps owner actions in the navbar and removes the profile media tab', () => {
const profileStart = source.indexOf('class="feather-profile"')
const tabsStart = source.indexOf(
'class="feather-profile-tabs"',
profileStart,
)
const profile = source.slice(profileStart, tabsStart)
expect(profile).toContain('class="feather-profile__cover-title"')
expect(profile).not.toContain('class="feather-profile__logout"')
expect(source).toContain('class="feather-profile__logout"')
expect(source).toContain(':aria-label="phone.t(\'Common.signOut\')"')
expect(source).toContain('@click="logoutDialogOpen = true"')
expect(profile).not.toContain('feather-profile-action--logout')
expect(source).toContain('<Check :size="14" :stroke-width="2.8" />')
expect(source).not.toContain("profileView === 'media'")
expect(source).not.toContain("selectProfileView('media')")
expect(source).toMatch(
/\.feather-profile__cover-title\s*\{[^}]*color:\s*#fff/s,
)
expect(source).toMatch(
/\.feather-app--active \.feather-profile__logout\s*\{[^}]*--sky-app-accent:\s*#fff/s,
)
})
})
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@ const localeSource = readFileSync(
)
describe('FlareApp profile editing contract', () => {
it('opens the central photo source picker while creating an account', () => {
it('shows direct Gallery and Camera actions while creating an account', () => {
const onboardingStart = source.indexOf(
'<template v-else-if="!flare.profile">',
)
@@ -29,27 +29,68 @@ describe('FlareApp profile editing contract', () => {
expect(onboardingStart).toBeGreaterThan(-1)
expect(onboardingEnd).toBeGreaterThan(onboardingStart)
expect(onboarding).toContain('aria-controls="flare-photo-source-sheet"')
expect(onboarding).toContain('aria-haspopup="dialog"')
expect(onboarding).toContain('@click="openPhotoSourcePicker"')
expect(onboarding).not.toContain("openProfileMediaApp('photos')")
expect(onboarding).not.toContain("openProfileMediaApp('camera')")
expect(onboarding).toContain("openProfileMediaApp('photos')")
expect(onboarding).toContain("openProfileMediaApp('camera')")
expect(onboarding).toContain("phone.t('Apps.flare.choosePhotos')")
expect(onboarding).toContain("phone.t('Apps.flare.takePhoto')")
expect(onboarding).not.toContain('@click="openPhotoSourcePicker"')
})
it('uses the same Gallery and Camera picker for onboarding and editing', () => {
it('uses the central anchored SkyDropdown for photo sources while editing', () => {
const triggerClass = source.indexOf('class="flare-photo-add"')
const triggerStart = source.lastIndexOf('<sky-button', triggerClass)
const triggerEnd = source.indexOf('</sky-button>', triggerClass)
const trigger = source.slice(triggerStart, triggerEnd)
const dropdownStart = source.search(/<SkyDropdown\b/)
const dropdownEnd = source.indexOf('/>', dropdownStart)
const dropdown = source.slice(dropdownStart, dropdownEnd)
const pickerStart = source.indexOf('function openPhotoSourcePicker')
const pickerEnd = source.indexOf(
'function openProfileMediaApp',
pickerStart,
)
const picker = source.slice(pickerStart, pickerEnd)
const mediaAppStart = source.indexOf('function openProfileMediaApp')
const mediaAppEnd = source.indexOf('function removeDraftPhoto')
const mediaApp = source.slice(mediaAppStart, mediaAppEnd)
expect(source.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(2)
expect(source).toMatch(
/<sky-action-sheet\s+id=["']flare-photo-source-sheet["']/,
expect(triggerClass).toBeGreaterThan(-1)
expect(triggerStart).toBeGreaterThan(-1)
expect(triggerEnd).toBeGreaterThan(triggerStart)
expect(source.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(1)
expect(trigger).toContain('aria-controls="flare-photo-source-menu"')
expect(trigger).toContain('aria-haspopup="menu"')
expect(trigger).toContain(':aria-expanded="photoSourceOpened"')
expect(trigger).not.toContain('aria-haspopup="dialog"')
expect(dropdownStart).toBeGreaterThan(-1)
expect(dropdownEnd).toBeGreaterThan(dropdownStart)
expect(dropdown).toContain('id="flare-photo-source-menu"')
expect(dropdown).toContain(':items="photoSourceItems"')
expect(dropdown).toMatch(/:label="phone\.t\('Apps\.flare\.addPhotos'\)"/)
expect(dropdown).toContain(':opened="photoSourceOpened"')
expect(dropdown).toContain(':target="photoSourceTarget"')
expect(dropdown).toContain('@backdropclick="closePhotoSourcePicker"')
expect(dropdown).toContain('@escape="closePhotoSourcePicker"')
expect(dropdown).toContain('@positionerror="closePhotoSourcePicker"')
expect(dropdown).toContain('@select="selectPhotoSource"')
expect(source).toContain(
"{ id: 'photos', label: phone.t('Apps.flare.choosePhotos') }",
)
expect(source).toContain(
'<sky-action-button bold @click="openProfileMediaApp(\'photos\')">',
"{ id: 'camera', label: phone.t('Apps.flare.takePhoto') }",
)
expect(source).toContain(
'<sky-action-button @click="openProfileMediaApp(\'camera\')">',
expect(pickerStart).toBeGreaterThan(-1)
expect(pickerEnd).toBeGreaterThan(pickerStart)
expect(picker).toContain('event.currentTarget instanceof HTMLElement')
expect(picker).toContain('photoSourceTarget.value = event.currentTarget')
expect(picker).toContain("if (id !== 'photos' && id !== 'camera') return")
expect(picker).toContain('openProfileMediaApp(id)')
expect(source).not.toContain('flare-photo-source-sheet')
expect(source).not.toMatch(
/<sky-action-sheet\b[\s\S]*?openProfileMediaApp\(['"](?:photos|camera)['"]\)[\s\S]*?<\/sky-action-sheet>/i,
)
expect(mediaAppStart).toBeGreaterThan(-1)
expect(mediaAppEnd).toBeGreaterThan(mediaAppStart)
@@ -60,6 +101,51 @@ describe('FlareApp profile editing contract', () => {
expect(mediaApp).toContain("query: { mediaAttachment: 'photo' }")
})
it('requires one profile photo before creating or saving a profile', () => {
const saveStart = source.indexOf('async function saveProfile()')
const saveEnd = source.indexOf('\n\nfunction selectTab', saveStart)
const saveProfile = source.slice(saveStart, saveEnd)
expect(source).toContain(
'const hasRequiredProfilePhoto = computed(() => draftPhotos.value.length >= 1)',
)
expect(saveProfile).toContain('!hasRequiredProfilePhoto.value')
expect(
source.match(/:disabled="profileSaving \|\| !hasRequiredProfilePhoto"/g),
).toHaveLength(2)
expect(source).toContain('if (draftPhotos.value.length <= 1) return')
expect(source).toContain(
'if (flare.profile && draftPhotos.value.length === 0)',
)
expect(
source.match(
/<sky-link\s+v-if="draftPhotos\.length > 1"[\s\S]*?class="flare-photo-remove"/g,
),
).toHaveLength(2)
expect(source.match(/Apps\.flare\.profilePhotoRequired/g)).toHaveLength(2)
expect(serverSource).toMatch(
/#data\.photoMediaIds\s*<\s*1[\s\S]*?#data\.photoMediaIds\s*>\s*6/,
)
expect(localeSource).toContain(
'Add one to six photos from Photos or Camera.',
)
})
it('uses eligible in-game profile photos for Explorer covers', () => {
const explorerStart = source.indexOf("activeTab === 'explore'")
const explorerEnd = source.indexOf("activeTab === 'likes'", explorerStart)
const explorer = source.slice(explorerStart, explorerEnd)
expect(source).not.toContain('profiles-source.png')
expect(source).toContain('const exploreTiles = computed(')
expect(source).toContain('profile.photoUrls.length > 0')
expect(source).toContain("coverUrl: profile?.photoUrls[0] ?? ''")
expect(explorer).toContain(
':style="tile.coverUrl ? photoStyle(tile.coverUrl) : undefined"',
)
expect(explorer).not.toContain('avatarStyle(tile.avatar)')
})
it('opens the relationship goal editor from the profile summary card', () => {
const cardClass = source.indexOf('class="flare-profile-card"')
const cardStart = source.lastIndexOf('<sky-card', cardClass)
+112 -63
View File
@@ -1,9 +1,5 @@
<script setup lang="ts">
import {
SkyActionButton,
SkyActionGroup,
SkyActionSheet,
SkyActionsLabel,
SkyBadge,
SkyBlock,
SkyBlockTitle,
@@ -11,6 +7,7 @@ import {
SkyCard,
SkyDialog,
SkyDialogButton,
SkyDropdown,
SkyIcon,
SkyLink,
SkyList,
@@ -72,7 +69,6 @@ import {
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import profilesSprite from '@/assets/img/flare/profiles-source.png'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
import SharedContentCard from '@/components/SharedContentCard.vue'
@@ -137,6 +133,7 @@ const profileEditing = ref(false)
const profileSettings = ref(false)
const profileSaving = ref(false)
const photoSourceOpened = ref(false)
const photoSourceTarget = ref<HTMLElement | null>(null)
const discoverySaving = ref(false)
const signOutDialogOpened = ref(false)
const deleteAccountDialogOpened = ref(false)
@@ -152,6 +149,10 @@ const gifResults = ref<GifSearchResult[]>([])
const gifLoading = ref(false)
const gifError = ref<string | null>(null)
const gifHasMore = ref(true)
const photoSourceItems = computed(() => [
{ id: 'photos', label: phone.t('Apps.flare.choosePhotos') },
{ id: 'camera', label: phone.t('Apps.flare.takePhoto') },
])
function shareProfile(): void {
const profile = flare.profile
@@ -207,6 +208,7 @@ const activeChoiceTitle = computed(() =>
choiceFieldLabel(activeChoiceField.value),
)
const activeChoiceValue = computed(() => profileDraft[activeChoiceField.value])
const hasRequiredProfilePhoto = computed(() => draftPhotos.value.length >= 1)
const filteredSuggestions = computed(() =>
activeExploreMode.value === 'all'
@@ -250,21 +252,35 @@ const cardStyle = computed(() => ({
transform: `translateX(${cardOffset.value}px) rotate(${cardOffset.value / 22}deg)`,
transition: dragging.value ? 'none' : undefined,
}))
const exploreTiles = [
{ avatar: 0, key: 'forYou', mode: 'all', tone: 'coral' },
{ avatar: 1, key: 'longTerm', mode: 'longTerm', tone: 'violet' },
{ avatar: 4, key: 'newFriends', mode: 'friends', tone: 'blue' },
{ avatar: 3, key: 'dateNight', mode: 'dates', tone: 'amber' },
const exploreTileDefinitions = [
{ key: 'forYou', mode: 'all', tone: 'coral' },
{ key: 'longTerm', mode: 'longTerm', tone: 'violet' },
{ key: 'newFriends', mode: 'friends', tone: 'blue' },
{ key: 'dateNight', mode: 'dates', tone: 'amber' },
] as const
const exploreTiles = computed(() => {
const profiles = flare.suggestions.filter(
(profile) => profile.photoUrls.length > 0,
)
return exploreTileDefinitions.map((tile) => {
const profile =
tile.mode === 'all'
? profiles[0]
: (profiles.find((candidate) => candidate.lookingFor === tile.mode) ??
profiles[0])
return {
...tile,
coverUrl: profile?.photoUrls[0] ?? '',
}
})
})
function avatarStyle(avatar: number): Record<string, string> {
const safeAvatar = Math.max(0, Math.min(5, Math.floor(avatar)))
const column = safeAvatar % 3
const row = Math.floor(safeAvatar / 3)
return {
backgroundImage: `url(${profilesSprite})`,
backgroundPosition: `${column * 50}% ${row * 100}%`,
backgroundSize: '300% 200%',
backgroundImage: `linear-gradient(${135 + safeAvatar * 12}deg, var(--flare), var(--flare-warm) 52%, var(--flare-ink))`,
backgroundPosition: 'center',
backgroundSize: 'cover',
}
}
@@ -347,8 +363,10 @@ function resetProfileDraft(): void {
ownPhotoIndex.value = 0
}
function openPhotoSourcePicker(): void {
function openPhotoSourcePicker(event: MouseEvent): void {
if (draftPhotos.value.length >= 6) return
if (!(event.currentTarget instanceof HTMLElement)) return
photoSourceTarget.value = event.currentTarget
photoSourceOpened.value = true
}
@@ -356,6 +374,11 @@ function closePhotoSourcePicker(): void {
photoSourceOpened.value = false
}
function selectPhotoSource(id: string): void {
if (id !== 'photos' && id !== 'camera') return
openProfileMediaApp(id)
}
function openProfileMediaApp(app: 'camera' | 'photos'): void {
const remaining = 6 - draftPhotos.value.length
if (remaining < 1) return
@@ -378,6 +401,7 @@ function openProfileMediaApp(app: 'camera' | 'photos'): void {
}
function removeDraftPhoto(id: number): void {
if (draftPhotos.value.length <= 1) return
draftPhotos.value = draftPhotos.value.filter((photo) => photo.id !== id)
profileDraft.photoMediaIds = draftPhotos.value.map((photo) => photo.id)
}
@@ -585,7 +609,7 @@ async function refreshFlareState(): Promise<void> {
}
async function saveProfile(): Promise<void> {
if (profileSaving.value) return
if (profileSaving.value || !hasRequiredProfilePhoto.value) return
profileSaving.value = true
const creatingProfile = !flare.profile
try {
@@ -913,6 +937,10 @@ onMounted(async () => {
profileEditing.value = selection.context.editing
} else {
syncDraft()
if (flare.profile && draftPhotos.value.length === 0) {
activeTab.value = 'profile'
profileEditing.value = true
}
if (route.query.profileEdit === '1' && flare.profile) {
activeTab.value = 'profile'
profileEditing.value = true
@@ -1012,6 +1040,7 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.flare.primaryPhoto') }}
</span>
<sky-link
v-if="draftPhotos.length > 1"
component="button"
icon-only
class="flare-photo-remove"
@@ -1025,19 +1054,27 @@ onBeforeUnmount(() => {
<X />
</sky-link>
</div>
<sky-button
v-if="draftPhotos.length < 6"
clear
class="flare-photo-add"
:class="{ 'is-empty': !draftPhotos.length }"
aria-controls="flare-photo-source-sheet"
aria-haspopup="dialog"
@click="openPhotoSourcePicker"
>
<Plus />
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
</div>
<div
v-if="draftPhotos.length < 6"
class="flare-onboarding-photo-actions"
>
<sky-button outline rounded @click="openProfileMediaApp('photos')">
<Images :size="19" />
<span>{{ phone.t('Apps.flare.choosePhotos') }}</span>
</sky-button>
<sky-button outline rounded @click="openProfileMediaApp('camera')">
<Camera :size="19" />
<span>{{ phone.t('Apps.flare.takePhoto') }}</span>
</sky-button>
</div>
<p
v-if="!hasRequiredProfilePhoto"
class="flare-photo-required"
role="status"
>
{{ phone.t('Apps.flare.profilePhotoRequired') }}
</p>
</sky-card>
<sky-list inset strong>
<sky-field
@@ -1104,7 +1141,7 @@ onBeforeUnmount(() => {
<sky-button
large
rounded
:disabled="profileSaving"
:disabled="profileSaving || !hasRequiredProfilePhoto"
:aria-busy="profileSaving"
@click="saveProfile"
>
@@ -1332,7 +1369,7 @@ onBeforeUnmount(() => {
"
component="button"
class="flare-navbar-done"
:disabled="profileSaving"
:disabled="profileSaving || !hasRequiredProfilePhoto"
:aria-busy="profileSaving"
@click="saveProfile"
>
@@ -1512,6 +1549,8 @@ onBeforeUnmount(() => {
<sky-card
v-for="tile in exploreTiles"
:key="tile.key"
component="button"
type="button"
:content-wrap="false"
class="flare-explore-card"
:class="`flare-explore-card--${tile.tone}`"
@@ -1519,7 +1558,8 @@ onBeforeUnmount(() => {
>
<div
class="flare-explore-card__photo"
:style="avatarStyle(tile.avatar)"
:class="{ 'is-fallback': !tile.coverUrl }"
:style="tile.coverUrl ? photoStyle(tile.coverUrl) : undefined"
/>
<div class="flare-explore-card__shade" />
<strong>{{
@@ -1819,6 +1859,7 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.flare.primaryPhoto') }}
</span>
<sky-link
v-if="draftPhotos.length > 1"
component="button"
icon-only
class="flare-photo-remove"
@@ -1837,14 +1878,22 @@ onBeforeUnmount(() => {
clear
class="flare-photo-add"
:class="{ 'is-empty': !draftPhotos.length }"
aria-controls="flare-photo-source-sheet"
aria-haspopup="dialog"
aria-controls="flare-photo-source-menu"
aria-haspopup="menu"
:aria-expanded="photoSourceOpened"
@click="openPhotoSourcePicker"
>
<Plus />
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
</sky-button>
</div>
<p
v-if="!hasRequiredProfilePhoto"
class="flare-photo-required"
role="status"
>
{{ phone.t('Apps.flare.profilePhotoRequired') }}
</p>
</sky-card>
<sky-list inset strong>
<sky-field
@@ -2028,36 +2077,17 @@ onBeforeUnmount(() => {
</div>
</div>
<sky-action-sheet
id="flare-photo-source-sheet"
:aria-label="phone.t('Apps.flare.addPhotos')"
<SkyDropdown
id="flare-photo-source-menu"
:items="photoSourceItems"
:label="phone.t('Apps.flare.addPhotos')"
:opened="photoSourceOpened"
:target="photoSourceTarget"
@backdropclick="closePhotoSourcePicker"
@escape="closePhotoSourcePicker"
>
<sky-action-group>
<sky-actions-label>
{{ phone.t('Apps.flare.addPhotos') }}
</sky-actions-label>
<sky-action-button bold @click="openProfileMediaApp('photos')">
<span class="flare-photo-source-action">
<Images :size="20" />
{{ phone.t('Apps.flare.choosePhotos') }}
</span>
</sky-action-button>
<sky-action-button @click="openProfileMediaApp('camera')">
<span class="flare-photo-source-action">
<Camera :size="20" />
{{ phone.t('Apps.flare.takePhoto') }}
</span>
</sky-action-button>
</sky-action-group>
<sky-action-group>
<sky-action-button @click="closePhotoSourcePicker">
{{ phone.t('Common.cancel') }}
</sky-action-button>
</sky-action-group>
</sky-action-sheet>
@positionerror="closePhotoSourcePicker"
@select="selectPhotoSource"
/>
<div class="flare-choice-sheet">
<sky-sheet
@@ -2596,6 +2626,9 @@ onBeforeUnmount(() => {
background-repeat: no-repeat;
filter: saturate(0.82);
}
.flare-explore-card__photo.is-fallback {
background: linear-gradient(145deg, var(--flare), var(--flare-warm));
}
.flare-explore-card__shade {
background: linear-gradient(15deg, rgb(0 0 0 / 72%), transparent 70%);
mix-blend-mode: multiply;
@@ -3158,11 +3191,27 @@ onBeforeUnmount(() => {
width: 25px;
height: 25px;
}
.flare-photo-source-action {
display: inline-flex;
align-items: center;
.flare-onboarding-photo-actions {
margin-top: var(--sky-space-3);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-2);
}
.flare-onboarding-photo-actions :deep(.sky-button) {
min-width: 0;
padding-inline: var(--sky-space-2);
}
.flare-onboarding-photo-actions :deep(.sky-button span) {
overflow: hidden;
text-overflow: ellipsis;
}
.flare-photo-required {
margin: var(--sky-space-3) 0 0;
color: var(--flare);
font-size: var(--sky-font-caption);
font-weight: 650;
line-height: 1.35;
}
.flare-error {
margin: 8px 22px 0;
color: #ff3b30;
@@ -12,6 +12,14 @@ const headerActions = source.slice(
)
describe('GalleryApp import action', () => {
it('opens a configured wallpaper upload and returns the imported photo directly', () => {
expect(source).toContain("route.query.wallpaperUpload === '1'")
expect(source).toContain("requestedMessageMedia.value === 'photo'")
expect(source).toContain('openImport()')
expect(source).toContain('messageMedia.complete(response.data)')
expect(source).toContain('await router.push(returnPath)')
})
it('keeps import available as a header button', () => {
expect(headerActions).toContain('<SkyToolbarPane')
expect(headerActions).toContain('gallery-header-tool--icon')
@@ -61,11 +69,17 @@ describe('GalleryApp import action', () => {
expect(source).toContain("id: 'sort-oldest'")
expect(source).toContain("id: 'show-all'")
expect(source).toContain("id: 'show-favorites'")
expect(source).toContain("group: 'sort'")
expect(source).toContain("group: 'show'")
expect(source).toContain("phone.t('Apps.photos.sorting.title')")
expect(source).toContain("phone.t('Apps.photos.sorting.show')")
expect(source).toContain('orderMedia(media.value, sortOrder.value)')
})
it('starts with the newest media at the bottom-right and loads older media above', () => {
expect(source).toContain("const sortOrder = ref<GallerySortOrder>('newest')")
expect(source).toContain(
"const sortOrder = ref<GallerySortOrder>('newest')",
)
expect(source).toContain(
'galleryContent.value.scrollTop = galleryContent.value.scrollHeight',
)
@@ -81,9 +95,9 @@ describe('GalleryApp import action', () => {
expect(source).toContain('v-if="selectionMode"')
expect(source).toContain('selectedCountText')
expect(source).toContain('shareSelection')
expect(source.match(/<SkyToolbarPane class="gallery-selection-action">/g)).toHaveLength(
2,
)
expect(
source.match(/<SkyToolbarPane class="gallery-selection-action">/g),
).toHaveLength(2)
expect(source).toContain('<div class="gallery-selection-actions">')
expect(source).toMatch(
/\.gallery-selection-actions\s*\{[^}]*display:\s*flex;[^}]*gap:\s*var\(--sky-space-2\);/s,
@@ -133,7 +147,9 @@ describe('GalleryApp import action', () => {
expect(source).toContain(
'galleryContent.value.scrollTop = galleryReturnScrollTop',
)
expect(source).toMatch(/async function closeMedia[\s\S]*?await nextTick\(\)/)
expect(source).toMatch(
/async function closeMedia[\s\S]*?await nextTick\(\)/,
)
expect(source).toContain('@wheel.prevent="zoomImageWithWheel"')
expect(source).toContain('media.clientWidth / bounds.width')
expect(source).toContain('media.clientHeight / bounds.height')
@@ -162,4 +178,28 @@ describe('GalleryApp import action', () => {
expect(source).toContain('id: 13 + index')
expect(source).not.toContain('picsum.photos')
})
it('routes single-image deletion through the browser API when apiPort is set', () => {
const deleteSelectedStart = source.indexOf(
'async function deleteSelected()',
)
const deleteSelected = source.slice(
deleteSelectedStart,
source.indexOf('function onMessage', deleteSelectedStart),
)
expect(deleteSelected).toContain(
'if (isDevelopment && !developmentApiEnabled)',
)
expect(deleteSelected).toContain(
"const response = await nuiCall('gallery:delete'",
)
expect(deleteSelected).toContain(
'if (isDevelopment && developmentApiEnabled)',
)
expect(deleteSelected).toContain("type: 'media:deleteResult'")
expect(deleteSelected).toContain('error: response.error')
expect(deleteSelected).toContain('success: response.success')
expect(deleteSelected).not.toMatch(/if \(isDevelopment\)\s*\{/)
})
})
+56 -11
View File
@@ -47,6 +47,7 @@ import type {
GallerySortOrder,
MediaImportSource,
MediaImportSources,
MediaType,
PhoneMedia,
} from '@/types/media'
import {
@@ -77,7 +78,7 @@ const easyShare = useEasyShareStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
const requestedMessageMedia = computed<MediaType | null>(() => {
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
@@ -144,22 +145,30 @@ const orderedMedia = computed(() => orderMedia(media.value, sortOrder.value))
const sortMenuItems = computed(() => [
{
checked: sortOrder.value === 'newest',
group: 'sort',
groupLabel: phone.t('Apps.photos.sorting.title'),
id: 'sort-newest',
label: phone.t('Apps.photos.sorting.newestFirst'),
},
{
checked: sortOrder.value === 'oldest',
group: 'sort',
groupLabel: phone.t('Apps.photos.sorting.title'),
id: 'sort-oldest',
label: phone.t('Apps.photos.sorting.oldestFirst'),
},
{
checked: !favoritesOnly.value,
group: 'show',
groupLabel: phone.t('Apps.photos.sorting.show'),
id: 'show-all',
label: phone.t('Apps.photos.sorting.allItems'),
separatorBefore: true,
},
{
checked: favoritesOnly.value,
group: 'show',
groupLabel: phone.t('Apps.photos.sorting.show'),
id: 'show-favorites',
label: phone.t('Apps.photos.sorting.favorites'),
},
@@ -406,7 +415,19 @@ async function loadImportSources(): Promise<void> {
if (isDevelopment && !developmentApiEnabled) return
const response = await nuiCall<MediaImportSources>('media:import:sources')
if (!response.success || !response.data) return
importSources.value = response.data.sources
const requestedType = requestedMessageMedia.value
importSources.value = requestedType
? response.data.sources.filter((source) =>
source.mediaTypes.includes(requestedType),
)
: response.data.sources
if (
route.query.wallpaperUpload === '1' &&
requestedType === 'photo' &&
importSources.value.length > 0
) {
openImport()
}
}
function selectImportSource(source: MediaImportSource): void {
@@ -465,6 +486,16 @@ async function commitUrlImport(): Promise<void> {
}
media.value = mergeMedia(media.value, [response.data])
await fetchCounts()
if (
route.query.wallpaperUpload === '1' &&
requestedMessageMedia.value === 'photo'
) {
const returnPath = messageMedia.complete(response.data)
if (returnPath) {
await router.push(returnPath)
return
}
}
closeImport()
showToast(phone.t('Apps.photos.import.linkCompleted'))
}
@@ -959,12 +990,13 @@ async function initializeVideo(event: Event): Promise<void> {
async function deleteSelected(): Promise<void> {
if (!selected.value || deleting.value) return
const selectedId = selected.value.id
deleting.value = true
deleteDialogOpened.value = false
pendingDeleteCorrelation = `${Date.now()}-${crypto.randomUUID()}`
if (isDevelopment) {
if (isDevelopment && !developmentApiEnabled) {
const developmentIndex = developmentMedia.findIndex(
(entry) => entry.id === selected.value?.id,
(entry) => entry.id === selectedId,
)
if (developmentIndex >= 0) developmentMedia.splice(developmentIndex, 1)
window.setTimeout(() => {
@@ -973,7 +1005,7 @@ async function deleteSelected(): Promise<void> {
data: {
data: {
correlationId: pendingDeleteCorrelation,
id: selected.value?.id,
id: selectedId,
success: true,
},
type: 'media:deleteResult',
@@ -983,10 +1015,25 @@ async function deleteSelected(): Promise<void> {
}, 500)
return
}
await nuiCall('gallery:delete', {
const response = await nuiCall('gallery:delete', {
correlationId: pendingDeleteCorrelation,
id: selected.value.id,
id: selectedId,
})
if (isDevelopment && developmentApiEnabled) {
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
correlationId: pendingDeleteCorrelation,
error: response.error,
id: selectedId,
success: response.success,
},
type: 'media:deleteResult',
},
}),
)
}
}
function onMessage(event: MessageEvent): void {
@@ -1846,13 +1893,11 @@ onBeforeUnmount(() => {
transition: transform var(--sky-transition-fast, 100ms) ease;
}
@media (hover: hover) {
.gallery-detail-navbar
:deep(.gallery-detail-back:hover:not(:disabled)) {
.gallery-detail-navbar :deep(.gallery-detail-back:hover:not(:disabled)) {
background: rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.gallery-detail-navbar
:deep(.gallery-detail-back:hover:not(:disabled) svg) {
.gallery-detail-navbar :deep(.gallery-detail-back:hover:not(:disabled) svg) {
transform: translateX(-2px);
}
}
+36 -72
View File
@@ -1,17 +1,4 @@
<script setup lang="ts">
import {
SkyBlockTitle,
SkyButton,
SkyCard,
SkyDialog,
SkyDialogButton,
SkyList,
SkyListItem,
SkyNavbar,
SkyAppPage,
SkySpinner,
SkySheet,
} from '@/ui'
import {
Camera,
CarFront,
@@ -30,15 +17,29 @@ import {
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useHousingStore } from '@/stores/housing'
import { usePullToRefresh } from '@/composables/usePullToRefresh'
import { useEasyShareStore } from '@/stores/easyshare'
import { useHousingStore } from '@/stores/housing'
import { usePhoneStore } from '@/stores/phone'
import type {
HousingKey,
HousingKeyCandidate,
HousingProperty,
} from '@/types/housing'
import { SkyToast } from '@/ui'
import {
SkyAppPage,
SkyBlockTitle,
SkyButton,
SkyCard,
SkyDialog,
SkyDialogButton,
SkyList,
SkyListItem,
SkyNavbar,
SkySheet,
SkySpinner,
SkyToast,
} from '@/ui'
const phone = usePhoneStore()
const housing = useHousingStore()
@@ -49,15 +50,26 @@ const revokeCandidate = ref<HousingKey | null>(null)
const toastOpened = ref(false)
const toastText = ref('')
const houseScroll = ref<HTMLElement | null>(null)
const isRefreshing = ref(false)
const pullDistance = ref(0)
const pullThreshold = 56
let pullStartY = 0
let isPulling = false
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
let toastTimer: number | undefined
const {
finishPull,
movePull,
pullDistance,
pullThreshold,
pullWithWheel: updatePullWithWheel,
refresh,
startPull,
} = usePullToRefresh({
isAtTop: () => (houseScroll.value?.scrollTop ?? 0) <= 0,
refresh: async () => {
const loaded = await housing.load(true)
if (!loaded && housing.error === 'reload_cooldown') {
showToast(translatedError(housing.error))
}
},
})
const properties = computed(() => housing.overview?.properties ?? [])
const ownedCount = computed(
() =>
@@ -93,57 +105,9 @@ function isPending(action: string, property: HousingProperty): boolean {
return housing.pendingAction === `${action}:${property.id}`
}
async function refresh(): Promise<void> {
if (isRefreshing.value) return
isRefreshing.value = true
pullDistance.value = pullThreshold
const loaded = await housing.load(true)
isRefreshing.value = false
pullDistance.value = 0
if (!loaded && housing.error === 'reload_cooldown') {
showToast(translatedError(housing.error))
}
}
function atTop(): boolean {
return (houseScroll.value?.scrollTop ?? 0) <= 0
}
function startPull(event: TouchEvent): void {
if (!atTop() || isRefreshing.value) return
pullStartY = event.touches[0]?.clientY ?? 0
isPulling = true
}
function movePull(event: TouchEvent): void {
if (!isPulling || isRefreshing.value) return
const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY
if (distance <= 0) {
pullDistance.value = 0
return
}
pullDistance.value = Math.min(pullThreshold + 20, distance * 0.45)
}
function finishPull(): void {
if (!isPulling && pullDistance.value === 0) return
isPulling = false
if (pullDistance.value >= pullThreshold) {
void refresh()
return
}
pullDistance.value = 0
}
function pullWithWheel(event: WheelEvent): void {
if (!atTop() || isRefreshing.value || event.deltaY >= 0) return
pullDistance.value = Math.min(
pullThreshold + 20,
pullDistance.value + Math.abs(event.deltaY) * 0.18,
)
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
if (!updatePullWithWheel(event)) return
if (toastTimer) clearTimeout(toastTimer)
wheelRefreshTimeout = setTimeout(finishPull, 130)
}
async function runCommand(
@@ -233,7 +197,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
if (toastTimer) clearTimeout(toastTimer)
})
</script>
@@ -0,0 +1,60 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./LocalPagesApp.vue', import.meta.url),
'utf8',
)
describe('LocalPagesApp Sky UI contract', () => {
it('uses the current Sky UI navigation and scroll primitives', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toContain('SkyTabBar')
expect(source).not.toContain('SkyTabButton')
expect(source).not.toContain('SkyToolbarPane')
expect(source).toContain('<SkyPillNavigation')
expect(source).toContain('<SkySegmented')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('with-tabbar')
})
it('keeps the city banner copy white on its colored surface', () => {
expect(source).toMatch(
/\.pages-hero-glass\s*\{[^}]*background:\s*linear-gradient[^}]*color:\s*#fff/s,
)
expect(source).toMatch(/\.pages__hero\s*\{[^}]*color:\s*#fff/s)
})
it('animates successful likes and saves with reduced motion support', () => {
expect(source).toContain('function triggerReactionPulse(')
expect(source).toContain('triggerReactionPulse(post.id, kind)')
expect(source).toContain('triggerReactionPulse(selected.value.id, kind)')
expect(source).toContain('pages-reaction-pop')
expect(source).toContain('pages-reaction-glow')
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
})
it('gives the composer more top clearance and compact navbar actions', () => {
expect(source).toMatch(
/\.pages-create-navbar\s*\{[^}]*--sky-safe-area-top:\s*112px/s,
)
expect(source).toMatch(/\.pages__compose-scroll\s*\{[^}]*top:\s*170px/s)
expect(source).toMatch(
/\.pages-create-navbar :deep\(\.sky-navbar__left\),[\s\S]*?background:\s*transparent;[\s\S]*?backdrop-filter:\s*none;/,
)
expect(source).toMatch(
/\.pages-create-close,[\s\S]*?min-width:\s*44px[\s\S]*?height:\s*30px[\s\S]*?background:\s*transparent/,
)
expect(source).toMatch(/class="pages-create-close"[\s\S]*?<X :size="17"/)
expect(source).toMatch(/clear[\s\S]*?class="pages-create-publish"/)
})
it('places sign out as an icon in the profile navbar', () => {
expect(source).toContain('class="pages__navbar-logout"')
expect(source).toMatch(/clear[\s\S]*?class="pages__navbar-logout"/)
expect(source).toContain(':aria-label="phone.t(\'Common.signOut\')"')
expect(source).not.toContain('class="pages__logout-glass"')
expect(source).not.toContain('class="pages__logout"')
})
})
+249 -210
View File
@@ -21,16 +21,17 @@ import {
X,
} from 'lucide-vue-next'
import {
SkyButton,
SkyGlass,
SkyIcon,
SkyNavbar,
SkyAppPage,
SkyPillNavigation,
SkySearchbar,
SkyTabBar,
SkyTabButton,
SkyToolbarPane,
SkyScrollArea,
SkySegmented,
SkySegmentedButton,
} from '@/ui'
import { computed, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
@@ -85,6 +86,7 @@ const search = ref('')
const category = ref<string>('all')
const feedback = ref('')
const reactionPending = ref(false)
const reactionPulse = ref('')
const onboardingReady = ref(false)
const authMode = ref<'login' | 'register'>('login')
const authUsername = ref('')
@@ -180,6 +182,25 @@ const canSaveProfile = computed(() => {
profileDraft.value.bio.trim().length <= 160
)
})
const tabIndex = computed(() => (tab.value === 'feed' ? 0 : 2))
let reactionPulseTimer: ReturnType<typeof setTimeout> | undefined
function triggerReactionPulse(id: string, kind: 'like' | 'save'): void {
reactionPulse.value = ''
void nextTick(() => {
reactionPulse.value = `${id}:${kind}`
if (reactionPulseTimer) clearTimeout(reactionPulseTimer)
reactionPulseTimer = setTimeout(() => {
reactionPulse.value = ''
reactionPulseTimer = undefined
}, 520)
})
}
onBeforeUnmount(() => {
if (reactionPulseTimer) clearTimeout(reactionPulseTimer)
})
const profileAvatarUrl = computed(
() =>
selectedProfilePhoto.value?.url ??
@@ -565,6 +586,7 @@ async function react(kind: 'like' | 'save'): Promise<void> {
)
selected.value.is_liked = active
} else selected.value.is_saved = active
if (active) triggerReactionPulse(selected.value.id, kind)
}
}
@@ -581,7 +603,8 @@ async function reactToPost(
kind === 'like' ? !Boolean(post.is_liked) : !Boolean(post.is_saved)
reactionPending.value = true
try {
await pages.react(post.id, kind, active)
const success = await pages.react(post.id, kind, active)
if (success && active) triggerReactionPulse(post.id, kind)
} finally {
reactionPending.value = false
}
@@ -753,11 +776,26 @@ onMounted(async () => {
tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile',
)
"
/>
>
<template v-if="tab === 'profile' && !profileEditing" #right>
<SkyButton
clear
icon-only
rounded
small
class="pages__navbar-logout"
:aria-label="phone.t('Common.signOut')"
@click="logoutDialogOpen = true"
>
<LogOut :size="16" />
</SkyButton>
</template>
</sky-navbar>
<section
<SkyScrollArea
v-if="onboardingReady"
class="pages__content"
:with-tabbar="isAuthenticated && Boolean(pages.profile?.exists)"
:class="{
'pages__content--gate': !isAuthenticated || !pages.profile?.exists,
}"
@@ -985,35 +1023,28 @@ onMounted(async () => {
</button>
</div>
</sky-glass>
<sky-glass class="pages-segmented-glass">
<div class="pages__segmented">
<button
:class="{ active: profileMode === 'own' }"
@click="profileMode = 'own'"
>
{{ phone.t('Apps.localPages.myPosts') }}</button
><button
:class="{ active: profileMode === 'saved' }"
@click="profileMode = 'saved'"
>
{{ phone.t('Apps.localPages.saved') }}
</button>
</div>
</sky-glass>
</template>
<sky-glass
v-if="!profileEditing && pages.profile?.exists"
class="pages__logout-glass"
>
<button
type="button"
class="pages__logout"
@click="logoutDialogOpen = true"
<SkySegmented
class="pages__segmented"
:active-index="profileMode === 'own' ? 0 : 1"
:aria-label="phone.t('Apps.localPages.profile')"
:item-count="2"
rounded
strong
>
<LogOut :size="16" />
{{ phone.t('Common.signOut') }}
</button>
</sky-glass>
<SkySegmentedButton
:active="profileMode === 'own'"
@click="profileMode = 'own'"
>
{{ phone.t('Apps.localPages.myPosts') }}
</SkySegmentedButton>
<SkySegmentedButton
:active="profileMode === 'saved'"
@click="profileMode = 'saved'"
>
{{ phone.t('Apps.localPages.saved') }}
</SkySegmentedButton>
</SkySegmented>
</template>
</template>
</template>
@@ -1077,7 +1108,11 @@ onMounted(async () => {
<div class="pages__post-foot">
<button
type="button"
:class="{ active: post.is_liked }"
class="pages__reaction"
:class="{
active: post.is_liked,
'is-pulsing': reactionPulse === `${post.id}:like`,
}"
:disabled="reactionPending"
:aria-label="phone.t('Apps.localPages.likes')"
@click="reactToPost(post, 'like')"
@@ -1100,7 +1135,11 @@ onMounted(async () => {
</button>
<button
type="button"
:class="{ active: post.is_saved }"
class="pages__reaction"
:class="{
active: post.is_saved,
'is-pulsing': reactionPulse === `${post.id}:save`,
}"
:disabled="reactionPending"
:aria-label="phone.t('Apps.localPages.save')"
@click="reactToPost(post, 'save')"
@@ -1121,65 +1160,47 @@ onMounted(async () => {
><span>{{ phone.t('Apps.localPages.noPostsBody') }}</span>
</div>
</div>
</section>
</SkyScrollArea>
<sky-tab-bar
<SkyPillNavigation
v-if="isAuthenticated && pages.profile?.exists"
component="nav"
icons
labels
class="bottom-0 left-0 fixed"
inner-class="!w-full !max-w-none !gap-0 !px-1"
:aria-label="phone.t('Apps.localPages.name')"
class="pages-navigation"
:label="phone.t('Apps.localPages.name')"
layout="full"
>
<sky-toolbar-pane class="pages__tab-pane">
<sky-tab-button
component="button"
<SkySegmented
class="pages-navigation__segments"
:active-index="tabIndex"
:aria-label="phone.t('Apps.localPages.name')"
:item-count="3"
navigation
strong
>
<SkySegmentedButton
:active="tab === 'feed'"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
class="pages-navigation__button"
@click="selectTab('feed')"
>
<template #label
><span class="pages__tab-label">{{
phone.t('Apps.localPages.discover')
}}</span></template
>
<template #icon
><sky-icon><Compass :size="20" /></sky-icon
></template>
</sky-tab-button>
<sky-tab-button
component="button"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
<Compass :size="19" />
<small>{{ phone.t('Apps.localPages.discover') }}</small>
</SkySegmentedButton>
<SkySegmentedButton
class="pages-navigation__button"
@click="selectTab('create')"
>
<template #label
><span class="pages__tab-label">{{
phone.t('Apps.localPages.create')
}}</span></template
>
<template #icon
><span class="pages__tab-icon pages__tab-icon--create"
><sky-icon><Plus :size="21" /></sky-icon></span
></template>
</sky-tab-button>
<sky-tab-button
component="button"
<span class="pages-navigation__create"><Plus :size="19" /></span>
<small>{{ phone.t('Apps.localPages.create') }}</small>
</SkySegmentedButton>
<SkySegmentedButton
:active="tab === 'profile'"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
class="pages-navigation__button"
@click="selectTab('profile')"
>
<template #label
><span class="pages__tab-label">{{
phone.t('Apps.localPages.profile')
}}</span></template
>
<template #icon
><sky-icon><UserRound :size="20" /></sky-icon
></template>
</sky-tab-button>
</sky-toolbar-pane>
</sky-tab-bar>
<UserRound :size="19" />
<small>{{ phone.t('Apps.localPages.profile') }}</small>
</SkySegmentedButton>
</SkySegmented>
</SkyPillNavigation>
</template>
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
@@ -1206,7 +1227,11 @@ onMounted(async () => {
v-else
component="button"
type="button"
class="pages__detail-control"
class="pages__detail-control pages__reaction"
:class="{
active: selected.is_saved,
'is-pulsing': reactionPulse === `${selected.id}:save`,
}"
@click="react('save')"
>
<Bookmark
@@ -1215,7 +1240,7 @@ onMounted(async () => {
/>
</sky-glass>
</header>
<div class="pages__detail-scroll">
<SkyScrollArea as="div" class="pages__detail-scroll">
<div
v-if="selected.images.length"
class="pages__gallery"
@@ -1272,11 +1297,15 @@ onMounted(async () => {
>
</button>
</article>
</div>
</SkyScrollArea>
<sky-glass class="pages__detail-actions">
<button
type="button"
:class="{ active: selected.is_liked }"
class="pages__reaction"
:class="{
active: selected.is_liked,
'is-pulsing': reactionPulse === `${selected.id}:like`,
}"
@click="react('like')"
>
<Heart
@@ -1287,7 +1316,15 @@ onMounted(async () => {
<button type="button" @click="sharePost(selected)">
<Share2 :size="19" />{{ phone.t('Apps.easyShare.share') }}
</button>
<button type="button" @click="react('save')">
<button
type="button"
class="pages__reaction"
:class="{
active: selected.is_saved,
'is-pulsing': reactionPulse === `${selected.id}:save`,
}"
@click="react('save')"
>
<Bookmark
:size="19"
:fill="selected.is_saved ? 'currentColor' : 'none'"
@@ -1304,8 +1341,6 @@ onMounted(async () => {
<sky-navbar
class="pages-create-navbar"
center-title
left-class="pages-create-action pages-create-action--close !min-w-[58px] !h-11 !p-0 !rounded-full"
right-class="pages-create-action pages-create-action--publish !min-w-[58px] !h-11 !p-0 !rounded-full"
:title="
phone.t(
cityMarktListing
@@ -1322,27 +1357,32 @@ onMounted(async () => {
"
>
<template #left>
<button
<SkyButton
clear
icon-only
rounded
small
class="pages-create-close"
type="button"
:aria-label="phone.t('Common.close')"
@click="closeCompose"
>
{{ phone.t('Common.close') }}
</button>
<X :size="17" />
</SkyButton>
</template>
<template #right>
<button
<SkyButton
clear
rounded
small
class="pages-create-publish"
type="button"
:disabled="!canPublish"
@click="publish"
>
{{ phone.t('Apps.localPages.publish') }}
</button>
</SkyButton>
</template>
</sky-navbar>
<div class="pages__compose-scroll">
<SkyScrollArea as="div" class="pages__compose-scroll">
<sky-glass v-if="cityMarktListing" class="pages__citymarkt-source">
<Store :size="19" />
<span
@@ -1459,7 +1499,7 @@ onMounted(async () => {
</button>
</div>
</section>
</div>
</SkyScrollArea>
</section>
<AccountLogoutDialog
v-model:opened="logoutDialogOpen"
@@ -1481,6 +1521,8 @@ onMounted(async () => {
--ink: #15191d;
--panel: #20262c;
--muted: #9ba4aa;
--sky-app-accent: var(--yellow);
--sky-app-accent-soft: rgb(255 214 62 / 16%);
position: absolute;
inset: 0;
padding: 47px 0 24px;
@@ -1553,7 +1595,7 @@ onMounted(async () => {
align-items: center;
justify-content: space-between;
background: linear-gradient(125deg, #514005, #d99a00);
color: #fff7d2;
color: #fff;
box-shadow: 0 8px 22px #0003;
}
.pages__hero div {
@@ -1579,7 +1621,7 @@ onMounted(async () => {
line-height: 1.35;
}
.pages__hero > svg {
color: var(--yellow);
color: #fff;
filter: drop-shadow(0 4px 6px #0005);
}
.pages__search {
@@ -1783,22 +1825,18 @@ onMounted(async () => {
font-size: 9px;
}
.pages__segmented {
--sky-segmented-strong-highlight: #fff;
width: 100%;
margin: 3px 0 10px;
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
padding: 4px;
border-radius: 11px;
display: flex;
background: var(--panel);
border-radius: var(--sky-radius-pill);
background: color-mix(in srgb, var(--panel) 90%, transparent);
}
.pages__segmented button {
flex: 1;
padding: 8px;
border-radius: 8px;
background: none;
font-size: 10px;
}
.pages__segmented button.active {
background: var(--yellow);
color: #17191a;
font-weight: 800;
min-height: 34px;
font-size: 11px;
font-weight: 750;
}
.pages__tabbar {
position: absolute;
@@ -2175,29 +2213,6 @@ onMounted(async () => {
transform: translateY(8px);
opacity: 0;
}
.pages__tabbar {
height: 58px;
padding: 7px 7px 0;
justify-content: space-around;
backdrop-filter: blur(18px);
}
.pages--light .pages__tabbar {
border-color: #00000012;
}
.pages__tabbar button {
width: 54px;
gap: 2px;
}
.pages__tabbar button > span {
position: relative;
}
.pages__tabbar .create span {
width: 37px;
height: 30px;
margin-top: -4px;
border-radius: 10px;
box-shadow: none;
}
.pages__header {
height: 64px;
padding: 6px 16px 8px;
@@ -2267,6 +2282,41 @@ onMounted(async () => {
.pages__post-foot button:disabled {
opacity: 0.55;
}
.pages__reaction svg {
transform-origin: center;
}
.pages__reaction.is-pulsing svg {
animation: pages-reaction-pop 480ms cubic-bezier(0.2, 0.9, 0.25, 1.35);
}
.pages__reaction.is-pulsing {
animation: pages-reaction-glow 480ms ease-out;
}
@keyframes pages-reaction-pop {
0%,
100% {
transform: scale(1);
}
38% {
transform: scale(1.48) rotate(-8deg);
}
68% {
transform: scale(0.9) rotate(3deg);
}
}
@keyframes pages-reaction-glow {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 34%, transparent);
}
100% {
box-shadow: 0 0 0 12px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.pages__reaction.is-pulsing,
.pages__reaction.is-pulsing svg {
animation: none;
}
}
.pages__post-foot button svg:last-child {
margin-left: 0;
}
@@ -2736,6 +2786,10 @@ onMounted(async () => {
}
.pages-hero-glass {
margin-bottom: 10px;
border-color: rgb(255 255 255 / 16%);
background: linear-gradient(125deg, #514005, #d99a00);
color: #fff;
box-shadow: 0 8px 22px rgb(0 0 0 / 22%);
}
.pages__hero {
height: 105px;
@@ -2749,33 +2803,12 @@ onMounted(async () => {
.pages-profile-glass {
margin: 3px 0 10px;
}
.pages__logout-glass {
margin: 10px 0;
border-radius: 12px;
}
.pages__logout {
width: 100%;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
background: transparent;
color: #ff6b70;
font-size: 11px;
font-weight: 800;
}
.pages__profile {
margin: 0;
background: transparent;
}
.pages-segmented-glass {
padding: 4px;
border-radius: 13px;
}
.pages__segmented {
padding: 0;
background: transparent;
color: inherit;
}
.pages-post-glass {
overflow: hidden;
@@ -2784,82 +2817,88 @@ onMounted(async () => {
background: transparent;
box-shadow: none;
}
.pages__tab-pane {
width: 100% !important;
max-width: none;
margin-right: auto;
margin-left: auto;
flex: none;
justify-content: space-around;
gap: 2px;
padding: 0 4px;
.pages-navigation {
--sky-app-accent: var(--yellow);
}
.pages__tab-label {
.pages-navigation__segments {
width: 100%;
}
.pages-navigation__button {
min-width: 0;
gap: 2px;
padding-inline: 2px;
}
.pages-navigation__button small {
display: block;
max-width: 52px;
max-width: 76px;
overflow: hidden;
font-size: 9.5px;
line-height: 12px;
font-size: 9px;
font-weight: 750;
line-height: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.pages__tab-pane) {
width: 100% !important;
max-width: none;
margin-right: auto;
margin-left: auto;
flex: none;
justify-content: space-around;
.pages-navigation__create {
display: grid;
place-items: center;
}
:global(.pages-tab-button) {
width: 20% !important;
min-width: 0 !important;
max-width: 58px !important;
flex: 0 0 20% !important;
padding-right: 3px !important;
padding-left: 3px !important;
.pages__navbar-logout {
--sky-app-accent: #ff6b70;
width: 34px;
height: 34px;
min-height: 34px;
border: 0;
padding: 0;
background: transparent;
box-shadow: none;
}
.pages-create-navbar {
--sky-safe-area-top: 46px;
--sky-safe-area-top: 112px;
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
background: transparent;
}
.pages-create-action {
height: 44px;
border-radius: 9999px;
}
.pages-create-action--close,
.pages-create-action--publish {
min-width: 58px;
}
.pages-create-close,
.pages-create-publish {
width: 100%;
height: 44px;
.pages-create-navbar :deep(.sky-navbar__left),
.pages-create-navbar :deep(.sky-navbar__right) {
padding: 0;
border: 0;
appearance: none;
background: transparent;
color: inherit;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.pages-create-close,
.pages-create-publish {
min-width: 58px;
padding: 0 13px;
display: grid;
place-items: center;
font-size: 12px;
width: auto;
min-width: 44px;
height: 30px;
min-height: 30px;
border: 0;
padding: 0 8px;
background: transparent;
box-shadow: none;
font-size: 10px;
font-weight: 800;
}
.pages-create-close {
width: 30px;
min-width: 30px;
padding: 0;
color: inherit;
}
.pages-create-publish {
--sky-app-accent: var(--yellow);
color: var(--yellow);
}
.pages-create-publish:disabled {
opacity: 0.38;
}
.pages__compose-scroll {
position: absolute;
top: 104px;
top: 170px;
right: 0;
bottom: 0;
left: 0;
@@ -12,7 +12,10 @@ const mailServerSource = readFileSync(
'utf8',
)
const migrationSource = readFileSync(
new URL('../../../../sky_phone/source/server/db_migrate.lua', import.meta.url),
new URL(
'../../../../sky_phone/source/server/db_migrate.lua',
import.meta.url,
),
'utf8',
)
const clientSource = readFileSync(
@@ -52,9 +55,7 @@ describe('MailApp Sky UI contract', () => {
})
it('provides a Sky UI modal to create and open custom mailboxes', () => {
const mailboxStart = source.indexOf(
'class="mail-modal mail-mailbox-modal"',
)
const mailboxStart = source.indexOf('class="mail-modal mail-mailbox-modal"')
const mailboxEnd = source.indexOf('</sky-sheet>', mailboxStart)
const mailboxModal = source.slice(mailboxStart, mailboxEnd)
@@ -67,19 +68,19 @@ describe('MailApp Sky UI contract', () => {
expect(source).toContain('v-model="mailboxName"')
expect(source).toContain("phone.t('Apps.mail.mailboxLocation')")
expect(source).toContain('@click="createMailbox"')
expect(source).toContain('@click="openFolder(mailboxFolderKey(mailbox.id))"')
expect(source).toContain(
'@click="openFolder(mailboxFolderKey(mailbox.id))"',
)
expect(source).toContain('@click="moveMessageToMailbox(mailbox)"')
expect(source).toContain('@click="moveMessageToDefaultMailbox"')
expect(mailboxModal).toContain('class="mail-modal__nav-button"')
expect(mailboxModal).toContain(":aria-label=\"phone.t('Common.cancel')\"")
expect(mailboxModal).toContain(":aria-label=\"phone.t('Common.save')\"")
expect(mailboxModal).toContain(':aria-label="phone.t(\'Common.cancel\')"')
expect(mailboxModal).toContain(':aria-label="phone.t(\'Common.save\')"')
expect(mailboxModal).toContain('<X :size="20" />')
expect(mailboxModal).toContain('<Check :size="20" />')
expect(mailboxModal).not.toContain('<sky-spinner')
expect(mailboxModal).toContain('class="mail-mailbox-create__name"')
expect(mailboxModal).toContain(
'class="mail-mailbox-create__location-row"',
)
expect(mailboxModal).toContain('class="mail-mailbox-create__location-row"')
expect(mailboxModal).not.toContain(' outline')
expect(source).toMatch(
/\.mail-mailbox-create__form\s*\{[^}]*overflow-y:\s*auto/s,
@@ -121,9 +122,7 @@ describe('MailApp Sky UI contract', () => {
})
it('offers multiple filter criteria in one scrolling Sky UI modal', () => {
const filterStart = source.indexOf(
'class="mail-modal mail-filter-modal"',
)
const filterStart = source.indexOf('class="mail-modal mail-filter-modal"')
const filterEnd = source.indexOf('</sky-sheet>', filterStart)
const filterScreen = source.slice(filterStart, filterEnd)
@@ -132,7 +131,9 @@ describe('MailApp Sky UI contract', () => {
expect(filterScreen).toContain('@backdropclick="closeMailFilters"')
expect(filterScreen).toContain('@escape="closeMailFilters"')
expect(filterScreen).toContain('@swipeclose="closeMailFilters"')
expect(filterScreen).toContain('class="mail-modal__navbar mail-filters__navbar"')
expect(filterScreen).toContain(
'class="mail-modal__navbar mail-filters__navbar"',
)
expect(filterScreen).toContain('@click="applyMailFilters"')
expect(filterScreen).toContain("toggleDraftReadFilter('unread')")
expect(filterScreen).toContain("toggleDraftReadFilter('read')")
@@ -157,9 +158,11 @@ describe('MailApp Sky UI contract', () => {
it('sends active filters through the paginated mail list contract', () => {
expect(source).toContain('mail.setListFilters(mailFilters.value)')
expect(mailServerSource).toContain('local function normalize_list_filters')
expect(mailServerSource).toContain('local filters = normalize_list_filters(data.filters)')
expect(mailServerSource).toContain("filters.read == \"unread\"")
expect(mailServerSource).toContain("filters.address == \"to-me\"")
expect(mailServerSource).toContain(
'local filters = normalize_list_filters(data.filters)',
)
expect(mailServerSource).toContain('filters.read == "unread"')
expect(mailServerSource).toContain('filters.address == "to-me"')
expect(mailServerSource).toContain('m.`created_at` >= CURRENT_DATE()')
})
@@ -241,9 +244,7 @@ describe('MailMarkdownEditor Sky toolbar contract', () => {
expect(editorSource).toMatch(
/\.mail-editor__tools\s*\{[^}]*padding-right:\s*var\(--sky-space-3\)[^}]*padding-left:\s*var\(--sky-space-3\)/s,
)
expect(editorSource).toMatch(
/:deep\(\.tiptap p\)\s*\{[^}]*margin:\s*0;/s,
)
expect(editorSource).toMatch(/:deep\(\.tiptap p\)\s*\{[^}]*margin:\s*0;/s)
expect(editorSource).not.toContain(':deep(.tiptap p:last-child)')
})
})
@@ -262,16 +263,16 @@ describe('Mail list toolbar styling contract', () => {
expect(source).toMatch(
/\.mail-folders-toolbar\s+:deep\(\.sky-fab\)\s*\{[^}]*border:\s*1px solid var\(--sky-hairline\)/s,
)
expect(source).toMatch(
/\.mail-folder-row\s*\{[^}]*min-height:\s*52px/s,
)
expect(source).toMatch(/\.mail-folder-row\s*\{[^}]*min-height:\s*52px/s)
})
})
describe('Mail custom mailbox server contract', () => {
it('persists account-owned mailboxes and custom entry placement', () => {
expect(migrationSource).toContain('name = "sky_phone_mailboxes"')
expect(migrationSource).toContain('{ name = "mailbox_id", type = "BIGINT UNSIGNED NULL" }')
expect(migrationSource).toContain(
'{ name = "mailbox_id", type = "BIGINT UNSIGNED NULL" }',
)
expect(mailServerSource).toContain(
'Bridge.Callbacks.Register("sky_phone:mail:create-mailbox"',
)
@@ -281,9 +282,7 @@ describe('Mail custom mailbox server contract', () => {
expect(mailServerSource).toContain(
'Bridge.Callbacks.Register("sky_phone:mail:move"',
)
expect(mailServerSource).toContain(
'WHERE `id` = ? AND `account_id` = ?',
)
expect(mailServerSource).toContain('WHERE `id` = ? AND `account_id` = ?')
expect(mailServerSource).toContain(
'WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NULL',
)
@@ -300,3 +299,58 @@ describe('Mail custom mailbox server contract', () => {
}
})
})
function functionSource(name: string, nextName: string): string {
const start = source.indexOf(`async function ${name}`)
const end = source.indexOf(`async function ${nextName}`, start + 1)
return source.slice(start, end)
}
describe('MailApp contact compose deep-link contract', () => {
it('normalizes a compose=1 recipient only after mail authentication', () => {
const consumeRequest = functionSource(
'consumeContactComposeRequest',
'closeCompose',
)
expect(source).toContain('const route = useRoute()')
expect(source).toContain('const router = useRouter()')
expect(consumeRequest).toContain(
"if (!authenticated.value || route.query.compose !== '1') return",
)
expect(consumeRequest).toContain("typeof route.query.to === 'string'")
expect(consumeRequest).toContain('normalizeMailAddress(route.query.to)')
expect(consumeRequest).toContain(
"beginCompose({ body: '', recipients: [requestedRecipient], subject: '' })",
)
})
it('consumes the route after handling the request', () => {
const consumeRequest = functionSource(
'consumeContactComposeRequest',
'closeCompose',
)
const composeIndex = consumeRequest.indexOf('beginCompose(')
const replaceIndex = consumeRequest.indexOf(
"await router.replace('/apps/mail')",
)
expect(replaceIndex).toBeGreaterThan(composeIndex)
})
it('retries the pending compose request after auth and on authenticated mount', () => {
const submitAuth = functionSource('submitAuth', 'signOut')
const mounted = source.slice(
source.indexOf('onMounted(() => {'),
source.indexOf('onBeforeUnmount(() => {'),
)
expect(submitAuth).toMatch(
/if \(!response\.success\)[\s\S]*await consumeContactComposeRequest\(\)/,
)
expect(mounted).toContain('void consumeContactComposeRequest()')
expect(source).toMatch(
/watch\(authenticated,[\s\S]*isAuthenticated && !wasAuthenticated && !submitting\.value[\s\S]*consumeContactComposeRequest\(\)/,
)
})
})
+27 -1
View File
@@ -41,6 +41,7 @@ import {
X,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import mailIcon from '@/assets/img/app-icons/mail.webp'
import MailMarkdownEditor, {
@@ -70,6 +71,7 @@ import {
MAIL_ADDRESS_INPUT_MAX_LENGTH,
MAIL_RECIPIENT_INPUT_MAX_LENGTH,
mailPlainText,
normalizeMailAddress,
parseMailRecipients,
} from '@/utils/mail'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
@@ -106,6 +108,8 @@ const MAIL_DELETE_BATCH_SIZE = 50
const phone = usePhoneStore()
const mail = useMailStore()
const easyShare = useEasyShareStore()
const route = useRoute()
const router = useRouter()
const authMode = ref<AuthMode>('login')
const authEmail = ref('')
const authPassword = ref('')
@@ -613,6 +617,7 @@ async function submitAuth(): Promise<void> {
authPassword.value = ''
authConfirm.value = ''
screen.value = 'folders'
await consumeContactComposeRequest()
}
async function signOut(): Promise<void> {
@@ -795,6 +800,18 @@ function beginCompose(draft?: MailComposeDraft): void {
screen.value = 'compose'
}
async function consumeContactComposeRequest(): Promise<void> {
if (!authenticated.value || route.query.compose !== '1') return
const requestedRecipient =
typeof route.query.to === 'string'
? normalizeMailAddress(route.query.to)
: null
if (requestedRecipient) {
beginCompose({ body: '', recipients: [requestedRecipient], subject: '' })
}
await router.replace('/apps/mail')
}
async function closeCompose(): Promise<void> {
await saveDraftNow()
screen.value = composeReturn.value
@@ -964,7 +981,16 @@ watch(
},
)
onMounted(() => window.addEventListener('message', onMailEvent))
watch(authenticated, (isAuthenticated, wasAuthenticated) => {
if (isAuthenticated && !wasAuthenticated && !submitting.value) {
void consumeContactComposeRequest()
}
})
onMounted(() => {
window.addEventListener('message', onMailEvent)
void consumeContactComposeRequest()
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMailEvent)
@@ -6,6 +6,10 @@ const source = readFileSync(
new URL('./MessagesApp.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
describe('MessagesApp Sky UI contract', () => {
it('uses first-party Sky UI without direct Konsta markup', () => {
@@ -15,21 +19,109 @@ describe('MessagesApp Sky UI contract', () => {
expect(source).toContain('<SkyNavbar')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('<SkySearchbar')
expect(source).toContain('<SkySegmented')
expect(source).toContain('<SkyDropdown')
expect(source).toContain('<SkyFab')
expect(source).toContain('<SkyMessages')
expect(source).toContain('<SkyMessagebar')
expect(source).toContain('<SkyPillNavigation')
expect(source).toContain('<SkySettingsGroup')
expect(source).toContain('<SkySettingsRow')
expect(source).toContain('<SkySheet')
expect(source).toContain('<SkyToolbar')
expect(source).not.toContain('<SkySegmented')
})
it('keeps inbox search focused on text and exposes clear filters', () => {
const inboxStart = source.indexOf('messages-sky-inbox')
it('uses the anchored central dropdown for sort, filter, and edit actions', () => {
const inboxStart = source.indexOf(
'class="messages-sky-page messages-sky-inbox"',
)
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
expect(inbox).toContain('<SkySearchbar')
expect(inbox).not.toContain('messages-inbox-search__voice')
expect(inbox).toContain('Apps.messages.allMessages')
expect(inbox).toContain('Apps.messages.unreadMessages')
expect(source).toContain("id: 'sort-newest'")
expect(source).toContain("id: 'sort-oldest'")
expect(source).toContain("id: 'filter-all'")
expect(source).toContain("id: 'filter-unread'")
expect(source).toContain("id: 'edit'")
expect(source).toContain("phone.t('Apps.messages.sortNewest')")
expect(source).toContain("phone.t('Apps.messages.sortOldest')")
expect(source).toContain("phone.t('Apps.messages.allMessages')")
expect(source).toContain("phone.t('Apps.messages.unreadMessages')")
expect(source).toContain("phone.t('Common.edit')")
expect(source).toContain("group: 'sort'")
expect(source).toContain("group: 'filter'")
expect(source).toContain("phone.t('Apps.messages.sortLabel')")
expect(source).toContain("phone.t('Apps.messages.filterLabel')")
expect(source).toContain(
'inboxMenuTarget.value = event.currentTarget as HTMLElement',
)
expect(source).toContain("if (id === 'edit') toggleListEditing()")
expect(inbox).toContain('class="messages-sky-inbox-menu-trigger"')
expect(inbox).toContain('aria-haspopup="menu"')
expect(inbox).toContain(':aria-expanded="inboxMenuOpened"')
expect(inbox).toContain('@click="openInboxMenu"')
expect(inbox).toContain('id="messages-inbox-menu"')
expect(inbox).toContain(':items="inboxMenuItems"')
expect(inbox).toContain(':opened="inboxMenuOpened"')
expect(inbox).toContain(':target="inboxMenuTarget"')
expect(inbox).toContain('@backdropclick="dismissInboxMenu"')
expect(inbox).toContain('@escape="dismissInboxMenu"')
expect(inbox).toContain('@positionerror="dismissInboxMenu"')
expect(inbox).toContain('@select="selectInboxMenuItem"')
})
it('places search and the compose action together in the bottom toolbar', () => {
const inboxStart = source.indexOf(
'class="messages-sky-page messages-sky-inbox"',
)
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
const scrollEnd = inbox.indexOf('</SkyScrollArea>')
const toolbarStart = inbox.indexOf('<SkyToolbar')
const toolbarEnd = inbox.indexOf('</SkyToolbar>', toolbarStart)
const toolbar = inbox.slice(toolbarStart, toolbarEnd)
const searchbarStart = toolbar.indexOf('<SkySearchbar')
const fabStart = toolbar.indexOf('<SkyFab')
expect(toolbarStart).toBeGreaterThan(scrollEnd)
expect(toolbar).toContain('class="messages-sky-inbox-toolbar"')
expect(toolbar).toContain('component="footer"')
expect(searchbarStart).toBeGreaterThan(-1)
expect(fabStart).toBeGreaterThan(searchbarStart)
expect(toolbar).toContain('v-model="search"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('@click="beginCompose"')
expect(toolbar).toContain('<SquarePen :size="21" />')
expect(inbox).not.toContain('messages-sky-compose-navigation')
})
it('uses the results-empty state for search and unread filters', () => {
expect(source).toContain('search || showUnreadOnly')
expect(source).toContain('v-if="!search && !showUnreadOnly" #actions')
})
it('keeps the unread marker in the media slot left of the avatar', () => {
const inboxStart = source.indexOf(
'class="messages-sky-page messages-sky-inbox"',
)
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
const mediaSlotStart = inbox.indexOf('<template #media>')
const unreadSlot = inbox.indexOf(
'class="messages-sky-unread-slot"',
mediaSlotStart,
)
const avatar = inbox.indexOf('class="messages-avatar"', mediaSlotStart)
expect(mediaSlotStart).toBeGreaterThan(-1)
expect(unreadSlot).toBeGreaterThan(mediaSlotStart)
expect(avatar).toBeGreaterThan(unreadSlot)
expect(inbox).toContain(
"'is-visible': !editingList && conversation.unread > 0",
)
expect(source).toContain('count: String(conversation.unread)')
expect(inbox).toContain("'aria-pressed': selectedNumbers.includes(")
})
it('uses one compact scroll region and an in-flow composer in threads', () => {
@@ -48,8 +140,92 @@ describe('MessagesApp Sky UI contract', () => {
)
})
it('provides a full-size back target and a compact recipient row', () => {
expect(source).toContain('back-appearance="plain"')
it('keeps add and text entry in separate glass surfaces', () => {
expect(source).toContain('class="messages-sky-composer-row"')
expect(source).toMatch(
/<SkyGlass[\s\S]*?class="messages-sky-messagebar__action messages-sky-messagebar__plus"[\s\S]*?<\/SkyGlass>[\s\S]*?<SkyGlass[\s\S]*?class="messages-sky-composer-pill"/,
)
})
it('round-trips up to six media items into a removable composer preview', () => {
expect(source).toContain('const MAX_PENDING_ATTACHMENTS = 6')
expect(source).toContain(
'pendingAttachments: [...pendingAttachments.value]',
)
expect(source).toContain('messageMedia.consumeMany<MessagesMediaContext>')
expect(source).toContain('class="messages-pending-media"')
expect(source).toContain('@click="removePendingAttachment(media.id)"')
expect(source).toContain('Apps.photos.videoAlt')
expect(source).not.toMatch(/await sendAttachment\([\s\S]*?media\.mediaType/)
})
it('sends selected media in order and captions the last item', () => {
expect(source).toContain(
'for (const [index, media] of queuedAttachments.entries())',
)
expect(source).toContain(
'index === queuedAttachments.length - 1 ? body : undefined',
)
expect(source).toContain("media.mediaType === 'photo' ? 'image' : 'video'")
})
it('normalizes voice waveforms and uses the regular unfilled send icon', () => {
expect(source).toContain(
'compressWaveformSamples(recordingSamples, WAVEFORM_SAMPLES)',
)
expect(source).toContain('class="messages-recorder__send"')
expect(source).toContain('<ArrowUpCircle :size="27" :stroke-width="2.4" />')
expect(source).not.toContain('fill="currentColor" />\n </SkyButton>')
expect(source).toContain('const recordingStarting = ref(false)')
expect(source).toContain('if (requestId !== recordingRequestId)')
expect(source).toContain('requestedStream.getTracks().forEach')
expect(source).toContain(':disabled="sending || recordingStarting"')
})
it('uses provider dimensions for proportional GIF picker results', () => {
expect(source).toContain(
'aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`',
)
})
it('opens contact sharing in a draggable Sky UI bottom sheet', () => {
const sheetStart = source.indexOf('class="messages-media-picker-sheet"')
const sheetEnd = source.indexOf('</SkySheet>', sheetStart)
const sheet = source.slice(sheetStart, sheetEnd)
expect(sheetStart).toBeGreaterThan(-1)
expect(sheetEnd).toBeGreaterThan(sheetStart)
expect(sheet).toContain(
':opened="activeCanMessage && attachmentPicker !== null"',
)
expect(sheet).toContain('swipe-to-close')
expect(sheet).toContain('grabber-clickable')
expect(sheet).toContain('@backdropclick="closeAttachmentPicker"')
expect(sheet).toContain('@escape="closeAttachmentPicker"')
expect(sheet).toContain('@grabberclick="closeAttachmentPicker"')
expect(sheet).toContain('@swipeclose="closeAttachmentPicker"')
expect(sheet).toContain('v-if="attachmentPicker === \'contacts\'"')
expect(sheet).toContain('@click="sendContact(contact)"')
expect(source).toContain('function closeAttachmentPicker(): void')
expect(styles).toMatch(
/\.messages-media-picker-sheet \.sky-sheet__panel\s*\{[^}]*border-radius:\s*30px 30px 0 0/s,
)
})
it('uses a surface back target in the thread and a compact recipient row', () => {
const threadStart = source.indexOf(
'class="messages-sky-page messages-sky-thread"',
)
const contactProfileStart = source.indexOf(
'v-if="contactDetailsOpen"',
threadStart,
)
const threadHeader = source.slice(threadStart, contactProfileStart)
expect(threadHeader).toContain('class="messages-sky-thread-navbar"')
expect(threadHeader).toContain('show-back')
expect(threadHeader).toContain('back-appearance="surface"')
expect(threadHeader).toContain('@back="goBack"')
expect(source).toContain('class="messages-recipient-field"')
expect(source).toContain('layout="inline"')
expect(source).toMatch(
@@ -57,17 +233,103 @@ describe('MessagesApp Sky UI contract', () => {
)
})
it('keeps the SMS compose action in the inbox navbar', () => {
const inboxStart = source.indexOf('messages-sky-inbox')
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
it('renders contact details read-only with icon actions and contact routing', () => {
const contactOverlayClass = source.indexOf(
'class="messages-contact-overlay"',
)
const contactProfileStart = source.lastIndexOf(
'<SkyAppPage',
contactOverlayClass,
)
const threadScrollStart = source.indexOf(
'class="messages-sky-thread-scroll"',
contactProfileStart,
)
const contactProfile = source.slice(contactProfileStart, threadScrollStart)
expect(inbox).toContain('@click="beginCompose"')
expect(inbox).toContain('<SquarePen')
expect(inbox).not.toContain('messages-sky-compose-navigation')
expect(contactProfileStart).toBeGreaterThan(-1)
expect(threadScrollStart).toBeGreaterThan(contactProfileStart)
expect(contactProfile).toContain('component="section"')
expect(contactProfile).toContain('back-appearance="surface"')
expect(contactProfile).toContain('activeContact?.avatar_url')
expect(contactProfile).toContain('<SkySettingsGroup')
expect(contactProfile).toContain('<SkySettingsRow')
expect(contactProfile).toContain('activeContact?.organization')
expect(contactProfile).toContain(':value="activeContact.organization"')
expect(contactProfile).toContain('activeContact?.notes')
expect(contactProfile).not.toContain('<SkyField')
expect(contactProfile).toMatch(
/<SkyButton\s+v-if="activeContact\?\.canCall !== false"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="callActiveContact"/,
)
expect(contactProfile).toMatch(
/<SkyButton\s+v-if="activeCanMessage"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="contactDetailsOpen = false"/,
)
expect(contactProfile).toMatch(
/<SkyButton\s+v-if="activeContactEmail"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="mailActiveContact"/,
)
expect(contactProfile).toContain('<PhoneIcon :size="22" />')
expect(contactProfile).toContain('<MessageCircle :size="22" />')
expect(contactProfile).toContain('<Mail :size="22" />')
expect(contactProfile).toContain('Apps.messages.showInContacts')
expect(contactProfile).toContain('Apps.messages.addContact')
expect(contactProfile).toContain('@activate="openActiveContactInPhone"')
expect(source).toContain("path: '/apps/phone'")
expect(source).toContain('{ contactId: activeContact.value.id }')
expect(source).toContain('{ newContactNumber: messages.activeNumber }')
expect(source).toContain("path: '/apps/mail'")
expect(source).toContain(
"query: { compose: '1', to: activeContactEmail.value }",
)
expect(contactProfile).not.toContain('Common.edit')
expect(source).not.toContain('contactEditing')
expect(source).not.toContain('saveContactDetails')
expect(source).not.toContain('deleteActiveContact')
expect(source).not.toContain('Apps.messages.deleteContact')
expect(contactProfile).not.toContain('<Pencil')
expect(source).toContain(':inert="contactDetailsOpen || undefined"')
expect(source).toContain(':aria-hidden="contactDetailsOpen"')
})
it('confirms contact blocking in a dismissible dialog', () => {
const contactOverlayClass = source.indexOf(
'class="messages-contact-overlay"',
)
const contactProfileStart = source.lastIndexOf(
'<SkyAppPage',
contactOverlayClass,
)
const threadScrollStart = source.indexOf(
'class="messages-sky-thread-scroll"',
contactProfileStart,
)
const contactProfile = source.slice(contactProfileStart, threadScrollStart)
const blockDialogOpened = source.indexOf(':opened="blockDialogOpened"')
const blockDialogStart = source.lastIndexOf('<SkyDialog', blockDialogOpened)
const toastStart = source.indexOf('<SkyToast', blockDialogStart)
const blockDialog = source.slice(blockDialogStart, toastStart)
expect(contactProfile).toContain('kind="action"')
expect(contactProfile).toContain('tone="danger"')
expect(contactProfile).toContain('Apps.messages.blockContact')
expect(contactProfile).toContain('@activate="confirmBlockActiveContact"')
expect(blockDialogStart).toBeGreaterThan(-1)
expect(blockDialog).toContain('@backdropclick="blockDialogOpened = false"')
expect(blockDialog).toContain('@escape="blockDialogOpened = false"')
expect(blockDialog).toContain('Apps.messages.blockContactTitle')
expect(blockDialog).toContain('Apps.messages.blockContactBody')
expect(blockDialog).toContain('@click="blockActiveContact"')
expect(source).toContain(
'const response = await calls.blockNumber(messages.activeNumber)',
)
})
it('keeps SMS conversations and recipients in flat iMessage-style lists', () => {
expect(source).not.toMatch(
/\.messages-sky-page\s*\{[^}]*--sky-page-gutter/s,
)
expect(source).toMatch(
/v-if="filteredConversations.length"\s+flush\s+class="messages-sky-conversation-list"/,
)
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./NotesApp.vue', import.meta.url), 'utf8')
const source = readFileSync(
new URL('./NotesApp.vue', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const menuSource = source.slice(
source.indexOf('<SkyActionSheet'),
source.indexOf('</SkyActionSheet>') + '</SkyActionSheet>'.length,
@@ -18,4 +18,46 @@ describe('PhoneApp EasyShare contract', () => {
expect(source).toContain('<sky-tab-button')
expect(source).not.toContain('<sky-segmented')
})
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
const mounted = source.slice(
source.indexOf('onMounted(async () => {'),
source.indexOf('onBeforeUnmount(() => {'),
)
const bootstrapIndex = mounted.indexOf('await calls.bootstrap()')
const contactRequestIndex = mounted.indexOf(
"typeof route.query.contactId === 'string'",
)
expect(bootstrapIndex).toBeGreaterThanOrEqual(0)
expect(contactRequestIndex).toBeGreaterThan(bootstrapIndex)
expect(mounted).toContain(
'(contact) => contact.id === route.query.contactId',
)
expect(mounted).toContain("tab.value = 'contacts'")
expect(mounted).toContain('openRecentDetail(requestedContact.phone_number)')
expect(mounted).toMatch(
/route\.query\.contactId[\s\S]*await router\.replace\('\/apps\/phone'\)/,
)
})
it('opens new-contact deep links in the contact editor and consumes the query', () => {
const mounted = source.slice(
source.indexOf('onMounted(async () => {'),
source.indexOf('onBeforeUnmount(() => {'),
)
const bootstrapIndex = mounted.indexOf('await calls.bootstrap()')
const newContactRequestIndex = mounted.indexOf(
"typeof route.query.newContactNumber === 'string'",
)
expect(bootstrapIndex).toBeGreaterThanOrEqual(0)
expect(newContactRequestIndex).toBeGreaterThan(bootstrapIndex)
expect(mounted).toContain(
'openContact(undefined, route.query.newContactNumber)',
)
expect(mounted).toMatch(
/route\.query\.newContactNumber[\s\S]*await router\.replace\('\/apps\/phone'\)/,
)
})
})
+29 -1
View File
@@ -41,7 +41,7 @@ import {
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useCallsStore } from '@/stores/calls'
import { useEasyShareStore } from '@/stores/easyshare'
@@ -60,6 +60,7 @@ type ContactPhotoContext = {
avatarMediaId: number | null
avatarUrl: string
contactId?: string
email: string
firstName: string
lastName: string
notes: string
@@ -72,6 +73,7 @@ const calls = useCallsStore()
const easyShare = useEasyShareStore()
const mediaPicker = useMessageMediaStore()
const messages = useMessagesStore()
const route = useRoute()
const router = useRouter()
const tab = ref<PhoneTab>('recents')
const query = ref('')
@@ -86,6 +88,7 @@ const editingContact = ref<PhoneContact | null>(null)
const contactFirstName = ref('')
const contactLastName = ref('')
const contactOrganization = ref('')
const contactEmail = ref('')
const contactNotes = ref('')
const contactNumber = ref('')
const contactAvatarMediaId = ref<number | null>(null)
@@ -258,6 +261,7 @@ function openContact(contact?: PhoneContact, number = ''): void {
contactFirstName.value = nameParts.shift() ?? ''
contactLastName.value = nameParts.join(' ')
contactOrganization.value = contact?.organization ?? ''
contactEmail.value = contact?.email ?? ''
contactNotes.value = contact?.notes ?? ''
contactNumber.value = contact?.phone_number ?? number
contactAvatarMediaId.value = contact?.avatar_media_id ?? null
@@ -271,6 +275,7 @@ function openContactPhotoPicker(source: 'camera' | 'photos'): void {
avatarMediaId: contactAvatarMediaId.value,
avatarUrl: contactAvatarUrl.value,
contactId: editingContact.value?.id,
email: contactEmail.value,
firstName: contactFirstName.value,
lastName: contactLastName.value,
notes: contactNotes.value,
@@ -386,6 +391,7 @@ async function saveContact(): Promise<void> {
}
const response = await calls.saveContact({
avatarMediaId: contactAvatarMediaId.value,
email: contactEmail.value.trim(),
id: editingContact.value?.id,
name,
notes: contactNotes.value.trim(),
@@ -672,6 +678,7 @@ onMounted(async () => {
: null
contactFirstName.value = context?.firstName ?? ''
contactLastName.value = context?.lastName ?? ''
contactEmail.value = context?.email ?? ''
contactNotes.value = context?.notes ?? ''
contactOrganization.value = context?.organization ?? ''
contactNumber.value = context?.phoneNumber ?? ''
@@ -683,6 +690,19 @@ onMounted(async () => {
}
tab.value = 'contacts'
editorOpened.value = true
} else if (typeof route.query.contactId === 'string') {
const requestedContact = calls.contacts.find(
(contact) => contact.id === route.query.contactId,
)
if (requestedContact) {
tab.value = 'contacts'
openRecentDetail(requestedContact.phone_number)
}
await router.replace('/apps/phone')
} else if (typeof route.query.newContactNumber === 'string') {
tab.value = 'contacts'
openContact(undefined, route.query.newContactNumber)
await router.replace('/apps/phone')
}
callClock = window.setInterval(updateCallElapsed, 500)
})
@@ -1572,6 +1592,14 @@ onBeforeUnmount(() => {
:readonly="editingContact?.readonly"
@input="contactOrganization = eventValue($event)"
/>
<sky-field
:value="contactEmail"
type="email"
:placeholder="phone.t('Apps.phone.mail')"
autocomplete="email"
:readonly="editingContact?.readonly"
@input="contactEmail = eventValue($event)"
/>
</sky-list>
<sky-list strong inset class="phone-contact-editor__number-list">
@@ -19,6 +19,9 @@ describe('SettingsApp Sky UI contract', () => {
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('<SkySettingsGroup')
expect(source).toContain('<SkySettingsRow')
expect(source).toContain('<SkySettingsRangeRow')
expect(source).toContain('<template #leading><Volume1 /></template>')
expect(source).toContain('<template #trailing><Volume2 /></template>')
expect(source).toContain(
`<template v-if="activeView === 'account' && !account.email" #right>`,
)
@@ -36,9 +39,14 @@ describe('SettingsApp Sky UI contract', () => {
)
})
it('offers photo, camera, recent, and two-column built-in wallpaper choices', () => {
it('offers photo, camera, configured custom upload, recent, and built-in wallpaper choices', () => {
expect(source).toContain("openWallpaperMedia('photos')")
expect(source).toContain("openWallpaperMedia('camera')")
expect(source).toContain('customWallpaperUploadAvailable')
expect(source).toContain('openWallpaperCustomUpload')
expect(source).toContain("nuiCall<MediaConfig>('media:config')")
expect(source).toContain('nuiCall<MediaImportSources>')
expect(source).toContain("'media:import:sources'")
expect(source).toContain('`settings:wallpaper:${wallpaperTarget.value}`')
expect(source).toContain("wallpaperTarget === 'home'")
expect(source).toContain("wallpaperTarget === 'lock'")
+86 -6
View File
@@ -17,7 +17,9 @@ import {
Signal,
Smartphone,
Sun,
Upload,
UserRound,
Volume1,
Volume2,
Wifi,
} from 'lucide-vue-next'
@@ -47,6 +49,7 @@ import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
} from '@/types/apps'
import type { MediaConfig, MediaImportSources } from '@/types/media'
import {
filterMailAddressInput,
MAIL_ADDRESS_INPUT_MAX_LENGTH,
@@ -159,6 +162,8 @@ const simEjectOpened = ref(false)
const factoryResetting = ref(false)
const factoryResetProgress = ref(0)
const wallpaperTarget = ref<WallpaperTarget>('home')
const customWallpaperUploadAvailable = ref(false)
let wallpaperMediaConfigLoaded = false
const selectedFrameColor = computed(
() => PHONE_FRAME_COLORS[phone.preferences.settings.frame],
)
@@ -299,6 +304,44 @@ function openWallpaperMedia(app: 'photos' | 'camera'): void {
})
}
async function loadWallpaperMediaConfig(): Promise<void> {
if (wallpaperMediaConfigLoaded) return
wallpaperMediaConfigLoaded = true
const configResponse = await nuiCall<MediaConfig>('media:config')
if (
!configResponse.success ||
configResponse.data?.customWallpaperUploadEnabled !== true
) {
return
}
const sourcesResponse = await nuiCall<MediaImportSources>(
'media:import:sources',
)
customWallpaperUploadAvailable.value =
sourcesResponse.success &&
Boolean(
sourcesResponse.data?.sources.some((source) =>
source.mediaTypes.includes('photo'),
),
)
}
function openWallpaperCustomUpload(): void {
const target = wallpaperTarget.value
mediaPicker.begin(
`settings:wallpaper:${target}`,
'photo',
`/apps/settings?wallpaper=1&wallpaperTarget=${target}`,
1,
)
void router.push({
path: '/apps/photos',
query: { mediaAttachment: 'photo', wallpaperUpload: '1' },
})
}
function wallpaperPreviewStyle(
entry: WallpaperHistoryEntry,
): Record<string, string> | undefined {
@@ -404,6 +447,7 @@ function openView(view: SubmenuView): void {
if (view === 'security') {
passcodeLength.value = phone.security.length ?? 6
}
if (view === 'wallpaper') void loadWallpaperMediaConfig()
activeView.value = view
scrollPageToTop()
}
@@ -700,6 +744,7 @@ onMounted(() => {
activeView.value = 'wallpaper'
wallpaperTarget.value =
route.query.wallpaperTarget === 'lock' ? 'lock' : 'home'
void loadWallpaperMediaConfig()
}
const selectedPhoto = mediaPicker.consume(
@@ -1198,7 +1243,10 @@ onBeforeUnmount(() => {
@update:model-value="
updateNumberPreference('ringtoneVolume', $event)
"
/>
>
<template #leading><Volume1 /></template>
<template #trailing><Volume2 /></template>
</SkySettingsRangeRow>
<SkySettingsRangeRow
:model-value="phone.preferences.settings.notificationVolume"
:title="phone.t('Apps.settings.notificationVolume')"
@@ -1211,7 +1259,10 @@ onBeforeUnmount(() => {
@update:model-value="
updateNumberPreference('notificationVolume', $event)
"
/>
>
<template #leading><Volume1 /></template>
<template #trailing><Volume2 /></template>
</SkySettingsRangeRow>
</SkySettingsGroup>
<SkySettingsGroup :title="phone.t('Apps.settings.ringtone')">
@@ -1458,7 +1509,10 @@ onBeforeUnmount(() => {
@update:model-value="
updateNumberPreference('screenBrightness', $event)
"
/>
>
<template #leading><Sun /></template>
<template #trailing><Sun /></template>
</SkySettingsRangeRow>
</SkySettingsGroup>
<SkySettingsGroup :aria-label="phone.t('Apps.settings.phoneScale')">
@@ -1471,7 +1525,10 @@ onBeforeUnmount(() => {
:max="PHONE_SCALE_MAX"
:step="PHONE_SCALE_STEP"
@update:model-value="updateNumberPreference('phoneScale', $event)"
/>
>
<template #leading><Smartphone /></template>
<template #trailing><Smartphone /></template>
</SkySettingsRangeRow>
</SkySettingsGroup>
<SkySettingsGroup :title="phone.t('Apps.settings.phoneFrame')">
@@ -1541,6 +1598,24 @@ onBeforeUnmount(() => {
}}</small>
</span>
</button>
<button
v-if="customWallpaperUploadAvailable"
type="button"
class="settings-wallpaper-actions__custom"
@click="openWallpaperCustomUpload"
>
<span class="settings-wallpaper-actions__icon" aria-hidden="true">
<Upload />
</span>
<span>
<strong>{{
phone.t('Apps.settings.wallpaperCustomUpload')
}}</strong>
<small>{{
phone.t('Apps.settings.wallpaperCustomUploadDescription')
}}</small>
</span>
</button>
</section>
<section
@@ -1938,6 +2013,11 @@ onBeforeUnmount(() => {
transform: scale(0.97);
}
.settings-wallpaper-actions > .settings-wallpaper-actions__custom {
grid-column: 1 / -1;
min-height: 92px;
}
.settings-wallpaper-actions > button:focus-visible,
.settings-wallpaper-history > button:focus-visible,
.settings-wallpaper-choice:focus-visible {
@@ -1951,8 +2031,8 @@ onBeforeUnmount(() => {
display: grid;
place-items: center;
border-radius: 12px;
background: var(--sky-app-accent-soft);
color: var(--sky-app-accent);
background: var(--sky-surface-muted);
color: #fff;
}
.settings-wallpaper-actions__icon > svg {
+15 -50
View File
@@ -11,6 +11,7 @@ import {
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import WeatherConditionIcon from '@/components/WeatherConditionIcon.vue'
import { usePullToRefresh } from '@/composables/usePullToRefresh'
import { usePhoneStore } from '@/stores/phone'
import { useWeatherStore } from '@/stores/weather'
import type { WeatherConditionId } from '@/types/weather'
@@ -27,14 +28,23 @@ import {
const phone = usePhoneStore()
const weather = useWeatherStore()
const forecast = computed(() => weather.forecast)
const pullDistance = ref(0)
const cooldownToastOpened = ref(false)
const pullThreshold = 56
let pullStartY = 0
let isPulling = false
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
let cooldownToastTimer: ReturnType<typeof setTimeout> | undefined
const {
finishPull,
movePull,
pullDistance,
pullThreshold,
pullWithWheel,
startPull,
} = usePullToRefresh({
isAtTop: (event) =>
(event.currentTarget as HTMLElement | null)?.scrollTop === 0,
isBusy: () => weather.isLoading,
refresh: () => weather.refresh(true, true),
})
function closeCooldownToast(): void {
cooldownToastOpened.value = false
if (weather.error === 'reload_cooldown') weather.error = null
@@ -114,52 +124,7 @@ function formatHour(timestamp: number, index: number): string {
}).format(timestamp)
}
async function refresh(): Promise<void> {
if (weather.isLoading) return
pullDistance.value = pullThreshold
await weather.refresh(true, true)
pullDistance.value = 0
}
function atTop(event: Event): boolean {
return (event.currentTarget as HTMLElement | null)?.scrollTop === 0
}
function startPull(event: TouchEvent): void {
if (!atTop(event) || weather.isLoading) return
pullStartY = event.touches[0]?.clientY ?? 0
isPulling = true
}
function movePull(event: TouchEvent): void {
if (!isPulling || weather.isLoading) return
const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY
pullDistance.value =
distance > 0 ? Math.min(pullThreshold + 20, distance * 0.45) : 0
}
function finishPull(): void {
if (!isPulling && pullDistance.value === 0) return
isPulling = false
if (pullDistance.value >= pullThreshold) {
void refresh()
return
}
pullDistance.value = 0
}
function pullWithWheel(event: WheelEvent): void {
if (!atTop(event) || weather.isLoading || event.deltaY >= 0) return
pullDistance.value = Math.min(
pullThreshold + 20,
pullDistance.value + Math.abs(event.deltaY) * 0.18,
)
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
wheelRefreshTimeout = setTimeout(finishPull, 130)
}
onBeforeUnmount(() => {
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
if (cooldownToastTimer) clearTimeout(cooldownToastTimer)
})
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./weazel-news-app.vue', import.meta.url),
new URL('./WeazelNewsApp.vue', import.meta.url),
'utf8',
)