feat: init mobile entry (#7905)

This commit is contained in:
pengx17 2024-08-21 13:17:35 +00:00
parent 3db95bafa2
commit 5acf1b5309
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
33 changed files with 744 additions and 28 deletions

View File

@ -73,7 +73,7 @@ export const WorkspaceLayout = function WorkspaceLayout({
);
};
const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => {
export const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => {
const t = useI18n();
const pushGlobalLoadingEvent = useSetAtom(pushGlobalLoadingEventAtom);
const resolveGlobalLoadingEvent = useSetAtom(resolveGlobalLoadingEventAtom);

View File

@ -1,5 +1,6 @@
import { FrameworkScope, useLiveData } from '@toeverything/infra';
import { lazy as reactLazy, useLayoutEffect, useMemo } from 'react';
import { useLayoutEffect, useMemo } from 'react';
import type { RouteObject } from 'react-router-dom';
import {
createMemoryRouter,
RouterProvider,
@ -7,30 +8,16 @@ import {
UNSAFE_RouteContext,
} from 'react-router-dom';
import { viewRoutes } from '../../../router';
import type { View } from '../entities/view';
import { RouteContainer } from './route-container';
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
const Component = reactLazy(() =>
lazy().then(m => ({
default: m.Component as React.ComponentType,
}))
);
const route = {
Component,
};
return {
path,
Component: () => {
return <RouteContainer route={route} />;
},
};
});
export const ViewRoot = ({ view }: { view: View }) => {
const viewRouter = useMemo(() => createMemoryRouter(warpedRoutes), []);
export const ViewRoot = ({
view,
routes,
}: {
view: View;
routes: RouteObject[];
}) => {
const viewRouter = useMemo(() => createMemoryRouter(routes), [routes]);
const location = useLiveData(view.location$);

View File

@ -1,5 +1,6 @@
import { ResizePanel } from '@affine/component/resize-panel';
import { rightSidebarWidthAtom } from '@affine/core/atoms';
import { viewRoutes } from '@affine/core/router';
import {
appSettingAtom,
FrameworkScope,
@ -7,13 +8,21 @@ import {
useService,
} from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
lazy as reactLazy,
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import type { View } from '../entities/view';
import { WorkbenchService } from '../services/workbench';
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
import { RouteContainer } from './route-container';
import { SidebarContainer } from './sidebar/sidebar-container';
import { SplitView } from './split-view/split-view';
import { ViewIslandRegistryProvider } from './view-islands';
@ -24,6 +33,24 @@ const useAdapter = environment.isDesktop
? useBindWorkbenchToDesktopRouter
: useBindWorkbenchToBrowserRouter;
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
const Component = reactLazy(() =>
lazy().then(m => ({
default: m.Component as React.ComponentType,
}))
);
const route = {
Component,
};
return {
path,
Component: () => {
return <RouteContainer route={route} />;
},
};
});
export const WorkbenchRoot = memo(() => {
const workbench = useService(WorkbenchService).workbench;
@ -93,7 +120,7 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
return (
<div className={styles.workbenchViewContainer} ref={containerRef}>
<ViewRoot key={view.id} view={view} />
<ViewRoot routes={warpedRoutes} key={view.id} view={view} />
</div>
);
};

View File

@ -0,0 +1,26 @@
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useBindWorkbenchToBrowserRouter } from '@affine/core/modules/workbench/view/browser-adapter';
import { ViewRoot } from '@affine/core/modules/workbench/view/view-root';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { type RouteObject, useLocation } from 'react-router-dom';
export const MobileWorkbenchRoot = ({ routes }: { routes: RouteObject[] }) => {
const workbench = useService(WorkbenchService).workbench;
// for debugging
(window as any).workbench = workbench;
const views = useLiveData(workbench.views$);
const location = useLocation();
const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
useBindWorkbenchToBrowserRouter(workbench, basename);
useEffect(() => {
workbench.updateBasename(basename);
}, [basename, workbench]);
return <ViewRoot routes={routes} view={views[0]} />;
};

View File

@ -11,7 +11,7 @@ import {
export const NavigateContext = createContext<NavigateFunction | null>(null);
function RootRouter() {
export function RootRouter() {
const navigate = useNavigate();
const [ready, setReady] = useState(false);
useEffect(() => {

View File

@ -0,0 +1,28 @@
{
"name": "@affine/mobile",
"version": "0.16.0",
"description": "AFFiNE Desktop Web application",
"private": true,
"browser": "src/index.tsx",
"scripts": {
"build": "cross-env DISTRIBUTION=mobile yarn workspace @affine/cli build",
"dev": "yarn workspace @affine/cli dev"
},
"dependencies": {
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/env": "workspace:*",
"@sentry/react": "^8.0.0",
"core-js": "^3.36.1",
"intl-segmenter-polyfill-rs": "^0.1.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@affine/cli": "workspace:*",
"@types/react": "^18.2.75",
"@types/react-dom": "^18.2.24",
"cross-env": "^7.0.3",
"typescript": "^5.4.5"
}
}

View File

@ -0,0 +1,69 @@
{
"name": "@affine/mobile",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"targets": {
"build": {
"executor": "nx:run-script",
"dependsOn": [
{
"projects": ["tag:infra"],
"target": "build",
"params": "ignore"
},
"^build"
],
"inputs": [
"{projectRoot}/**/*",
"{workspaceRoot}/tools/**/*",
"{workspaceRoot}/packages/frontend/core/**/*",
"{workspaceRoot}/packages/**/*",
{
"env": "BUILD_TYPE"
},
{
"env": "BUILD_TYPE_OVERRIDE"
},
{
"env": "PERFSEE_TOKEN"
},
{
"env": "SENTRY_ORG"
},
{
"env": "SENTRY_PROJECT"
},
{
"env": "SENTRY_AUTH_TOKEN"
},
{
"env": "SENTRY_DSN"
},
{
"env": "DISTRIBUTION"
},
{
"env": "COVERAGE"
},
{
"env": "DISABLE_DEV_OVERLAY"
},
{
"env": "CAPTCHA_SITE_KEY"
},
{
"env": "R2_ACCOUNT_ID"
},
{
"env": "R2_ACCESS_KEY_ID"
},
{
"env": "R2_SECRET_ACCESS_KEY"
}
],
"options": {
"script": "build"
},
"outputs": ["{projectRoot}/dist"]
}
}
}

View File

@ -0,0 +1,91 @@
import '@affine/component/theme/global.css';
import '@affine/component/theme/theme.css';
import { NotificationCenter } from '@affine/component';
import { AffineContext } from '@affine/component/context';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { configureCommonModules } from '@affine/core/modules';
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import {
configureBrowserWorkspaceFlavours,
configureIndexedDBWorkspaceEngineStorageProvider,
} from '@affine/core/modules/workspace-engine';
import {
performanceLogger,
performanceRenderLogger,
} from '@affine/core/shared';
import { Telemetry } from '@affine/core/telemetry';
import { createI18n, setUpLanguage } from '@affine/i18n';
import {
Framework,
FrameworkRoot,
getCurrentStore,
LifecycleService,
} from '@toeverything/infra';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
if (!environment.isBrowser && environment.isDebug) {
document.body.innerHTML = `<h1 style="color:red;font-size:5rem;text-align:center;">Don't run web entry in electron.</h1>`;
throw new Error('Wrong distribution');
}
const future = {
v7_startTransition: true,
} as const;
const performanceI18nLogger = performanceLogger.namespace('i18n');
async function loadLanguage() {
performanceI18nLogger.info('start');
const i18n = createI18n();
document.documentElement.lang = i18n.language;
performanceI18nLogger.info('set up');
await setUpLanguage(i18n);
performanceI18nLogger.info('done');
}
let languageLoadingPromise: Promise<void> | null = null;
const framework = new Framework();
configureCommonModules(framework);
configureBrowserWorkbenchModule(framework);
configureLocalStorageStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureIndexedDBWorkspaceEngineStorageProvider(framework);
const frameworkProvider = framework.provider();
// setup application lifecycle events, and emit application start event
window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationFocus();
});
frameworkProvider.get(LifecycleService).applicationStart();
export function App() {
performanceRenderLogger.debug('App');
if (!languageLoadingPromise) {
languageLoadingPromise = loadLanguage().catch(console.error);
}
return (
<Suspense>
<FrameworkRoot framework={frameworkProvider}>
<AffineContext store={getCurrentStore()}>
<Telemetry />
<NotificationCenter />
<RouterProvider
fallbackElement={<AppFallback key="RouterFallback" />}
router={router}
future={future}
/>
</AffineContext>
</FrameworkRoot>
</Suspense>
);
}

View File

@ -0,0 +1,76 @@
import './polyfill/dispose';
import './polyfill/intl-segmenter';
import './polyfill/promise-with-resolvers';
import './polyfill/request-idle-callback';
import '@affine/core/bootstrap/preload';
import { performanceLogger } from '@affine/core/shared';
import { isDesktop } from '@affine/env/constant';
import {
init,
reactRouterV6BrowserTracingIntegration,
setTags,
} from '@sentry/react';
import { StrictMode, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import { App } from './app';
const performanceMainLogger = performanceLogger.namespace('main');
function main() {
performanceMainLogger.info('start');
// skip bootstrap setup for desktop onboarding
if (isDesktop && window.appInfo?.windowName === 'onboarding') {
performanceMainLogger.info('skip setup');
} else {
performanceMainLogger.info('setup start');
if (window.SENTRY_RELEASE || environment.isDebug) {
// https://docs.sentry.io/platforms/javascript/guides/react/#configure
init({
dsn: process.env.SENTRY_DSN,
environment: process.env.BUILD_TYPE ?? 'development',
integrations: [
reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
});
setTags({
appVersion: runtimeConfig.appVersion,
editorVersion: runtimeConfig.editorVersion,
});
}
performanceMainLogger.info('setup done');
}
mountApp();
}
function mountApp() {
performanceMainLogger.info('import app');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = document.getElementById('app')!;
performanceMainLogger.info('render app');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);
}
try {
main();
} catch (err) {
console.error('Failed to bootstrap app', err);
}

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/404</div>;
};

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/auth/*</div>;
};

View File

@ -0,0 +1,8 @@
import { Component as IndexComponent } from '@affine/core/pages/index';
// Default route fallback for mobile
export const Component = () => {
// TODO: replace with a mobile version
return <IndexComponent />;
};

View File

@ -0,0 +1,7 @@
// Default route fallback for mobile
import { SignIn } from '@affine/core/pages/sign-in';
export const Component = () => {
// placeholder impl
return <SignIn />;
};

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/all</div>;
};

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/collection/:collectionId</div>;
};

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/collection</div>;
};

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/:pageId</div>;
};

View File

@ -0,0 +1,112 @@
import { AppFallback } from '@affine/core/components/affine/app-container';
import { RouteContainer } from '@affine/core/modules/workbench/view/route-container';
import { PageNotFound } from '@affine/core/pages/404';
import { MobileWorkbenchRoot } from '@affine/core/pages/workspace/workbench-root';
import {
useLiveData,
useServices,
WorkspacesService,
} from '@toeverything/infra';
import { lazy as reactLazy, useEffect, useMemo, useState } from 'react';
import { matchPath, useLocation, useParams } from 'react-router-dom';
import { viewRoutes } from '../../router';
import { WorkspaceLayout } from './layout';
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
const Component = reactLazy(() =>
lazy().then(m => ({
default: m.Component as React.ComponentType,
}))
);
const route = {
Component,
};
return {
path,
Component: () => {
return <RouteContainer route={route} />;
},
};
});
export const Component = () => {
const { workspacesService } = useServices({
WorkspacesService,
});
const params = useParams();
const location = useLocation();
// todo(pengx17): dedupe the code with core
// check if we are in detail doc route, if so, maybe render share page
const detailDocRoute = useMemo(() => {
const match = matchPath(
'/workspace/:workspaceId/:docId',
location.pathname
);
if (
match &&
match.params.docId &&
match.params.workspaceId &&
// TODO(eyhn): need a better way to check if it's a docId
viewRoutes.find(route => matchPath(route.path, '/' + match.params.docId))
?.path === '/:pageId'
) {
return {
docId: match.params.docId,
workspaceId: match.params.workspaceId,
};
} else {
return null;
}
}, [location.pathname]);
const [workspaceNotFound, setWorkspaceNotFound] = useState(false);
const listLoading = useLiveData(workspacesService.list.isRevalidating$);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const meta = useMemo(() => {
return workspaces.find(({ id }) => id === params.workspaceId);
}, [workspaces, params.workspaceId]);
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
useEffect(() => {
if (listLoading === false && meta === undefined) {
setWorkspaceNotFound(true);
}
if (meta) {
setWorkspaceNotFound(false);
}
}, [listLoading, meta, workspacesService]);
// if workspace is not found, we should revalidate in interval
useEffect(() => {
if (listLoading === false && meta === undefined) {
const timer = setInterval(
() => workspacesService.list.revalidate(),
5000
);
return () => clearInterval(timer);
}
return;
}, [listLoading, meta, workspaceNotFound, workspacesService]);
if (workspaceNotFound) {
if (
detailDocRoute /* */ &&
environment.isBrowser /* only browser has share page */
) {
return <div>TODO: share page</div>;
}
return <PageNotFound noPermission />;
}
if (!meta) {
return <AppFallback key="workspaceLoading" />;
}
return (
<WorkspaceLayout meta={meta}>
<MobileWorkbenchRoot routes={warpedRoutes} />
</WorkspaceLayout>
);
};

View File

@ -0,0 +1,92 @@
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { WorkspaceLayoutProviders } from '@affine/core/layouts/workspace-layout';
import {
AllWorkspaceModals,
CurrentWorkspaceModals,
} from '@affine/core/providers/modal-provider';
import { SWRConfigProvider } from '@affine/core/providers/swr-config-provider';
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import {
FrameworkScope,
GlobalContextService,
useLiveData,
useServices,
WorkspacesService,
} from '@toeverything/infra';
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useState,
} from 'react';
export const WorkspaceLayout = ({
meta,
children,
}: PropsWithChildren<{ meta: WorkspaceMetadata }>) => {
// todo: reduce code duplication with packages\frontend\core\src\pages\workspace\index.tsx
const { workspacesService, globalContextService } = useServices({
WorkspacesService,
GlobalContextService,
});
const [workspace, setWorkspace] = useState<Workspace | null>(null);
useLayoutEffect(() => {
const ref = workspacesService.open({ metadata: meta });
setWorkspace(ref.workspace);
return () => {
ref.dispose();
};
}, [meta, workspacesService]);
useEffect(() => {
if (workspace) {
// for debug purpose
window.currentWorkspace = workspace ?? undefined;
window.dispatchEvent(
new CustomEvent('affine:workspace:change', {
detail: {
id: workspace.id,
},
})
);
localStorage.setItem('last_workspace_id', workspace.id);
globalContextService.globalContext.workspaceId.set(workspace.id);
return () => {
window.currentWorkspace = undefined;
globalContextService.globalContext.workspaceId.set(null);
};
}
return;
}, [globalContextService, workspace]);
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
if (!workspace) {
return null; // skip this, workspace will be set in layout effect
}
if (!isRootDocReady) {
return (
<FrameworkScope scope={workspace.scope}>
<AppFallback key="workspaceLoading" />
<AllWorkspaceModals />
</FrameworkScope>
);
}
return (
<FrameworkScope scope={workspace.scope}>
<AffineErrorBoundary height="100vh">
<SWRConfigProvider>
<AllWorkspaceModals />
<CurrentWorkspaceModals />
<WorkspaceLayoutProviders>{children}</WorkspaceLayoutProviders>
</SWRConfigProvider>
</AffineErrorBoundary>
</FrameworkScope>
);
};

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/tag/:tagId</div>;
};

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/tag</div>;
};

