diff --git a/.vscode/launch.json b/.vscode/launch.json index af463338..ec0bc584 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -61,15 +61,17 @@ "sourceMaps": true }, { - "name": "Next.js: debug server-side", + "name": "Launch Website via Next.js (server-side)", "type": "node-terminal", "request": "launch", + "cwd": "${workspaceFolder}/apps/website", "command": "yarn dev" }, { - "name": "Next.js: debug client-side", + "name": "Attach to Website via Next.js (client-side)", "type": "chrome", "request": "launch", + "webRoot": "${workspaceFolder}/apps/website", "url": "http://localhost:3000", "skipFiles": [ ".next/**", @@ -81,15 +83,17 @@ }, // todo: consider https://code.visualstudio.com/docs/editor/debugging#_compound-launch-configurations instead // todo: consider client+prelaunch as full stack + // todo: consider workspaces https://code.visualstudio.com/docs/editor/multi-root-workspaces#_debugging { - "name": "Next.js: debug full stack", + "name": "Launch Website via Next.js (full stack)", "type": "node-terminal", "request": "launch", + "cwd": "${workspaceFolder}/apps/website", "command": "yarn dev -p 3000", "serverReadyAction": { "pattern": "started server on .+, url: (https?://.+)", "action": "startDebugging", - "name": "Next.js: debug client-side", + "name": "Attach to Website via Next.js (client-side)", "killOnServerStop": false }, "skipFiles": [ diff --git a/apps/web/package.json b/apps/web/package.json index 53991b83..dd2f98fa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "preview": "TAMAGUI_TARGET=web vite preview", "lint": "eslint src", "typecheck": "tsc", - "clean": "rimraf node_modules .turbo" + "clean": "rimraf node_modules dist .turbo" }, "dependencies": { "@status-im/components": "*", diff --git a/apps/website/package.json b/apps/website/package.json index c2010e8a..6c288006 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc", - "clean": "rimraf .next .tamagui .vercel/output node_modules", + "clean": "rimraf .next .tamagui .vercel/output node_modules .turbo", "preview": "next start --port 8151" }, "dependencies": { @@ -21,14 +21,17 @@ "@status-im/icons": "*", "@status-im/js": "*", "@tamagui/next-theme": "1.11.1", + "@vercel/og": "^0.5.4", "class-variance-authority": "^0.6.0", "@visx/visx": "^2.18.0", "d3-array": "^3.2.3", "d3-time-format": "^4.1.0", "next": "13.2.4", + "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-native-web": "^0.18.12", + "@tanstack/react-query": "^4.29.7", "ts-pattern": "^4.3.0" }, "devDependencies": { diff --git a/apps/website/src/components/error-page.tsx b/apps/website/src/components/error-page.tsx new file mode 100644 index 00000000..d7cf3a0e --- /dev/null +++ b/apps/website/src/components/error-page.tsx @@ -0,0 +1,51 @@ +import { Text } from '@status-im/components' + +import { ERROR_CODES } from '@/consts/error-codes' + +type Props = { + errorCode: (typeof ERROR_CODES)[keyof typeof ERROR_CODES] +} + +export const ErrorPage = (props: Props) => { + const { errorCode } = props + + switch (errorCode) { + // todo!: design review, not in designs + case ERROR_CODES.NOT_FOUND: + return ( +
+
+ + Page not found. + +
+ ) + + // todo!: design review, not in designs + case ERROR_CODES.UNVERIFIED_CONTENT: + return ( +
+
+ + Unverified content. + +
+ ) + + case ERROR_CODES.INTERNAL_SERVER_ERROR: + default: + return ( +
+
+
+ + {"Oh no, something's wrong!"} + + + Try reloading the page or come back later! + +
+
+ ) + } +} diff --git a/apps/website/src/components/head.tsx b/apps/website/src/components/head.tsx new file mode 100644 index 00000000..8bb42724 --- /dev/null +++ b/apps/website/src/components/head.tsx @@ -0,0 +1,53 @@ +import Head from 'next/head' + +type Props = { + index?: boolean + children?: React.ReactElement + imageUrl?: string +} + +function _Head(props: Props) { + const { index = true, children, imageUrl } = props + return ( + + Status + + + + {/* todo: app stores banners/redirects */} + {/* todo: eval following meta tags */} + + + + + + + {imageUrl && } + + + + {/* */} + + + + + + + + + {/* todo?: except communities; ask product */} + {!index && } + {/* todo?: entity QR */} + {/* todo?: fallback OG */} + {children} + + ) +} + +export { _Head as Head } diff --git a/apps/website/src/components/nav-menu.tsx b/apps/website/src/components/nav-menu.tsx index 328a58cd..8053df8a 100644 --- a/apps/website/src/components/nav-menu.tsx +++ b/apps/website/src/components/nav-menu.tsx @@ -85,7 +85,7 @@ export const NavMenu = () => {
diff --git a/apps/website/src/components/preview-page.tsx b/apps/website/src/components/preview-page.tsx new file mode 100644 index 00000000..da29a55d --- /dev/null +++ b/apps/website/src/components/preview-page.tsx @@ -0,0 +1,628 @@ +// todo?: rename to preview/onboarding/sharing/conversion-page/screen/invite.tsx + +import { useMemo } from 'react' + +import { neutral } from '@status-im/colors' +import { + Avatar, + Button, + ContextTag, + Counter, + Skeleton, + Tag, + Text, + ToastContainer, + useToast, +} from '@status-im/components' +import { + DownloadIcon, + InfoIcon, + MembersIcon, + QrCodeIcon, +} from '@status-im/icons' +import { useQuery } from '@tanstack/react-query' +import { useRouter } from 'next/router' + +import { Head } from '@/components/head' +import { ERROR_CODES } from '@/consts/error-codes' +import { useURLData } from '@/hooks/use-url-data' +import { getRequestClient } from '@/lib/request-client' + +import { ErrorPage } from './error-page' +import { QrDialog } from './qr-dialog' + +import type { ChannelInfo, CommunityInfo, UserInfo } from '@status-im/js' +import type { + decodeChannelURLData, + decodeCommunityURLData, + decodeUserURLData, +} from '@status-im/js/encode-url-data' +import type { CSSProperties } from 'react' + +type Type = 'community' | 'channel' | 'profile' + +type PreviewPageProps = { + type: Type + unverifiedEncodedData?: string | null + index?: boolean +} & ( + | { + type: 'community' + unverifiedDecodedData?: ReturnType | null + } + | { + type: 'channel' + unverifiedDecodedData?: ReturnType | null + channelUuid?: string + } + | { + type: 'profile' + unverifiedDecodedData?: ReturnType | null + } +) + +export type VerifiedData = + | { + type: 'community' + info: CommunityInfo + } + | { + type: 'channel' + info: + | ChannelInfo + // if from url + | (Omit & { + community: Pick + }) + } + | { + type: 'profile' + info: UserInfo + } + +const INSTRUCTIONS_HEADING: Record = { + community: 'How to join this community:', + channel: 'How to join this channel:', + profile: 'How to connect with this profile:', +} + +const JOIN_BUTTON_LABEL: Record = { + community: 'Join community in Status', + channel: 'View channel in Status', + profile: 'Open profile in Status', +} + +export function PreviewPage(props: PreviewPageProps) { + const { type, unverifiedDecodedData, unverifiedEncodedData } = props + + const { asPath } = useRouter() + + const toast = useToast() + + // todo: default og image, not dynamic + // const ogImageUrl = getOgImageUrl(props.unverifiedDecodedData) + // todo?: pass meta, info as component + // todo?: pass image, color as props + + const { + publicKey, + channelUuid: urlChannelUuid, + verifiedURLData, + errorCode: urlErrorCode, + } = useURLData(type, unverifiedDecodedData, unverifiedEncodedData) + + const { + data: verifiedWakuData, + isLoading, + status, + refetch, + } = useQuery({ + refetchOnWindowFocus: false, + queryKey: [type], + enabled: !!publicKey, + queryFn: async function ({ queryKey }): Promise { + const client = await getRequestClient() + + switch (queryKey[0]) { + case 'community': { + const info = await client.fetchCommunity(publicKey!) + + if (!info) { + return null + } + + return { type: 'community', info } + } + case 'channel': { + const channelUuid = + 'channelUuid' in props && props.channelUuid + ? props.channelUuid + : urlChannelUuid + + if (!channelUuid) { + return null + } + + const info = await client.fetchChannel(publicKey!, channelUuid) + + if (!info) { + return null + } + + return { type: 'channel', info } + } + case 'profile': { + const info = await client.fetchUser(publicKey!) + + if (!info) { + return null + } + + return { type: 'profile', info } + } + } + }, + onSettled: (data, error) => { + if (!data || error) { + // todo?: rephrase to "fetch latest" + toast.negative("Couldn't fetch information", { + action: 'Retry', + onAction: refetch, + }) + + return + } + + if (verifiedURLData) { + toast.custom('Information just updated', ) + } + }, + }) + + const loading = status === 'loading' || isLoading + const verifiedData: VerifiedData | undefined = + verifiedWakuData ?? verifiedURLData + + const { avatarURL, bannerURL } = useMemo(() => { + if (!verifiedData) { + return {} + } + + const avatarURL = getAvatarURL(verifiedData) + const bannerURL = getBannerURL(verifiedData) + + return { avatarURL, bannerURL } + }, [verifiedData]) + + if (urlErrorCode) { + return + } + + if (!loading && !verifiedData) { + return + } + + if ((loading && !verifiedData) || !verifiedData || !publicKey) { + return ( + <> +
+
+
+
+ {/* avatar */} +
+ +
+ {/* display name */} + + {/* description */} + + {/* description */} + +
+ +
+ {/* instructions */} + + {/* instructions */} + +
+ +
+ + {/* logo */} + + +
+
+
+
+ {/* banner */} + +
+
+ + ) + } + + return ( + <> + + <> + {/* todo: theme; based on user system settings */} + {/* todo: (system or both?) install banner */} +
+
+
+ {bannerURL && ( + + )} +
+ +
+
+ {/* HERO */} +
+
+ {verifiedData.type === 'community' && ( + + )} + {verifiedData.type === 'channel' && ( + + )} + {verifiedData.type === 'profile' && ( + + )} +
+ +

+ {verifiedData.type === 'channel' && '#'} + {verifiedData.info.displayName} +

+

+ {verifiedData.info.description} +

+ + {verifiedData.type === 'community' && ( + <> +
+ + + {formatNumber(verifiedData.info.membersCount)} + +
+ {verifiedData.info.tags?.length > 0 && ( +
+ {verifiedData.info.tags.map(tag => ( + + ))} +
+ )} + + )} + {verifiedData.type === 'channel' && ( +
+ + Channel in + + +
+ )} + {verifiedData.type === 'profile' && ( +

+ {verifiedData.info.emojiHash} +

+ )} +
+ + {/* INSTRUCTIONS */} +
+
+

+ {INSTRUCTIONS_HEADING[type]} +

+
    + + + + {/* todo?: delete step; merge with download */} + + Install Status + + + Complete the onboarding + + + + and voilá + +
+
+ +
+
+ + Have Status already? + + Scan the QR code with your device +
+ + + + +
+
+ + {/* FOOTER */} +
+ + Powered by + + +
+
+
+ +
+
+ {bannerURL && ( + + )} +
+
+
+ + + + + ) +} + +const formatNumber = (n: number) => { + const formatter = Intl.NumberFormat('en', { notation: 'compact' }) + return formatter.format(n) +} + +const getGradientStyles = (data: VerifiedData): CSSProperties => { + return { + // @ts-expect-error CSSProperties do not handle inline CSS variables + '--gradient-color': 'color' in data.info ? data.info.color : neutral[100], + } +} + +const getAvatarURL = (data: VerifiedData): string | undefined => { + let avatar: Uint8Array | undefined + switch (data.type) { + case 'community': + avatar = data.info.photo + + break + case 'profile': + avatar = data.info.photo + + break + } + + if (!avatar) { + return + } + + const url = URL.createObjectURL( + new Blob([avatar], { + type: 'image/jpeg', + }) + ) + + return url +} + +const getBannerURL = (data: VerifiedData): string | undefined => { + let banner: Uint8Array | undefined + switch (data.type) { + case 'community': + banner = data.info.banner + + break + case 'channel': + banner = + 'banner' in data.info.community ? data.info.community.banner : undefined + + break + } + + if (!banner) { + return + } + + const url = URL.createObjectURL( + new Blob([banner], { + type: 'image/jpeg', + }) + ) + + return url +} + +type ListItemProps = { + order: number + children: React.ReactNode +} + +const ListItem = (props: ListItemProps) => { + const { order, children } = props + + return ( +
  • + + {children} +
  • + ) +} + +const StatusLogo = () => ( + + + + + +) diff --git a/apps/website/src/components/qr-dialog.tsx b/apps/website/src/components/qr-dialog.tsx new file mode 100644 index 00000000..4f16e77c --- /dev/null +++ b/apps/website/src/components/qr-dialog.tsx @@ -0,0 +1,52 @@ +import { cloneElement, useState } from 'react' + +import * as Dialog from '@radix-ui/react-dialog' +import { Button, Text } from '@status-im/components' +import { CloseIcon } from '@status-im/icons' +import { QRCodeSVG } from 'qrcode.react' + +type Props = { + value: string + children: React.ReactElement +} + +export const QrDialog = (props: Props) => { + const { value, children } = props + + const [open, setOpen] = useState(false) + + return ( + + + {cloneElement(children, { onPress: () => setOpen(true) })} + + + + {/* */} + +
    +
    +
    +
    + +
    +
    + + Scan with Status Desktop or Status Mobile + +
    + +
    +
    +
    +
    +
    +
    + ) +} diff --git a/apps/website/src/consts/error-codes.ts b/apps/website/src/consts/error-codes.ts new file mode 100644 index 00000000..e0ab1eca --- /dev/null +++ b/apps/website/src/consts/error-codes.ts @@ -0,0 +1,6 @@ +export const ERROR_CODES = { + NOT_FOUND: 404, + INTERNAL_SERVER_ERROR: 500, + UNVERIFIED_CONTENT: 600, + INVALID_PUBLIC_KEY: 601, +} diff --git a/apps/website/src/hooks/use-url-data.ts b/apps/website/src/hooks/use-url-data.ts new file mode 100644 index 00000000..f8e7ae6e --- /dev/null +++ b/apps/website/src/hooks/use-url-data.ts @@ -0,0 +1,158 @@ +// todo?: rename to use-encoded-url-data, url-params + +import { useEffect, useState } from 'react' + +import { + deserializePublicKey, + indicesToTags, + publicKeyToColorHash, + publicKeyToEmojiHash, + verifyEncodedURLData, +} from '@status-im/js' +import { decodeVerificationURLHash } from '@status-im/js/encode-url-hash' + +import { ERROR_CODES } from '@/consts/error-codes' + +import type { VerifiedData } from '@/components/preview-page' +import type { ChannelInfo, CommunityInfo, UserInfo } from '@status-im/js' +import type { + decodeChannelURLData, + decodeCommunityURLData, + decodeUserURLData, +} from '@status-im/js/encode-url-data' + +export const useURLData = ( + type: 'community' | 'channel' | 'profile', + unverifiedDecodedData: + | ReturnType + | ReturnType + | ReturnType + | undefined + | null, + unverifiedEncodedData: string | undefined | null +) => { + const [publicKey, setPublicKey] = useState() + const [channelUuid, setChannelUuid] = useState() + const [info, setInfo] = useState() + const [error, setError] = useState() + + const compressPublicKey = type !== 'profile' + + useEffect(() => { + try { + // todo: set constrains on url data (e.g. max lenght, byte) + // todo: decoded url data againts schema (e.g. length) + // if (/* invalid schema */) { + // return + // } + + if (!unverifiedDecodedData || !unverifiedEncodedData) { + const hash = window.location.hash.replace('#', '') + + if (!hash) { + setError('NOT_FOUND') + + return + } + + try { + const publicKey = deserializePublicKey(hash, { + compress: compressPublicKey, + }) + + setPublicKey(publicKey) + } catch (error) { + console.error(error) + setError('INVALID_PUBLIC_KEY') + } + + return + } + + const hash = window.location.hash.replace('#', '') + const { signature, publicKey } = decodeVerificationURLHash(hash) + + if (!signature || !publicKey) { + setError('UNVERIFIED_CONTENT') + + return + } + + if (!verifyEncodedURLData(unverifiedEncodedData, hash)) { + setError('UNVERIFIED_CONTENT') + + return + } + + const deserializedPublicKey = deserializePublicKey(publicKey, { + compress: compressPublicKey, + }) + + const verifiedDecodedData = unverifiedDecodedData + switch (type) { + case 'community': { + const data = verifiedDecodedData as Required< + ReturnType + > + const info: CommunityInfo = { + displayName: data.displayName, + description: data.description, + color: data.color, + membersCount: data.membersCount, + tags: indicesToTags(data.tagIndices), + } + + setInfo({ type: 'community', info }) + + break + } + case 'channel': { + const data = verifiedDecodedData as Required< + ReturnType + > + const info: Omit & { + community: Pick + } = { + displayName: data.displayName, + description: data.description, + color: data.color, + emoji: data.emoji, + community: { displayName: data.community.displayName }, + } + + setInfo({ type: 'channel', info }) + setChannelUuid(data.uuid) + + break + } + case 'profile': { + const data = verifiedDecodedData as Required< + ReturnType + > + const info: UserInfo = { + displayName: data.displayName, + description: data.description, + colorHash: publicKeyToColorHash(deserializedPublicKey), + emojiHash: publicKeyToEmojiHash(deserializedPublicKey), + } + + setInfo({ type: 'profile', info }) + + break + } + } + + setPublicKey(deserializedPublicKey) + } catch (error) { + console.error(error) + setError('INTERNAL_SERVER_ERROR') + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return { + publicKey, + channelUuid, + verifiedURLData: info, + errorCode: error ? ERROR_CODES[error] : undefined, + } +} diff --git a/apps/website/src/lib/request-client.ts b/apps/website/src/lib/request-client.ts new file mode 100644 index 00000000..59e01652 --- /dev/null +++ b/apps/website/src/lib/request-client.ts @@ -0,0 +1,15 @@ +import { createRequestClient } from '@status-im/js' + +import type { RequestClient } from '@status-im/js' + +let client: RequestClient | undefined + +export async function getRequestClient(): Promise { + if (!client) { + client = await createRequestClient({ environment: 'production' }) + + return client + } + + return client +} diff --git a/apps/website/src/pages/404.tsx b/apps/website/src/pages/404.tsx new file mode 100644 index 00000000..7ead005f --- /dev/null +++ b/apps/website/src/pages/404.tsx @@ -0,0 +1,7 @@ +// todo: user per preiview page only +import { ErrorPage } from '@/components/error-page' +import { ERROR_CODES } from '@/consts/error-codes' + +export default function Custom404() { + return +} diff --git a/apps/website/src/pages/500.tsx b/apps/website/src/pages/500.tsx new file mode 100644 index 00000000..6209ee17 --- /dev/null +++ b/apps/website/src/pages/500.tsx @@ -0,0 +1,6 @@ +import { ErrorPage } from '@/components/error-page' +import { ERROR_CODES } from '@/consts/error-codes' + +export default function Custom500() { + return +} diff --git a/apps/website/src/pages/_app.tsx b/apps/website/src/pages/_app.tsx index 75174466..30fd7e0e 100644 --- a/apps/website/src/pages/_app.tsx +++ b/apps/website/src/pages/_app.tsx @@ -1,12 +1,15 @@ import '@/styles/global.css' import '@/styles/nav-nested-links.css' -import { Provider } from '@status-im/components' +import { ThemeProvider } from '@status-im/components' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Inter } from 'next/font/google' import type { Page, PageLayout } from 'next' import type { AppProps } from 'next/app' +const queryClient = new QueryClient() + const inter = Inter({ variable: '--font-inter', weight: ['400', '500', '600', '700'], @@ -22,7 +25,9 @@ export default function App({ Component, pageProps }: Props) { return (
    - {getLayout()} + + {getLayout()} +
    ) } diff --git a/apps/website/src/pages/c/[slug].tsx b/apps/website/src/pages/c/[slug].tsx new file mode 100644 index 00000000..4ff14bab --- /dev/null +++ b/apps/website/src/pages/c/[slug].tsx @@ -0,0 +1,22 @@ +import { decodeCommunityURLData } from '@status-im/js/encode-url-data' + +import { PreviewPage } from '@/components/preview-page' +import { createGetServerSideProps } from '@/server/ssr' + +import type { ServerSideProps } from '@/server/ssr' + +export const getServerSideProps = createGetServerSideProps( + decodeCommunityURLData +) + +export default function CommunityPreviewPage( + props: ServerSideProps> +) { + return ( + + ) +} diff --git a/apps/website/src/pages/c/index.tsx b/apps/website/src/pages/c/index.tsx new file mode 100644 index 00000000..4b54cfca --- /dev/null +++ b/apps/website/src/pages/c/index.tsx @@ -0,0 +1,5 @@ +import { PreviewPage } from '@/components/preview-page' + +export default function CommunityPreviewPage() { + return +} diff --git a/apps/website/src/pages/cc/[slug].tsx b/apps/website/src/pages/cc/[slug].tsx new file mode 100644 index 00000000..f6c816b8 --- /dev/null +++ b/apps/website/src/pages/cc/[slug].tsx @@ -0,0 +1,21 @@ +import { decodeChannelURLData } from '@status-im/js/encode-url-data' + +import { PreviewPage } from '@/components/preview-page' +import { createGetServerSideProps } from '@/server/ssr' + +import type { ServerSideProps } from '@/server/ssr' + +export const getServerSideProps = createGetServerSideProps(decodeChannelURLData) + +export default function ChannelPreviewPage( + props: ServerSideProps> +) { + return ( + + ) +} diff --git a/apps/website/src/pages/index.tsx b/apps/website/src/pages/index.tsx index d5bafa64..aaee1e1e 100644 --- a/apps/website/src/pages/index.tsx +++ b/apps/website/src/pages/index.tsx @@ -25,7 +25,7 @@ const HomePage: Page = () => {