[website] Add link previews (#407)
--------- Co-authored-by: Pavel Prichodko <14926950+prichodko@users.noreply.github.com>
This commit is contained in:
parent
ca6490783f
commit
eb5cbcdda3
|
@ -61,15 +61,17 @@
|
||||||
"sourceMaps": true
|
"sourceMaps": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Next.js: debug server-side",
|
"name": "Launch Website via Next.js (server-side)",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}/apps/website",
|
||||||
"command": "yarn dev"
|
"command": "yarn dev"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Next.js: debug client-side",
|
"name": "Attach to Website via Next.js (client-side)",
|
||||||
"type": "chrome",
|
"type": "chrome",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
|
"webRoot": "${workspaceFolder}/apps/website",
|
||||||
"url": "http://localhost:3000",
|
"url": "http://localhost:3000",
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
".next/**",
|
".next/**",
|
||||||
|
@ -81,15 +83,17 @@
|
||||||
},
|
},
|
||||||
// todo: consider https://code.visualstudio.com/docs/editor/debugging#_compound-launch-configurations instead
|
// todo: consider https://code.visualstudio.com/docs/editor/debugging#_compound-launch-configurations instead
|
||||||
// todo: consider client+prelaunch as full stack
|
// 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",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}/apps/website",
|
||||||
"command": "yarn dev -p 3000",
|
"command": "yarn dev -p 3000",
|
||||||
"serverReadyAction": {
|
"serverReadyAction": {
|
||||||
"pattern": "started server on .+, url: (https?://.+)",
|
"pattern": "started server on .+, url: (https?://.+)",
|
||||||
"action": "startDebugging",
|
"action": "startDebugging",
|
||||||
"name": "Next.js: debug client-side",
|
"name": "Attach to Website via Next.js (client-side)",
|
||||||
"killOnServerStop": false
|
"killOnServerStop": false
|
||||||
},
|
},
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
"preview": "TAMAGUI_TARGET=web vite preview",
|
"preview": "TAMAGUI_TARGET=web vite preview",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc",
|
"typecheck": "tsc",
|
||||||
"clean": "rimraf node_modules .turbo"
|
"clean": "rimraf node_modules dist .turbo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@status-im/components": "*",
|
"@status-im/components": "*",
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc",
|
"typecheck": "tsc",
|
||||||
"clean": "rimraf .next .tamagui .vercel/output node_modules",
|
"clean": "rimraf .next .tamagui .vercel/output node_modules .turbo",
|
||||||
"preview": "next start --port 8151"
|
"preview": "next start --port 8151"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -21,14 +21,17 @@
|
||||||
"@status-im/icons": "*",
|
"@status-im/icons": "*",
|
||||||
"@status-im/js": "*",
|
"@status-im/js": "*",
|
||||||
"@tamagui/next-theme": "1.11.1",
|
"@tamagui/next-theme": "1.11.1",
|
||||||
|
"@vercel/og": "^0.5.4",
|
||||||
"class-variance-authority": "^0.6.0",
|
"class-variance-authority": "^0.6.0",
|
||||||
"@visx/visx": "^2.18.0",
|
"@visx/visx": "^2.18.0",
|
||||||
"d3-array": "^3.2.3",
|
"d3-array": "^3.2.3",
|
||||||
"d3-time-format": "^4.1.0",
|
"d3-time-format": "^4.1.0",
|
||||||
"next": "13.2.4",
|
"next": "13.2.4",
|
||||||
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-native-web": "^0.18.12",
|
"react-native-web": "^0.18.12",
|
||||||
|
"@tanstack/react-query": "^4.29.7",
|
||||||
"ts-pattern": "^4.3.0"
|
"ts-pattern": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -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 (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-8 bg-white text-center">
|
||||||
|
<div className="h-[160px] w-[160px] rounded-full bg-[#b3b3b3]" />
|
||||||
|
<Text size={27} weight="semibold">
|
||||||
|
Page not found.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// todo!: design review, not in designs
|
||||||
|
case ERROR_CODES.UNVERIFIED_CONTENT:
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-8 bg-white text-center">
|
||||||
|
<div className="h-[160px] w-[160px] rounded-full bg-[#ffd455]" />
|
||||||
|
<Text size={27} weight="semibold">
|
||||||
|
Unverified content.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case ERROR_CODES.INTERNAL_SERVER_ERROR:
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-8 bg-white text-center">
|
||||||
|
<div className="h-[160px] w-[160px] rounded-full bg-[hsla(355,47%,50%,1)]" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Text size={27} weight="semibold">
|
||||||
|
{"Oh no, something's wrong!"}
|
||||||
|
</Text>
|
||||||
|
<Text size={19} weight="regular">
|
||||||
|
Try reloading the page or come back later!
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<Head>
|
||||||
|
<title>Status</title>
|
||||||
|
<meta name="description" content="Generated by create next app" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
{/* todo: app stores banners/redirects */}
|
||||||
|
{/* todo: eval following meta tags */}
|
||||||
|
<meta property="og:site_name" content="Join Status" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Status — A secure messaging app, crypto wallet, and Web3 browser"
|
||||||
|
/>
|
||||||
|
<meta property="og:title" content="Join [@|#]<name> in Status" />
|
||||||
|
<meta property="og:url" content="<url>" />
|
||||||
|
|
||||||
|
{imageUrl && <meta property="og:image" content={imageUrl} />}
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:site" content="@ethstatus" />
|
||||||
|
{/* <meta property="twitter:image" content="<logo>" /> */}
|
||||||
|
<meta property="twitter:image:alt" content="Status logo" />
|
||||||
|
<meta property="status-im:target" content="<displayName>" />
|
||||||
|
<meta property="al:ios:url" content="status-im:/<url>" />
|
||||||
|
<meta property="al:ios:app_store_id" content="1178893006" />
|
||||||
|
<meta property="al:ios:app_name" content="Status — Ethereum. Anywhere" />
|
||||||
|
<meta property="al:android:url" content="status-im:/<url>" />
|
||||||
|
<meta property="al:android:package" content="im.status.ethereum" />
|
||||||
|
<meta
|
||||||
|
property="al:android:app_name"
|
||||||
|
content="Status — Ethereum. Anywhere"
|
||||||
|
/>
|
||||||
|
{/* todo?: except communities; ask product */}
|
||||||
|
{!index && <meta name="robots" content="noindex" />}
|
||||||
|
{/* todo?: entity QR */}
|
||||||
|
{/* todo?: fallback OG */}
|
||||||
|
{children}
|
||||||
|
</Head>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { _Head as Head }
|
|
@ -85,7 +85,7 @@ export const NavMenu = () => {
|
||||||
</NavigationMenu.List>
|
</NavigationMenu.List>
|
||||||
|
|
||||||
<Button size={32} icon={<DownloadIcon size={20} />}>
|
<Button size={32} icon={<DownloadIcon size={20} />}>
|
||||||
Get Status
|
Sign up for early access
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -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 (
|
||||||
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
{cloneElement(children, { onPress: () => setOpen(true) })}
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="data-[state=open]:animate-overlayShow fixed inset-0 bg-[#000]/50 backdrop-blur" />
|
||||||
|
{/* <Dialog.Content className="inset-0 data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-[6px] bg-white p-[25px] shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none"> */}
|
||||||
|
<Dialog.Content className="data-[state=open]:animate-contentShow fixed inset-0 flex items-center justify-center focus:outline-none">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="bg-white-5 aspect-square w-full max-w-[335px] rounded-2xl p-3">
|
||||||
|
<div className="bg-white-100 rounded-xl p-3">
|
||||||
|
<QRCodeSVG value={value} height={286} width={286} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Text size={13} color="$white-70">
|
||||||
|
Scan with Status Desktop or Status Mobile
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute right-5 top-5">
|
||||||
|
<Button
|
||||||
|
icon={<CloseIcon size={20} />}
|
||||||
|
size={32}
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const ERROR_CODES = {
|
||||||
|
NOT_FOUND: 404,
|
||||||
|
INTERNAL_SERVER_ERROR: 500,
|
||||||
|
UNVERIFIED_CONTENT: 600,
|
||||||
|
INVALID_PUBLIC_KEY: 601,
|
||||||
|
}
|
|
@ -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<typeof decodeCommunityURLData>
|
||||||
|
| ReturnType<typeof decodeChannelURLData>
|
||||||
|
| ReturnType<typeof decodeUserURLData>
|
||||||
|
| undefined
|
||||||
|
| null,
|
||||||
|
unverifiedEncodedData: string | undefined | null
|
||||||
|
) => {
|
||||||
|
const [publicKey, setPublicKey] = useState<string>()
|
||||||
|
const [channelUuid, setChannelUuid] = useState<string>()
|
||||||
|
const [info, setInfo] = useState<VerifiedData>()
|
||||||
|
const [error, setError] = useState<keyof typeof ERROR_CODES>()
|
||||||
|
|
||||||
|
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<typeof decodeCommunityURLData>
|
||||||
|
>
|
||||||
|
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<typeof decodeChannelURLData>
|
||||||
|
>
|
||||||
|
const info: Omit<ChannelInfo, 'community'> & {
|
||||||
|
community: Pick<ChannelInfo['community'], 'displayName'>
|
||||||
|
} = {
|
||||||
|
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<typeof decodeUserURLData>
|
||||||
|
>
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<RequestClient> {
|
||||||
|
if (!client) {
|
||||||
|
client = await createRequestClient({ environment: 'production' })
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
|
@ -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 <ErrorPage errorCode={ERROR_CODES.NOT_FOUND} />
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { ErrorPage } from '@/components/error-page'
|
||||||
|
import { ERROR_CODES } from '@/consts/error-codes'
|
||||||
|
|
||||||
|
export default function Custom500() {
|
||||||
|
return <ErrorPage errorCode={ERROR_CODES.INTERNAL_SERVER_ERROR} />
|
||||||
|
}
|
|
@ -1,12 +1,15 @@
|
||||||
import '@/styles/global.css'
|
import '@/styles/global.css'
|
||||||
import '@/styles/nav-nested-links.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 { Inter } from 'next/font/google'
|
||||||
|
|
||||||
import type { Page, PageLayout } from 'next'
|
import type { Page, PageLayout } from 'next'
|
||||||
import type { AppProps } from 'next/app'
|
import type { AppProps } from 'next/app'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: '--font-inter',
|
variable: '--font-inter',
|
||||||
weight: ['400', '500', '600', '700'],
|
weight: ['400', '500', '600', '700'],
|
||||||
|
@ -22,7 +25,9 @@ export default function App({ Component, pageProps }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="app" className={inter.variable + ' font-sans'}>
|
<div id="app" className={inter.variable + ' font-sans'}>
|
||||||
<Provider>{getLayout(<Component {...pageProps} />)}</Provider>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>{getLayout(<Component {...pageProps} />)}</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<ReturnType<typeof decodeCommunityURLData>>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<PreviewPage
|
||||||
|
type="community"
|
||||||
|
unverifiedDecodedData={props.unverifiedDecodedData}
|
||||||
|
unverifiedEncodedData={props.uverifiedEncodedData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { PreviewPage } from '@/components/preview-page'
|
||||||
|
|
||||||
|
export default function CommunityPreviewPage() {
|
||||||
|
return <PreviewPage type="community" />
|
||||||
|
}
|
|
@ -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<ReturnType<typeof decodeChannelURLData>>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<PreviewPage
|
||||||
|
type="channel"
|
||||||
|
unverifiedDecodedData={props.unverifiedDecodedData}
|
||||||
|
unverifiedEncodedData={props.uverifiedEncodedData}
|
||||||
|
channelUuid={props.channelUuid}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ const HomePage: Page = () => {
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button size={40} icon={<DownloadIcon size={20} />}>
|
<Button size={40} icon={<DownloadIcon size={20} />}>
|
||||||
Get Status
|
Sign up for early access
|
||||||
</Button>
|
</Button>
|
||||||
<Button size={40} variant="outline" icon={<PlayIcon size={20} />}>
|
<Button size={40} variant="outline" icon={<PlayIcon size={20} />}>
|
||||||
Watch Video
|
Watch Video
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { decodeUserURLData } 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(decodeUserURLData)
|
||||||
|
|
||||||
|
export default function UserPreviewPage(
|
||||||
|
props: ServerSideProps<ReturnType<typeof decodeUserURLData>>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<PreviewPage
|
||||||
|
type="profile"
|
||||||
|
unverifiedDecodedData={props.unverifiedDecodedData}
|
||||||
|
unverifiedEncodedData={props.uverifiedEncodedData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { PreviewPage } from '@/components/preview-page'
|
||||||
|
|
||||||
|
export default function UserPreviewPage() {
|
||||||
|
return <PreviewPage type="profile" index={false} />
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
// see https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation
|
||||||
|
|
||||||
|
import { ImageResponse } from '@vercel/og'
|
||||||
|
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
runtime: 'edge',
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo?: set cache header too
|
||||||
|
export function createHandler(
|
||||||
|
createComponent: (url: URL) => React.ReactElement
|
||||||
|
) {
|
||||||
|
const handler = async (req: NextRequest) => {
|
||||||
|
try {
|
||||||
|
const component = createComponent(new URL(req.url))
|
||||||
|
|
||||||
|
return new ImageResponse(component, {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
|
||||||
|
return new Response(`Failed to generate the image`, {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type {
|
||||||
|
decodeChannelURLData,
|
||||||
|
decodeCommunityURLData,
|
||||||
|
decodeUserURLData,
|
||||||
|
} from '@status-im/js/encode-url-data'
|
||||||
|
import type { GetServerSideProps } from 'next'
|
||||||
|
import type { ParsedUrlQuery } from 'querystring'
|
||||||
|
|
||||||
|
type DecodeType =
|
||||||
|
| typeof decodeCommunityURLData
|
||||||
|
| typeof decodeChannelURLData
|
||||||
|
| typeof decodeUserURLData
|
||||||
|
|
||||||
|
export type ServerSideProps<T = ReturnType<DecodeType>> = {
|
||||||
|
/**
|
||||||
|
* For verifying on client without decoding or re-encoding.
|
||||||
|
*
|
||||||
|
* Verification in general is done on encoded data, so it is not
|
||||||
|
* decoded, decompressed and deserialized unnecessarily if not to be
|
||||||
|
* displayed or othwerwise needed.
|
||||||
|
*/
|
||||||
|
uverifiedEncodedData: string | null
|
||||||
|
/**
|
||||||
|
* For instaneous preview even if the data is not verified yet.
|
||||||
|
*/
|
||||||
|
unverifiedDecodedData: T | null
|
||||||
|
channelUuid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query = ParsedUrlQuery & {
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGetServerSideProps(decodeURLData: DecodeType) {
|
||||||
|
const getServerSideProps: GetServerSideProps<
|
||||||
|
ServerSideProps,
|
||||||
|
Query
|
||||||
|
> = async context => {
|
||||||
|
try {
|
||||||
|
const { params, res } = context
|
||||||
|
|
||||||
|
const channelUuid = params!.slug.match(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
)
|
||||||
|
|
||||||
|
if (channelUuid) {
|
||||||
|
const props: ServerSideProps = {
|
||||||
|
channelUuid: channelUuid[0],
|
||||||
|
uverifiedEncodedData: null,
|
||||||
|
unverifiedDecodedData: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { props }
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedData = params!.slug
|
||||||
|
|
||||||
|
if (!encodedData) {
|
||||||
|
const props: ServerSideProps = {
|
||||||
|
uverifiedEncodedData: null,
|
||||||
|
unverifiedDecodedData: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { props }
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedData = decodeURLData(encodedData)
|
||||||
|
const props: ServerSideProps = {
|
||||||
|
uverifiedEncodedData: encodedData,
|
||||||
|
unverifiedDecodedData: decodedData || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixme: set Cache-Control
|
||||||
|
res.setHeader(
|
||||||
|
'Cache-Control',
|
||||||
|
'public, max-age=0, s-maxage=180, stale-while-revalidate=239'
|
||||||
|
// 'public, max-age=0, s-maxage=1, stale-while-revalidate=900',
|
||||||
|
// 'public, max-age=0, s-maxage=600, stale-while-revalidate=900',
|
||||||
|
// 'public, s-maxage=10, stale-while-revalidate=59',
|
||||||
|
// 'public, max-age=31536000, immutable',
|
||||||
|
)
|
||||||
|
|
||||||
|
return { props }
|
||||||
|
} catch (error) {
|
||||||
|
return { notFound: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getServerSideProps
|
||||||
|
}
|
|
@ -1,36 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#__next {
|
|
||||||
height: 100%;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
user-select: none;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
*::selection {
|
|
||||||
color: #fff;
|
|
||||||
background: hsla(229, 71%, 57%, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation for skeleton placeholder */
|
|
||||||
@keyframes gradient {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,7 +4,19 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply !bg-neutral-100;
|
@apply bg-neutral-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,6 +56,7 @@ body,
|
||||||
#__next,
|
#__next,
|
||||||
#app {
|
#app {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
height: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
/* overflow: hidden; */
|
/* overflow: hidden; */
|
||||||
overscroll-behavior-y: none; /* not working on Safari */
|
overscroll-behavior-y: none; /* not working on Safari */
|
||||||
|
|
|
@ -10,12 +10,13 @@
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"react-native": ["react-native-web"],
|
"react-native": ["react-native-web"],
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
// "@status-im/*": ["./node_modules/@status-im/*"]
|
"@status-im/js/encode-url-data": [
|
||||||
// "@status-im/js": ["./node_modules/@status-im/js/packages/status-js"],
|
"../../packages/status-js/src/utils/encode-url-data"
|
||||||
// "@status-im/components": [
|
],
|
||||||
// "./node_modules/@status-im/components/packages/components"
|
"@status-im/js/encode-url-hash": [
|
||||||
// ]
|
"../../packages/status-js/src/utils/encode-url-hash"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
|
|
@ -7,7 +7,8 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"clean": "rimraf node_modules .next"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@status-im/react": "^0.1.1",
|
"@status-im/react": "^0.1.1",
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"start": "vite preview"
|
"start": "vite preview",
|
||||||
|
"clean": "rimraf node_modules dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@status-im/react": "^0.1.1",
|
"@status-im/react": "^0.1.1",
|
||||||
|
|
|
@ -323,11 +323,12 @@ export const community: StoryObj<CommunityAvatarProps> = {
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelArgs = Pick<ChannelAvatarProps, 'type' | 'emoji'>
|
type ChannelArgs = Pick<ChannelAvatarProps, 'type' | 'emoji' | 'name'>
|
||||||
|
|
||||||
export const Channel: StoryObj<ChannelArgs> = {
|
export const Channel: StoryObj<ChannelArgs> = {
|
||||||
args: {
|
args: {
|
||||||
type: 'channel',
|
type: 'channel',
|
||||||
|
name: 'random',
|
||||||
emoji: '🍑',
|
emoji: '🍑',
|
||||||
} as ChannelArgs,
|
} as ChannelArgs,
|
||||||
parameters: {
|
parameters: {
|
||||||
|
|
|
@ -42,7 +42,8 @@ type WalletAvatarProps = {
|
||||||
type ChannelAvatarProps = {
|
type ChannelAvatarProps = {
|
||||||
type: 'channel'
|
type: 'channel'
|
||||||
size: 80 | 32 | 28 | 24 | 20
|
size: 80 | 32 | 28 | 24 | 20
|
||||||
emoji: string
|
name: string
|
||||||
|
emoji?: string
|
||||||
backgroundColor?: ColorTokens
|
backgroundColor?: ColorTokens
|
||||||
background?: ColorTokens
|
background?: ColorTokens
|
||||||
lock?: 'locked' | 'unlocked'
|
lock?: 'locked' | 'unlocked'
|
||||||
|
@ -268,7 +269,15 @@ const Avatar = (props: AvatarProps) => {
|
||||||
</Fallback>
|
</Fallback>
|
||||||
)
|
)
|
||||||
case 'channel':
|
case 'channel':
|
||||||
return <Text size={channelEmojiSizes[props.size]}>{props.emoji}</Text>
|
if (props.emoji) {
|
||||||
|
return <Text size={channelEmojiSizes[props.size]}>{props.emoji}</Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size={textSizes[props.size]}>
|
||||||
|
{props.name.slice(0, 1).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
case 'icon':
|
case 'icon':
|
||||||
return cloneElement(props.icon, { color: props.color ?? '$white-100' })
|
return cloneElement(props.icon, { color: props.color ?? '$white-100' })
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -63,7 +63,7 @@ const Button = (props: Props, ref: Ref<HTMLButtonElement>) => {
|
||||||
size={size}
|
size={size}
|
||||||
iconOnly={iconOnly}
|
iconOnly={iconOnly}
|
||||||
>
|
>
|
||||||
{icon ? cloneElement(icon, { color: textColor }) : null}
|
{icon ? cloneElement(icon, { color: '$neutral-40' }) : null}
|
||||||
<Text weight="medium" color={textColor} size={textSize}>
|
<Text weight="medium" color={textColor} size={textSize}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -95,7 +95,13 @@ const Channel = (props: Props) => {
|
||||||
state={active ? 'active' : selected ? 'selected' : undefined}
|
state={active ? 'active' : selected ? 'selected' : undefined}
|
||||||
>
|
>
|
||||||
<Stack flexDirection="row" gap={8} alignItems="center">
|
<Stack flexDirection="row" gap={8} alignItems="center">
|
||||||
<Avatar type="channel" emoji={emoji} size={24} lock={lock} />
|
<Avatar
|
||||||
|
type="channel"
|
||||||
|
name={children}
|
||||||
|
emoji={emoji}
|
||||||
|
size={24}
|
||||||
|
lock={lock}
|
||||||
|
/>
|
||||||
<Text size={15} weight="medium" color={textColor}>
|
<Text size={15} weight="medium" color={textColor}>
|
||||||
# {children}
|
# {children}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -22,7 +22,7 @@ type Props = {
|
||||||
icon: React.ReactElement
|
icon: React.ReactElement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| { type: 'community'; community: { name: string; src: string } }
|
| { type: 'community'; community: { name: string; src?: string } }
|
||||||
| {
|
| {
|
||||||
type: 'channel'
|
type: 'channel'
|
||||||
channel: { communityName: string; src: string; name: string }
|
channel: { communityName: string; src: string; name: string }
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { styled } from '@tamagui/core'
|
import { Stack, styled } from '@tamagui/core'
|
||||||
import { View } from 'react-native'
|
|
||||||
|
|
||||||
import { Text } from '../text'
|
import { Text } from '../text'
|
||||||
|
|
||||||
|
@ -29,7 +28,7 @@ const Counter = (props: Props) => {
|
||||||
export { Counter }
|
export { Counter }
|
||||||
export type { Props as CounterProps }
|
export type { Props as CounterProps }
|
||||||
|
|
||||||
const Base = styled(View, {
|
const Base = styled(Stack, {
|
||||||
padding: 2,
|
padding: 2,
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
@ -37,7 +36,7 @@ const Base = styled(View, {
|
||||||
flexBasis: 'fit-content',
|
flexBasis: 'fit-content',
|
||||||
})
|
})
|
||||||
|
|
||||||
const Content = styled(View, {
|
const Content = styled(Stack, {
|
||||||
backgroundColor: '$primary-50',
|
backgroundColor: '$primary-50',
|
||||||
paddingHorizontal: 3,
|
paddingHorizontal: 3,
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
|
|
|
@ -3,6 +3,8 @@ export * from './avatar'
|
||||||
export * from './button'
|
export * from './button'
|
||||||
export * from './community'
|
export * from './community'
|
||||||
export * from './composer'
|
export * from './composer'
|
||||||
|
export * from './context-tag'
|
||||||
|
export * from './counter'
|
||||||
export * from './dividers'
|
export * from './dividers'
|
||||||
export * from './dynamic-button'
|
export * from './dynamic-button'
|
||||||
export * from './gap-messages'
|
export * from './gap-messages'
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export { useAppDispatch, useAppState } from './app-context'
|
export { useAppDispatch, useAppState } from './app-context'
|
||||||
export { useChatDispatch, useChatState } from './chat-context'
|
export { useChatDispatch, useChatState } from './chat-context'
|
||||||
export { Provider } from './provider'
|
export { Provider } from './provider'
|
||||||
|
export { ThemeProvider } from './theme-context'
|
||||||
|
|
|
@ -69,6 +69,7 @@ const Base = styled(Stack, {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '$neutral-20',
|
borderColor: '$neutral-20',
|
||||||
borderRadius: '$full',
|
borderRadius: '$full',
|
||||||
|
backgroundColor: '$white-100',
|
||||||
|
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|
|
@ -3,9 +3,26 @@
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/types/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/types/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./encode-url-data": {
|
||||||
|
"types": "./dist/types/utils/encode-url-data.d.ts",
|
||||||
|
"browser": null,
|
||||||
|
"import": "./dist/encode-url-data.js"
|
||||||
|
},
|
||||||
|
"./encode-url-hash": {
|
||||||
|
"types": "./dist/types/utils/encode-url-hash.d.ts",
|
||||||
|
"browser": null,
|
||||||
|
"import": "./dist/encode-url-hash.js"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "https://github.com/status-im/status-web.git",
|
"url": "https://github.com/status-im/status-web.git",
|
||||||
"directory": "packages/status-js",
|
"directory": "packages/status-js",
|
||||||
|
@ -50,8 +67,5 @@
|
||||||
],
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
|
||||||
"browser": {
|
|
||||||
"./src/utils/encode-url-data": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { getPublicKey, utils } from 'ethereum-cryptography/secp256k1'
|
import { getPublicKey, utils } from 'ethereum-cryptography/secp256k1'
|
||||||
import { bytesToHex, hexToBytes } from 'ethereum-cryptography/utils'
|
import { bytesToHex, hexToBytes } from 'ethereum-cryptography/utils'
|
||||||
|
|
||||||
import { compressPublicKey } from '../utils/compress-public-key'
|
import { createUserURLWithChatKey } from '../utils/create-url'
|
||||||
import { createUserURLWithPublicKey } from '../utils/create-url'
|
|
||||||
import { generateUsername } from '../utils/generate-username'
|
import { generateUsername } from '../utils/generate-username'
|
||||||
|
import { serializePublicKey } from '../utils/serialize-public-key'
|
||||||
import { signData, verifySignedData } from '../utils/sign-data'
|
import { signData, verifySignedData } from '../utils/sign-data'
|
||||||
|
|
||||||
|
import type { ContactCodeAdvertisement } from '../protos/push-notifications_pb'
|
||||||
import type { Client } from './client'
|
import type { Client } from './client'
|
||||||
import type { Community } from './community/community'
|
import type { Community } from './community/community'
|
||||||
|
|
||||||
|
@ -18,7 +19,9 @@ export class Account {
|
||||||
publicKey: string
|
publicKey: string
|
||||||
chatKey: string
|
chatKey: string
|
||||||
username: string
|
username: string
|
||||||
|
ensName?: string
|
||||||
membership: MembershipStatus
|
membership: MembershipStatus
|
||||||
|
description?: ContactCodeAdvertisement
|
||||||
|
|
||||||
constructor(client: Client, initialAccount?: Account) {
|
constructor(client: Client, initialAccount?: Account) {
|
||||||
this.#client = client
|
this.#client = client
|
||||||
|
@ -30,13 +33,13 @@ export class Account {
|
||||||
|
|
||||||
this.privateKey = bytesToHex(privateKey)
|
this.privateKey = bytesToHex(privateKey)
|
||||||
this.publicKey = bytesToHex(publicKey)
|
this.publicKey = bytesToHex(publicKey)
|
||||||
this.chatKey = '0x' + compressPublicKey(this.publicKey)
|
this.chatKey = serializePublicKey('0x' + this.publicKey)
|
||||||
this.username = generateUsername('0x' + this.publicKey)
|
this.username = generateUsername('0x' + this.publicKey)
|
||||||
this.membership = initialAccount ? initialAccount.membership : 'none'
|
this.membership = initialAccount ? initialAccount.membership : 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
public get link(): URL {
|
public get link(): URL {
|
||||||
return createUserURLWithPublicKey(this.chatKey)
|
return createUserURLWithChatKey(this.chatKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
async sign(payload: Uint8Array | string) {
|
async sign(payload: Uint8Array | string) {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
} from '../protos/chat-message_pb'
|
} from '../protos/chat-message_pb'
|
||||||
import { EmojiReaction, EmojiReaction_Type } from '../protos/emoji-reaction_pb'
|
import { EmojiReaction, EmojiReaction_Type } from '../protos/emoji-reaction_pb'
|
||||||
import { MessageType } from '../protos/enums_pb'
|
import { MessageType } from '../protos/enums_pb'
|
||||||
import { createChannelURLWithPublicKey } from '../utils/create-url'
|
import { createChannelURLWithChatKey } from '../utils/create-url'
|
||||||
import { generateKeyFromPassword } from '../utils/generate-key-from-password'
|
import { generateKeyFromPassword } from '../utils/generate-key-from-password'
|
||||||
import { getNextClock } from '../utils/get-next-clock'
|
import { getNextClock } from '../utils/get-next-clock'
|
||||||
import { idToContentTopic } from '../utils/id-to-content-topic'
|
import { idToContentTopic } from '../utils/id-to-content-topic'
|
||||||
|
@ -155,10 +155,7 @@ export class Chat {
|
||||||
}
|
}
|
||||||
|
|
||||||
public get link(): URL {
|
public get link(): URL {
|
||||||
return createChannelURLWithPublicKey(
|
return createChannelURLWithChatKey(this.uuid, this.client.community.chatKey)
|
||||||
this.uuid,
|
|
||||||
this.client.community.publicKey
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onChange = (callback: (description: CommunityChat) => void) => {
|
public onChange = (callback: (description: CommunityChat) => void) => {
|
||||||
|
|
|
@ -7,10 +7,11 @@ import { ApplicationMetadataMessage_Type } from '../../protos/application-metada
|
||||||
import { CommunityRequestToJoin } from '../../protos/communities_pb'
|
import { CommunityRequestToJoin } from '../../protos/communities_pb'
|
||||||
import { MessageType } from '../../protos/enums_pb'
|
import { MessageType } from '../../protos/enums_pb'
|
||||||
import { compressPublicKey } from '../../utils/compress-public-key'
|
import { compressPublicKey } from '../../utils/compress-public-key'
|
||||||
import { createCommunityURLWithPublicKey } from '../../utils/create-url'
|
import { createCommunityURLWithChatKey } from '../../utils/create-url'
|
||||||
import { generateKeyFromPassword } from '../../utils/generate-key-from-password'
|
import { generateKeyFromPassword } from '../../utils/generate-key-from-password'
|
||||||
import { getNextClock } from '../../utils/get-next-clock'
|
import { getNextClock } from '../../utils/get-next-clock'
|
||||||
import { idToContentTopic } from '../../utils/id-to-content-topic'
|
import { idToContentTopic } from '../../utils/id-to-content-topic'
|
||||||
|
import { serializePublicKey } from '../../utils/serialize-public-key'
|
||||||
import { Chat } from '../chat'
|
import { Chat } from '../chat'
|
||||||
import { Member } from '../member'
|
import { Member } from '../member'
|
||||||
|
|
||||||
|
@ -27,6 +28,7 @@ export class Community {
|
||||||
|
|
||||||
/** Compressed. */
|
/** Compressed. */
|
||||||
public publicKey: string
|
public publicKey: string
|
||||||
|
public chatKey: string
|
||||||
public id: string
|
public id: string
|
||||||
private contentTopic!: string
|
private contentTopic!: string
|
||||||
private symmetricKey!: Uint8Array
|
private symmetricKey!: Uint8Array
|
||||||
|
@ -40,6 +42,7 @@ export class Community {
|
||||||
this.client = client
|
this.client = client
|
||||||
|
|
||||||
this.publicKey = publicKey
|
this.publicKey = publicKey
|
||||||
|
this.chatKey = serializePublicKey(this.publicKey)
|
||||||
this.id = publicKey.replace(/^0[xX]/, '')
|
this.id = publicKey.replace(/^0[xX]/, '')
|
||||||
|
|
||||||
this.#clock = BigInt(Date.now())
|
this.#clock = BigInt(Date.now())
|
||||||
|
@ -85,7 +88,7 @@ export class Community {
|
||||||
}
|
}
|
||||||
|
|
||||||
public get link(): URL {
|
public get link(): URL {
|
||||||
return createCommunityURLWithPublicKey(this.publicKey)
|
return createCommunityURLWithChatKey(this.chatKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
public fetch = async () => {
|
public fetch = async () => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { compressPublicKey } from '../utils/compress-public-key'
|
import { createUserURLWithChatKey } from '../utils/create-url'
|
||||||
import { createUserURLWithPublicKey } from '../utils/create-url'
|
|
||||||
import { generateUsername } from '../utils/generate-username'
|
import { generateUsername } from '../utils/generate-username'
|
||||||
import { publicKeyToColorHash } from '../utils/public-key-to-color-hash'
|
import { publicKeyToColorHash } from '../utils/public-key-to-color-hash'
|
||||||
|
import { serializePublicKey } from '../utils/serialize-public-key'
|
||||||
|
|
||||||
import type { ColorHash } from '../utils/public-key-to-color-hash'
|
import type { ColorHash } from '../utils/public-key-to-color-hash'
|
||||||
|
|
||||||
|
@ -13,12 +13,12 @@ export class Member {
|
||||||
|
|
||||||
constructor(publicKey: string) {
|
constructor(publicKey: string) {
|
||||||
this.publicKey = publicKey
|
this.publicKey = publicKey
|
||||||
this.chatKey = '0x' + compressPublicKey(publicKey)
|
this.chatKey = serializePublicKey(this.publicKey)
|
||||||
this.username = generateUsername(publicKey)
|
this.username = generateUsername(publicKey)
|
||||||
this.colorHash = publicKeyToColorHash(publicKey)
|
this.colorHash = publicKeyToColorHash(publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
public get link(): URL {
|
public get link(): URL {
|
||||||
return createUserURLWithPublicKey(this.chatKey)
|
return createUserURLWithChatKey(this.chatKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,12 +13,13 @@ export type { Reaction, Reactions } from './client/community/get-reactions'
|
||||||
export type { Member } from './client/member'
|
export type { Member } from './client/member'
|
||||||
export { peers } from './consts/peers'
|
export { peers } from './consts/peers'
|
||||||
export { EthereumClient } from './ethereum-client/ethereum-client'
|
export { EthereumClient } from './ethereum-client/ethereum-client'
|
||||||
|
export { indicesToTags } from './request-client/indices-to-tags'
|
||||||
export type { ChannelInfo } from './request-client/map-channel'
|
export type { ChannelInfo } from './request-client/map-channel'
|
||||||
export type { CommunityInfo } from './request-client/map-community'
|
export type { CommunityInfo } from './request-client/map-community'
|
||||||
export type { UserInfo } from './request-client/map-user'
|
export type { UserInfo } from './request-client/map-user'
|
||||||
export { RequestClient } from './request-client/request-client'
|
export { RequestClient } from './request-client/request-client'
|
||||||
export { createRequestClient } from './request-client/request-client'
|
export { createRequestClient } from './request-client/request-client'
|
||||||
export { deserializePublicKey } from './utils/deserialize-public-key'
|
export { deserializePublicKey } from './utils/deserialize-public-key'
|
||||||
export * from './utils/encode-url-data'
|
export { publicKeyToColorHash } from './utils/public-key-to-color-hash'
|
||||||
export { publicKeyToEmojiHash } from './utils/public-key-to-emoji-hash'
|
export { publicKeyToEmojiHash } from './utils/public-key-to-emoji-hash'
|
||||||
export { verifyEncodedURLData } from './utils/sign-url-data'
|
export { verifyEncodedURLData } from './utils/sign-url-data'
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
import "chat-identity.proto";
|
import "chat-identity.proto";
|
||||||
import "url-data.proto";
|
import "url.proto";
|
||||||
|
|
||||||
message Grant {
|
message Grant {
|
||||||
bytes community_id = 1;
|
bytes community_id = 1;
|
||||||
|
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
} from '@bufbuild/protobuf'
|
} from '@bufbuild/protobuf'
|
||||||
import { Message, proto3, protoInt64 } from '@bufbuild/protobuf'
|
import { Message, proto3, protoInt64 } from '@bufbuild/protobuf'
|
||||||
import { ChatIdentity } from './chat-identity_pb.js'
|
import { ChatIdentity } from './chat-identity_pb.js'
|
||||||
import { URLParams } from './url-data_pb.js'
|
import { URLParams } from './url_pb.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message Grant
|
* @generated from message Grant
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
import "chat-identity.proto";
|
import "chat-identity.proto";
|
||||||
import "url-data.proto";
|
import "url.proto";
|
||||||
|
|
||||||
message PushNotificationRegistration {
|
message PushNotificationRegistration {
|
||||||
enum TokenType {
|
enum TokenType {
|
||||||
|
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
} from '@bufbuild/protobuf'
|
} from '@bufbuild/protobuf'
|
||||||
import { Message, proto3, protoInt64 } from '@bufbuild/protobuf'
|
import { Message, proto3, protoInt64 } from '@bufbuild/protobuf'
|
||||||
import { ChatIdentity } from './chat-identity_pb.js'
|
import { ChatIdentity } from './chat-identity_pb.js'
|
||||||
import { URLParams } from './url-data_pb.js'
|
import { URLParams } from './url_pb.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message PushNotificationRegistration
|
* @generated from message PushNotificationRegistration
|
||||||
|
|
|
@ -23,11 +23,21 @@ message User {
|
||||||
string color = 3;
|
string color = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Verification {
|
||||||
|
string signature = 1;
|
||||||
|
string public_key = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message URLData {
|
message URLData {
|
||||||
// Community, Channel, or User
|
// Community, Channel, or User
|
||||||
bytes content = 1;
|
bytes content = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message URLHash {
|
||||||
|
// Verification
|
||||||
|
bytes content = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message URLParams {
|
message URLParams {
|
||||||
string encoded_url_data = 1;
|
string encoded_url_data = 1;
|
||||||
// Signature of encoded URL data
|
// Signature of encoded URL data
|
|
@ -1,5 +1,5 @@
|
||||||
// @generated by protoc-gen-es v1.0.0 with parameter "target=ts"
|
// @generated by protoc-gen-es v1.0.0 with parameter "target=ts"
|
||||||
// @generated from file url-data.proto (syntax proto3)
|
// @generated from file url.proto (syntax proto3)
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
|
@ -267,6 +267,61 @@ export class User extends Message<User> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message Verification
|
||||||
|
*/
|
||||||
|
export class Verification extends Message<Verification> {
|
||||||
|
/**
|
||||||
|
* @generated from field: string signature = 1;
|
||||||
|
*/
|
||||||
|
signature = ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string public_key = 2;
|
||||||
|
*/
|
||||||
|
publicKey = ''
|
||||||
|
|
||||||
|
constructor(data?: PartialMessage<Verification>) {
|
||||||
|
super()
|
||||||
|
proto3.util.initPartial(data, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly runtime = proto3
|
||||||
|
static readonly typeName = 'Verification'
|
||||||
|
static readonly fields: FieldList = proto3.util.newFieldList(() => [
|
||||||
|
{ no: 1, name: 'signature', kind: 'scalar', T: 9 /* ScalarType.STRING */ },
|
||||||
|
{ no: 2, name: 'public_key', kind: 'scalar', T: 9 /* ScalarType.STRING */ },
|
||||||
|
])
|
||||||
|
|
||||||
|
static fromBinary(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options?: Partial<BinaryReadOptions>
|
||||||
|
): Verification {
|
||||||
|
return new Verification().fromBinary(bytes, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(
|
||||||
|
jsonValue: JsonValue,
|
||||||
|
options?: Partial<JsonReadOptions>
|
||||||
|
): Verification {
|
||||||
|
return new Verification().fromJson(jsonValue, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJsonString(
|
||||||
|
jsonString: string,
|
||||||
|
options?: Partial<JsonReadOptions>
|
||||||
|
): Verification {
|
||||||
|
return new Verification().fromJsonString(jsonString, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
static equals(
|
||||||
|
a: Verification | PlainMessage<Verification> | undefined,
|
||||||
|
b: Verification | PlainMessage<Verification> | undefined
|
||||||
|
): boolean {
|
||||||
|
return proto3.util.equals(Verification, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message URLData
|
* @generated from message URLData
|
||||||
*/
|
*/
|
||||||
|
@ -318,6 +373,57 @@ export class URLData extends Message<URLData> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message URLHash
|
||||||
|
*/
|
||||||
|
export class URLHash extends Message<URLHash> {
|
||||||
|
/**
|
||||||
|
* Verification
|
||||||
|
*
|
||||||
|
* @generated from field: bytes content = 1;
|
||||||
|
*/
|
||||||
|
content = new Uint8Array(0)
|
||||||
|
|
||||||
|
constructor(data?: PartialMessage<URLHash>) {
|
||||||
|
super()
|
||||||
|
proto3.util.initPartial(data, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly runtime = proto3
|
||||||
|
static readonly typeName = 'URLHash'
|
||||||
|
static readonly fields: FieldList = proto3.util.newFieldList(() => [
|
||||||
|
{ no: 1, name: 'content', kind: 'scalar', T: 12 /* ScalarType.BYTES */ },
|
||||||
|
])
|
||||||
|
|
||||||
|
static fromBinary(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options?: Partial<BinaryReadOptions>
|
||||||
|
): URLHash {
|
||||||
|
return new URLHash().fromBinary(bytes, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(
|
||||||
|
jsonValue: JsonValue,
|
||||||
|
options?: Partial<JsonReadOptions>
|
||||||
|
): URLHash {
|
||||||
|
return new URLHash().fromJson(jsonValue, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJsonString(
|
||||||
|
jsonString: string,
|
||||||
|
options?: Partial<JsonReadOptions>
|
||||||
|
): URLHash {
|
||||||
|
return new URLHash().fromJsonString(jsonString, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
static equals(
|
||||||
|
a: URLHash | PlainMessage<URLHash> | undefined,
|
||||||
|
b: URLHash | PlainMessage<URLHash> | undefined
|
||||||
|
): boolean {
|
||||||
|
return proto3.util.equals(URLHash, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message URLParams
|
* @generated from message URLParams
|
||||||
*/
|
*/
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { expect, test } from 'vitest'
|
||||||
|
|
||||||
|
import { indicesToTags } from './indices-to-tags'
|
||||||
|
|
||||||
|
test('should return tags for indices', () => {
|
||||||
|
expect(indicesToTags([1, 2, 3])).toEqual([
|
||||||
|
{
|
||||||
|
emoji: '🎨',
|
||||||
|
text: 'Art',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emoji: '🔗',
|
||||||
|
text: 'Blockchain',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emoji: '📚',
|
||||||
|
text: 'Books & blogs',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should not return tags for no indices', () => {
|
||||||
|
expect(indicesToTags([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should not return tags for unknown indices', () => {
|
||||||
|
expect(indicesToTags([-1, 53])).toEqual([])
|
||||||
|
})
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { tags as tagsMap } from './tags'
|
||||||
|
|
||||||
|
import type { Tag } from './map-community'
|
||||||
|
|
||||||
|
export const indicesToTags = (indices: number[]) => {
|
||||||
|
const tagsMapByIndex = Object.entries(tagsMap)
|
||||||
|
|
||||||
|
return indices.reduce<Tag[]>((tags, index) => {
|
||||||
|
const tag = tagsMapByIndex[index]
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push({ text: tag[0], emoji: tag[1] })
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}, [])
|
||||||
|
}
|
|
@ -8,13 +8,15 @@ export type CommunityInfo = {
|
||||||
displayName: string
|
displayName: string
|
||||||
description: string
|
description: string
|
||||||
membersCount: number
|
membersCount: number
|
||||||
tags: Array<{
|
tags: Tag[]
|
||||||
emoji: string
|
|
||||||
text: string
|
|
||||||
}>
|
|
||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Tag = {
|
||||||
|
emoji: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export function mapCommunity(
|
export function mapCommunity(
|
||||||
communityDescription: CommunityDescription
|
communityDescription: CommunityDescription
|
||||||
): CommunityInfo | undefined {
|
): CommunityInfo | undefined {
|
||||||
|
@ -30,7 +32,7 @@ export function mapCommunity(
|
||||||
displayName: identity.displayName,
|
displayName: identity.displayName,
|
||||||
description: identity.description,
|
description: identity.description,
|
||||||
membersCount: Object.keys(members).length,
|
membersCount: Object.keys(members).length,
|
||||||
tags: tags.reduce<CommunityInfo['tags']>((tags, nextTag) => {
|
tags: tags.reduce<Tag[]>((tags, nextTag) => {
|
||||||
const emoji = tagsMap[nextTag as keyof typeof tagsMap]
|
const emoji = tagsMap[nextTag as keyof typeof tagsMap]
|
||||||
|
|
||||||
if (!emoji) {
|
if (!emoji) {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { publicKeyToColorHash } from '../utils/public-key-to-color-hash'
|
||||||
import { publicKeyToEmojiHash } from '../utils/public-key-to-emoji-hash'
|
import { publicKeyToEmojiHash } from '../utils/public-key-to-emoji-hash'
|
||||||
|
|
||||||
import type { ContactCodeAdvertisement } from '../protos/push-notifications_pb'
|
import type { ContactCodeAdvertisement } from '../protos/push-notifications_pb'
|
||||||
|
@ -6,6 +7,7 @@ export type UserInfo = {
|
||||||
photo?: Uint8Array
|
photo?: Uint8Array
|
||||||
displayName: string
|
displayName: string
|
||||||
description?: string
|
description?: string
|
||||||
|
colorHash: number[][]
|
||||||
emojiHash: string
|
emojiHash: string
|
||||||
// todo: currently not in protobuf nor in product
|
// todo: currently not in protobuf nor in product
|
||||||
// color: string
|
// color: string
|
||||||
|
@ -25,6 +27,7 @@ export function mapUser(
|
||||||
photo: identity.images.thumbnail?.payload,
|
photo: identity.images.thumbnail?.payload,
|
||||||
displayName: identity.displayName,
|
displayName: identity.displayName,
|
||||||
description: identity.description,
|
description: identity.description,
|
||||||
|
colorHash: publicKeyToColorHash(userPublicKey),
|
||||||
emojiHash: publicKeyToEmojiHash(userPublicKey),
|
emojiHash: publicKeyToEmojiHash(userPublicKey),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -94,7 +94,7 @@ class RequestClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public fetchCommunity = async (
|
public fetchCommunity = async (
|
||||||
/** Uncompressed */
|
/** Compressed */
|
||||||
publicKey: string
|
publicKey: string
|
||||||
): Promise<CommunityInfo | undefined> => {
|
): Promise<CommunityInfo | undefined> => {
|
||||||
const communityDescription = await this.fetchCommunityDescription(publicKey)
|
const communityDescription = await this.fetchCommunityDescription(publicKey)
|
||||||
|
|
|
@ -1,85 +1,165 @@
|
||||||
import { describe, expect, test } from 'vitest'
|
import { describe, expect, test, vi } from 'vitest'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createChannelURLWithPublicKey,
|
createChannelURLWithChatKey,
|
||||||
createChannelURLWithSignature,
|
createChannelURLWithData,
|
||||||
createCommunityURLWithPublicKey,
|
createCommunityURLWithChatKey,
|
||||||
createCommunityURLWithSignature,
|
createCommunityURLWithData,
|
||||||
createUserURLWithPublicKey,
|
createUserURLWithChatKey,
|
||||||
createUserURLWithSignature,
|
createUserURLWithData,
|
||||||
|
createUserURLWithENS,
|
||||||
} from './create-url'
|
} from './create-url'
|
||||||
|
|
||||||
|
import type { Account } from '../client/account'
|
||||||
|
import type { Chat } from '../client/chat'
|
||||||
|
import type { Community } from '../client/community/community'
|
||||||
|
import type {
|
||||||
|
CommunityChat,
|
||||||
|
CommunityDescription,
|
||||||
|
} from '../protos/communities_pb'
|
||||||
|
import type { ContactCodeAdvertisement } from '../protos/push-notifications_pb'
|
||||||
|
import type { Channel as ChannelProto } from '../protos/url_pb'
|
||||||
|
import type { PlainMessage } from '@bufbuild/protobuf'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://github.com/microsoft/TypeScript/issues/24509
|
||||||
|
*/
|
||||||
|
type Mutable<T> = {
|
||||||
|
-readonly [P in keyof T]: T[P]
|
||||||
|
}
|
||||||
|
|
||||||
describe('Create URLs', () => {
|
describe('Create URLs', () => {
|
||||||
test('should create community URL', () => {
|
test('should create community URL', async () => {
|
||||||
expect(
|
const community = vi.fn() as unknown as Mutable<
|
||||||
createCommunityURLWithPublicKey(
|
Community & { privateKey: string }
|
||||||
'zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU'
|
>
|
||||||
).toString()
|
community.privateKey =
|
||||||
).toBe(
|
'87734578951189d843c7acd05b133a0e0d02c4110ea961df812f7ea15648e0d8'
|
||||||
'https://status.app/c#zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU'
|
community.chatKey = 'zQ3shYSHp7GoiXaauJMnDcjwU2yNjdzpXLosAWapPS4CFxc11'
|
||||||
|
community.description = {
|
||||||
|
members: {
|
||||||
|
'0x04b226ea6d3a96a6ef43106d773c730179d91f5a1d8d701ab40a8f576811fcc8de36df0b756938cc540cd24590a49791aec1489a1ba4af9a13ebdfbc7c3115283d':
|
||||||
|
{},
|
||||||
|
},
|
||||||
|
identity: {
|
||||||
|
description: 'Coloring the world with joy • ᴗ •',
|
||||||
|
displayName: 'Doodles',
|
||||||
|
color: '#131D2F',
|
||||||
|
},
|
||||||
|
tags: ['Art', 'NFT', 'Web3'],
|
||||||
|
} as unknown as CommunityDescription
|
||||||
|
|
||||||
|
expect(createCommunityURLWithChatKey(community.chatKey).toString()).toBe(
|
||||||
|
'https://status.app/c#zQ3shYSHp7GoiXaauJMnDcjwU2yNjdzpXLosAWapPS4CFxc11'
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
createCommunityURLWithSignature(
|
(
|
||||||
'G74AgK0ObFNmYT-WC_Jcc9KfSjHXAQo9THKEEbgPaJoItceMES-bUxr2Tj9efv447rRefBIUg9CEsSFyjBOFTRdZ9PH2wUOW8hVNYqIje3BC96mZ8uFogqM6k7gCCJnMHy4ulsmsgHTdeh5dAzTNNuG8m9XB8oVeildTCKlRhINnTZh4kAl5sP8SzBB4V2_I41a8PKl3mcS0z_eF5gA=',
|
await createCommunityURLWithData(
|
||||||
new Uint8Array([
|
{
|
||||||
94, 52, 162, 140, 177, 216, 189, 16, 47, 100, 230, 195, 33, 131, 3,
|
description: community.description.identity!.description,
|
||||||
66, 86, 100, 186, 198, 234, 159, 193, 19, 133, 58, 232, 29, 52, 159,
|
displayName: community.description.identity!.displayName,
|
||||||
5, 113, 2, 146, 158, 85, 67, 236, 96, 96, 219, 109, 146, 23, 0, 141,
|
color: community.description.identity!.color,
|
||||||
1, 30, 20, 187, 181, 204, 82, 68, 22, 26, 208, 232, 206, 93, 52, 119,
|
membersCount: 446_744,
|
||||||
148, 57, 0,
|
tagIndices: [1, 33, 51],
|
||||||
])
|
},
|
||||||
|
community.privateKey
|
||||||
|
)
|
||||||
).toString()
|
).toString()
|
||||||
).toBe(
|
).toBe(
|
||||||
'https://status.app/c/G74AgK0ObFNmYT-WC_Jcc9KfSjHXAQo9THKEEbgPaJoItceMES-bUxr2Tj9efv447rRefBIUg9CEsSFyjBOFTRdZ9PH2wUOW8hVNYqIje3BC96mZ8uFogqM6k7gCCJnMHy4ulsmsgHTdeh5dAzTNNuG8m9XB8oVeildTCKlRhINnTZh4kAl5sP8SzBB4V2_I41a8PKl3mcS0z_eF5gA=#XjSijLHYvRAvZObDIYMDQlZkusbqn8EThTroHTSfBXECkp5VQ-xgYNttkhcAjQEeFLu1zFJEFhrQ6M5dNHeUOQA='
|
'https://status.app/c/iyKACkQKB0Rvb2RsZXMSJ0NvbG9yaW5nIHRoZSB3b3JsZCB3aXRoIGpveSDigKIg4bSXIOKAohiYohsiByMxMzFEMkYqAwEhMwM=#Co0BClhRbk8yaHc1dFZBRS1NRDVpOE1xNHNfb0dXZDByUkZtbE9iZ1JVTlFYdFVOd1AxaXhGdzkxNFk0LUJRcEYwOEtPcXBhVUxDaDdVQ3RsV1ItTzBZUDhNd0E9EjF6UTNzaFlTSHA3R29pWGFhdUpNbkRjandVMnlOamR6cFhMb3NBV2FwUFM0Q0Z4YzEx'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should create channel URL', () => {
|
test('should create channel URL', async () => {
|
||||||
|
const community = vi.fn() as unknown as Community & { privateKey: string }
|
||||||
|
community.privateKey =
|
||||||
|
'87734578951189d843c7acd05b133a0e0d02c4110ea961df812f7ea15648e0d8'
|
||||||
|
community.chatKey = 'zQ3shYSHp7GoiXaauJMnDcjwU2yNjdzpXLosAWapPS4CFxc11'
|
||||||
|
community.description = {
|
||||||
|
members: {
|
||||||
|
'0x04b226ea6d3a96a6ef43106d773c730179d91f5a1d8d701ab40a8f576811fcc8de36df0b756938cc540cd24590a49791aec1489a1ba4af9a13ebdfbc7c3115283d':
|
||||||
|
{},
|
||||||
|
},
|
||||||
|
identity: {
|
||||||
|
description: 'Coloring the world with joy • ᴗ •',
|
||||||
|
displayName: 'Doodles',
|
||||||
|
color: '#131D2F',
|
||||||
|
},
|
||||||
|
tags: ['Art', 'NFT', 'Web3'],
|
||||||
|
} as unknown as CommunityDescription
|
||||||
|
|
||||||
|
const chat = vi.fn() as unknown as Mutable<Chat>
|
||||||
|
chat.uuid = '003cdcd5-e065-48f9-b166-b1a94ac75a11'
|
||||||
|
chat.description = {
|
||||||
|
identity: {
|
||||||
|
description:
|
||||||
|
'The quick brown fox jumped over the lazy dog because it was too lazy to go around.',
|
||||||
|
displayName: 'design',
|
||||||
|
emoji: '🍿',
|
||||||
|
color: '#131D2F',
|
||||||
|
},
|
||||||
|
} as unknown as CommunityChat
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
createChannelURLWithPublicKey(
|
createChannelURLWithChatKey(chat.uuid, community.chatKey).toString()
|
||||||
'30804ea7-bd66-4d5d-91eb-b2dcfe2515b3',
|
|
||||||
'zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU'
|
|
||||||
).toString()
|
|
||||||
).toBe(
|
).toBe(
|
||||||
'https://status.app/cc/30804ea7-bd66-4d5d-91eb-b2dcfe2515b3#zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU'
|
'https://status.app/cc/003cdcd5-e065-48f9-b166-b1a94ac75a11#zQ3shYSHp7GoiXaauJMnDcjwU2yNjdzpXLosAWapPS4CFxc11'
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
createChannelURLWithSignature(
|
(
|
||||||
'G70BYJwHdqxloHnQV-SSlY7OfdEB_f8igUIHtomMR1igUTaaRSFVBhJ-mjSn8BPqdBHk0PiHrEsBk8WBTo6_gK0tSiwQDLCWpwnmKeU2Bo7j005CuygCCwWebictMe-XLrHfyPEUmLllOKoRCBtcLDALSYQvF5NCoieM550vx-sAmlmSK871edYL67bCK-PPYghGByWEGNMFs9lOIoFx2H_mJDkNNs9bYsbbaRl_uoStzrokUn0u578yAg16mYwLh-287482y4Ibg9640rAW9JNkrfwstJ2qbLLXJ2CYUOa5ftZlFZk2TnzTxIGvfdznZLVXePelos5rWwI=',
|
await createChannelURLWithData(
|
||||||
new Uint8Array([
|
{
|
||||||
94, 52, 162, 140, 177, 216, 189, 16, 47, 100, 230, 195, 33, 131, 3,
|
description: chat.description.identity!.description,
|
||||||
66, 86, 100, 186, 198, 234, 159, 193, 19, 133, 58, 232, 29, 52, 159,
|
displayName: chat.description.identity!.displayName,
|
||||||
5, 113, 2, 146, 158, 85, 67, 236, 96, 96, 219, 109, 146, 23, 0, 141,
|
emoji: chat.description.identity!.emoji,
|
||||||
1, 30, 20, 187, 181, 204, 82, 68, 22, 26, 208, 232, 206, 93, 52, 119,
|
color: chat.description.identity!.color,
|
||||||
148, 57, 0,
|
uuid: chat.uuid,
|
||||||
])
|
community: {
|
||||||
|
displayName: community.description.identity!.displayName,
|
||||||
|
},
|
||||||
|
} as unknown as PlainMessage<ChannelProto>,
|
||||||
|
community.privateKey
|
||||||
|
)
|
||||||
).toString()
|
).toString()
|
||||||
).toBe(
|
).toBe(
|
||||||
'https://status.app/cc/G70BYJwHdqxloHnQV-SSlY7OfdEB_f8igUIHtomMR1igUTaaRSFVBhJ-mjSn8BPqdBHk0PiHrEsBk8WBTo6_gK0tSiwQDLCWpwnmKeU2Bo7j005CuygCCwWebictMe-XLrHfyPEUmLllOKoRCBtcLDALSYQvF5NCoieM550vx-sAmlmSK871edYL67bCK-PPYghGByWEGNMFs9lOIoFx2H_mJDkNNs9bYsbbaRl_uoStzrokUn0u578yAg16mYwLh-287482y4Ibg9640rAW9JNkrfwstJ2qbLLXJ2CYUOa5ftZlFZk2TnzTxIGvfdznZLVXePelos5rWwI=#XjSijLHYvRAvZObDIYMDQlZkusbqn8EThTroHTSfBXECkp5VQ-xgYNttkhcAjQEeFLu1zFJEFhrQ6M5dNHeUOQA='
|
'https://status.app/cc/G54AAKwObLdpiGjXnckYzRcOSq0QQAS_CURGfqVU42ceGHCObstUIknTTZDOKF3E8y2MSicncpO7fTskXnoACiPKeejvjtLTGWNxUhlT7fyQS7Jrr33UVHluxv_PLjV2ePGw5GQ33innzeK34pInIgUGs5RjdQifMVmURalxxQKwiuoY5zwIjixWWRHqjHM=#Co0BClg3YWVCLU02cElidnBTVkdNNFRlSmtLV1B5YTRZUkFIYnE0YW1MMGNIbFNCcFJLbjdfbHlSNGt4RURvMmhDNGtvcVBXWWVfYWsyUjljU1ZLU2lWX25OQUE9EjF6UTNzaFlTSHA3R29pWGFhdUpNbkRjandVMnlOamR6cFhMb3NBV2FwUFM0Q0Z4YzEx'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should create user URL', () => {
|
test('should create user URL', async () => {
|
||||||
expect(
|
const account = vi.fn() as unknown as Mutable<Account>
|
||||||
createUserURLWithPublicKey(
|
account.ensName = 'testing.stateofus.eth'
|
||||||
'zQ3shUHp2rAM1yqBYeo6LhFbtrozG5mZeA6cRoGohsudtsieT'
|
account.privateKey =
|
||||||
).toString()
|
'e922443102af10422970269a8bc575cbdfd70487e4d9051f4b091edd8def5254'
|
||||||
).toBe(
|
account.chatKey = 'zQ3shwQPhRuDJSjVGVBnTjCdgXy5i9WQaeVPdGJD6yTarJQSj'
|
||||||
'https://status.app/u#zQ3shUHp2rAM1yqBYeo6LhFbtrozG5mZeA6cRoGohsudtsieT'
|
account.description = {
|
||||||
|
chatIdentity: {
|
||||||
|
description:
|
||||||
|
'Visual designer @Status, cat lover, pizza enthusiast, yoga afficionada',
|
||||||
|
displayName: 'Mark Cole',
|
||||||
|
color: '#BA434D',
|
||||||
|
},
|
||||||
|
} as unknown as ContactCodeAdvertisement
|
||||||
|
|
||||||
|
expect(createUserURLWithENS(account.ensName).toString()).toBe(
|
||||||
|
'https://status.app/u#testing.stateofus.eth'
|
||||||
|
)
|
||||||
|
expect(createUserURLWithChatKey(account.chatKey).toString()).toBe(
|
||||||
|
'https://status.app/u#zQ3shwQPhRuDJSjVGVBnTjCdgXy5i9WQaeVPdGJD6yTarJQSj'
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
createUserURLWithSignature(
|
(
|
||||||
'GxgBoJwHdsOLl4DWt55mGELN6clGsb1UKTEkT0KUMDfwhWFpUyWH_cefTnvlcSf2JUXCOAWoY5ywzry-LnJ-PjgOGT1Pkb8riQp7ghv6Zu-x70x4m8lncZaRWpDN-sEfT85idUCWvppT_QFNa2A6J3Gr69UJGvWmL3S4DBwX2Jr7LBTNOvFPo6lejNUb-xizlAMUTrokunCH-qNmgtU6UK0J6Vkn8Ce35XGBFObxpxnAtnC_J_D-SrBCBnjiUlwH0ViNr3lHBg==',
|
await createUserURLWithData(
|
||||||
new Uint8Array([
|
{
|
||||||
96, 175, 248, 14, 248, 9, 32, 79, 13, 43, 138, 182, 215, 25, 138, 187,
|
description: account.description.chatIdentity!.description,
|
||||||
188, 246, 133, 199, 190, 112, 234, 162, 99, 181, 248, 13, 136, 66, 65,
|
displayName: account.description.chatIdentity!.displayName,
|
||||||
37, 106, 108, 229, 159, 10, 69, 241, 50, 134, 122, 138, 171, 62, 252,
|
color: account.description.chatIdentity!.color,
|
||||||
197, 77, 125, 77, 161, 58, 114, 26, 200, 93, 51, 255, 113, 127, 132,
|
},
|
||||||
154, 145, 164, 1,
|
account.privateKey
|
||||||
])
|
)
|
||||||
).toString()
|
).toString()
|
||||||
).toBe(
|
).toBe(
|
||||||
'https://status.app/u/GxgBoJwHdsOLl4DWt55mGELN6clGsb1UKTEkT0KUMDfwhWFpUyWH_cefTnvlcSf2JUXCOAWoY5ywzry-LnJ-PjgOGT1Pkb8riQp7ghv6Zu-x70x4m8lncZaRWpDN-sEfT85idUCWvppT_QFNa2A6J3Gr69UJGvWmL3S4DBwX2Jr7LBTNOvFPo6lejNUb-xizlAMUTrokunCH-qNmgtU6UK0J6Vkn8Ce35XGBFObxpxnAtnC_J_D-SrBCBnjiUlwH0ViNr3lHBg==#YK_4DvgJIE8NK4q21xmKu7z2hce-cOqiY7X4DYhCQSVqbOWfCkXxMoZ6iqs-_MVNfU2hOnIayF0z_3F_hJqRpAE='
|
'https://status.app/u/G10A4B0JdgwyRww90WXtnP1oNH1ZLQNM0yX0Ja9YyAMjrqSZIYINOHCbFhrnKRAcPGStPxCMJDSZlGCKzmZrJcimHY8BbcXlORrElv_BbQEegnMDPx1g9C5VVNl0fE4y#Co0BClhMYlFVZEpESENLb2k4RHpvWXlYODlicEtyVGpWVjNTaHFIM0U2NGJEaWZKQjJHa2VkdExCZlZLQTAyUmJVZlgwNzRwYjlpM293R3dSZFM2eF9udHhyUUE9EjF6UTNzaHdRUGhSdURKU2pWR1ZCblRqQ2RnWHk1aTlXUWFlVlBkR0pENnlUYXJKUVNq'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,45 +1,75 @@
|
||||||
import { base64url } from '@scure/base'
|
import {
|
||||||
|
encodeChannelURLData,
|
||||||
|
encodeCommunityURLData,
|
||||||
|
encodeUserURLData,
|
||||||
|
} from './encode-url-data'
|
||||||
|
import { signEncodedURLData } from './sign-url-data'
|
||||||
|
|
||||||
|
import type { Channel, Community, User } from '../protos/url_pb'
|
||||||
|
import type { PlainMessage } from '@bufbuild/protobuf'
|
||||||
|
|
||||||
const BASE_URL = 'https://status.app'
|
const BASE_URL = 'https://status.app'
|
||||||
|
|
||||||
export function createCommunityURLWithPublicKey(publicKey: string): URL {
|
export function createCommunityURLWithChatKey(chatKey: string): URL {
|
||||||
return new URL(`${BASE_URL}/c#${publicKey}`)
|
return new URL(`${BASE_URL}/c#${chatKey}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCommunityURLWithSignature(
|
export async function createCommunityURLWithData(
|
||||||
encodedCommunityURLData: string,
|
communityData: PlainMessage<Community>,
|
||||||
signature: Uint8Array
|
communityPrivateKey: Uint8Array | string
|
||||||
): URL {
|
): Promise<URL> {
|
||||||
|
const encodedURLData = encodeCommunityURLData(communityData)
|
||||||
|
const encodedVerificationURLHash = await signEncodedURLData(
|
||||||
|
encodedURLData,
|
||||||
|
communityPrivateKey
|
||||||
|
)
|
||||||
|
|
||||||
return new URL(
|
return new URL(
|
||||||
`${BASE_URL}/c/${encodedCommunityURLData}#${base64url.encode(signature)}`
|
`${BASE_URL}/c/${encodedURLData}#${encodedVerificationURLHash}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChannelURLWithPublicKey(
|
export function createChannelURLWithChatKey(
|
||||||
channelUuid: string,
|
channelUuid: string,
|
||||||
communityPublicKey: string
|
communityChatKey: string
|
||||||
): URL {
|
): URL {
|
||||||
return new URL(`${BASE_URL}/cc/${channelUuid}#${communityPublicKey}`)
|
return new URL(`${BASE_URL}/cc/${channelUuid}#${communityChatKey}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChannelURLWithSignature(
|
export async function createChannelURLWithData(
|
||||||
encodedChannelURLData: string,
|
channelData: PlainMessage<Channel>,
|
||||||
signature: Uint8Array
|
communityPrivateKey: Uint8Array | string
|
||||||
): URL {
|
): Promise<URL> {
|
||||||
|
const encodedURLData = encodeChannelURLData(channelData)
|
||||||
|
const encodedVerificationURLHash = await signEncodedURLData(
|
||||||
|
encodedURLData,
|
||||||
|
communityPrivateKey
|
||||||
|
)
|
||||||
|
|
||||||
return new URL(
|
return new URL(
|
||||||
`${BASE_URL}/cc/${encodedChannelURLData}#${base64url.encode(signature)}`
|
`${BASE_URL}/cc/${encodedURLData}#${encodedVerificationURLHash}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUserURLWithPublicKey(publicKey: string): URL {
|
export function createUserURLWithENS(ensName: string): URL {
|
||||||
return new URL(`${BASE_URL}/u#${publicKey}`)
|
return new URL(`${BASE_URL}/u#${ensName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUserURLWithSignature(
|
export function createUserURLWithChatKey(chatKey: string): URL {
|
||||||
encodedURLData: string,
|
return new URL(`${BASE_URL}/u#${chatKey}`)
|
||||||
signature: Uint8Array
|
}
|
||||||
): URL {
|
|
||||||
|
export async function createUserURLWithData(
|
||||||
|
userData: PlainMessage<User>,
|
||||||
|
userPrivateKey: Uint8Array | string
|
||||||
|
): Promise<URL> {
|
||||||
|
const encodedURLData = encodeUserURLData(userData)
|
||||||
|
const encodedVerificationURLHash = await signEncodedURLData(
|
||||||
|
encodedURLData,
|
||||||
|
userPrivateKey
|
||||||
|
)
|
||||||
|
|
||||||
return new URL(
|
return new URL(
|
||||||
`${BASE_URL}/u/${encodedURLData}#${base64url.encode(signature)}`
|
`${BASE_URL}/u/${encodedURLData}#${encodedVerificationURLHash}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
encodeUserURLData,
|
encodeUserURLData,
|
||||||
} from './encode-url-data'
|
} from './encode-url-data'
|
||||||
|
|
||||||
import type { Channel } from '../protos/url-data_pb'
|
import type { Channel } from '../protos/url_pb'
|
||||||
import type { PlainMessage } from '@bufbuild/protobuf'
|
import type { PlainMessage } from '@bufbuild/protobuf'
|
||||||
|
|
||||||
describe('Encode URL data', () => {
|
describe('Encode URL data', () => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { base64url } from '@scure/base'
|
import { base64url } from '@scure/base'
|
||||||
import { brotliCompressSync, brotliDecompressSync } from 'zlib'
|
import { brotliCompressSync, brotliDecompressSync } from 'zlib'
|
||||||
|
|
||||||
import { Channel, Community, URLData, User } from '../protos/url-data_pb'
|
import { Channel, Community, URLData, User } from '../protos/url_pb'
|
||||||
|
|
||||||
import type { PlainMessage } from '@bufbuild/protobuf'
|
import type { PlainMessage } from '@bufbuild/protobuf'
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodeVerificationURLHash,
|
||||||
|
encodeVerificationURLHash,
|
||||||
|
} from './encode-url-hash'
|
||||||
|
|
||||||
|
describe('Encode URL hash', () => {
|
||||||
|
test('should encode and decode verification hash', () => {
|
||||||
|
const data = {
|
||||||
|
signature:
|
||||||
|
'k-n7d-9Pcx6ht87F4riP5xAw1v7S-e1HGMRaeaO068Q3IF1Jo8xOyeMT9Yr3Wv349Z2CdBzylw8M83CgQhcMogA=', // not generated by the pk
|
||||||
|
publicKey: 'zQ3shUHp2rAM1yqBYeo6LhFbtrozG5mZeA6cRoGohsudtsieT',
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedHash = encodeVerificationURLHash(data)
|
||||||
|
const decodedHash = decodeVerificationURLHash(encodedHash)
|
||||||
|
|
||||||
|
expect(encodedHash).toBe(
|
||||||
|
'Co0BClhrLW43ZC05UGN4Nmh0ODdGNHJpUDV4QXcxdjdTLWUxSEdNUmFlYU8wNjhRM0lGMUpvOHhPeWVNVDlZcjNXdjM0OVoyQ2RCenlsdzhNODNDZ1FoY01vZ0E9EjF6UTNzaFVIcDJyQU0xeXFCWWVvNkxoRmJ0cm96RzVtWmVBNmNSb0dvaHN1ZHRzaWVU'
|
||||||
|
)
|
||||||
|
expect(decodedHash).toEqual(data)
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { base64url } from '@scure/base'
|
||||||
|
|
||||||
|
import { URLHash, Verification } from '../protos/url_pb'
|
||||||
|
|
||||||
|
import type { PlainMessage } from '@bufbuild/protobuf'
|
||||||
|
|
||||||
|
export type EncodedVerificationURLHash = string & {
|
||||||
|
_: 'EncodedVerificationURLHash'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeVerificationURLHash(
|
||||||
|
data: PlainMessage<Verification>
|
||||||
|
): EncodedVerificationURLHash {
|
||||||
|
return encodeURLHash(
|
||||||
|
new Verification(data).toBinary()
|
||||||
|
) as EncodedVerificationURLHash
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeVerificationURLHash(
|
||||||
|
data: string
|
||||||
|
): PlainMessage<Verification> {
|
||||||
|
const deserialized = decodeURLHash(data)
|
||||||
|
|
||||||
|
return Verification.fromBinary(
|
||||||
|
deserialized.content
|
||||||
|
).toJson() as PlainMessage<Verification>
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeURLHash(data: Uint8Array): string {
|
||||||
|
const serialized = new URLHash({ content: data }).toBinary()
|
||||||
|
const encoded = base64url.encode(serialized)
|
||||||
|
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeURLHash(data: string): URLHash {
|
||||||
|
const decoded = base64url.decode(data)
|
||||||
|
const deserialized = URLHash.fromBinary(decoded)
|
||||||
|
|
||||||
|
return deserialized
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { expect, test } from 'vitest'
|
||||||
|
|
||||||
|
import { serializePublicKey } from './serialize-public-key'
|
||||||
|
|
||||||
|
test('should serialize compressed public key to base58btc encoding', () => {
|
||||||
|
expect(
|
||||||
|
serializePublicKey(
|
||||||
|
'0x029f196bbfef4fa6a5eb81dd802133a63498325445ca1af1d154b1bb4542955133'
|
||||||
|
)
|
||||||
|
).toEqual('zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should serialize uncompressed public key to base58btc encoding', () => {
|
||||||
|
expect(
|
||||||
|
serializePublicKey(
|
||||||
|
'0x049f196bbfef4fa6a5eb81dd802133a63498325445ca1af1d154b1bb454295513305b23fcf11d005ee622144fc402b713a8928f80d705781e2e78d701c6e01bfc4'
|
||||||
|
)
|
||||||
|
).toEqual('zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return the public key if already serialized to base58btc encoding', () => {
|
||||||
|
expect(
|
||||||
|
serializePublicKey('zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU')
|
||||||
|
).toEqual('zQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw when serializing unsupported multibase encoding', () => {
|
||||||
|
expect(() =>
|
||||||
|
serializePublicKey('ZQ3shY7r4cAdg4eUF5dfcuCqCFzWmdjHW4SX5hspM9ucAarfU')
|
||||||
|
).toThrowError()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw when serializing invalid public key', () => {
|
||||||
|
expect(() =>
|
||||||
|
serializePublicKey(
|
||||||
|
'0x019f196bbfef4fa6a5eb81dd802133a63498325445ca1af1d154b1bb4542955133'
|
||||||
|
)
|
||||||
|
).toThrowError()
|
||||||
|
})
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { hexToBytes } from 'ethereum-cryptography/utils'
|
||||||
|
import { base58btc } from 'multiformats/bases/base58'
|
||||||
|
|
||||||
|
import { deserializePublicKey } from './deserialize-public-key'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://specs.status.im/spec/2#public-key-serialization for specification
|
||||||
|
*/
|
||||||
|
export function serializePublicKey(
|
||||||
|
publicKey: string // uncompressed, compressed, or compressed & encoded
|
||||||
|
): string {
|
||||||
|
const hexadecimalPublicKey = hexToBytes(
|
||||||
|
deserializePublicKey(publicKey).replace(/^0[xX]/, '')
|
||||||
|
) // validated and compressed
|
||||||
|
|
||||||
|
return base58btc.encode(new Uint8Array([231, 1, ...hexadecimalPublicKey]))
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, test } from 'vitest'
|
import { expect, test } from 'vitest'
|
||||||
|
|
||||||
|
import { encodeVerificationURLHash } from './encode-url-hash'
|
||||||
import { signEncodedURLData, verifyEncodedURLData } from './sign-url-data'
|
import { signEncodedURLData, verifyEncodedURLData } from './sign-url-data'
|
||||||
|
|
||||||
import type { EncodedURLData } from './encode-url-data'
|
import type { EncodedURLData } from './encode-url-data'
|
||||||
|
@ -8,24 +9,54 @@ const privateKey = new Uint8Array([
|
||||||
233, 34, 68, 49, 2, 175, 16, 66, 41, 112, 38, 154, 139, 197, 117, 203, 223,
|
233, 34, 68, 49, 2, 175, 16, 66, 41, 112, 38, 154, 139, 197, 117, 203, 223,
|
||||||
215, 4, 135, 228, 217, 5, 31, 75, 9, 30, 221, 141, 239, 82, 84,
|
215, 4, 135, 228, 217, 5, 31, 75, 9, 30, 221, 141, 239, 82, 84,
|
||||||
])
|
])
|
||||||
|
const publicKey =
|
||||||
|
'0x04f9134866f2bd8f45f2bc7893c95a6b989378c370088c9a1a5a53eda2ebb8a1e8386921592b6bd56fc3573f03c46df3396cc42e2993cdc001855c858865d768a7'
|
||||||
const encodedURLData =
|
const encodedURLData =
|
||||||
'G74AgK0ObFNmYT-WC_Jcc9KfSjHXAQo9THKEEbgPaJoItceMES-bUxr2Tj9efv447rRefBIUg9CEsSFyjBOFTRdZ9PH2wUOW8hVNYqIje3BC96mZ8uFogqM6k7gCCJnMHy4ulsmsgHTdeh5dAzTNNuG8m9XB8oVeildTCKlRhINnTZh4kAl5sP8SzBB4V2_I41a8PKl3mcS0z_eF5gA=' as EncodedURLData
|
'G74AgK0ObFNmYT-WC_Jcc9KfSjHXAQo9THKEEbgPaJoItceMES-bUxr2Tj9efv447rRefBIUg9CEsSFyjBOFTRdZ9PH2wUOW8hVNYqIje3BC96mZ8uFogqM6k7gCCJnMHy4ulsmsgHTdeh5dAzTNNuG8m9XB8oVeildTCKlRhINnTZh4kAl5sP8SzBB4V2_I41a8PKl3mcS0z_eF5gA=' as EncodedURLData
|
||||||
|
|
||||||
test('should sign and verify URL data', async () => {
|
test('should verify URL data and correspoinding signature', async () => {
|
||||||
const signature = await signEncodedURLData(encodedURLData, privateKey)
|
const encodedVerificationURLHash = await signEncodedURLData(
|
||||||
|
encodedURLData,
|
||||||
expect(signature).toBe(
|
privateKey
|
||||||
'k-n7d-9Pcx6ht87F4riP5xAw1v7S-e1HGMRaeaO068Q3IF1Jo8xOyeMT9Yr3Wv349Z2CdBzylw8M83CgQhcMogA='
|
)
|
||||||
|
|
||||||
|
expect(verifyEncodedURLData(encodedURLData, encodedVerificationURLHash)).toBe(
|
||||||
|
true
|
||||||
)
|
)
|
||||||
expect(verifyEncodedURLData(signature, encodedURLData)).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test.todo('should not verify URL data', async () => {
|
test('should not verify URL data and random signature', async () => {
|
||||||
const signatureForAnotherContent =
|
const randomSignature =
|
||||||
'OyOgY6Zta8S7U4l5Bv_9E_7snALhixwvjxORVAVJ-YJk-tMSGgstOy5XEEQx25TQJIAtpWf8eHnEmV8V-GmouQA='
|
'OyOgY6Zta8S7U4l5Bv_9E_7snALhixwvjxORVAVJ-YJk-tMSGgstOy5XEEQx25TQJIAtpWf8eHnEmV8V-GmouQA='
|
||||||
|
|
||||||
expect(verifyEncodedURLData(signatureForAnotherContent, encodedURLData)).toBe(
|
const encodedVerificationURLHash = encodeVerificationURLHash({
|
||||||
|
signature: randomSignature,
|
||||||
|
publicKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(verifyEncodedURLData(encodedURLData, encodedVerificationURLHash)).toBe(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
// see https://github.com/paulmillr/noble-secp256k1/issues/43#issuecomment-1020214968
|
||||||
|
// expect(verifyEncodedURLData(randomSignature, encodedURLData)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should not verify random URL data and random signature', async () => {
|
||||||
|
const randomEncodedURLData =
|
||||||
|
'CyeACk0KHkxvcmVtIGlwc3VtIGRvbG9yIHNpdCBlZ2VzdGFzLhIYV2UgZG8gbm90IHN1cHBvcnQgQWxpY2UuGMCEPSIHIzQzNjBERioEAQIDBAM=' as EncodedURLData
|
||||||
|
const randomSignature =
|
||||||
|
'k-n7d-9Pcx6ht87F4riP5xAw1v7S-e1HGMRaeaO068Q3IF1Jo8xOyeMT9Yr3Wv349Z2CdBzylw8M83CgQhcMogA='
|
||||||
|
|
||||||
|
const encodedVerificationURLHash = encodeVerificationURLHash({
|
||||||
|
signature: randomSignature,
|
||||||
|
publicKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
verifyEncodedURLData(randomEncodedURLData, encodedVerificationURLHash)
|
||||||
|
).toBe(false)
|
||||||
|
// see https://github.com/paulmillr/noble-secp256k1/issues/43#issuecomment-1020214968
|
||||||
|
// expect(verifyEncodedURLData(randomSignature, randomEncodedURLData)).toBe(
|
||||||
|
// false
|
||||||
|
// )
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,23 +1,51 @@
|
||||||
import { base64url } from '@scure/base'
|
import { base64url } from '@scure/base'
|
||||||
|
import { getPublicKey } from 'ethereum-cryptography/secp256k1'
|
||||||
|
import { bytesToHex } from 'ethereum-cryptography/utils'
|
||||||
|
|
||||||
|
import { deserializePublicKey } from './deserialize-public-key'
|
||||||
|
import {
|
||||||
|
decodeVerificationURLHash,
|
||||||
|
encodeVerificationURLHash,
|
||||||
|
} from './encode-url-hash'
|
||||||
|
import { serializePublicKey } from './serialize-public-key'
|
||||||
import { signData, verifySignedData } from './sign-data'
|
import { signData, verifySignedData } from './sign-data'
|
||||||
|
|
||||||
import type { EncodedURLData } from './encode-url-data'
|
import type { EncodedURLData } from './encode-url-data'
|
||||||
|
import type { EncodedVerificationURLHash } from './encode-url-hash'
|
||||||
|
|
||||||
export async function signEncodedURLData(
|
export async function signEncodedURLData(
|
||||||
encodedURLData: EncodedURLData,
|
encodedURLData: EncodedURLData,
|
||||||
privateKey: Uint8Array | string
|
privateKey: Uint8Array | string
|
||||||
): Promise<string> {
|
): Promise<EncodedVerificationURLHash> {
|
||||||
const signature = await signData(encodedURLData, privateKey)
|
const signature = await signData(encodedURLData, privateKey)
|
||||||
|
|
||||||
return base64url.encode(signature)
|
const encodedSignature = base64url.encode(signature)
|
||||||
|
const serializedPublicKey = serializePublicKey(
|
||||||
|
`0x${bytesToHex(getPublicKey(privateKey))}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return encodeVerificationURLHash({
|
||||||
|
signature: encodedSignature,
|
||||||
|
publicKey: serializedPublicKey,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyEncodedURLData(
|
export function verifyEncodedURLData(
|
||||||
encodedSignature: string,
|
encodedURLData: string,
|
||||||
encodedURLData: EncodedURLData
|
encodedVerificationURLHash: string
|
||||||
): boolean {
|
): boolean {
|
||||||
const signature = base64url.decode(encodedSignature)
|
const { signature, publicKey } = decodeVerificationURLHash(
|
||||||
|
encodedVerificationURLHash
|
||||||
|
)
|
||||||
|
|
||||||
return verifySignedData(signature, encodedURLData)
|
const decodedSignature = base64url.decode(signature)
|
||||||
|
const deserializedPublicKey = deserializePublicKey(publicKey, {
|
||||||
|
compress: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return verifySignedData(
|
||||||
|
decodedSignature,
|
||||||
|
encodedURLData,
|
||||||
|
deserializedPublicKey
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,9 +26,24 @@ export default defineConfig(({ mode }) => {
|
||||||
build: {
|
build: {
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
lib: {
|
lib: {
|
||||||
entry: './src/index.ts',
|
entry: [
|
||||||
fileName: 'index',
|
'./src/index.ts',
|
||||||
formats: ['es', 'cjs'],
|
'./src/utils/encode-url-data.ts',
|
||||||
|
'./src/utils/encode-url-hash.ts',
|
||||||
|
],
|
||||||
|
fileName: (format, entryName) => {
|
||||||
|
if (!['es'].includes(format)) {
|
||||||
|
throw new Error(`Unexpected format: ${format}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'es':
|
||||||
|
return `${entryName}.js`
|
||||||
|
default:
|
||||||
|
throw new Error(`Undefined format: ${format}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formats: ['es'],
|
||||||
},
|
},
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
emptyOutDir: mode === 'production',
|
emptyOutDir: mode === 'production',
|
||||||
|
|
Loading…
Reference in New Issue