This commit is contained in:
Roj Serbest 2021-11-13 21:22:28 +03:00
parent ab164dc65d
commit 33b5b2d179
28 changed files with 1672 additions and 236 deletions

13
.prettierrc Normal file
View File

@ -0,0 +1,13 @@
{
"semi": false,
"singleQuote": true,
"importOrder": [
"^react(.*)",
"^next(.*)",
"@mui(.*)",
"<THIRD_PARTY_MODULES>",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"prettier.configPath": ".prettierrc"
}

38
cache/index.ts vendored Normal file
View File

@ -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]))
}

40
components/Header.tsx Normal file
View File

@ -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>
)
}

21
components/Loader.tsx Normal file
View File

@ -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>
)
}

108
components/Post.tsx Normal file
View File

@ -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} &middot;{' '}
{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>
)
}

23
components/Theme.tsx Normal file
View File

@ -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>
)
}

View File

@ -1,4 +1,11 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
webpack: (config) => {
if (typeof config.resolve.fallback !== 'undefined') {
config.resolve.fallback.fs = false
}
return config
},
}

View File

@ -5,18 +5,31 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"pretty": "prettier --config .prettierrc run --write pages/ utilities/ themes/ specifications/ socialvoid/"
},
"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",
"notistack": "^2.0.3",
"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": {
"@trivago/prettier-plugin-sort-imports": "^3.1.1",
"@types/moment": "^2.13.0",
"@types/node": "16.11.7",
"@types/react": "17.0.34",
"eslint": "7",
"eslint-config-next": "12.0.3",
"prettier": "^2.4.1",
"typescript": "4.4.4"
}
}

View File

@ -1,8 +1,23 @@
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { AppProps } from 'next/app'
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
import Container from '@mui/material/Container'
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

View File

@ -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' })
}

View File

@ -1,72 +1,43 @@
import type { NextPage } from 'next'
import Head from 'next/head'
import Image from 'next/image'
import styles from '../styles/Home.module.css'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
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 (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<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 &rarr;</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 &rarr;</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 &rarr;</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 &rarr;</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>
<>
{posts ? (
posts.map((post) => (
<Post key={post.id} post={post} router={router} sx={{ mt: 3 }} />
))
) : (
<Loader />
)}
</>
)
}
export default Home

27
pages/post/[id].tsx Normal file
View File

@ -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 />
}

79
pages/signin.tsx Normal file
View File

@ -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' }}>
Dont have an account?
</Link>
</Box>
)
}

15
pages/signup.tsx Normal file
View File

@ -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 />
}

135
pages/signup/[tosId].tsx Normal file
View File

@ -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 dont 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>
)
}

83
pages/tos.tsx Normal file
View File

@ -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 />
)
}

54
socialvoid/index.ts Normal file
View File

@ -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
}
}
}
}
}

8
specifications/index.ts Normal file
View File

@ -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 cant 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.')

View File

@ -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;
}
}

View File

@ -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;
}

5
themes/dark.ts Normal file
View File

@ -0,0 +1,5 @@
import createTheme from '@mui/material/styles/createTheme'
const dark = createTheme({ palette: { mode: 'dark' } })
export default dark

56
utilities/errors.ts Normal file
View File

@ -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,
})
}
}
}

90
utilities/parser.ts Normal file
View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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 />')
}

15
utilities/redirect.ts Normal file
View File

@ -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')
}
}

11
utilities/time.ts Normal file
View File

@ -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)
}

15
utilities/types.ts Normal file
View File

@ -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
}

766
yarn.lock

File diff suppressed because it is too large Load Diff