View File

@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/trash</div>;
};

View File

@ -0,0 +1,2 @@
import 'core-js/modules/esnext.symbol.async-dispose';
import 'core-js/modules/esnext.symbol.dispose';

View File

@ -0,0 +1,11 @@
if (Intl.Segmenter === undefined) {
await import('intl-segmenter-polyfill-rs').then(({ Segmenter }) => {
Object.defineProperty(Intl, 'Segmenter', {
value: Segmenter,
configurable: true,
writable: true,
});
});
}
export {};

View File

@ -0,0 +1 @@
import 'core-js/features/promise/with-resolvers';

View File

@ -0,0 +1,19 @@
window.requestIdleCallback =
window.requestIdleCallback ||
function (cb) {
const start = Date.now();
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
};
window.cancelIdleCallback =
window.cancelIdleCallback ||
function (id) {
clearTimeout(id);
};

View File

@ -0,0 +1 @@
import 'setimmediate';

View File

@ -0,0 +1,95 @@
import { RootRouter } from '@affine/core/router';
import { wrapCreateBrowserRouter } from '@sentry/react';
import type { RouteObject } from 'react-router-dom';
import {
createBrowserRouter as reactRouterCreateBrowserRouter,
redirect,
} from 'react-router-dom';
export const topLevelRoutes = [
{
element: <RootRouter />,
children: [
{
path: '/',
lazy: () => import('./pages/index'),
},
{
path: '/workspace/:workspaceId/*',
lazy: () => import('./pages/workspace/index'),
},
{
path: '/share/:workspaceId/:pageId',
loader: ({ params }) => {
return redirect(`/workspace/${params.workspaceId}/${params.pageId}`);
},
},
{
path: '/404',
lazy: () => import('./pages/404'),
},
{
path: '/auth/:authType',
lazy: () => import('./pages/auth'),
},
{
path: '/sign-in',
lazy: () => import('./pages/sign-in'),
},
{
path: '/redirect-proxy',
lazy: () => import('@affine/core/pages/redirect'),
},
{
path: '*',
lazy: () => import('./pages/404'),
},
],
},
] satisfies [RouteObject, ...RouteObject[]];
export const viewRoutes = [
{
path: '/all',
lazy: () => import('./pages/workspace/all'),
},
{
path: '/collection',
lazy: () => import('./pages/workspace/collection/index'),
},
{
path: '/collection/:collectionId',
lazy: () => import('./pages/workspace/collection/detail'),
},
{
path: '/tag',
lazy: () => import('./pages/workspace/tag/index'),
},
{
path: '/tag/:tagId',
lazy: () => import('./pages/workspace/tag/detail'),
},
{
path: '/trash',
lazy: () => import('./pages/workspace/trash'),
},
{
path: '/:pageId',
lazy: () => import('./pages/workspace/detail'),
},
{
path: '*',
lazy: () => import('./pages/404'),
},
] satisfies [RouteObject, ...RouteObject[]];
const createBrowserRouter = wrapCreateBrowserRouter(
reactRouterCreateBrowserRouter
);
export const router = (
window.SENTRY_RELEASE ? createBrowserRouter : reactRouterCreateBrowserRouter
)(topLevelRoutes, {
future: {
v7_normalizeFormMethod: true,
},
});

