Next
This commit is contained in:
parent
ab164dc65d
commit
33b5b2d179
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"importOrder": [
|
||||||
|
"^react(.*)",
|
||||||
|
"^next(.*)",
|
||||||
|
"@mui(.*)",
|
||||||
|
"<THIRD_PARTY_MODULES>",
|
||||||
|
"^[./]"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderSortSpecifiers": true
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"prettier.configPath": ".prettierrc"
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { IDBPDatabase, openDB } from 'idb'
|
||||||
|
|
||||||
|
import { client } from '../socialvoid'
|
||||||
|
|
||||||
|
let _db: IDBPDatabase | undefined
|
||||||
|
|
||||||
|
async function getDB() {
|
||||||
|
if (typeof _db !== 'undefined') {
|
||||||
|
return _db
|
||||||
|
}
|
||||||
|
|
||||||
|
_db = await openDB('socialvoidCaches', 1, {
|
||||||
|
upgrade(db) {
|
||||||
|
db.createObjectStore('documents')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return _db
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStore() {
|
||||||
|
return (await getDB())
|
||||||
|
.transaction('documents', 'readwrite')
|
||||||
|
.objectStore('documents')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocument(id: string): Promise<string> {
|
||||||
|
const cache = await (await getStore()).get(id)
|
||||||
|
|
||||||
|
if (typeof cache !== 'undefined') {
|
||||||
|
return URL.createObjectURL(new Blob([cache]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await client.cdn.download(id)) as ArrayBuffer
|
||||||
|
|
||||||
|
await (await getStore()).put(data, id)
|
||||||
|
|
||||||
|
return URL.createObjectURL(new Blob([data]))
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import AppBar from '@mui/material/AppBar'
|
||||||
|
import Container from '@mui/material/Container'
|
||||||
|
import Paper from '@mui/material/Paper'
|
||||||
|
import Slide from '@mui/material/Slide'
|
||||||
|
import Toolbar from '@mui/material/Toolbar'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import useScrollTrigger from '@mui/material/useScrollTrigger'
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const trigger = useScrollTrigger()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slide appear={false} direction="down" in={!trigger}>
|
||||||
|
<Paper
|
||||||
|
component="header"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
borderTop: 'none',
|
||||||
|
borderRight: 'none',
|
||||||
|
borderLeft: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<AppBar
|
||||||
|
position="sticky"
|
||||||
|
color="transparent"
|
||||||
|
sx={{ boxShadow: 'none' }}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" component="div">
|
||||||
|
Socialvoid
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
</Container>
|
||||||
|
</Paper>
|
||||||
|
</Slide>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
import { useTheme } from '@mui/material/styles'
|
||||||
|
|
||||||
|
export default function Router() {
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 10,
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress sx={{ color: theme.palette.text.primary }} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { NextRouter } from 'next/router'
|
||||||
|
|
||||||
|
import Card, { CardProps } from '@mui/material/Card'
|
||||||
|
import CardActionArea from '@mui/material/CardActionArea'
|
||||||
|
import CardContent from '@mui/material/CardContent'
|
||||||
|
import CardHeader from '@mui/material/CardHeader'
|
||||||
|
import CardMedia from '@mui/material/CardMedia'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
|
import moment from 'moment'
|
||||||
|
import * as sv from 'socialvoid'
|
||||||
|
|
||||||
|
import { getDocument } from '../cache'
|
||||||
|
import { dispatch } from '../socialvoid'
|
||||||
|
import { unparse } from '../utilities/parser'
|
||||||
|
import { NotDeletedPost, postNotDeleted } from '../utilities/types'
|
||||||
|
|
||||||
|
type PostProps = CardProps & {
|
||||||
|
post: sv.Post
|
||||||
|
repost?: boolean
|
||||||
|
router?: NextRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostFrame(props: CardProps) {
|
||||||
|
return <Card variant="outlined" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerPostFrame(props: CardProps) {
|
||||||
|
return (
|
||||||
|
<PostFrame
|
||||||
|
sx={{ mb: 3, mr: 'auto', ml: 'auto', width: '90%' }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeletedPostView() {
|
||||||
|
return (
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="body1">This post is deleted.</Typography>
|
||||||
|
</CardContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostView({ post }: { post: NotDeletedPost }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CardHeader
|
||||||
|
title={
|
||||||
|
<Typography>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>{post.peer.name}</span>{' '}
|
||||||
|
<span style={{ opacity: 0.5 }}>
|
||||||
|
@{post.peer.username} ·{' '}
|
||||||
|
{moment(post.posted_timestamp * 1000).fromNow()}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: unparse(post.text, post.entities),
|
||||||
|
}}
|
||||||
|
></Typography>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{post.reposted_post && (
|
||||||
|
<InnerPostFrame>
|
||||||
|
{postNotDeleted(post.reposted_post) ? (
|
||||||
|
<PostView post={post.reposted_post} />
|
||||||
|
) : (
|
||||||
|
<DeletedPostView />
|
||||||
|
)}
|
||||||
|
</InnerPostFrame>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Post(props: PostProps) {
|
||||||
|
const { post, router } = props
|
||||||
|
|
||||||
|
const body = postNotDeleted(post) ? (
|
||||||
|
<PostView post={post} />
|
||||||
|
) : (
|
||||||
|
<DeletedPostView />
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PostFrame variant="outlined" {...props}>
|
||||||
|
{router ? (
|
||||||
|
<CardActionArea
|
||||||
|
onClick={() =>
|
||||||
|
router.push({ pathname: '/post/[id]', query: { id: post.id } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</CardActionArea>
|
||||||
|
) : (
|
||||||
|
body
|
||||||
|
)}
|
||||||
|
</PostFrame>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles'
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
|
|
||||||
|
import { SnackbarProvider } from 'notistack'
|
||||||
|
|
||||||
|
export default function Theme({ children }: { children: React.ReactNode }) {
|
||||||
|
const dark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
|
const theme = useMemo(
|
||||||
|
() => createTheme({ palette: { mode: dark ? 'dark' : 'light' } }),
|
||||||
|
[dark]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<SnackbarProvider>{children}</SnackbarProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,4 +1,11 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
webpack: (config) => {
|
||||||
|
if (typeof config.resolve.fallback !== 'undefined') {
|
||||||
|
config.resolve.fallback.fs = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
17
package.json
17
package.json
|
@ -5,18 +5,31 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"pretty": "prettier --config .prettierrc run --write pages/ utilities/ themes/ specifications/ socialvoid/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.5.0",
|
||||||
|
"@emotion/styled": "^11.3.0",
|
||||||
|
"@mui/icons-material": "^5.1.0",
|
||||||
|
"@mui/material": "^5.1.0",
|
||||||
|
"idb": "^6.1.5",
|
||||||
|
"moment": "^2.29.1",
|
||||||
"next": "12.0.3",
|
"next": "12.0.3",
|
||||||
|
"notistack": "^2.0.3",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2"
|
"react-dom": "17.0.2",
|
||||||
|
"socialvoid": "^0.0.0-alpha.8",
|
||||||
|
"zod": "^3.11.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^3.1.1",
|
||||||
|
"@types/moment": "^2.13.0",
|
||||||
"@types/node": "16.11.7",
|
"@types/node": "16.11.7",
|
||||||
"@types/react": "17.0.34",
|
"@types/react": "17.0.34",
|
||||||
"eslint": "7",
|
"eslint": "7",
|
||||||
"eslint-config-next": "12.0.3",
|
"eslint-config-next": "12.0.3",
|
||||||
|
"prettier": "^2.4.1",
|
||||||
"typescript": "4.4.4"
|
"typescript": "4.4.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,23 @@
|
||||||
import '../styles/globals.css'
|
import { AppProps } from 'next/app'
|
||||||
import type { AppProps } from 'next/app'
|
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }: AppProps) {
|
import Container from '@mui/material/Container'
|
||||||
return <Component {...pageProps} />
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
|
|
||||||
|
import { SnackbarProvider } from 'notistack'
|
||||||
|
|
||||||
|
import Header from '../components/Header'
|
||||||
|
import Theme from '../components/Theme'
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
|
return (
|
||||||
|
<Theme>
|
||||||
|
<CssBaseline />
|
||||||
|
<SnackbarProvider>
|
||||||
|
<Header />
|
||||||
|
<Container component="main" maxWidth="sm">
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</Container>
|
||||||
|
</SnackbarProvider>
|
||||||
|
</Theme>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MyApp
|
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
|
||||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
|
||||||
|
|
||||||
type Data = {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function handler(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse<Data>
|
|
||||||
) {
|
|
||||||
res.status(200).json({ name: 'John Doe' })
|
|
||||||
}
|
|
107
pages/index.tsx
107
pages/index.tsx
|
@ -1,72 +1,43 @@
|
||||||
import type { NextPage } from 'next'
|
import { useEffect, useState } from 'react'
|
||||||
import Head from 'next/head'
|
|
||||||
import Image from 'next/image'
|
import { useRouter } from 'next/router'
|
||||||
import styles from '../styles/Home.module.css'
|
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
|
||||||
|
import Loader from '../components/Loader'
|
||||||
|
import Post from '../components/Post'
|
||||||
|
import { dispatch } from '../socialvoid'
|
||||||
|
import { redirectIfNotAuthenticated } from '../utilities/redirect'
|
||||||
|
import { NotDeletedPost, postNotDeleted } from '../utilities/types'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const router = useRouter()
|
||||||
|
const snackbar = useSnackbar()
|
||||||
|
|
||||||
|
const [posts, setPosts] = useState<NotDeletedPost[]>()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
redirectIfNotAuthenticated(router)
|
||||||
|
|
||||||
|
dispatch(async (client) => {
|
||||||
|
const posts = (await client.timeline.retrieveFeed(page)).filter(
|
||||||
|
postNotDeleted
|
||||||
|
)
|
||||||
|
|
||||||
|
setPosts(posts)
|
||||||
|
}, snackbar)
|
||||||
|
}, [page])
|
||||||
|
|
||||||
const Home: NextPage = () => {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<>
|
||||||
<Head>
|
{posts ? (
|
||||||
<title>Create Next App</title>
|
posts.map((post) => (
|
||||||
<meta name="description" content="Generated by create next app" />
|
<Post key={post.id} post={post} router={router} sx={{ mt: 3 }} />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
))
|
||||||
</Head>
|
) : (
|
||||||
|
<Loader />
|
||||||
<main className={styles.main}>
|
)}
|
||||||
<h1 className={styles.title}>
|
</>
|
||||||
Welcome to <a href="https://nextjs.org">Next.js!</a>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className={styles.description}>
|
|
||||||
Get started by editing{' '}
|
|
||||||
<code className={styles.code}>pages/index.tsx</code>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className={styles.grid}>
|
|
||||||
<a href="https://nextjs.org/docs" className={styles.card}>
|
|
||||||
<h2>Documentation →</h2>
|
|
||||||
<p>Find in-depth information about Next.js features and API.</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://nextjs.org/learn" className={styles.card}>
|
|
||||||
<h2>Learn →</h2>
|
|
||||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://github.com/vercel/next.js/tree/master/examples"
|
|
||||||
className={styles.card}
|
|
||||||
>
|
|
||||||
<h2>Examples →</h2>
|
|
||||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
|
||||||
className={styles.card}
|
|
||||||
>
|
|
||||||
<h2>Deploy →</h2>
|
|
||||||
<p>
|
|
||||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className={styles.footer}>
|
|
||||||
<a
|
|
||||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Powered by{' '}
|
|
||||||
<span className={styles.logo}>
|
|
||||||
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home
|
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import * as sv from 'socialvoid'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import Loader from '../../components/Loader'
|
||||||
|
import Post from '../../components/Post'
|
||||||
|
import { dispatch } from '../../socialvoid'
|
||||||
|
|
||||||
|
export default function Post_() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [post, setPost] = useState<sv.Post>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(async (client) => {
|
||||||
|
const post = await client.timeline.getPost(
|
||||||
|
z.string().parse(router.query.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
setPost(post)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return post ? <Post post={post} sx={{ mt: 3 }} /> : <Loader />
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { dispatch } from '../socialvoid'
|
||||||
|
import { spassword } from '../specifications'
|
||||||
|
import { handleZodErrors } from '../utilities/errors'
|
||||||
|
import { redirectIfAuthenticated } from '../utilities/redirect'
|
||||||
|
|
||||||
|
export default function SignUp() {
|
||||||
|
const router = useRouter()
|
||||||
|
const snackbar = useSnackbar()
|
||||||
|
|
||||||
|
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
handleZodErrors(() => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const data = new FormData(event.currentTarget)
|
||||||
|
|
||||||
|
const { username, password } = z
|
||||||
|
.object({
|
||||||
|
username: z.string().nonempty(),
|
||||||
|
password: spassword,
|
||||||
|
})
|
||||||
|
.parse({
|
||||||
|
username: data.get('username'),
|
||||||
|
password: data.get('password'),
|
||||||
|
})
|
||||||
|
|
||||||
|
dispatch(async (client) => {
|
||||||
|
await client.newSession()
|
||||||
|
await client.session.authenticateUser(username, password)
|
||||||
|
|
||||||
|
router.replace('/')
|
||||||
|
}, snackbar)
|
||||||
|
}, snackbar)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
redirectIfAuthenticated(router)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="form" onSubmit={submit} sx={{ mt: 3 }} noValidate>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="username"
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="password"
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth variant="contained" sx={{ mt: 3, mb: 3 }}>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
<Link href="/signup" variant="body2" sx={{ float: 'right' }}>
|
||||||
|
Don’t have an account?
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import Loader from '../components/Loader'
|
||||||
|
|
||||||
|
export default function SignUp() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace('/tos')
|
||||||
|
})
|
||||||
|
|
||||||
|
return <Loader />
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { dispatch } from '../../socialvoid'
|
||||||
|
import { spassword } from '../../specifications'
|
||||||
|
import { handleZodErrors } from '../../utilities/errors'
|
||||||
|
import { redirectIfAuthenticated } from '../../utilities/redirect'
|
||||||
|
|
||||||
|
export default function SignUp() {
|
||||||
|
const router = useRouter()
|
||||||
|
const snackbar = useSnackbar()
|
||||||
|
|
||||||
|
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
handleZodErrors(() => {
|
||||||
|
event.preventDefault()
|
||||||
|
const data = new FormData(event.currentTarget)
|
||||||
|
|
||||||
|
const params = z
|
||||||
|
.object({
|
||||||
|
tosId: z.string().nonempty(),
|
||||||
|
username: z.string().nonempty(),
|
||||||
|
password: spassword,
|
||||||
|
passwordConfirmation: z.string(),
|
||||||
|
firstName: z.string().nonempty(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
})
|
||||||
|
.parse({
|
||||||
|
tosId: router.query.tosId,
|
||||||
|
username: data.get('username'),
|
||||||
|
password: data.get('password'),
|
||||||
|
passwordConfirmation: data.get('passwordConfirmation'),
|
||||||
|
firstName: data.get('firstName'),
|
||||||
|
lastName: data.get('lastName'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (params.password !== params.passwordConfirmation) {
|
||||||
|
snackbar.enqueueSnackbar('Passwords don’t match.', {
|
||||||
|
variant: 'warning',
|
||||||
|
preventDuplicate: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(async (client) => {
|
||||||
|
await client.newSession()
|
||||||
|
|
||||||
|
await client.session.register(
|
||||||
|
params.tosId,
|
||||||
|
params.username,
|
||||||
|
params.password,
|
||||||
|
params.firstName,
|
||||||
|
params.lastName
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.session.authenticateUser(params.username, params.password)
|
||||||
|
|
||||||
|
snackbar.enqueueSnackbar('Signed up successfully.', {
|
||||||
|
variant: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
router.replace('/')
|
||||||
|
}, snackbar)
|
||||||
|
}, snackbar)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
redirectIfAuthenticated(router)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="form" onSubmit={submit} sx={{ mt: 3 }} noValidate>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="firstName"
|
||||||
|
label="First name"
|
||||||
|
name="firstName"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
id="lastName"
|
||||||
|
label="Last name"
|
||||||
|
name="lastName"
|
||||||
|
autoComplete="off"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="username"
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="off"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="password"
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="passwordConfirmation"
|
||||||
|
label="Confirm password"
|
||||||
|
name="passwordConfirmation"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth variant="contained" sx={{ mt: 3, mb: 3 }}>
|
||||||
|
Sign Up
|
||||||
|
</Button>
|
||||||
|
<Link href="/signin" variant="body2" sx={{ float: 'right' }}>
|
||||||
|
Already have an account?
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Checkbox from '@mui/material/Checkbox'
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
import { HelpDocument } from 'socialvoid'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import Loader from '../components/Loader'
|
||||||
|
import { dispatch } from '../socialvoid'
|
||||||
|
import { unparse } from '../utilities/parser'
|
||||||
|
import { redirectIfAuthenticated } from '../utilities/redirect'
|
||||||
|
|
||||||
|
export default function ToS() {
|
||||||
|
const router = useRouter()
|
||||||
|
const snackbar = useSnackbar()
|
||||||
|
const [document, setDocument] = useState<HelpDocument>()
|
||||||
|
|
||||||
|
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const data = new FormData(event.currentTarget)
|
||||||
|
|
||||||
|
console.log(data.get('acceptTermOfServices'))
|
||||||
|
|
||||||
|
if (data.get('acceptTermOfServices') !== 'on') {
|
||||||
|
snackbar.enqueueSnackbar('You must read and accept.', {
|
||||||
|
variant: 'info',
|
||||||
|
preventDuplicate: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snackbar.closeSnackbar()
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
pathname: '/signup/[tosId]',
|
||||||
|
query: { tosId: z.string().parse(data.get('tosId')) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
redirectIfAuthenticated(router)
|
||||||
|
|
||||||
|
dispatch(async (client) => {
|
||||||
|
setDocument(await client.help.getTermsOfService())
|
||||||
|
}, snackbar)
|
||||||
|
})
|
||||||
|
|
||||||
|
return document ? (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: unparse(document.text, document.entities),
|
||||||
|
}}
|
||||||
|
></Typography>
|
||||||
|
<Box component="form" noValidate sx={{ mt: 3 }} onSubmit={submit}>
|
||||||
|
<input type="hidden" name="tosId" value={document.id} />
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox name="acceptTermOfServices" color="primary" />}
|
||||||
|
label="I have read and accepted."
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
sx={{ mt: 3, mb: 2 }}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { ProviderContext } from 'notistack'
|
||||||
|
import { Client, errors } from 'socialvoid'
|
||||||
|
|
||||||
|
export const client = new Client()
|
||||||
|
|
||||||
|
export async function dispatch(
|
||||||
|
func: (client: Client) => Promise<void> | void,
|
||||||
|
snackbar?: ProviderContext
|
||||||
|
) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Cannot dispatch requests in server-side')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await func(client)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof errors.SocialvoidError) {
|
||||||
|
if (
|
||||||
|
err instanceof errors.SessionExpired ||
|
||||||
|
err instanceof errors.BadSessionChallengeAnswer ||
|
||||||
|
err instanceof errors.InvalidSessionIdentification
|
||||||
|
) {
|
||||||
|
client.deleteSession()
|
||||||
|
|
||||||
|
if (snackbar) {
|
||||||
|
snackbar.enqueueSnackbar('Session expired.', {
|
||||||
|
variant: 'error',
|
||||||
|
preventDuplicate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snackbar) {
|
||||||
|
snackbar.enqueueSnackbar(
|
||||||
|
err.errorMessage.endsWith('.')
|
||||||
|
? err.errorMessage
|
||||||
|
: err.errorMessage + '.',
|
||||||
|
{
|
||||||
|
variant: 'error',
|
||||||
|
preventDuplicate: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
switch (err.message) {
|
||||||
|
case 'Session does not exist':
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const spassword = z
|
||||||
|
.string({ required_error: 'Password is required.' })
|
||||||
|
// .min(12, "The password should be 12 characters at least.")
|
||||||
|
.max(128, 'The password can’t be longer than 128 characters.')
|
||||||
|
.regex(/.*[A-Z].*/, 'The password should contain a capital letter.')
|
||||||
|
.regex(/.*[0-9].*[0-9].*/, 'The password should contain 2 numbers at least.')
|
|
@ -1,116 +0,0 @@
|
||||||
.container {
|
|
||||||
padding: 0 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 4rem 0;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
padding: 2rem 0;
|
|
||||||
border-top: 1px solid #eaeaea;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title a {
|
|
||||||
color: #0070f3;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title a:hover,
|
|
||||||
.title a:focus,
|
|
||||||
.title a:active {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.15;
|
|
||||||
font-size: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title,
|
|
||||||
.description {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
margin: 4rem 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code {
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
|
||||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
margin: 1rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: left;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
border: 1px solid #eaeaea;
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: color 0.15s ease, border-color 0.15s ease;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover,
|
|
||||||
.card:focus,
|
|
||||||
.card:active {
|
|
||||||
color: #0070f3;
|
|
||||||
border-color: #0070f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 1em;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.grid {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
html,
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
|
||||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import createTheme from '@mui/material/styles/createTheme'
|
||||||
|
|
||||||
|
const dark = createTheme({ palette: { mode: 'dark' } })
|
||||||
|
|
||||||
|
export default dark
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { ProviderContext } from 'notistack'
|
||||||
|
import { ZodError, defaultErrorMap, setErrorMap } from 'zod'
|
||||||
|
|
||||||
|
function camelToNormal(s: string) {
|
||||||
|
const chars = new Array<string>()
|
||||||
|
|
||||||
|
for (const char of s) {
|
||||||
|
if (char === char.toUpperCase()) {
|
||||||
|
if (chars.length === 0) {
|
||||||
|
chars.push(char)
|
||||||
|
} else {
|
||||||
|
chars.push(' ')
|
||||||
|
chars.push(char.toLowerCase())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chars.push(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chars.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMap((issue, _ctx) => {
|
||||||
|
let path = issue.path[0].toString()
|
||||||
|
|
||||||
|
path = path.charAt(0).toUpperCase() + path.slice(1)
|
||||||
|
|
||||||
|
if (
|
||||||
|
(issue.code === 'too_small' && issue.minimum === 1) ||
|
||||||
|
(issue.code === 'invalid_type' && issue.received === 'undefined')
|
||||||
|
) {
|
||||||
|
return { message: `${camelToNormal(path)} is empty.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultErrorMap(issue, _ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function handleZodErrors(func: () => void, snackbar: ProviderContext) {
|
||||||
|
try {
|
||||||
|
func()
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ZodError)) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatten = err.flatten()
|
||||||
|
const fieldErrors = Object.values(flatten.fieldErrors)
|
||||||
|
|
||||||
|
if (fieldErrors.length !== 0) {
|
||||||
|
snackbar.enqueueSnackbar(fieldErrors[0][0], {
|
||||||
|
variant: 'warning',
|
||||||
|
preventDuplicate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { TextEntity } from 'socialvoid'
|
||||||
|
|
||||||
|
function is(x: TextEntity, y: TextEntity) {
|
||||||
|
return (
|
||||||
|
x.offset === y.offset &&
|
||||||
|
x.length === y.length &&
|
||||||
|
x.type === y.type &&
|
||||||
|
x.value === y.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlEscape(s: string) {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unparse(
|
||||||
|
text: string,
|
||||||
|
entities: TextEntity[],
|
||||||
|
offset: number,
|
||||||
|
length: number
|
||||||
|
) {
|
||||||
|
let noffset = offset
|
||||||
|
let ntext = ''
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (offset >= entity.length + entity.offset) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ntext += htmlEscape(text.slice(offset, entity.offset))
|
||||||
|
|
||||||
|
const netntities = entities.filter(
|
||||||
|
(entity2) =>
|
||||||
|
entity.offset + entity.length >= entity2.offset && !is(entity, entity2)
|
||||||
|
)
|
||||||
|
|
||||||
|
let atext = ''
|
||||||
|
|
||||||
|
if (netntities) {
|
||||||
|
atext = _unparse(text, netntities, entity.offset, entity.length)
|
||||||
|
} else {
|
||||||
|
atext = htmlEscape(
|
||||||
|
text.slice(entity.offset, entity.offset + entity.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (entity.type) {
|
||||||
|
case 'BOLD':
|
||||||
|
ntext += `<b>${atext}</b>`
|
||||||
|
break
|
||||||
|
case 'ITALIC':
|
||||||
|
ntext += `<i>${atext}</i>`
|
||||||
|
break
|
||||||
|
case 'CODE':
|
||||||
|
ntext += `<code>${atext}</code>`
|
||||||
|
break
|
||||||
|
case 'STRIKE':
|
||||||
|
ntext += `<s>${atext}</s>`
|
||||||
|
break
|
||||||
|
case 'UNDERLINE':
|
||||||
|
ntext += `<u>${atext}</u>`
|
||||||
|
break
|
||||||
|
case 'URL':
|
||||||
|
ntext += `<a href="${htmlEscape(entity.value!)}">${atext}</a>`
|
||||||
|
break
|
||||||
|
case 'MENTION':
|
||||||
|
ntext += `<a href="sv://peer/${encodeURIComponent(
|
||||||
|
entity.value!
|
||||||
|
)}">${atext}</a>`
|
||||||
|
break
|
||||||
|
case 'HASHTAG':
|
||||||
|
ntext += atext
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = entity.offset + entity.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return ntext + htmlEscape(text.slice(offset, length + noffset))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unparse(text: string, entities: TextEntity[]) {
|
||||||
|
entities = entities.sort((a) => a.offset - a.length)
|
||||||
|
|
||||||
|
return _unparse(text, entities, 0, text.length).replace(/\n/g, '<br />')
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import router, { NextRouter } from 'next/router'
|
||||||
|
|
||||||
|
import { client } from '../socialvoid'
|
||||||
|
|
||||||
|
export function redirectIfAuthenticated(router: NextRouter) {
|
||||||
|
if (client.sessionExists) {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectIfNotAuthenticated(router: NextRouter) {
|
||||||
|
if (!client.sessionExists) {
|
||||||
|
router.replace('/signin')
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function humanize(unix: number) {
|
||||||
|
const delta = new Date().getTime() - new Date(unix * 3000).getTime()
|
||||||
|
|
||||||
|
const seconds = Math.round(delta / 1000)
|
||||||
|
const minutes = Math.round(seconds / 60)
|
||||||
|
const hours = Math.round(minutes / 60)
|
||||||
|
const days = Math.round(hours / 24)
|
||||||
|
const weeks = Math.round(days / 7)
|
||||||
|
const months = Math.round(weeks / 4)
|
||||||
|
const years = Math.round(months / 12)
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Post } from 'socialvoid'
|
||||||
|
|
||||||
|
export type NotDeletedPost = Post & {
|
||||||
|
peer: NonNullable<Post['peer']>
|
||||||
|
source: NonNullable<Post['source']>
|
||||||
|
text: NonNullable<Post['text']>
|
||||||
|
like_count: NonNullable<Post['like_count']>
|
||||||
|
repost_count: NonNullable<Post['repost_count']>
|
||||||
|
quote_count: NonNullable<Post['quote_count']>
|
||||||
|
reply_count: NonNullable<Post['reply_count']>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postNotDeleted(post: Post): post is NotDeletedPost {
|
||||||
|
return post.peer != null
|
||||||
|
}
|
Loading…
Reference in New Issue