Add prettier. Format.
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli'
|
||||
import type { CapacitorConfig } from "@capacitor/cli";
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'dev.nativevuerouter.messenger',
|
||||
appName: 'Native Vue Messenger',
|
||||
webDir: '../demo/dist',
|
||||
backgroundColor: '#0b0d12',
|
||||
appId: "dev.nativevuerouter.messenger",
|
||||
appName: "Native Vue Messenger",
|
||||
webDir: "../demo/dist",
|
||||
backgroundColor: "#0b0d12",
|
||||
plugins: {
|
||||
App: { disableBackButtonHandler: true },
|
||||
SplashScreen: {
|
||||
launchAutoHide: true,
|
||||
backgroundColor: '#0b0d12',
|
||||
androidScaleType: 'CENTER_CROP',
|
||||
backgroundColor: "#0b0d12",
|
||||
androidScaleType: "CENTER_CROP",
|
||||
},
|
||||
StatusBar: { style: 'DARK', backgroundColor: '#0b0d12' },
|
||||
StatusBar: { style: "DARK", backgroundColor: "#0b0d12" },
|
||||
},
|
||||
android: { backgroundColor: '#0b0d12' },
|
||||
ios: { backgroundColor: '#0b0d12', contentInset: 'never' },
|
||||
}
|
||||
android: { backgroundColor: "#0b0d12" },
|
||||
ios: { backgroundColor: "#0b0d12", contentInset: "never" },
|
||||
};
|
||||
|
||||
export default config
|
||||
export default config;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
"idiom": "universal",
|
||||
"filename": "splash-2732x2732-2.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
"idiom": "universal",
|
||||
"filename": "splash-2732x2732-1.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
"idiom": "universal",
|
||||
"filename": "splash-2732x2732.png",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,400 +1,542 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function captureTransitions(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const state = window as typeof window & {
|
||||
__nvrEvents?: Array<{ direction: string | null; presentation: string | null }>
|
||||
__nvrObserver?: MutationObserver
|
||||
}
|
||||
state.__nvrObserver?.disconnect()
|
||||
state.__nvrEvents = []
|
||||
const view = document.querySelector('.nvr-router-view')
|
||||
if (!view) throw new Error('Native router view did not render')
|
||||
__nvrEvents?: Array<{
|
||||
direction: string | null;
|
||||
presentation: string | null;
|
||||
}>;
|
||||
__nvrObserver?: MutationObserver;
|
||||
};
|
||||
state.__nvrObserver?.disconnect();
|
||||
state.__nvrEvents = [];
|
||||
const view = document.querySelector(".nvr-router-view");
|
||||
if (!view) throw new Error("Native router view did not render");
|
||||
state.__nvrObserver = new MutationObserver(() => {
|
||||
if (view.classList.contains('nvr-router-view--interactive')) {
|
||||
if (view.classList.contains("nvr-router-view--interactive")) {
|
||||
state.__nvrEvents?.push({
|
||||
direction: view.getAttribute('data-native-direction'),
|
||||
presentation: view.getAttribute('data-native-presentation'),
|
||||
})
|
||||
direction: view.getAttribute("data-native-direction"),
|
||||
presentation: view.getAttribute("data-native-presentation"),
|
||||
});
|
||||
}
|
||||
})
|
||||
state.__nvrObserver.observe(view, { attributes: true })
|
||||
})
|
||||
});
|
||||
state.__nvrObserver.observe(view, { attributes: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function recordedTransitions(page: Page) {
|
||||
return await page.evaluate(() => (window as typeof window & {
|
||||
__nvrEvents?: Array<{ direction: string | null; presentation: string | null }>
|
||||
}).__nvrEvents ?? [])
|
||||
return await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__nvrEvents?: Array<{
|
||||
direction: string | null;
|
||||
presentation: string | null;
|
||||
}>;
|
||||
}
|
||||
).__nvrEvents ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTransition(page: Page) {
|
||||
await expect(page.locator('.nvr-router-view')).not.toHaveClass(/nvr-router-view--interactive/)
|
||||
await expect(page.locator(".nvr-router-view")).not.toHaveClass(
|
||||
/nvr-router-view--interactive/,
|
||||
);
|
||||
}
|
||||
|
||||
async function flickToNextTab(page: Page, leaveSlowSpring = false) {
|
||||
// Start on the route header, outside conversation-owned drag targets.
|
||||
const surface = await page.locator('.nvr-router-view').boundingBox()
|
||||
const header = await page.locator('[data-native-role="active"] .app-header, [data-native-role="to"] .app-header').last().boundingBox()
|
||||
if (!surface || !header) throw new Error('Active route header did not render')
|
||||
const y = header.y + header.height * 0.5
|
||||
await page.mouse.move(surface.x + surface.width * 0.72, y)
|
||||
await page.mouse.down()
|
||||
const surface = await page.locator(".nvr-router-view").boundingBox();
|
||||
const header = await page
|
||||
.locator(
|
||||
'[data-native-role="active"] .app-header, [data-native-role="to"] .app-header',
|
||||
)
|
||||
.last()
|
||||
.boundingBox();
|
||||
if (!surface || !header)
|
||||
throw new Error("Active route header did not render");
|
||||
const y = header.y + header.height * 0.5;
|
||||
await page.mouse.move(surface.x + surface.width * 0.72, y);
|
||||
await page.mouse.down();
|
||||
if (leaveSlowSpring) {
|
||||
await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 })
|
||||
await page.waitForTimeout(90)
|
||||
await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 });
|
||||
await page.waitForTimeout(90);
|
||||
}
|
||||
await page.mouse.move(surface.x + surface.width * 0.27, y)
|
||||
await page.mouse.up()
|
||||
await page.mouse.move(surface.x + surface.width * 0.27, y);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test('navigates a conversation and returns through the native runtime', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
||||
await page.getByText('Maya Chen').last().click()
|
||||
await expect(page).toHaveURL(/\/chat\/maya$/)
|
||||
await expect(page.getByRole('heading', { name: 'Maya Chen' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Back' }).click()
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
await waitForTransition(page)
|
||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
||||
})
|
||||
test("navigates a conversation and returns through the native runtime", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||
await page.getByText("Maya Chen").last().click();
|
||||
await expect(page).toHaveURL(/\/chat\/maya$/);
|
||||
await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
await waitForTransition(page);
|
||||
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||
});
|
||||
|
||||
test('switches sibling routes without growing the primary history flow', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
await waitForTransition(page)
|
||||
await expect(page.getByRole('heading', { name: 'Stories' })).toBeVisible()
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await expect(page.getByRole('heading', { name: 'You' })).toBeVisible()
|
||||
})
|
||||
test("switches sibling routes without growing the primary history flow", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByRole("link", { name: /Stories/ }).click();
|
||||
await expect(page).toHaveURL(/\/stories$/);
|
||||
await waitForTransition(page);
|
||||
await expect(page.getByRole("heading", { name: "Stories" })).toBeVisible();
|
||||
await page.getByRole("link", { name: /You/ }).click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
await expect(page.getByRole("heading", { name: "You" })).toBeVisible();
|
||||
});
|
||||
|
||||
test('uses route order for tab direction and does not animate the active tab', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await captureTransitions(page)
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
await waitForTransition(page)
|
||||
expect(await recordedTransitions(page)).toContainEqual({ direction: 'forward', presentation: 'slide' })
|
||||
test("uses route order for tab direction and does not animate the active tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await captureTransitions(page);
|
||||
await page.getByRole("link", { name: /Stories/ }).click();
|
||||
await expect(page).toHaveURL(/\/stories$/);
|
||||
await waitForTransition(page);
|
||||
expect(await recordedTransitions(page)).toContainEqual({
|
||||
direction: "forward",
|
||||
presentation: "slide",
|
||||
});
|
||||
|
||||
await captureTransitions(page)
|
||||
await page.getByRole('link', { name: /Inbox/ }).click()
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
await waitForTransition(page)
|
||||
expect(await recordedTransitions(page)).toContainEqual({ direction: 'back', presentation: 'slide' })
|
||||
await captureTransitions(page);
|
||||
await page.getByRole("link", { name: /Inbox/ }).click();
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
await waitForTransition(page);
|
||||
expect(await recordedTransitions(page)).toContainEqual({
|
||||
direction: "back",
|
||||
presentation: "slide",
|
||||
});
|
||||
|
||||
await captureTransitions(page)
|
||||
await page.getByRole('link', { name: /Inbox/ }).click()
|
||||
await page.waitForTimeout(100)
|
||||
expect(await recordedTransitions(page)).toEqual([])
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
})
|
||||
await captureTransitions(page);
|
||||
await page.getByRole("link", { name: /Inbox/ }).click();
|
||||
await page.waitForTimeout(100);
|
||||
expect(await recordedTransitions(page)).toEqual([]);
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
});
|
||||
|
||||
test('interrupts an active tab animation when another tab is tapped', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
const routerView = page.locator('.nvr-router-view')
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
|
||||
const firstTransaction = await routerView.getAttribute('data-native-transaction')
|
||||
expect(firstTransaction).not.toBeNull()
|
||||
test("interrupts an active tab animation when another tab is tapped", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
const routerView = page.locator(".nvr-router-view");
|
||||
await page.getByRole("link", { name: /Stories/ }).click();
|
||||
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
|
||||
const firstTransaction = await routerView.getAttribute(
|
||||
"data-native-transaction",
|
||||
);
|
||||
expect(firstTransaction).not.toBeNull();
|
||||
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(routerView).not.toHaveAttribute('data-native-transaction', firstTransaction!, { timeout: 250 })
|
||||
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute('data-native-route', '/stories')
|
||||
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute('data-native-route', '/profile')
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
})
|
||||
await page.getByRole("link", { name: /You/ }).click();
|
||||
await expect(routerView).not.toHaveAttribute(
|
||||
"data-native-transaction",
|
||||
firstTransaction!,
|
||||
{ timeout: 250 },
|
||||
);
|
||||
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute(
|
||||
"data-native-route",
|
||||
"/stories",
|
||||
);
|
||||
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute(
|
||||
"data-native-route",
|
||||
"/profile",
|
||||
);
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
});
|
||||
|
||||
test('interrupts a settling push animation with an edge-back gesture', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.getByText('Maya Chen').last().click()
|
||||
await expect(page).toHaveURL(/\/chat\/maya$/)
|
||||
const routerView = page.locator('.nvr-router-view')
|
||||
await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
|
||||
const pushTransaction = await routerView.getAttribute('data-native-transaction')
|
||||
test("interrupts a settling push animation with an edge-back gesture", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByText("Maya Chen").last().click();
|
||||
await expect(page).toHaveURL(/\/chat\/maya$/);
|
||||
const routerView = page.locator(".nvr-router-view");
|
||||
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
|
||||
const pushTransaction = await routerView.getAttribute(
|
||||
"data-native-transaction",
|
||||
);
|
||||
|
||||
const frame = await page.locator('.app-frame').boundingBox()
|
||||
if (!frame) throw new Error('App frame did not render')
|
||||
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(frame.x + frame.width * 0.34, frame.y + frame.height * 0.5, { steps: 16 })
|
||||
const frame = await page.locator(".app-frame").boundingBox();
|
||||
if (!frame) throw new Error("App frame did not render");
|
||||
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
frame.x + frame.width * 0.34,
|
||||
frame.y + frame.height * 0.5,
|
||||
{ steps: 16 },
|
||||
);
|
||||
|
||||
await expect(routerView).not.toHaveAttribute('data-native-transaction', pushTransaction!, { timeout: 250 })
|
||||
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute('data-native-route', '/chat/maya')
|
||||
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute('data-native-route', '/inbox')
|
||||
await page.mouse.up()
|
||||
})
|
||||
await expect(routerView).not.toHaveAttribute(
|
||||
"data-native-transaction",
|
||||
pushTransaction!,
|
||||
{ timeout: 250 },
|
||||
);
|
||||
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute(
|
||||
"data-native-route",
|
||||
"/chat/maya",
|
||||
);
|
||||
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute(
|
||||
"data-native-route",
|
||||
"/inbox",
|
||||
);
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test('moves sibling screens edge-to-edge at one-to-one drag progress', async ({ page }) => {
|
||||
await page.goto('/stories')
|
||||
const routerView = page.locator('.nvr-router-view')
|
||||
const frame = await routerView.boundingBox()
|
||||
if (!frame) throw new Error('Native router view did not render')
|
||||
test("moves sibling screens edge-to-edge at one-to-one drag progress", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/stories");
|
||||
const routerView = page.locator(".nvr-router-view");
|
||||
const frame = await routerView.boundingBox();
|
||||
if (!frame) throw new Error("Native router view did not render");
|
||||
|
||||
await page.mouse.move(frame.x + frame.width * 0.55, frame.y + frame.height * 0.45)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(frame.x + frame.width * 0.8, frame.y + frame.height * 0.45, { steps: 18 })
|
||||
await expect(routerView).toHaveAttribute('data-native-presentation', 'slide')
|
||||
await expect(routerView).toHaveAttribute('data-native-direction', 'back')
|
||||
await page.mouse.move(
|
||||
frame.x + frame.width * 0.55,
|
||||
frame.y + frame.height * 0.45,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
frame.x + frame.width * 0.8,
|
||||
frame.y + frame.height * 0.45,
|
||||
{ steps: 18 },
|
||||
);
|
||||
await expect(routerView).toHaveAttribute("data-native-presentation", "slide");
|
||||
await expect(routerView).toHaveAttribute("data-native-direction", "back");
|
||||
|
||||
const from = await page.locator('[data-native-role="from"]').boundingBox()
|
||||
const to = await page.locator('[data-native-role="to"]').boundingBox()
|
||||
if (!from || !to) throw new Error('Both sibling pages must be live during a drag')
|
||||
expect(Math.abs(from.x - (to.x + to.width))).toBeLessThan(3)
|
||||
await page.mouse.up()
|
||||
})
|
||||
const from = await page.locator('[data-native-role="from"]').boundingBox();
|
||||
const to = await page.locator('[data-native-role="to"]').boundingBox();
|
||||
if (!from || !to)
|
||||
throw new Error("Both sibling pages must be live during a drag");
|
||||
expect(Math.abs(from.x - (to.x + to.width))).toBeLessThan(3);
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test('accepts a second fast tab flick while the first spring is still settling', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
const routerView = page.locator('.nvr-router-view')
|
||||
test("accepts a second fast tab flick while the first spring is still settling", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
const routerView = page.locator(".nvr-router-view");
|
||||
|
||||
// Commit by distance with a deliberately slow final sample, leaving enough
|
||||
// baseline spring for the second fast gesture to interrupt deterministically.
|
||||
await flickToNextTab(page, true)
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
|
||||
await flickToNextTab(page, true);
|
||||
await expect(page).toHaveURL(/\/stories$/);
|
||||
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
|
||||
|
||||
await flickToNextTab(page)
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await flickToNextTab(page);
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
|
||||
// A stale pointer-up cleanup used to leave an orphaned transaction here,
|
||||
// permanently blocking both subsequent swipes and imperative tab links.
|
||||
await page.getByRole('link', { name: /Inbox/ }).click()
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
await waitForTransition(page)
|
||||
await expect(routerView).not.toHaveAttribute('data-native-transaction')
|
||||
})
|
||||
await page.getByRole("link", { name: /Inbox/ }).click();
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
await waitForTransition(page);
|
||||
await expect(routerView).not.toHaveAttribute("data-native-transaction");
|
||||
});
|
||||
|
||||
test('renders a suspended pushed sibling and evicts it after backing out', async ({ page }) => {
|
||||
await page.goto('/profile')
|
||||
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
|
||||
test("renders a suspended pushed sibling and evicts it after backing out", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/profile");
|
||||
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
|
||||
|
||||
await expect(page.getByTestId('async-data-loading')).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
|
||||
await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /You/ })).toHaveAttribute('aria-current', 'page')
|
||||
await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 })
|
||||
await expect(page.getByTestId("async-data-loading")).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Primary navigation" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /You/ })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
||||
timeout: 2_000,
|
||||
});
|
||||
|
||||
const lab = page.getByTestId('runtime-lab-view')
|
||||
const firstMountId = await lab.getAttribute('data-mount-id')
|
||||
await page.getByRole('button', { name: 'Back' }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
const lab = page.getByTestId("runtime-lab-view");
|
||||
const firstMountId = await lab.getAttribute("data-mount-id");
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
|
||||
// Popping a pushed route removes it after the exit animation. Keeping the
|
||||
// descriptor allows browser-forward navigation without retaining its DOM.
|
||||
await expect(page.getByTestId('runtime-lab-view')).toHaveCount(0)
|
||||
await expect(page.getByTestId("runtime-lab-view")).toHaveCount(0);
|
||||
|
||||
// browser forward exists only because this sibling opted into push history.
|
||||
await page.evaluate(() => history.forward())
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
|
||||
await expect(page.getByTestId('async-data-loading')).toBeVisible()
|
||||
await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 })
|
||||
await expect(page.getByTestId('runtime-lab-view')).not.toHaveAttribute('data-mount-id', firstMountId!)
|
||||
})
|
||||
await page.evaluate(() => history.forward());
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||
await expect(page.getByTestId("async-data-loading")).toBeVisible();
|
||||
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
||||
timeout: 2_000,
|
||||
});
|
||||
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
|
||||
"data-mount-id",
|
||||
firstMountId!,
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking the root tab from a pushed sibling collapses its back history', async ({ page }) => {
|
||||
await page.goto('/profile')
|
||||
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
|
||||
test("clicking the root tab from a pushed sibling collapses its back history", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/profile");
|
||||
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await page.getByRole("link", { name: /You/ }).click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
|
||||
await expect(page.locator('.nvr-navigator')).toHaveAttribute('data-native-can-go-back', 'false')
|
||||
await expect(page.getByTestId('runtime-lab-view')).toHaveCount(0)
|
||||
})
|
||||
await expect(page.locator(".nvr-navigator")).toHaveAttribute(
|
||||
"data-native-can-go-back",
|
||||
"false",
|
||||
);
|
||||
await expect(page.getByTestId("runtime-lab-view")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('lazily caches a visited sibling and pauses its active work while hidden', async ({ page }) => {
|
||||
await page.goto('/stories')
|
||||
const stories = page.getByTestId('stories-view')
|
||||
const mountId = await stories.getAttribute('data-mount-id')
|
||||
await expect.poll(async () => Number(await stories.getAttribute('data-active-ticks'))).toBeGreaterThan(1)
|
||||
test("lazily caches a visited sibling and pauses its active work while hidden", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/stories");
|
||||
const stories = page.getByTestId("stories-view");
|
||||
const mountId = await stories.getAttribute("data-mount-id");
|
||||
await expect
|
||||
.poll(async () => Number(await stories.getAttribute("data-active-ticks")))
|
||||
.toBeGreaterThan(1);
|
||||
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await expect(stories).toHaveCount(1)
|
||||
await page.getByRole("link", { name: /You/ }).click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
await expect(stories).toHaveCount(1);
|
||||
|
||||
const hiddenTicks = Number(await stories.getAttribute('data-active-ticks'))
|
||||
await page.waitForTimeout(600)
|
||||
await expect(stories).toHaveAttribute('data-active-ticks', String(hiddenTicks))
|
||||
const hiddenTicks = Number(await stories.getAttribute("data-active-ticks"));
|
||||
await page.waitForTimeout(600);
|
||||
await expect(stories).toHaveAttribute(
|
||||
"data-active-ticks",
|
||||
String(hiddenTicks),
|
||||
);
|
||||
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
await waitForTransition(page)
|
||||
await expect(stories).toHaveAttribute('data-mount-id', mountId!)
|
||||
await expect.poll(async () => Number(await stories.getAttribute('data-active-ticks'))).toBeGreaterThan(hiddenTicks)
|
||||
})
|
||||
await page.getByRole("link", { name: /Stories/ }).click();
|
||||
await expect(page).toHaveURL(/\/stories$/);
|
||||
await waitForTransition(page);
|
||||
await expect(stories).toHaveAttribute("data-mount-id", mountId!);
|
||||
await expect
|
||||
.poll(async () => Number(await stories.getAttribute("data-active-ticks")))
|
||||
.toBeGreaterThan(hiddenTicks);
|
||||
});
|
||||
|
||||
test('evicts a cached sibling when its dynamic entry guard rejects it', async ({ page }) => {
|
||||
await page.goto('/stories')
|
||||
const stories = page.getByTestId('stories-view')
|
||||
const firstMountId = await stories.getAttribute('data-mount-id')
|
||||
test("evicts a cached sibling when its dynamic entry guard rejects it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/stories");
|
||||
const stories = page.getByTestId("stories-view");
|
||||
const firstMountId = await stories.getAttribute("data-mount-id");
|
||||
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await expect(stories).toHaveCount(1)
|
||||
await page.getByRole("link", { name: /You/ }).click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
await expect(stories).toHaveCount(1);
|
||||
|
||||
const guardToggle = page.getByRole('button', { name: 'Block Stories re-entry' })
|
||||
await guardToggle.click()
|
||||
await expect(guardToggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
const guardToggle = page.getByRole("button", {
|
||||
name: "Block Stories re-entry",
|
||||
});
|
||||
await guardToggle.click();
|
||||
await expect(guardToggle).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("link", { name: /Stories/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await expect(page.getByTestId('story-guard-status')).toHaveText('blocked')
|
||||
await waitForTransition(page)
|
||||
await expect(page.getByTestId('stories-view')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await expect(page.getByTestId("story-guard-status")).toHaveText("blocked");
|
||||
await waitForTransition(page);
|
||||
await expect(page.getByTestId("stories-view")).toHaveCount(0);
|
||||
|
||||
await guardToggle.click()
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
await waitForTransition(page)
|
||||
await expect(page.getByTestId('stories-view')).not.toHaveAttribute('data-mount-id', firstMountId!)
|
||||
})
|
||||
await guardToggle.click();
|
||||
await page.getByRole("link", { name: /Stories/ }).click();
|
||||
await expect(page).toHaveURL(/\/stories$/);
|
||||
await waitForTransition(page);
|
||||
await expect(page.getByTestId("stories-view")).not.toHaveAttribute(
|
||||
"data-mount-id",
|
||||
firstMountId!,
|
||||
);
|
||||
});
|
||||
|
||||
test('manually unloads an inactive route through the public API demo', async ({ page }) => {
|
||||
await page.goto('/stories')
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await expect(page.getByTestId('stories-view')).toHaveCount(1)
|
||||
test("manually unloads an inactive route through the public API demo", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/stories");
|
||||
await page.getByRole("link", { name: /You/ }).click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
await expect(page.getByTestId("stories-view")).toHaveCount(1);
|
||||
|
||||
await page.getByRole('link', { name: /Navigation lab/ }).click()
|
||||
await expect(page).toHaveURL(/\/settings$/)
|
||||
await waitForTransition(page)
|
||||
await page.getByTestId('unload-stories').click()
|
||||
await page.getByRole("link", { name: /Navigation lab/ }).click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await waitForTransition(page);
|
||||
await page.getByTestId("unload-stories").click();
|
||||
|
||||
await expect(page.getByTestId('unload-stories')).toHaveText('Unloaded 1 Stories view')
|
||||
await expect(page.getByTestId('stories-view')).toHaveCount(0)
|
||||
})
|
||||
await expect(page.getByTestId("unload-stories")).toHaveText(
|
||||
"Unloaded 1 Stories view",
|
||||
);
|
||||
await expect(page.getByTestId("stories-view")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('records a navigation frame profile across route changes', async ({ page }) => {
|
||||
await page.goto('/settings')
|
||||
await page.getByTestId('profile-start').click()
|
||||
await expect(page.locator('.profiler-badge')).toBeVisible()
|
||||
test("records a navigation frame profile across route changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings");
|
||||
await page.getByTestId("profile-start").click();
|
||||
await expect(page.locator(".profiler-badge")).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Back' }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
|
||||
await page.getByRole('link', { name: /You/ }).click()
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
await page.getByRole('link', { name: /Navigation lab/ }).click()
|
||||
await expect(page).toHaveURL(/\/settings$/)
|
||||
await waitForTransition(page)
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||
await page.getByRole("link", { name: /You/ }).click();
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
await waitForTransition(page);
|
||||
await page.getByRole("link", { name: /Navigation lab/ }).click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await waitForTransition(page);
|
||||
|
||||
await page.getByTestId('profile-stop').click()
|
||||
await expect(page.locator('.profiler-badge')).toHaveCount(0)
|
||||
await expect(page.getByTestId('profile-status')).toContainText('navigations')
|
||||
await expect(page.getByTestId('profile-export')).toBeEnabled()
|
||||
})
|
||||
await page.getByTestId("profile-stop").click();
|
||||
await expect(page.locator(".profiler-badge")).toHaveCount(0);
|
||||
await expect(page.getByTestId("profile-status")).toContainText("navigations");
|
||||
await expect(page.getByTestId("profile-export")).toBeEnabled();
|
||||
});
|
||||
|
||||
test('opens and dismisses the compose sheet', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.getByRole('button', { name: 'Compose' }).click()
|
||||
await expect(page).toHaveURL(/\/compose$/)
|
||||
await expect(page.getByRole('heading', { name: 'New message' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
||||
})
|
||||
test("opens and dismisses the compose sheet", async ({ page }) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByRole("button", { name: "Compose" }).click();
|
||||
await expect(page).toHaveURL(/\/compose$/);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "New message" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||
});
|
||||
|
||||
test('keeps the target live during a held component drag', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
const row = page.locator('.conversation-row').first()
|
||||
const box = await row.boundingBox()
|
||||
if (!box) throw new Error('Conversation row did not render')
|
||||
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, { steps: 12 })
|
||||
await expect(page.locator('[data-native-role="to"]')).toBeVisible()
|
||||
await page.mouse.up()
|
||||
await expect(page.getByRole('heading', { name: 'Maya Chen' })).toBeVisible()
|
||||
})
|
||||
test("keeps the target live during a held component drag", async ({ page }) => {
|
||||
await page.goto("/inbox");
|
||||
const row = page.locator(".conversation-row").first();
|
||||
const box = await row.boundingBox();
|
||||
if (!box) throw new Error("Conversation row did not render");
|
||||
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, {
|
||||
steps: 12,
|
||||
});
|
||||
await expect(page.locator('[data-native-role="to"]')).toBeVisible();
|
||||
await page.mouse.up();
|
||||
await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
|
||||
});
|
||||
|
||||
test('holds an edge-back preview without committing the URL', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.getByText('Maya Chen').last().click()
|
||||
await expect(page).toHaveURL(/\/chat\/maya$/)
|
||||
const frame = await page.locator('.app-frame').boundingBox()
|
||||
if (!frame) throw new Error('App frame did not render')
|
||||
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(frame.x + frame.width * 0.55, frame.y + frame.height * 0.5, { steps: 14 })
|
||||
await expect(page.locator('[data-native-role="to"]')).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/chat\/maya$/)
|
||||
await page.mouse.up()
|
||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
||||
})
|
||||
test("holds an edge-back preview without committing the URL", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByText("Maya Chen").last().click();
|
||||
await expect(page).toHaveURL(/\/chat\/maya$/);
|
||||
const frame = await page.locator(".app-frame").boundingBox();
|
||||
if (!frame) throw new Error("App frame did not render");
|
||||
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
frame.x + frame.width * 0.55,
|
||||
frame.y + frame.height * 0.5,
|
||||
{ steps: 14 },
|
||||
);
|
||||
await expect(page.locator('[data-native-role="to"]')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/chat\/maya$/);
|
||||
await page.mouse.up();
|
||||
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||
});
|
||||
|
||||
test('never previews a stale conversation after a cancelled back gesture', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.getByText('Maya Chen').last().click()
|
||||
await page.getByRole('button', { name: 'Back' }).click()
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
await waitForTransition(page)
|
||||
await page.getByText('Noah Williams').last().click()
|
||||
await expect(page).toHaveURL(/\/chat\/noah$/)
|
||||
await waitForTransition(page)
|
||||
test("never previews a stale conversation after a cancelled back gesture", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByText("Maya Chen").last().click();
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
await waitForTransition(page);
|
||||
await page.getByText("Noah Williams").last().click();
|
||||
await expect(page).toHaveURL(/\/chat\/noah$/);
|
||||
await waitForTransition(page);
|
||||
|
||||
const frame = await page.locator('.app-frame').boundingBox()
|
||||
if (!frame) throw new Error('App frame did not render')
|
||||
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(frame.x + frame.width * 0.055, frame.y + frame.height * 0.5, { steps: 16 })
|
||||
await page.mouse.up()
|
||||
await waitForTransition(page)
|
||||
await expect(page).toHaveURL(/\/chat\/noah$/)
|
||||
const frame = await page.locator(".app-frame").boundingBox();
|
||||
if (!frame) throw new Error("App frame did not render");
|
||||
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
frame.x + frame.width * 0.055,
|
||||
frame.y + frame.height * 0.5,
|
||||
{ steps: 16 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await waitForTransition(page);
|
||||
await expect(page).toHaveURL(/\/chat\/noah$/);
|
||||
|
||||
await page.getByRole('button', { name: 'Back' }).click()
|
||||
const backTarget = page.locator('[data-native-role="to"]')
|
||||
await expect(backTarget.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
||||
await expect(backTarget.getByRole('heading', { name: 'Maya Chen' })).toHaveCount(0)
|
||||
await waitForTransition(page)
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
})
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
const backTarget = page.locator('[data-native-role="to"]');
|
||||
await expect(
|
||||
backTarget.getByRole("heading", { name: "Messages" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
backTarget.getByRole("heading", { name: "Maya Chen" }),
|
||||
).toHaveCount(0);
|
||||
await waitForTransition(page);
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
});
|
||||
|
||||
test('always opens compose as a vertical sheet after prior navigation', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.getByText('Maya Chen').last().click()
|
||||
await page.getByRole('button', { name: 'Back' }).click()
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
await waitForTransition(page)
|
||||
await page.getByRole('link', { name: /Stories/ }).click()
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
await waitForTransition(page)
|
||||
await page.getByRole('link', { name: /Inbox/ }).click()
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
await waitForTransition(page)
|
||||
test("always opens compose as a vertical sheet after prior navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByText("Maya Chen").last().click();
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
await waitForTransition(page);
|
||||
await page.getByRole("link", { name: /Stories/ }).click();
|
||||
await expect(page).toHaveURL(/\/stories$/);
|
||||
await waitForTransition(page);
|
||||
await page.getByRole("link", { name: /Inbox/ }).click();
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
await waitForTransition(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Compose' }).click()
|
||||
const routerView = page.locator('.nvr-router-view')
|
||||
await expect(routerView).toHaveAttribute('data-native-presentation', 'sheet')
|
||||
await expect(routerView).toHaveAttribute('data-native-direction', 'up')
|
||||
const frame = await routerView.boundingBox()
|
||||
const sheet = await page.locator('[data-native-role="to"]').boundingBox()
|
||||
if (!frame || !sheet) throw new Error('Sheet transition did not render')
|
||||
expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3)
|
||||
await waitForTransition(page)
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
await page.getByRole("button", { name: "Compose" }).click();
|
||||
const routerView = page.locator(".nvr-router-view");
|
||||
await expect(routerView).toHaveAttribute("data-native-presentation", "sheet");
|
||||
await expect(routerView).toHaveAttribute("data-native-direction", "up");
|
||||
const frame = await routerView.boundingBox();
|
||||
const sheet = await page.locator('[data-native-role="to"]').boundingBox();
|
||||
if (!frame || !sheet) throw new Error("Sheet transition did not render");
|
||||
expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3);
|
||||
await waitForTransition(page);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
});
|
||||
|
||||
test('drags a sheet down to dismiss it', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.getByRole('button', { name: 'Compose' }).click()
|
||||
const sheet = await page.locator('.sheet-screen').boundingBox()
|
||||
if (!sheet) throw new Error('Sheet did not render')
|
||||
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + sheet.height * 0.55, { steps: 14 })
|
||||
await page.mouse.up()
|
||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
||||
})
|
||||
test("drags a sheet down to dismiss it", async ({ page }) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByRole("button", { name: "Compose" }).click();
|
||||
const sheet = await page.locator(".sheet-screen").boundingBox();
|
||||
if (!sheet) throw new Error("Sheet did not render");
|
||||
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
sheet.x + sheet.width / 2,
|
||||
sheet.y + sheet.height * 0.55,
|
||||
{ steps: 14 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,92 +1,157 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const iphoneUserAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 Version/18.5 Mobile/15E148 Safari/604.1'
|
||||
const iphoneUserAgent =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 Version/18.5 Mobile/15E148 Safari/604.1";
|
||||
|
||||
async function dispatchTouchStart(page: import('@playwright/test').Page, clientX: number) {
|
||||
return await page.locator('.nvr-navigator').evaluate((element, x) => {
|
||||
const event = new Event('touchstart', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'touches', {
|
||||
async function dispatchTouchStart(
|
||||
page: import("@playwright/test").Page,
|
||||
clientX: number,
|
||||
) {
|
||||
return await page.locator(".nvr-navigator").evaluate((element, x) => {
|
||||
const event = new Event("touchstart", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "touches", {
|
||||
value: [{ identifier: 7, clientX: x, clientY: 240 }],
|
||||
})
|
||||
return { dispatched: element.dispatchEvent(event), prevented: event.defaultPrevented }
|
||||
}, clientX)
|
||||
});
|
||||
return {
|
||||
dispatched: element.dispatchEvent(event),
|
||||
prevented: event.defaultPrevented,
|
||||
};
|
||||
}, clientX);
|
||||
}
|
||||
|
||||
test('ships an installable standalone manifest and iOS metadata', async ({ page, request }) => {
|
||||
await page.goto('/inbox')
|
||||
await expect(page.locator('meta[name="apple-mobile-web-app-capable"]')).toHaveAttribute('content', 'yes')
|
||||
await expect(page.locator('meta[name="apple-mobile-web-app-status-bar-style"]')).toHaveAttribute('content', 'black-translucent')
|
||||
const touchIconPath = await page.locator('link[rel="apple-touch-icon"]').evaluate((link) =>
|
||||
new URL(link.getAttribute('href') ?? '', document.baseURI).pathname,
|
||||
)
|
||||
expect(touchIconPath).toBe('/apple-touch-icon.png')
|
||||
test("ships an installable standalone manifest and iOS metadata", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await expect(
|
||||
page.locator('meta[name="apple-mobile-web-app-capable"]'),
|
||||
).toHaveAttribute("content", "yes");
|
||||
await expect(
|
||||
page.locator('meta[name="apple-mobile-web-app-status-bar-style"]'),
|
||||
).toHaveAttribute("content", "black-translucent");
|
||||
const touchIconPath = await page
|
||||
.locator('link[rel="apple-touch-icon"]')
|
||||
.evaluate(
|
||||
(link) =>
|
||||
new URL(link.getAttribute("href") ?? "", document.baseURI).pathname,
|
||||
);
|
||||
expect(touchIconPath).toBe("/apple-touch-icon.png");
|
||||
|
||||
const manifestResponse = await request.get('/manifest.webmanifest')
|
||||
expect(manifestResponse.ok()).toBe(true)
|
||||
const manifest = await manifestResponse.json()
|
||||
expect(manifest).toMatchObject({ id: '/', scope: '/', start_url: '/', display: 'standalone' })
|
||||
expect(manifest.icons).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }),
|
||||
expect.objectContaining({ src: '/pwa-512.png', sizes: '512x512', type: 'image/png' }),
|
||||
]))
|
||||
expect((await request.get('/apple-touch-icon.png')).headers()['content-type']).toContain('image/png')
|
||||
const workerResponse = await request.get('/sw.js')
|
||||
expect(workerResponse.ok()).toBe(true)
|
||||
expect(workerResponse.headers()['cache-control']).toContain('no-cache')
|
||||
const worker = await workerResponse.text()
|
||||
expect(worker).toContain('self.skipWaiting()')
|
||||
expect(worker).toContain('clientsClaim()')
|
||||
})
|
||||
const manifestResponse = await request.get("/manifest.webmanifest");
|
||||
expect(manifestResponse.ok()).toBe(true);
|
||||
const manifest = await manifestResponse.json();
|
||||
expect(manifest).toMatchObject({
|
||||
id: "/",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
});
|
||||
expect(manifest.icons).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
src: "/pwa-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
src: "/pwa-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
(await request.get("/apple-touch-icon.png")).headers()["content-type"],
|
||||
).toContain("image/png");
|
||||
const workerResponse = await request.get("/sw.js");
|
||||
expect(workerResponse.ok()).toBe(true);
|
||||
expect(workerResponse.headers()["cache-control"]).toContain("no-cache");
|
||||
const worker = await workerResponse.text();
|
||||
expect(worker).toContain("self.skipWaiting()");
|
||||
expect(worker).toContain("clientsClaim()");
|
||||
});
|
||||
|
||||
test('does not interfere with Safari edge touches before Home Screen installation', async ({ page }) => {
|
||||
test("does not interfere with Safari edge touches before Home Screen installation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((userAgent) => {
|
||||
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent })
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
|
||||
}, iphoneUserAgent)
|
||||
await page.goto('/chat/maya')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pwa-display-mode', 'browser')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pwa-edge-guard', 'inactive')
|
||||
expect((await dispatchTouchStart(page, 1)).prevented).toBe(false)
|
||||
})
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: userAgent,
|
||||
});
|
||||
Object.defineProperty(navigator, "standalone", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
}, iphoneUserAgent);
|
||||
await page.goto("/chat/maya");
|
||||
await expect(page.locator("html")).toHaveAttribute(
|
||||
"data-pwa-display-mode",
|
||||
"browser",
|
||||
);
|
||||
await expect(page.locator("html")).toHaveAttribute(
|
||||
"data-pwa-edge-guard",
|
||||
"inactive",
|
||||
);
|
||||
expect((await dispatchTouchStart(page, 1)).prevented).toBe(false);
|
||||
});
|
||||
|
||||
test('reserves only the leading edge in an installed iOS PWA', async ({ page }) => {
|
||||
test("reserves only the leading edge in an installed iOS PWA", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((userAgent) => {
|
||||
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent })
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||
}, iphoneUserAgent)
|
||||
await page.goto('/chat/maya')
|
||||
const root = page.locator('html')
|
||||
await expect(root).toHaveAttribute('data-pwa-platform', 'ios')
|
||||
await expect(root).toHaveAttribute('data-pwa-display-mode', 'standalone')
|
||||
await expect(root).toHaveAttribute('data-pwa-edge-guard', 'active')
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: userAgent,
|
||||
});
|
||||
Object.defineProperty(navigator, "standalone", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
}, iphoneUserAgent);
|
||||
await page.goto("/chat/maya");
|
||||
const root = page.locator("html");
|
||||
await expect(root).toHaveAttribute("data-pwa-platform", "ios");
|
||||
await expect(root).toHaveAttribute("data-pwa-display-mode", "standalone");
|
||||
await expect(root).toHaveAttribute("data-pwa-edge-guard", "active");
|
||||
|
||||
expect((await dispatchTouchStart(page, 80)).prevented).toBe(false)
|
||||
expect((await dispatchTouchStart(page, 1)).prevented).toBe(true)
|
||||
await expect(root).toHaveAttribute('data-pwa-edge-claims', '1')
|
||||
})
|
||||
expect((await dispatchTouchStart(page, 80)).prevented).toBe(false);
|
||||
expect((await dispatchTouchStart(page, 1)).prevented).toBe(true);
|
||||
await expect(root).toHaveAttribute("data-pwa-edge-claims", "1");
|
||||
});
|
||||
|
||||
test('registers and activates the offline service worker', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
test("registers and activates the offline service worker", async ({ page }) => {
|
||||
await page.goto("/inbox");
|
||||
const workerUrl = await page.evaluate(async () => {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
return registration.active?.scriptURL ?? ''
|
||||
})
|
||||
expect(workerUrl).toMatch(/\/sw\.js$/)
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-pwa-update-checks', '0')
|
||||
})
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
return registration.active?.scriptURL ?? "";
|
||||
});
|
||||
expect(workerUrl).toMatch(/\/sw\.js$/);
|
||||
await expect(page.locator("html")).not.toHaveAttribute(
|
||||
"data-pwa-update-checks",
|
||||
"0",
|
||||
);
|
||||
});
|
||||
|
||||
test('precaches lazily split routes for offline navigation', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.evaluate(async () => { await navigator.serviceWorker.ready })
|
||||
test("precaches lazily split routes for offline navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
});
|
||||
const cachedUrls = await page.evaluate(async () => {
|
||||
const urls: string[] = []
|
||||
const urls: string[] = [];
|
||||
for (const name of await caches.keys()) {
|
||||
const cache = await caches.open(name)
|
||||
urls.push(...(await cache.keys()).map((request) => request.url))
|
||||
const cache = await caches.open(name);
|
||||
urls.push(...(await cache.keys()).map((request) => request.url));
|
||||
}
|
||||
return urls
|
||||
})
|
||||
expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true)
|
||||
expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true)
|
||||
expect(cachedUrls.some((url) => /RuntimeLabView-.*\.js$/.test(url))).toBe(true)
|
||||
})
|
||||
return urls;
|
||||
});
|
||||
expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true);
|
||||
expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true);
|
||||
expect(cachedUrls.some((url) => /RuntimeLabView-.*\.js$/.test(url))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
const requestedAt = Date.now()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1_000))
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 3_000))
|
||||
const resolutionTime = Date.now() - requestedAt
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,103 +1,207 @@
|
||||
import { computed, reactive, readonly, ref } from 'vue'
|
||||
import { computed, reactive, readonly, ref } from "vue";
|
||||
|
||||
export interface Person {
|
||||
id: string
|
||||
name: string
|
||||
handle: string
|
||||
color: string
|
||||
online: boolean
|
||||
bio: string
|
||||
id: string;
|
||||
name: string;
|
||||
handle: string;
|
||||
color: string;
|
||||
online: boolean;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
personId: string
|
||||
body: string
|
||||
sentAt: number
|
||||
mine: boolean
|
||||
status: 'sent' | 'delivered' | 'failed'
|
||||
id: string;
|
||||
personId: string;
|
||||
body: string;
|
||||
sentAt: number;
|
||||
mine: boolean;
|
||||
status: "sent" | "delivered" | "failed";
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
personId: string
|
||||
unread: number
|
||||
pinned?: boolean
|
||||
messages: Message[]
|
||||
id: string;
|
||||
personId: string;
|
||||
unread: number;
|
||||
pinned?: boolean;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
const people: Person[] = [
|
||||
{ id: 'maya', name: 'Maya Chen', handle: '@mayac', color: '#ff7a8a', online: true, bio: 'Product designer · Sydney to everywhere.' },
|
||||
{ id: 'noah', name: 'Noah Williams', handle: '@noahw', color: '#4f9cff', online: true, bio: 'Film, tiny cameras, and very long walks.' },
|
||||
{ id: 'sofia', name: 'Sofia Rossi', handle: '@sofiar', color: '#9d67ff', online: false, bio: 'Making typefaces and better pasta.' },
|
||||
{ id: 'liam', name: 'Liam Park', handle: '@liamp', color: '#35c7a0', online: true, bio: 'Engineer. Climber. Questionable DJ.' },
|
||||
{ id: 'amara', name: 'Amara Okafor', handle: '@amarao', color: '#f4ad42', online: false, bio: 'Architecture and cities after dark.' },
|
||||
]
|
||||
{
|
||||
id: "maya",
|
||||
name: "Maya Chen",
|
||||
handle: "@mayac",
|
||||
color: "#ff7a8a",
|
||||
online: true,
|
||||
bio: "Product designer · Sydney to everywhere.",
|
||||
},
|
||||
{
|
||||
id: "noah",
|
||||
name: "Noah Williams",
|
||||
handle: "@noahw",
|
||||
color: "#4f9cff",
|
||||
online: true,
|
||||
bio: "Film, tiny cameras, and very long walks.",
|
||||
},
|
||||
{
|
||||
id: "sofia",
|
||||
name: "Sofia Rossi",
|
||||
handle: "@sofiar",
|
||||
color: "#9d67ff",
|
||||
online: false,
|
||||
bio: "Making typefaces and better pasta.",
|
||||
},
|
||||
{
|
||||
id: "liam",
|
||||
name: "Liam Park",
|
||||
handle: "@liamp",
|
||||
color: "#35c7a0",
|
||||
online: true,
|
||||
bio: "Engineer. Climber. Questionable DJ.",
|
||||
},
|
||||
{
|
||||
id: "amara",
|
||||
name: "Amara Okafor",
|
||||
handle: "@amarao",
|
||||
color: "#f4ad42",
|
||||
online: false,
|
||||
bio: "Architecture and cities after dark.",
|
||||
},
|
||||
];
|
||||
|
||||
const timestamp = Date.now()
|
||||
const timestamp = Date.now();
|
||||
const seedConversations: Conversation[] = [
|
||||
{
|
||||
id: 'maya', personId: 'maya', unread: 2, pinned: true,
|
||||
id: "maya",
|
||||
personId: "maya",
|
||||
unread: 2,
|
||||
pinned: true,
|
||||
messages: [
|
||||
{ id: 'm1', personId: 'maya', body: 'That transition feels ridiculously smooth ✨', sentAt: timestamp - 840_000, mine: false, status: 'delivered' },
|
||||
{ id: 'm2', personId: 'maya', body: 'Try holding it halfway, then let go slowly.', sentAt: timestamp - 780_000, mine: false, status: 'delivered' },
|
||||
{
|
||||
id: "m1",
|
||||
personId: "maya",
|
||||
body: "That transition feels ridiculously smooth ✨",
|
||||
sentAt: timestamp - 840_000,
|
||||
mine: false,
|
||||
status: "delivered",
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
personId: "maya",
|
||||
body: "Try holding it halfway, then let go slowly.",
|
||||
sentAt: timestamp - 780_000,
|
||||
mine: false,
|
||||
status: "delivered",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'noah', personId: 'noah', unread: 0,
|
||||
id: "noah",
|
||||
personId: "noah",
|
||||
unread: 0,
|
||||
messages: [
|
||||
{ id: 'n1', personId: 'noah', body: 'Uploaded the photos from yesterday.', sentAt: timestamp - 4_200_000, mine: false, status: 'delivered' },
|
||||
{ id: 'n2', personId: 'noah', body: 'The grain is perfect.', sentAt: timestamp - 4_000_000, mine: true, status: 'delivered' },
|
||||
{
|
||||
id: "n1",
|
||||
personId: "noah",
|
||||
body: "Uploaded the photos from yesterday.",
|
||||
sentAt: timestamp - 4_200_000,
|
||||
mine: false,
|
||||
status: "delivered",
|
||||
},
|
||||
{
|
||||
id: "n2",
|
||||
personId: "noah",
|
||||
body: "The grain is perfect.",
|
||||
sentAt: timestamp - 4_000_000,
|
||||
mine: true,
|
||||
status: "delivered",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sofia', personId: 'sofia', unread: 1,
|
||||
messages: [{ id: 's1', personId: 'sofia', body: 'Coffee at the new place tomorrow?', sentAt: timestamp - 18_000_000, mine: false, status: 'delivered' }],
|
||||
id: "sofia",
|
||||
personId: "sofia",
|
||||
unread: 1,
|
||||
messages: [
|
||||
{
|
||||
id: "s1",
|
||||
personId: "sofia",
|
||||
body: "Coffee at the new place tomorrow?",
|
||||
sentAt: timestamp - 18_000_000,
|
||||
mine: false,
|
||||
status: "delivered",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'liam', personId: 'liam', unread: 0,
|
||||
messages: [{ id: 'l1', personId: 'liam', body: 'The build is green. Ship it.', sentAt: timestamp - 86_000_000, mine: false, status: 'delivered' }],
|
||||
id: "liam",
|
||||
personId: "liam",
|
||||
unread: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "l1",
|
||||
personId: "liam",
|
||||
body: "The build is green. Ship it.",
|
||||
sentAt: timestamp - 86_000_000,
|
||||
mine: false,
|
||||
status: "delivered",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'amara', personId: 'amara', unread: 0,
|
||||
messages: [{ id: 'a1', personId: 'amara', body: 'This city never really goes quiet.', sentAt: timestamp - 172_000_000, mine: false, status: 'delivered' }],
|
||||
id: "amara",
|
||||
personId: "amara",
|
||||
unread: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "a1",
|
||||
personId: "amara",
|
||||
body: "This city never really goes quiet.",
|
||||
sentAt: timestamp - 172_000_000,
|
||||
mine: false,
|
||||
status: "delivered",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const conversations = ref<Conversation[]>(structuredClone(seedConversations))
|
||||
const ready = ref(false)
|
||||
const offline = ref(!navigator.onLine)
|
||||
const settings = reactive({ simulatedLatency: 180, simulateFailures: false })
|
||||
const conversations = ref<Conversation[]>(structuredClone(seedConversations));
|
||||
const ready = ref(false);
|
||||
const offline = ref(!navigator.onLine);
|
||||
const settings = reactive({ simulatedLatency: 180, simulateFailures: false });
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open('native-vue-messenger', 1)
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('state')
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
const request = indexedDB.open("native-vue-messenger", 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("state");
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function readStored() {
|
||||
const database = await openDatabase()
|
||||
const database = await openDatabase();
|
||||
return await new Promise<Conversation[] | undefined>((resolve, reject) => {
|
||||
const transaction = database.transaction('state', 'readonly')
|
||||
const request = transaction.objectStore('state').get('conversations')
|
||||
request.onsuccess = () => resolve(request.result as Conversation[] | undefined)
|
||||
request.onerror = () => reject(request.error)
|
||||
}).finally(() => database.close())
|
||||
const transaction = database.transaction("state", "readonly");
|
||||
const request = transaction.objectStore("state").get("conversations");
|
||||
request.onsuccess = () =>
|
||||
resolve(request.result as Conversation[] | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
}).finally(() => database.close());
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
try {
|
||||
const database = await openDatabase()
|
||||
const database = await openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction('state', 'readwrite')
|
||||
transaction.objectStore('state').put(structuredClone(conversations.value), 'conversations')
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
})
|
||||
database.close()
|
||||
const transaction = database.transaction("state", "readwrite");
|
||||
transaction
|
||||
.objectStore("state")
|
||||
.put(structuredClone(conversations.value), "conversations");
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
database.close();
|
||||
} catch {
|
||||
// Private browsing and locked-down webviews can reject IndexedDB.
|
||||
}
|
||||
@@ -105,48 +209,58 @@ async function persist() {
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
const stored = await readStored()
|
||||
if (stored?.length) conversations.value = stored
|
||||
else await persist()
|
||||
const stored = await readStored();
|
||||
if (stored?.length) conversations.value = stored;
|
||||
else await persist();
|
||||
} finally {
|
||||
ready.value = true
|
||||
ready.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('online', () => { offline.value = false })
|
||||
window.addEventListener('offline', () => { offline.value = true })
|
||||
void initialize()
|
||||
window.addEventListener("online", () => {
|
||||
offline.value = false;
|
||||
});
|
||||
window.addEventListener("offline", () => {
|
||||
offline.value = true;
|
||||
});
|
||||
void initialize();
|
||||
|
||||
export function useDemoStore() {
|
||||
const conversationFor = (id: string) => computed(() => conversations.value.find((conversation) => conversation.id === id))
|
||||
const personFor = (id: string) => people.find((person) => person.id === id)
|
||||
const conversationFor = (id: string) =>
|
||||
computed(() =>
|
||||
conversations.value.find((conversation) => conversation.id === id),
|
||||
);
|
||||
const personFor = (id: string) => people.find((person) => person.id === id);
|
||||
const markRead = (id: string) => {
|
||||
const conversation = conversations.value.find((item) => item.id === id)
|
||||
if (conversation) conversation.unread = 0
|
||||
void persist()
|
||||
}
|
||||
const conversation = conversations.value.find((item) => item.id === id);
|
||||
if (conversation) conversation.unread = 0;
|
||||
void persist();
|
||||
};
|
||||
const sendMessage = async (id: string, body: string) => {
|
||||
const conversation = conversations.value.find((item) => item.id === id)
|
||||
if (!conversation || !body.trim()) return false
|
||||
const conversation = conversations.value.find((item) => item.id === id);
|
||||
if (!conversation || !body.trim()) return false;
|
||||
const message: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
personId: id,
|
||||
body: body.trim(),
|
||||
sentAt: Date.now(),
|
||||
mine: true,
|
||||
status: 'sent',
|
||||
}
|
||||
conversation.messages.push(message)
|
||||
await persist()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settings.simulatedLatency))
|
||||
message.status = settings.simulateFailures || offline.value ? 'failed' : 'delivered'
|
||||
await persist()
|
||||
return message.status === 'delivered'
|
||||
}
|
||||
status: "sent",
|
||||
};
|
||||
conversation.messages.push(message);
|
||||
await persist();
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, settings.simulatedLatency),
|
||||
);
|
||||
message.status =
|
||||
settings.simulateFailures || offline.value ? "failed" : "delivered";
|
||||
await persist();
|
||||
return message.status === "delivered";
|
||||
};
|
||||
const reset = async () => {
|
||||
conversations.value = structuredClone(seedConversations)
|
||||
await persist()
|
||||
}
|
||||
conversations.value = structuredClone(seedConversations);
|
||||
await persist();
|
||||
};
|
||||
return {
|
||||
people: readonly(ref(people)),
|
||||
conversations: readonly(conversations),
|
||||
@@ -158,14 +272,14 @@ export function useDemoStore() {
|
||||
markRead,
|
||||
sendMessage,
|
||||
reset,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function relativeTime(value: number) {
|
||||
const minutes = Math.floor((Date.now() - value) / 60_000)
|
||||
if (minutes < 1) return 'now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
return `${Math.floor(hours / 24)}d`
|
||||
const minutes = Math.floor((Date.now() - value) / 60_000);
|
||||
if (minutes < 1) return "now";
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import { readonly, reactive } from 'vue'
|
||||
import { readonly, reactive } from "vue";
|
||||
|
||||
function createGuardState() {
|
||||
return reactive({
|
||||
blockEntry: false,
|
||||
checks: 0,
|
||||
status: 'idle' as 'idle' | 'checking' | 'allowed' | 'blocked',
|
||||
})
|
||||
status: "idle" as "idle" | "checking" | "allowed" | "blocked",
|
||||
});
|
||||
}
|
||||
|
||||
const storyState = createGuardState()
|
||||
const labState = createGuardState()
|
||||
const storyState = createGuardState();
|
||||
const labState = createGuardState();
|
||||
|
||||
export const storyEntryGuard = readonly(storyState)
|
||||
export const runtimeLabGuard = readonly(labState)
|
||||
export const storyEntryGuard = readonly(storyState);
|
||||
export const runtimeLabGuard = readonly(labState);
|
||||
|
||||
export function setStoryEntryBlocked(blocked: boolean) {
|
||||
storyState.blockEntry = blocked
|
||||
if (storyState.status !== 'checking') storyState.status = 'idle'
|
||||
storyState.blockEntry = blocked;
|
||||
if (storyState.status !== "checking") storyState.status = "idle";
|
||||
}
|
||||
|
||||
async function evaluate(state: ReturnType<typeof createGuardState>) {
|
||||
state.checks += 1
|
||||
state.status = 'checking'
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 320))
|
||||
const allowed = !state.blockEntry
|
||||
state.status = allowed ? 'allowed' : 'blocked'
|
||||
return allowed
|
||||
state.checks += 1;
|
||||
state.status = "checking";
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 320));
|
||||
const allowed = !state.blockEntry;
|
||||
state.status = allowed ? "allowed" : "blocked";
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/** Dynamic guard used to reject a sibling that may already be cached. */
|
||||
export function evaluateStoryEntry() {
|
||||
storyState.checks += 1
|
||||
storyState.checks += 1;
|
||||
if (!storyState.blockEntry) {
|
||||
storyState.status = 'allowed'
|
||||
return true
|
||||
storyState.status = "allowed";
|
||||
return true;
|
||||
}
|
||||
storyState.status = 'checking'
|
||||
storyState.status = "checking";
|
||||
return new Promise<boolean>((resolve) => {
|
||||
window.setTimeout(() => {
|
||||
storyState.status = 'blocked'
|
||||
resolve(false)
|
||||
}, 320)
|
||||
})
|
||||
storyState.status = "blocked";
|
||||
resolve(false);
|
||||
}, 320);
|
||||
});
|
||||
}
|
||||
|
||||
/** Always-allowing asynchronous guard for the deeper stress-lab route. */
|
||||
export function evaluateRuntimeLabEntry() {
|
||||
return evaluate(labState)
|
||||
return evaluate(labState);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createNativeRouter } from '@native-vue-router/core'
|
||||
import { createCapacitorAdapter } from '@native-vue-router/capacitor'
|
||||
import { createElectronRendererAdapter } from '@native-vue-router/electron'
|
||||
import App from './App.vue'
|
||||
import { createPwaAdapter } from './pwa'
|
||||
import { router } from './router'
|
||||
import './style.css'
|
||||
import { createApp } from "vue";
|
||||
import { createNativeRouter } from "@native-vue-router/core";
|
||||
import { createCapacitorAdapter } from "@native-vue-router/capacitor";
|
||||
import { createElectronRendererAdapter } from "@native-vue-router/electron";
|
||||
import App from "./App.vue";
|
||||
import { createPwaAdapter } from "./pwa";
|
||||
import { router } from "./router";
|
||||
import "./style.css";
|
||||
|
||||
const isElectron = navigator.userAgent.toLowerCase().includes('electron')
|
||||
const capacitorPlatform = createCapacitorAdapter({ haptics: true, exitAtRoot: true })
|
||||
const isElectron = navigator.userAgent.toLowerCase().includes("electron");
|
||||
const capacitorPlatform = createCapacitorAdapter({
|
||||
haptics: true,
|
||||
exitAtRoot: true,
|
||||
});
|
||||
const platform = isElectron
|
||||
? createElectronRendererAdapter()
|
||||
: capacitorPlatform.name !== 'capacitor-web'
|
||||
: capacitorPlatform.name !== "capacitor-web"
|
||||
? capacitorPlatform
|
||||
: createPwaAdapter()
|
||||
: createPwaAdapter();
|
||||
|
||||
const nativeRouter = createNativeRouter({
|
||||
router,
|
||||
cache: { maxInactive: 4 },
|
||||
platform,
|
||||
})
|
||||
});
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(nativeRouter)
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(nativeRouter);
|
||||
|
||||
await router.isReady()
|
||||
app.mount('#app')
|
||||
await router.isReady();
|
||||
app.mount("#app");
|
||||
|
||||
@@ -1,66 +1,74 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
createNativeNavigationProfiler,
|
||||
type NativeNavigationProfiler,
|
||||
type NativeProfilerReport,
|
||||
type NativeRouterRuntime,
|
||||
} from '@native-vue-router/core'
|
||||
import { pwaBuildId } from './pwa'
|
||||
} from "@native-vue-router/core";
|
||||
import { pwaBuildId } from "./pwa";
|
||||
|
||||
let profiler: NativeNavigationProfiler | undefined
|
||||
export const profilerRecording = ref(false)
|
||||
export const profilerHasCapture = ref(false)
|
||||
let profiler: NativeNavigationProfiler | undefined;
|
||||
export const profilerRecording = ref(false);
|
||||
export const profilerHasCapture = ref(false);
|
||||
|
||||
function instance(runtime: NativeRouterRuntime) {
|
||||
profiler ??= createNativeNavigationProfiler(runtime, {
|
||||
metadata: { app: 'nvr-messenger-demo', build: pwaBuildId },
|
||||
})
|
||||
return profiler
|
||||
metadata: { app: "nvr-messenger-demo", build: pwaBuildId },
|
||||
});
|
||||
return profiler;
|
||||
}
|
||||
|
||||
export function startDemoProfile(runtime: NativeRouterRuntime) {
|
||||
const active = instance(runtime)
|
||||
active.start()
|
||||
profilerRecording.value = true
|
||||
profilerHasCapture.value = true
|
||||
const active = instance(runtime);
|
||||
active.start();
|
||||
profilerRecording.value = true;
|
||||
profilerHasCapture.value = true;
|
||||
}
|
||||
|
||||
export function stopDemoProfile(runtime: NativeRouterRuntime) {
|
||||
const report = instance(runtime).stop()
|
||||
profilerRecording.value = false
|
||||
return report
|
||||
const report = instance(runtime).stop();
|
||||
profilerRecording.value = false;
|
||||
return report;
|
||||
}
|
||||
|
||||
export function snapshotDemoProfile(runtime: NativeRouterRuntime) {
|
||||
return instance(runtime).snapshot()
|
||||
return instance(runtime).snapshot();
|
||||
}
|
||||
|
||||
export async function shareDemoProfile(runtime: NativeRouterRuntime, report?: NativeProfilerReport) {
|
||||
const active = instance(runtime)
|
||||
const current = report ?? (profilerRecording.value ? stopDemoProfile(runtime) : active.snapshot())
|
||||
const json = active.toJSON(current)
|
||||
const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
|
||||
const filename = `native-vue-router-profile-${stamp}.json`
|
||||
const file = new File([json], filename, { type: 'application/json' })
|
||||
export async function shareDemoProfile(
|
||||
runtime: NativeRouterRuntime,
|
||||
report?: NativeProfilerReport,
|
||||
) {
|
||||
const active = instance(runtime);
|
||||
const current =
|
||||
report ??
|
||||
(profilerRecording.value ? stopDemoProfile(runtime) : active.snapshot());
|
||||
const json = active.toJSON(current);
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replaceAll(":", "-")
|
||||
.replaceAll(".", "-");
|
||||
const filename = `native-vue-router-profile-${stamp}.json`;
|
||||
const file = new File([json], filename, { type: "application/json" });
|
||||
const shareNavigator = navigator as Navigator & {
|
||||
canShare?: (data: ShareData) => boolean
|
||||
share?: (data: ShareData) => Promise<void>
|
||||
}
|
||||
canShare?: (data: ShareData) => boolean;
|
||||
share?: (data: ShareData) => Promise<void>;
|
||||
};
|
||||
|
||||
if (shareNavigator.share && shareNavigator.canShare?.({ files: [file] })) {
|
||||
await shareNavigator.share({
|
||||
title: 'Native Vue Router performance profile',
|
||||
text: 'Frame pacing and navigation diagnostics. Route params and query values are omitted.',
|
||||
title: "Native Vue Router performance profile",
|
||||
text: "Frame pacing and navigation diagnostics. Route params and query values are omitted.",
|
||||
files: [file],
|
||||
})
|
||||
return { report: current, method: 'shared' as const }
|
||||
});
|
||||
return { report: current, method: "shared" as const };
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1_000)
|
||||
return { report: current, method: 'downloaded' as const }
|
||||
const url = URL.createObjectURL(file);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
|
||||
return { report: current, method: "downloaded" as const };
|
||||
}
|
||||
|
||||
@@ -1,62 +1,77 @@
|
||||
import { reactive, readonly } from 'vue'
|
||||
import type { NativePlatformAdapter } from '@native-vue-router/core'
|
||||
import { reactive, readonly } from "vue";
|
||||
import type { NativePlatformAdapter } from "@native-vue-router/core";
|
||||
|
||||
interface StandaloneNavigator extends Navigator {
|
||||
standalone?: boolean
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
type ServiceWorkerState = 'unsupported' | 'installing' | 'ready'
|
||||
export type PwaUpdateState = 'idle' | 'checking' | 'current' | 'ready' | 'error'
|
||||
type ServiceWorkerState = "unsupported" | "installing" | "ready";
|
||||
export type PwaUpdateState =
|
||||
"idle" | "checking" | "current" | "ready" | "error";
|
||||
|
||||
declare const __NVR_BUILD_ID__: string
|
||||
declare const __NVR_BUILD_ID__: string;
|
||||
|
||||
export const pwaBuildId = __NVR_BUILD_ID__
|
||||
export const pwaBuildId = __NVR_BUILD_ID__;
|
||||
|
||||
const state = reactive({
|
||||
ios: false,
|
||||
standalone: false,
|
||||
edgeGuard: false,
|
||||
edgeClaims: 0,
|
||||
serviceWorker: 'installing' as ServiceWorkerState,
|
||||
updateState: 'idle' as PwaUpdateState,
|
||||
serviceWorker: "installing" as ServiceWorkerState,
|
||||
updateState: "idle" as PwaUpdateState,
|
||||
updateChecks: 0,
|
||||
})
|
||||
});
|
||||
|
||||
export const pwaEnvironment = readonly(state)
|
||||
export const pwaEnvironment = readonly(state);
|
||||
|
||||
export function recordPwaUpdateState(updateState: PwaUpdateState, checked = false) {
|
||||
state.updateState = updateState
|
||||
if (checked) state.updateChecks += 1
|
||||
document.documentElement.dataset.pwaUpdateState = state.updateState
|
||||
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks)
|
||||
export function recordPwaUpdateState(
|
||||
updateState: PwaUpdateState,
|
||||
checked = false,
|
||||
) {
|
||||
state.updateState = updateState;
|
||||
if (checked) state.updateChecks += 1;
|
||||
document.documentElement.dataset.pwaUpdateState = state.updateState;
|
||||
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks);
|
||||
}
|
||||
|
||||
export function isIOSWebKit() {
|
||||
const navigatorWithTouch = navigator as Navigator & { maxTouchPoints?: number }
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
|| (/Macintosh/.test(navigator.userAgent) && (navigatorWithTouch.maxTouchPoints ?? 0) > 1)
|
||||
const navigatorWithTouch = navigator as Navigator & {
|
||||
maxTouchPoints?: number;
|
||||
};
|
||||
return (
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(/Macintosh/.test(navigator.userAgent) &&
|
||||
(navigatorWithTouch.maxTouchPoints ?? 0) > 1)
|
||||
);
|
||||
}
|
||||
|
||||
export function isStandalonePwa() {
|
||||
return Boolean((navigator as StandaloneNavigator).standalone)
|
||||
|| window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.matchMedia('(display-mode: fullscreen)').matches
|
||||
return (
|
||||
Boolean((navigator as StandaloneNavigator).standalone) ||
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
window.matchMedia("(display-mode: fullscreen)").matches
|
||||
);
|
||||
}
|
||||
|
||||
function updateEnvironment() {
|
||||
state.ios = isIOSWebKit()
|
||||
state.standalone = isStandalonePwa()
|
||||
state.edgeGuard = state.ios && state.standalone
|
||||
document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other'
|
||||
document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser'
|
||||
document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive'
|
||||
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
|
||||
document.documentElement.dataset.pwaUpdateState = state.updateState
|
||||
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks)
|
||||
state.ios = isIOSWebKit();
|
||||
state.standalone = isStandalonePwa();
|
||||
state.edgeGuard = state.ios && state.standalone;
|
||||
document.documentElement.dataset.pwaPlatform = state.ios ? "ios" : "other";
|
||||
document.documentElement.dataset.pwaDisplayMode = state.standalone
|
||||
? "standalone"
|
||||
: "browser";
|
||||
document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard
|
||||
? "active"
|
||||
: "inactive";
|
||||
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims);
|
||||
document.documentElement.dataset.pwaUpdateState = state.updateState;
|
||||
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks);
|
||||
}
|
||||
|
||||
export interface PwaAdapterOptions {
|
||||
edgeWidth?: number
|
||||
edgeWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,65 +81,104 @@ export interface PwaAdapterOptions {
|
||||
* Safari does not expose WKWebView's native gesture switch to web content,
|
||||
* so this is deliberately scoped to standalone mode and reinforced by CSS.
|
||||
*/
|
||||
export function createPwaAdapter(options: PwaAdapterOptions = {}): NativePlatformAdapter {
|
||||
const edgeWidth = options.edgeWidth ?? 32
|
||||
export function createPwaAdapter(
|
||||
options: PwaAdapterOptions = {},
|
||||
): NativePlatformAdapter {
|
||||
const edgeWidth = options.edgeWidth ?? 32;
|
||||
return {
|
||||
name: 'pwa',
|
||||
name: "pwa",
|
||||
install() {
|
||||
updateEnvironment()
|
||||
const displayMode = window.matchMedia('(display-mode: standalone)')
|
||||
const update = () => updateEnvironment()
|
||||
displayMode.addEventListener('change', update)
|
||||
document.addEventListener('visibilitychange', update)
|
||||
updateEnvironment();
|
||||
const displayMode = window.matchMedia("(display-mode: standalone)");
|
||||
const update = () => updateEnvironment();
|
||||
displayMode.addEventListener("change", update);
|
||||
document.addEventListener("visibilitychange", update);
|
||||
|
||||
if (!('serviceWorker' in navigator)) state.serviceWorker = 'unsupported'
|
||||
if (!("serviceWorker" in navigator)) state.serviceWorker = "unsupported";
|
||||
else {
|
||||
state.serviceWorker = navigator.serviceWorker.controller ? 'ready' : 'installing'
|
||||
void navigator.serviceWorker.ready.then(() => { state.serviceWorker = 'ready' })
|
||||
navigator.serviceWorker.addEventListener('controllerchange', updateServiceWorkerState)
|
||||
state.serviceWorker = navigator.serviceWorker.controller
|
||||
? "ready"
|
||||
: "installing";
|
||||
void navigator.serviceWorker.ready.then(() => {
|
||||
state.serviceWorker = "ready";
|
||||
});
|
||||
navigator.serviceWorker.addEventListener(
|
||||
"controllerchange",
|
||||
updateServiceWorkerState,
|
||||
);
|
||||
}
|
||||
|
||||
let reservedTouch: number | null = null
|
||||
let reservedTouch: number | null = null;
|
||||
const touchAtLeadingEdge = (event: TouchEvent) => {
|
||||
if (!state.edgeGuard || event.touches.length !== 1) return undefined
|
||||
if (!(event.target instanceof Element) || !event.target.closest('.nvr-navigator')) return undefined
|
||||
const touch = event.touches[0]
|
||||
const rtl = getComputedStyle(document.documentElement).direction === 'rtl'
|
||||
const atEdge = rtl ? window.innerWidth - touch.clientX <= edgeWidth : touch.clientX <= edgeWidth
|
||||
return atEdge ? touch : undefined
|
||||
}
|
||||
if (!state.edgeGuard || event.touches.length !== 1) return undefined;
|
||||
if (
|
||||
!(event.target instanceof Element) ||
|
||||
!event.target.closest(".nvr-navigator")
|
||||
)
|
||||
return undefined;
|
||||
const touch = event.touches[0];
|
||||
const rtl =
|
||||
getComputedStyle(document.documentElement).direction === "rtl";
|
||||
const atEdge = rtl
|
||||
? window.innerWidth - touch.clientX <= edgeWidth
|
||||
: touch.clientX <= edgeWidth;
|
||||
return atEdge ? touch : undefined;
|
||||
};
|
||||
const reserveEdge = (event: TouchEvent) => {
|
||||
const touch = touchAtLeadingEdge(event)
|
||||
if (!touch) return
|
||||
reservedTouch = touch.identifier
|
||||
state.edgeClaims += 1
|
||||
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
|
||||
event.preventDefault()
|
||||
}
|
||||
const touch = touchAtLeadingEdge(event);
|
||||
if (!touch) return;
|
||||
reservedTouch = touch.identifier;
|
||||
state.edgeClaims += 1;
|
||||
document.documentElement.dataset.pwaEdgeClaims = String(
|
||||
state.edgeClaims,
|
||||
);
|
||||
event.preventDefault();
|
||||
};
|
||||
const holdEdge = (event: TouchEvent) => {
|
||||
if (reservedTouch === null) return
|
||||
if ([...event.touches].some((touch) => touch.identifier === reservedTouch)) event.preventDefault()
|
||||
}
|
||||
const releaseEdge = () => { reservedTouch = null }
|
||||
if (reservedTouch === null) return;
|
||||
if (
|
||||
[...event.touches].some((touch) => touch.identifier === reservedTouch)
|
||||
)
|
||||
event.preventDefault();
|
||||
};
|
||||
const releaseEdge = () => {
|
||||
reservedTouch = null;
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', reserveEdge, { capture: true, passive: false })
|
||||
document.addEventListener('touchmove', holdEdge, { capture: true, passive: false })
|
||||
document.addEventListener('touchend', releaseEdge, { capture: true, passive: true })
|
||||
document.addEventListener('touchcancel', releaseEdge, { capture: true, passive: true })
|
||||
document.addEventListener("touchstart", reserveEdge, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchmove", holdEdge, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchend", releaseEdge, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
document.addEventListener("touchcancel", releaseEdge, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
displayMode.removeEventListener('change', update)
|
||||
document.removeEventListener('visibilitychange', update)
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.removeEventListener('controllerchange', updateServiceWorkerState)
|
||||
document.removeEventListener('touchstart', reserveEdge, true)
|
||||
document.removeEventListener('touchmove', holdEdge, true)
|
||||
document.removeEventListener('touchend', releaseEdge, true)
|
||||
document.removeEventListener('touchcancel', releaseEdge, true)
|
||||
}
|
||||
displayMode.removeEventListener("change", update);
|
||||
document.removeEventListener("visibilitychange", update);
|
||||
if ("serviceWorker" in navigator)
|
||||
navigator.serviceWorker.removeEventListener(
|
||||
"controllerchange",
|
||||
updateServiceWorkerState,
|
||||
);
|
||||
document.removeEventListener("touchstart", reserveEdge, true);
|
||||
document.removeEventListener("touchmove", holdEdge, true);
|
||||
document.removeEventListener("touchend", releaseEdge, true);
|
||||
document.removeEventListener("touchcancel", releaseEdge, true);
|
||||
};
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updateServiceWorkerState() {
|
||||
state.serviceWorker = 'ready'
|
||||
state.serviceWorker = "ready";
|
||||
}
|
||||
|
||||
@@ -1,46 +1,116 @@
|
||||
import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { evaluateRuntimeLabEntry, evaluateStoryEntry } from './guard-state'
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
createWebHistory,
|
||||
type RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { evaluateRuntimeLabEntry, evaluateStoryEntry } from "./guard-state";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/inbox' },
|
||||
{ path: "/", redirect: "/inbox" },
|
||||
{
|
||||
path: '/inbox', name: 'inbox', component: () => import('./views/InboxView.vue'),
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 0, siblingHistory: 'replace', gesture: 'full' } },
|
||||
path: "/inbox",
|
||||
name: "inbox",
|
||||
component: () => import("./views/InboxView.vue"),
|
||||
meta: {
|
||||
tab: true,
|
||||
native: {
|
||||
siblingGroup: "primary",
|
||||
siblingOrder: 0,
|
||||
siblingHistory: "replace",
|
||||
gesture: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/stories', name: 'stories', component: () => import('./views/StoriesView.vue'),
|
||||
path: "/stories",
|
||||
name: "stories",
|
||||
component: () => import("./views/StoriesView.vue"),
|
||||
beforeEnter: evaluateStoryEntry,
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 1, siblingHistory: 'replace', gesture: 'full' } },
|
||||
meta: {
|
||||
tab: true,
|
||||
native: {
|
||||
siblingGroup: "primary",
|
||||
siblingOrder: 1,
|
||||
siblingHistory: "replace",
|
||||
gesture: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/profile', name: 'profile', component: () => import('./views/ProfileView.vue'),
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 2, siblingHistory: 'replace', gesture: 'full' } },
|
||||
path: "/profile",
|
||||
name: "profile",
|
||||
component: () => import("./views/ProfileView.vue"),
|
||||
meta: {
|
||||
tab: true,
|
||||
native: {
|
||||
siblingGroup: "primary",
|
||||
siblingOrder: 2,
|
||||
siblingHistory: "replace",
|
||||
gesture: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/profile/runtime-lab', name: 'runtime-lab', component: () => import('./views/RuntimeLabView.vue'),
|
||||
path: "/profile/runtime-lab",
|
||||
name: "runtime-lab",
|
||||
component: () => import("./views/RuntimeLabView.vue"),
|
||||
beforeEnter: evaluateRuntimeLabEntry,
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 3, siblingHistory: 'push', presentation: 'slide', parent: '/profile', gesture: 'full' } },
|
||||
meta: {
|
||||
tab: true,
|
||||
native: {
|
||||
siblingGroup: "primary",
|
||||
siblingOrder: 3,
|
||||
siblingHistory: "push",
|
||||
presentation: "slide",
|
||||
parent: "/profile",
|
||||
gesture: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/chat/:id', name: 'chat', component: () => import('./views/ChatView.vue'),
|
||||
meta: { native: { presentation: 'push', parent: '/inbox', gesture: 'edge' } },
|
||||
path: "/chat/:id",
|
||||
name: "chat",
|
||||
component: () => import("./views/ChatView.vue"),
|
||||
meta: {
|
||||
native: { presentation: "push", parent: "/inbox", gesture: "edge" },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/chat/:id/details', name: 'chat-details', component: () => import('./views/ContactView.vue'),
|
||||
meta: { native: { presentation: 'push', parent: (route) => `/chat/${String(route.params.id)}`, gesture: 'edge' } },
|
||||
path: "/chat/:id/details",
|
||||
name: "chat-details",
|
||||
component: () => import("./views/ContactView.vue"),
|
||||
meta: {
|
||||
native: {
|
||||
presentation: "push",
|
||||
parent: (route) => `/chat/${String(route.params.id)}`,
|
||||
gesture: "edge",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/compose', name: 'compose', component: () => import('./views/ComposeView.vue'),
|
||||
meta: { native: { presentation: 'sheet', parent: '/inbox', gesture: 'full' } },
|
||||
path: "/compose",
|
||||
name: "compose",
|
||||
component: () => import("./views/ComposeView.vue"),
|
||||
meta: {
|
||||
native: { presentation: "sheet", parent: "/inbox", gesture: "full" },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue'),
|
||||
meta: { native: { presentation: 'push', parent: '/profile', gesture: 'edge' } },
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
component: () => import("./views/SettingsView.vue"),
|
||||
meta: {
|
||||
native: { presentation: "push", parent: "/profile", gesture: "edge" },
|
||||
},
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: window.location.protocol === 'file:' ? createWebHashHistory() : createWebHistory(),
|
||||
history:
|
||||
window.location.protocol === "file:"
|
||||
? createWebHashHistory()
|
||||
: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
})
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user