View File

@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": true,
"outDir": "lib",
"moduleResolution": "Bundler",
"types": ["affine__env"],
"rootDir": "./src"
},
"include": ["./src"],
"references": [{ "path": "../core" }]
}

View File

@ -49,6 +49,9 @@ const buildFlags = process.argv.includes('--static')
{
value: 'admin',
},
{
value: 'mobile',
},
],
initialValue: 'browser',
}),

View File

@ -23,6 +23,8 @@ module.exports.getCwdFromDistribution = function getCwdFromDistribution(
return join(projectRoot, 'packages/frontend/electron/renderer');
case 'admin':
return join(projectRoot, 'packages/frontend/admin');
case 'mobile':
return join(projectRoot, 'packages/frontend/mobile');
default: {
throw new Error('DISTRIBUTION must be one of browser, desktop');
}

View File

@ -1,5 +1,5 @@
export type BuildFlags = {
distribution: 'browser' | 'desktop' | 'admin';
distribution: 'browser' | 'desktop' | 'admin' | 'mobile';
mode: 'development' | 'production';
channel: 'stable' | 'beta' | 'canary' | 'internal';
coverage?: boolean;

View File

@ -637,6 +637,26 @@ __metadata:
languageName: unknown
linkType: soft
"@affine/mobile@workspace:packages/frontend/mobile":
version: 0.0.0-use.local
resolution: "@affine/mobile@workspace:packages/frontend/mobile"
dependencies:
"@affine/cli": "workspace:*"
"@affine/component": "workspace:*"
"@affine/core": "workspace:*"
"@affine/env": "workspace:*"
"@sentry/react": "npm:^8.0.0"
"@types/react": "npm:^18.2.75"
"@types/react-dom": "npm:^18.2.24"
core-js: "npm:^3.36.1"
cross-env: "npm:^7.0.3"
intl-segmenter-polyfill-rs: "npm:^0.1.7"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
typescript: "npm:^5.4.5"
languageName: unknown
linkType: soft
"@affine/monorepo@workspace:.":
version: 0.0.0-use.local
resolution: "@affine/monorepo@workspace:."