chore: format (prettier) (#39)

* chore: add `prettier`

* chore: format
This commit is contained in:
r1oga
2022-10-20 06:32:58 +02:00
committed by GitHub
parent 461016c108
commit a70fa81c18
147 changed files with 16809 additions and 16727 deletions

6
package-lock.json generated
View File

@@ -19999,6 +19999,12 @@
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
"integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA=="
},
"prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
"integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
"dev": true
},
"pretty-bytes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.0.0.tgz",

View File

@@ -9,7 +9,9 @@
"dev": "NODE_ENV=development concurrently --kill-others-on-fail npm:dev-ui",
"build": "NODE_ENV=production npm run build-ui",
"test": "NODE_ENV=test jest --coverage=false",
"test:coverage": "NODE_ENV=test jest --coverage"
"test:coverage": "NODE_ENV=test jest --coverage",
"format:check": "prettier --check 'src'",
"format:write": "prettier --write 'src'"
},
"dependencies": {
"@draft-js-plugins/editor": "^4.1.0",
@@ -87,6 +89,7 @@
"jest": "^27.5.1",
"node-loader": "^0.6.0",
"node-sass": "^4.13.0",
"prettier": "^2.7.1",
"process": "^0.11.10",
"sass-loader": "^8.0.0",
"sinon": "^13.0.1",

18
prettier.config.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
arrowParens: 'avoid',
bracketSameLine: true,
bracketSpacing: true,
embeddedLanguageFormatting: 'auto',
endOfLine: 'lf',
htmlWhitespaceSensitivity: 'css',
insertPragma: false,
jsxSingleQuote: false,
printWidth: 100,
proseWrap: 'preserve',
quoteProps: 'as-needed',
requirePragma: false,
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'es5',
};

View File

@@ -1,34 +1,31 @@
import 'isomorphic-fetch';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
import {BrowserRouter} from 'react-router-dom';
import store from "./store/configureAppStore";
import App from "./pages/App";
import "./util/gun";
import {createServiceWorker} from "./util/sw";
import {ThemeProvider} from "./components/ThemeContext";
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import store from './store/configureAppStore';
import App from './pages/App';
import './util/gun';
import { createServiceWorker } from './util/sw';
import { ThemeProvider } from './components/ThemeContext';
(async () => {
if ('serviceWorker' in navigator) {
await createServiceWorker();
}
if ('serviceWorker' in navigator) {
await createServiceWorker();
}
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</Provider>,
document.getElementById('root'),
);
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
})();
if ((module as any).hot) {
(module as any).hot.accept();
(module as any).hot.accept();
}

View File

@@ -1,2 +1,2 @@
.avatar {
}
}

View File

@@ -1,206 +1,195 @@
import React, {ReactElement, useEffect, useState} from "react";
import React, { ReactElement, useEffect, useState } from 'react';
import makeBlockie from 'ethereum-blockies-base64';
import classNames from "classnames";
import Icon from "../Icon";
import {fetchAddressByName, getUser, User, useUser} from "../../ducks/users";
import {useDispatch} from "react-redux";
import Web3 from "web3";
import {getTwitterUser} from "../../util/twitter";
import {fetchNameByAddress} from "../../util/web3";
import {ellipsify} from "../../util/user";
import TwitterBronze from "../../../static/icons/twitter_bronze.svg";
import TwitterSilver from "../../../static/icons/twitter_silver.svg";
import TwitterGold from "../../../static/icons/twitter_gold.svg";
import TwitterUnrated from "../../../static/icons/twitter_unrated.svg";
import TAZLogo from "../../../static/icons/taz-logo.svg";
import "./avatar.scss";
import classNames from 'classnames';
import Icon from '../Icon';
import { fetchAddressByName, getUser, User, useUser } from '../../ducks/users';
import { useDispatch } from 'react-redux';
import Web3 from 'web3';
import { getTwitterUser } from '../../util/twitter';
import { fetchNameByAddress } from '../../util/web3';
import { ellipsify } from '../../util/user';
import TwitterBronze from '../../../static/icons/twitter_bronze.svg';
import TwitterSilver from '../../../static/icons/twitter_silver.svg';
import TwitterGold from '../../../static/icons/twitter_gold.svg';
import TwitterUnrated from '../../../static/icons/twitter_unrated.svg';
import TAZLogo from '../../../static/icons/taz-logo.svg';
import './avatar.scss';
type Props = {
name?: string;
address?: string;
className?: string;
incognito?: boolean;
group?: string | null;
semaphoreSignals?: any;
twitterUsername?: string;
}
name?: string;
address?: string;
className?: string;
incognito?: boolean;
group?: string | null;
semaphoreSignals?: any;
twitterUsername?: string;
};
const CACHE: {
[address: string]: string;
[address: string]: string;
} = {};
const GROUP_TO_PFP: {
[group: string]: string;
[group: string]: string;
} = {
'interrep_twitter_not_sufficient': TwitterUnrated,
'interrep_twitter_unrated': TwitterUnrated,
'interrep_twitter_bronze': TwitterBronze,
'interrep_twitter_silver': TwitterSilver,
'interrep_twitter_gold': TwitterGold,
'semaphore_taz_members': TAZLogo,
interrep_twitter_not_sufficient: TwitterUnrated,
interrep_twitter_unrated: TwitterUnrated,
interrep_twitter_bronze: TwitterBronze,
interrep_twitter_silver: TwitterSilver,
interrep_twitter_gold: TwitterGold,
semaphore_taz_members: TAZLogo,
};
export function Username(props: { address?: string }): ReactElement {
const [ensName, setEnsName] = useState('');
const {address = ''} = props;
const [ensName, setEnsName] = useState('');
const { address = '' } = props;
useEffect(() => {
(async () => {
setEnsName('');
if (!address) return;
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
useEffect(() => {
(async () => {
setEnsName('');
if (!address) return;
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
return (
<>
{ensName ? ensName : ellipsify(address)}
</>
);
return <>{ensName ? ensName : ellipsify(address)}</>;
}
export default function Avatar(props: Props): ReactElement {
const {
address,
name,
incognito,
group,
twitterUsername,
className,
} = props;
const { address, name, incognito, group, twitterUsername, className } = props;
const [username, setUsername] = useState('');
const [twitterProfileUrl, setTwitterProfileUrl] = useState('');
const [username, setUsername] = useState('');
const [twitterProfileUrl, setTwitterProfileUrl] = useState('');
const dispatch = useDispatch();
const [protocol, groupName] = group?.split('_') || [];
const dispatch = useDispatch();
const [protocol, groupName] = group?.split('_') || [];
const user = useUser(username);
const user = useUser(username);
useEffect(() => {
(async () => {
if (groupName) {
setUsername(groupName);
return;
}
useEffect(() => {
(async () => {
if (groupName) {
setUsername(groupName);
return;
}
if (name && !Web3.utils.isAddress(name)) {
const addr: any = await dispatch(fetchAddressByName(name));
setUsername(addr);
} else if (address) {
setUsername(address);
}
})();
}, [name, address, groupName]);
if (name && !Web3.utils.isAddress(name)) {
const addr: any = await dispatch(fetchAddressByName(name));
setUsername(addr);
} else if (address) {
setUsername(address);
}
})();
}, [name, address, groupName]);
useEffect(() => {
if (username) {
dispatch(getUser(username));
}
}, [username]);
useEffect(() => {
(async () => {
if (twitterUsername) {
const data = await getTwitterUser(twitterUsername);
setTwitterProfileUrl(data?.profile_image_url);
}
})();
}, [twitterUsername])
if (twitterUsername) {
return (
<div
className={classNames(
'inline-block',
'rounded-full',
'flex-shrink-0 flex-grow-0',
'bg-cover bg-center bg-no-repeat',
className,
)}
style={{
backgroundImage: `url(${twitterProfileUrl})`
}}
/>
)
useEffect(() => {
if (username) {
dispatch(getUser(username));
}
}, [username]);
if (incognito && protocol !== 'custom') {
const url = group ? GROUP_TO_PFP[group] : undefined;
return (
<Icon
className={classNames(
'inline-flex flex-row flex-nowrap items-center justify-center',
'rounded-full',
'flex-shrink-0 flex-grow-0',
'text-gray-100',
'avatar',
{
'bg-gray-800': !url,
'bg-transparent': url,
},
className,
)}
fa="fas fa-user-secret"
url={url}
/>
)
}
if (!user) {
return (
<div
className={classNames(
'inline-block',
'rounded-full',
'flex-shrink-0 flex-grow-0',
'bg-gray-100',
'bg-cover bg-center bg-no-repeat',
className,
)}
/>
);
}
const imageUrl = getImageUrl(user);
useEffect(() => {
(async () => {
if (twitterUsername) {
const data = await getTwitterUser(twitterUsername);
setTwitterProfileUrl(data?.profile_image_url);
}
})();
}, [twitterUsername]);
if (twitterUsername) {
return (
<div
className={classNames(
'inline-block',
'rounded-full',
'flex-shrink-0 flex-grow-0',
'bg-cover bg-center bg-no-repeat',
className,
)}
style={{
backgroundImage: `url(${imageUrl})`
}}
/>
)
<div
className={classNames(
'inline-block',
'rounded-full',
'flex-shrink-0 flex-grow-0',
'bg-cover bg-center bg-no-repeat',
className
)}
style={{
backgroundImage: `url(${twitterProfileUrl})`,
}}
/>
);
}
if (incognito && protocol !== 'custom') {
const url = group ? GROUP_TO_PFP[group] : undefined;
return (
<Icon
className={classNames(
'inline-flex flex-row flex-nowrap items-center justify-center',
'rounded-full',
'flex-shrink-0 flex-grow-0',
'text-gray-100',
'avatar',
{
'bg-gray-800': !url,
'bg-transparent': url,
},
className
)}
fa="fas fa-user-secret"
url={url}
/>
);
}
if (!user) {
return (
<div
className={classNames(
'inline-block',
'rounded-full',
'flex-shrink-0 flex-grow-0',
'bg-gray-100',
'bg-cover bg-center bg-no-repeat',
className
)}
/>
);
}
const imageUrl = getImageUrl(user);
return (
<div
className={classNames(
'inline-block',
'rounded-full',
'flex-shrink-0 flex-grow-0',
'bg-cover bg-center bg-no-repeat',
className
)}
style={{
backgroundImage: `url(${imageUrl})`,
}}
/>
);
}
export function getImageUrl (user: User | null): string {
if (!user) return '';
export function getImageUrl(user: User | null): string {
if (!user) return '';
let imageUrl = user?.profileImage;
let imageUrl = user?.profileImage;
if (!user?.profileImage && user.username) {
imageUrl = CACHE[user.username] ? CACHE[user.username] : makeBlockie(user.username);
CACHE[user.username] = imageUrl;
}
if (!user?.profileImage && user.username) {
imageUrl = CACHE[user.username] ? CACHE[user.username] : makeBlockie(user.username);
CACHE[user.username] = imageUrl;
}
if (imageUrl) {
try {
const avatar = new URL(imageUrl);
if (avatar.protocol === 'ipfs:') {
imageUrl = `https://ipfs.io/ipfs/${avatar.pathname.slice(2)}`;
} else {
imageUrl = avatar.href;
}
} catch (e) {}
}
if (imageUrl) {
try {
const avatar = new URL(imageUrl);
if (avatar.protocol === 'ipfs:') {
imageUrl = `https://ipfs.io/ipfs/${avatar.pathname.slice(2)}`;
} else {
imageUrl = avatar.href;
}
} catch (e) {}
}
return imageUrl;
}
return imageUrl;
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.dark {
.bottom-nav {
@@ -25,15 +25,14 @@
border-top: 1px solid $gray-100;
align-items: center;
flex: 0 0 auto;
padding: .5rem;
padding: 0.5rem;
}
&__icon {
flex: 1 0 auto;
height: 2rem;
width: 2rem;
transition: background-color 100ms ease-in-out,
color 100ms ease-in-out;
transition: background-color 100ms ease-in-out, color 100ms ease-in-out;
&:hover {
color: $primary-color;
@@ -64,7 +63,7 @@
&__unlock-menu {
bottom: 3.5rem;
top: auto !important;
right: .25rem;
right: 0.25rem;
position: fixed;
left: auto !important;
}
@@ -77,12 +76,11 @@
.menuable__menu {
bottom: 3.5rem;
top: auto !important;
right: .25rem;
right: 0.25rem;
position: fixed;
left: auto !important;
}
}
}
}
}
}

View File

@@ -1,66 +1,73 @@
import React, {ReactElement, useEffect, useState} from "react";
import "./bottom-nav.scss";
import Web3Button from "../Web3Button";
import {useHistory, useLocation} from "react-router";
import Icon from "../Icon";
import classNames from "classnames";
import {useAccount, useGunLoggedIn} from "../../ducks/web3";
import {useSelectedLocalId} from "../../ducks/worker";
import {fetchNameByAddress} from "../../util/web3";
import React, { ReactElement, useEffect, useState } from 'react';
import './bottom-nav.scss';
import Web3Button from '../Web3Button';
import { useHistory, useLocation } from 'react-router';
import Icon from '../Icon';
import classNames from 'classnames';
import { useAccount, useGunLoggedIn } from '../../ducks/web3';
import { useSelectedLocalId } from '../../ducks/worker';
import { fetchNameByAddress } from '../../util/web3';
export default function BottomNav(): ReactElement {
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const [ensName, setEnsName] = useState('');
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const [ensName, setEnsName] = useState('');
let address = '';
let address = '';
if (loggedIn) {
address = selectedLocalId?.address || account;
}
if (loggedIn) {
address = selectedLocalId?.address || account;
}
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
return (
<div className="bottom-nav">
<BottomNavIcon fa="fas fa-home" pathname="/home" disabled={!loggedIn} />
{/*<BottomNavIcon fa="fas fa-envelope" pathname={`/${ensName || address}/`} disabled={!loggedIn} />*/}
<BottomNavIcon fa="fas fa-envelope" pathname={`/chat`} disabled={!selectedLocalId} />
<BottomNavIcon fa="fas fa-globe-asia" pathname="/explore" />
<Web3Button className="bottom-nav__web3-icon" />
</div>
)
return (
<div className="bottom-nav">
<BottomNavIcon fa="fas fa-home" pathname="/home" disabled={!loggedIn} />
{/*<BottomNavIcon fa="fas fa-envelope" pathname={`/${ensName || address}/`} disabled={!loggedIn} />*/}
<BottomNavIcon fa="fas fa-envelope" pathname={`/chat`} disabled={!selectedLocalId} />
<BottomNavIcon fa="fas fa-globe-asia" pathname="/explore" />
<Web3Button className="bottom-nav__web3-icon" />
</div>
);
}
type BottomNavIconProps = {
fa: string;
pathname: string;
disabled?: boolean;
}
fa: string;
pathname: string;
disabled?: boolean;
};
function BottomNavIcon(props: BottomNavIconProps): ReactElement {
const history = useHistory();
const {pathname} = useLocation();
const history = useHistory();
const { pathname } = useLocation();
return (
<Icon
className={classNames(
'flex', 'flex-row', 'items-center', 'justify-center',
'bottom-nav__icon',
{
'bottom-nav__icon--selected': pathname === props.pathname,
'bottom-nav__icon--disabled': props.disabled,
}
)}
onClick={(pathname !== props.pathname && !props.disabled) ? () => history.push(props.pathname) : undefined}
fa={props.fa}
size={1.125}
/>
)
}
return (
<Icon
className={classNames(
'flex',
'flex-row',
'items-center',
'justify-center',
'bottom-nav__icon',
{
'bottom-nav__icon--selected': pathname === props.pathname,
'bottom-nav__icon--disabled': props.disabled,
}
)}
onClick={
pathname !== props.pathname && !props.disabled
? () => history.push(props.pathname)
: undefined
}
fa={props.fa}
size={1.125}
/>
);
}

View File

@@ -1,7 +1,6 @@
@import "../../util/variable";
@import '../../util/variable';
.button {
&:disabled {
opacity: 50%;
}
@@ -34,7 +33,7 @@
&:hover {
color: lighten($primary-color, 10);
border-color: lighten($primary-color, 10);;
border-color: lighten($primary-color, 10);
}
&:active {
@@ -52,8 +51,8 @@
}
&--small {
padding: .25rem .5rem !important;
font-size: .75rem !important;
padding: 0.25rem 0.5rem !important;
font-size: 0.75rem !important;
height: auto !important;
}
}
}

View File

@@ -1,45 +1,36 @@
import React, {ButtonHTMLAttributes, ReactElement} from "react";
import classNames from "classnames";
import "./button.scss";
import Icon from "../Icon";
import SpinnerGif from "../../../static/icons/spinner.gif";
import React, { ButtonHTMLAttributes, ReactElement } from 'react';
import classNames from 'classnames';
import './button.scss';
import Icon from '../Icon';
import SpinnerGif from '../../../static/icons/spinner.gif';
type Props = {
className?: string;
btnType?: 'primary' | 'secondary' | '';
loading?: boolean;
small?: boolean;
className?: string;
btnType?: 'primary' | 'secondary' | '';
loading?: boolean;
small?: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export default function Button(props: Props): ReactElement {
const {
className,
btnType = '',
children,
onClick,
disabled,
loading,
...btnProps
} = props;
return (
<button
className={classNames(
'rounded-xl',
'flex flex-row flex-nowrap items-center',
'h-10 px-4 button transition-colors',
{
'button--primary': btnType === 'primary',
'button--secondary': btnType === 'secondary',
'cursor-default': disabled || loading,
'button--small': !!props.small,
},
className,
)}
onClick={!disabled && !loading ? onClick : undefined}
disabled={disabled}
{...btnProps}
>
{loading ? <Icon url={SpinnerGif} size={2} />: children}
</button>
)
}
const { className, btnType = '', children, onClick, disabled, loading, ...btnProps } = props;
return (
<button
className={classNames(
'rounded-xl',
'flex flex-row flex-nowrap items-center',
'h-10 px-4 button transition-colors',
{
'button--primary': btnType === 'primary',
'button--secondary': btnType === 'secondary',
'cursor-default': disabled || loading,
'button--small': !!props.small,
},
className
)}
onClick={!disabled && !loading ? onClick : undefined}
disabled={disabled}
{...btnProps}>
{loading ? <Icon url={SpinnerGif} size={2} /> : children}
</button>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable.scss";
@import '../../util/variable.scss';
.dark {
.chat-content {
@@ -74,8 +74,8 @@
&__loading-gif {
position: absolute;
top: .25rem;
left: .25rem;
top: 0.25rem;
left: 0.25rem;
}
}
}
@@ -107,7 +107,6 @@
&__editor {
flex: 1 1 auto;
textarea {
}
}
}
@@ -118,7 +117,7 @@
border-radius: 1.25rem;
background-color: $gray-100;
max-width: 50%;
margin: .25rem 1rem;
margin: 0.25rem 1rem;
//border-bottom-left-radius: 0;
&--self {
@@ -147,6 +146,5 @@
}
&__time {
}
}
}

View File

@@ -1,279 +1,271 @@
import "./chat-content.scss";
import React, {ReactElement, useState, KeyboardEvent, useCallback, useEffect} from "react";
import classNames from "classnames";
import {useParams} from "react-router";
import InfiniteScrollable from "../InfiniteScrollable";
import {useSelectedLocalId, useSelectedZKGroup} from "../../ducks/worker";
import Nickname from "../Nickname";
import Avatar, {Username} from "../Avatar";
import Textarea from "../Textarea";
import {generateECDHKeyPairFromhex, generateZkIdentityFromHex, sha256, signWithP256} from "../../util/crypto";
import {FromNow} from "../ChatMenu";
import chats, {InflatedChat, useChatId, useChatMessage, useMessagesByChatId, zkchat} from "../../ducks/chats";
import Icon from "../Icon";
import SpinnerGIF from "../../../static/icons/spinner.gif";
import {useDispatch} from "react-redux";
import {findProof} from "../../util/merkle";
import {Strategy, ZkIdentity} from "@zk-kit/identity";
import {Chat} from "../../util/zkchat";
import {Identity} from "@semaphore-protocol/identity";
import './chat-content.scss';
import React, { ReactElement, useState, KeyboardEvent, useCallback, useEffect } from 'react';
import classNames from 'classnames';
import { useParams } from 'react-router';
import InfiniteScrollable from '../InfiniteScrollable';
import { useSelectedLocalId, useSelectedZKGroup } from '../../ducks/worker';
import Nickname from '../Nickname';
import Avatar, { Username } from '../Avatar';
import Textarea from '../Textarea';
import {
generateECDHKeyPairFromhex,
generateZkIdentityFromHex,
sha256,
signWithP256,
} from '../../util/crypto';
import { FromNow } from '../ChatMenu';
import chats, {
InflatedChat,
useChatId,
useChatMessage,
useMessagesByChatId,
zkchat,
} from '../../ducks/chats';
import Icon from '../Icon';
import SpinnerGIF from '../../../static/icons/spinner.gif';
import { useDispatch } from 'react-redux';
import { findProof } from '../../util/merkle';
import { Strategy, ZkIdentity } from '@zk-kit/identity';
import { Chat } from '../../util/zkchat';
import { Identity } from '@semaphore-protocol/identity';
export default function ChatContent(): ReactElement {
const { chatId } = useParams<{chatId: string}>();
const messages = useMessagesByChatId(chatId);
const chat = useChatId(chatId);
const params = useParams<{chatId: string}>();
const { chatId } = useParams<{ chatId: string }>();
const messages = useMessagesByChatId(chatId);
const chat = useChatId(chatId);
const params = useParams<{ chatId: string }>();
const loadMore = useCallback(async () => {
if (!chat) return;
await zkchat.fetchMessagesByChat(chat);
}, [chat])
const loadMore = useCallback(async () => {
if (!chat) return;
await zkchat.fetchMessagesByChat(chat);
}, [chat]);
useEffect(() => {
loadMore();
}, [loadMore]);
useEffect(() => {
loadMore();
}, [loadMore]);
if (!chat) return <></>;
if (!chat) return <></>;
return (
<div
className={classNames('chat-content', {
'chat-content--anon': chat?.senderHash,
'chat-content--chat-selected': params.chatId,
})}>
<ChatHeader />
<InfiniteScrollable
className="chat-content__messages"
onScrolledToTop={loadMore}
topOffset={128}
>
{messages.map(messageId => {
return (
<ChatMessageBubble
key={messageId}
messageId={messageId}
chat={chat}
/>
);
})}
</InfiniteScrollable>
<ChatEditor />
</div>
);
return (
<div
className={classNames('chat-content', {
'chat-content--anon': chat?.senderHash,
'chat-content--chat-selected': params.chatId,
})}>
<ChatHeader />
<InfiniteScrollable
className="chat-content__messages"
onScrolledToTop={loadMore}
topOffset={128}>
{messages.map(messageId => {
return <ChatMessageBubble key={messageId} messageId={messageId} chat={chat} />;
})}
</InfiniteScrollable>
<ChatEditor />
</div>
);
}
function ChatHeader(): ReactElement {
const { chatId } = useParams<{chatId: string}>();
const chat = useChatId(chatId);
const { chatId } = useParams<{ chatId: string }>();
const chat = useChatId(chatId);
return (
<div className="chat-content__header">
<Avatar
className="w-10 h-10"
address={chat?.receiver}
incognito={!chat?.receiver}
group={chat?.type === 'DIRECT' ? chat.group : undefined}
/>
<div className="flex flex-col flex-grow flex-shrink ml-2">
<Nickname
className="font-bold"
address={chat?.receiver}
group={chat?.type === 'DIRECT' ? chat.group : undefined}
/>
<div
className={classNames("text-xs", {
'text-gray-500': true,
})}
>
{chat?.receiver && (
<>
<span>@</span>
<Username address={chat?.receiver || ''} />
</>
)}
</div>
</div>
return (
<div className="chat-content__header">
<Avatar
className="w-10 h-10"
address={chat?.receiver}
incognito={!chat?.receiver}
group={chat?.type === 'DIRECT' ? chat.group : undefined}
/>
<div className="flex flex-col flex-grow flex-shrink ml-2">
<Nickname
className="font-bold"
address={chat?.receiver}
group={chat?.type === 'DIRECT' ? chat.group : undefined}
/>
<div
className={classNames('text-xs', {
'text-gray-500': true,
})}>
{chat?.receiver && (
<>
<span>@</span>
<Username address={chat?.receiver || ''} />
</>
)}
</div>
);
</div>
</div>
);
}
function ChatEditor(): ReactElement {
const { chatId } = useParams<{chatId: string}>();
const selected = useSelectedLocalId();
const [content, setContent] = useState('');
const chat = useChatId(chatId);
const [error, setError] = useState('');
const [isSending, setSending] = useState(false);
const dispatch = useDispatch();
const zkGroup = useSelectedZKGroup();
const { chatId } = useParams<{ chatId: string }>();
const selected = useSelectedLocalId();
const [content, setContent] = useState('');
const chat = useChatId(chatId);
const [error, setError] = useState('');
const [isSending, setSending] = useState(false);
const dispatch = useDispatch();
const zkGroup = useSelectedZKGroup();
useEffect(() => {
setContent('');
}, [chatId]);
useEffect(() => {
setContent('');
}, [chatId]);
const submitMessage = useCallback(async () => {
if (!chat || !content) return;
const submitMessage = useCallback(async () => {
if (!chat || !content) return;
let signature = '';
let merkleProof, identitySecretHash;
let signature = '';
let merkleProof, identitySecretHash;
if (selected?.type === 'gun') {
signature = signWithP256(selected.privateKey, selected.address) + '.' + selected.address;
if (chat.senderHash) {
const zkseed = await signWithP256(selected.privateKey, 'signing for zk identity - 0');
const zkHex = await sha256(zkseed);
const zkIdentity = await generateZkIdentityFromHex(zkHex);
merkleProof = await findProof(
'zksocial_all',
zkIdentity.genIdentityCommitment().toString(16),
);
identitySecretHash = zkIdentity.getSecretHash();
}
} else if (selected?.type === 'interrep') {
const {type, provider, name, identityCommitment, serializedIdentity} = selected;
const group = `${type}_${provider.toLowerCase()}_${name}`;
const zkIdentity = new ZkIdentity(Strategy.SERIALIZED, serializedIdentity);
merkleProof = await findProof(
group,
BigInt(identityCommitment).toString(16),
);
identitySecretHash = zkIdentity.getSecretHash();
} else if (selected?.type === 'taz') {
const {type, identityCommitment, serializedIdentity} = selected;
const group = `semaphore_taz_members`;
const zkIdentity = new Identity(serializedIdentity);
merkleProof = await findProof(
group,
BigInt(identityCommitment).toString(16),
);
}
const json = await zkchat.sendDirectMessage(
chat,
content,
{
'X-SIGNED-ADDRESS': signature,
},
merkleProof,
identitySecretHash,
if (selected?.type === 'gun') {
signature = signWithP256(selected.privateKey, selected.address) + '.' + selected.address;
if (chat.senderHash) {
const zkseed = await signWithP256(selected.privateKey, 'signing for zk identity - 0');
const zkHex = await sha256(zkseed);
const zkIdentity = await generateZkIdentityFromHex(zkHex);
merkleProof = await findProof(
'zksocial_all',
zkIdentity.genIdentityCommitment().toString(16)
);
identitySecretHash = zkIdentity.getSecretHash();
}
} else if (selected?.type === 'interrep') {
const { type, provider, name, identityCommitment, serializedIdentity } = selected;
const group = `${type}_${provider.toLowerCase()}_${name}`;
const zkIdentity = new ZkIdentity(Strategy.SERIALIZED, serializedIdentity);
merkleProof = await findProof(group, BigInt(identityCommitment).toString(16));
identitySecretHash = zkIdentity.getSecretHash();
} else if (selected?.type === 'taz') {
const { type, identityCommitment, serializedIdentity } = selected;
const group = `semaphore_taz_members`;
const zkIdentity = new Identity(serializedIdentity);
merkleProof = await findProof(group, BigInt(identityCommitment).toString(16));
}
const json = await zkchat.sendDirectMessage(
chat,
content,
{
'X-SIGNED-ADDRESS': signature,
},
merkleProof,
identitySecretHash
);
setContent('');
}, [content, selected, chat]);
setContent('');
}, [content, selected, chat]);
const onClickSend = useCallback(async () => {
const onClickSend = useCallback(async () => {
setSending(true);
setError('');
try {
await submitMessage();
} catch (e) {
setError(e.message);
} finally {
setSending(false);
}
}, [submitMessage]);
const onEnter = useCallback(
async (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (window.innerWidth < 768) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setSending(true);
setError('');
try {
await submitMessage();
await submitMessage();
} catch (e) {
setError(e.message);
setError(e.message);
} finally {
setSending(false);
setSending(false);
e.target.focus();
}
}, [submitMessage]);
}
},
[submitMessage]
);
const onEnter = useCallback(async (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (window.innerWidth < 768) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setSending(true);
setError('');
try {
await submitMessage();
} catch (e) {
setError(e.message);
} finally {
setSending(false);
e.target.focus();
}
}
}, [submitMessage]);
const onChange = useCallback(async (e: KeyboardEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
setError('');
}, []);
const onChange = useCallback(async (e: KeyboardEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
setError('');
}, []);
return (
<div className="chat-content__editor-wrapper">
{ !!error && <small className="error-message text-xs text-center text-red-500 mb-1 mt-2">{error}</small> }
<div className="flex flex-row w-full">
<div className="chat-content__editor ml-2">
<Textarea
key={chatId}
className="text-light border mr-2 my-2"
// ref={(el) => el?.focus()}
rows={Math.max(0, content.split('\n').length)}
value={content}
onChange={onChange}
onKeyPress={onEnter}
disabled={isSending}
autoFocus
/>
</div>
<div className="relative flex flex-row items-end">
{
content
? (
<Icon
className={classNames("m-2 text-white justify-center rounded-full", {
'opacity-50': isSending,
'w-10 h-10': !isSending,
'bg-primary-color': !zkGroup,
'bg-gray-800': zkGroup,
})}
onClick={onClickSend}
fa={isSending ? undefined : "fas fa-paper-plane"}
url={isSending ? SpinnerGIF : undefined}
size={isSending ? 2.5 : undefined}
/>
)
: (
<Avatar
className={classNames("w-10 h-10 m-2", {
'opacity-50': isSending,
})}
address={selected?.address}
incognito={!!chat?.senderHash}
group={zkGroup}
/>
)
}
</div>
</div>
return (
<div className="chat-content__editor-wrapper">
{!!error && (
<small className="error-message text-xs text-center text-red-500 mb-1 mt-2">{error}</small>
)}
<div className="flex flex-row w-full">
<div className="chat-content__editor ml-2">
<Textarea
key={chatId}
className="text-light border mr-2 my-2"
// ref={(el) => el?.focus()}
rows={Math.max(0, content.split('\n').length)}
value={content}
onChange={onChange}
onKeyPress={onEnter}
disabled={isSending}
autoFocus
/>
</div>
);
}
function ChatMessageBubble(props: {
messageId: string;
chat: Chat;
}) {
const chatMessage = useChatMessage(props.messageId);
if (chatMessage?.type !== 'DIRECT') return <></>;
return (
<div
key={chatMessage.messageId}
className={classNames("chat-message", {
'chat-message--self': chatMessage.sender.ecdh === props.chat.senderECDH,
'chat-message--anon': chatMessage.sender.hash,
})}
>
<div className={classNames("chat-message__content text-light", {
'italic opacity-70': chatMessage.encryptionError,
})}>
{chatMessage.encryptionError ? 'Cannot decrypt message' : chatMessage.content}
</div>
<FromNow
className="chat-message__time text-xs mt-2 text-gray-700"
timestamp={chatMessage.timestamp}
<div className="relative flex flex-row items-end">
{content ? (
<Icon
className={classNames('m-2 text-white justify-center rounded-full', {
'opacity-50': isSending,
'w-10 h-10': !isSending,
'bg-primary-color': !zkGroup,
'bg-gray-800': zkGroup,
})}
onClick={onClickSend}
fa={isSending ? undefined : 'fas fa-paper-plane'}
url={isSending ? SpinnerGIF : undefined}
size={isSending ? 2.5 : undefined}
/>
) : (
<Avatar
className={classNames('w-10 h-10 m-2', {
'opacity-50': isSending,
})}
address={selected?.address}
incognito={!!chat?.senderHash}
group={zkGroup}
/>
)}
</div>
);
</div>
</div>
);
}
function ChatMessageBubble(props: { messageId: string; chat: Chat }) {
const chatMessage = useChatMessage(props.messageId);
if (chatMessage?.type !== 'DIRECT') return <></>;
return (
<div
key={chatMessage.messageId}
className={classNames('chat-message', {
'chat-message--self': chatMessage.sender.ecdh === props.chat.senderECDH,
'chat-message--anon': chatMessage.sender.hash,
})}>
<div
className={classNames('chat-message__content text-light', {
'italic opacity-70': chatMessage.encryptionError,
})}>
{chatMessage.encryptionError ? 'Cannot decrypt message' : chatMessage.content}
</div>
<FromNow
className="chat-message__time text-xs mt-2 text-gray-700"
timestamp={chatMessage.timestamp}
/>
</div>
);
}

View File

@@ -1,5 +1,4 @@
@import "../../util/variable.scss";
@import '../../util/variable.scss';
.dark {
.chat-menu {
@@ -49,15 +48,15 @@
}
&__item {
margin: .25rem .5rem;
padding: .5rem;
border-radius: .5rem;
margin: 0.25rem 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 150ms ease-in-out;
align-items: center;
&:hover {
background-color: rgba($gray-100, .7);
background-color: rgba($gray-100, 0.7);
}
&:active,
@@ -83,7 +82,7 @@
&__header {
@extend %col-nowrap;
align-items: center;
margin: .5rem;
margin: 0.5rem;
&__r {
@extend %row-nowrap;
@@ -119,4 +118,4 @@
color: $white;
}
}
}
}

View File

@@ -1,202 +1,201 @@
import "./chat-menu.scss";
import React, {ChangeEvent, MouseEvent, ReactElement, useCallback, useEffect, useState} from "react";
import classNames from "classnames";
import Avatar, {Username} from "../Avatar";
import {useSelectedLocalId, useSelectedZKGroup} from "../../ducks/worker";
import moment from "moment";
import config from "../../util/config";
import Nickname from "../Nickname";
import {useHistory, useParams} from "react-router";
import {useDispatch} from "react-redux";
import chats, {fetchChats, setChats, useChatId, useChatIds, useLastNMessages, zkchat} from "../../ducks/chats";
import Icon from "../Icon";
import Input from "../Input";
import {Chat} from "../../util/zkchat";
import Modal, {ModalContent, ModalFooter, ModalHeader} from "../Modal";
import Button from "../Button";
import {getName} from "../../util/user";
import {useUser} from "../../ducks/users";
import sse from "../../util/sse";
import {useThemeContext} from "../ThemeContext";
import './chat-menu.scss';
import React, {
ChangeEvent,
MouseEvent,
ReactElement,
useCallback,
useEffect,
useState,
} from 'react';
import classNames from 'classnames';
import Avatar, { Username } from '../Avatar';
import { useSelectedLocalId, useSelectedZKGroup } from '../../ducks/worker';
import moment from 'moment';
import config from '../../util/config';
import Nickname from '../Nickname';
import { useHistory, useParams } from 'react-router';
import { useDispatch } from 'react-redux';
import chats, {
fetchChats,
setChats,
useChatId,
useChatIds,
useLastNMessages,
zkchat,
} from '../../ducks/chats';
import Icon from '../Icon';
import Input from '../Input';
import { Chat } from '../../util/zkchat';
import Modal, { ModalContent, ModalFooter, ModalHeader } from '../Modal';
import Button from '../Button';
import { getName } from '../../util/user';
import { useUser } from '../../ducks/users';
import sse from '../../util/sse';
import { useThemeContext } from '../ThemeContext';
export default function ChatMenu(): ReactElement {
const selected = useSelectedLocalId();
const selecteduser = useUser(selected?.address);
const history = useHistory();
const dispatch = useDispatch();
const chatIds = useChatIds();
const [showingCreateChat, setShowingCreateChat] = useState(false);
const [selectedNewConvo, selectNewConvo] = useState<Chat | null>(null);
const [searchParam, setSearchParam] = useState('');
const [searchResults, setSearchResults] = useState<Chat[] | null>(null);
const params = useParams<{chatId: string}>();
const selected = useSelectedLocalId();
const selecteduser = useUser(selected?.address);
const history = useHistory();
const dispatch = useDispatch();
const chatIds = useChatIds();
const [showingCreateChat, setShowingCreateChat] = useState(false);
const [selectedNewConvo, selectNewConvo] = useState<Chat | null>(null);
const [searchParam, setSearchParam] = useState('');
const [searchResults, setSearchResults] = useState<Chat[] | null>(null);
const params = useParams<{ chatId: string }>();
useEffect(() => {
if (selecteduser?.ecdh && selected?.type === 'gun') {
setTimeout(() => {
dispatch(fetchChats(selecteduser.ecdh));
}, 500);
} else if (selected?.type === 'interrep' || selected?.type === 'taz') {
setTimeout(() => {
dispatch(fetchChats(selected.identityCommitment));
}, 500);
}
}, [selected, selecteduser]);
useEffect(() => {
if (selecteduser?.ecdh && selected?.type === 'gun') {
setTimeout(() => {
dispatch(fetchChats(selecteduser.ecdh));
}, 500);
} else if (selected?.type === 'interrep' || selected?.type === 'taz') {
setTimeout(() => {
dispatch(fetchChats(selected.identityCommitment));
}, 500);
}
}, [selected, selecteduser]);
const onSearchNewChatChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
setSearchParam(e.target.value);
const res = await fetch(`${config.indexerAPI}/v1/zkchat/chats/search/${e.target.value}`);
const json = await res.json();
const onSearchNewChatChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
setSearchParam(e.target.value);
const res = await fetch(`${config.indexerAPI}/v1/zkchat/chats/search/${e.target.value}`);
const json = await res.json();
setSearchResults(json.payload.map((data: any) => ({
type: 'DIRECT',
receiver: data.receiver_address,
ecdh: data.receiver_ecdh,
})))
}, []);
setSearchResults(
json.payload.map((data: any) => ({
type: 'DIRECT',
receiver: data.receiver_address,
ecdh: data.receiver_ecdh,
}))
);
}, []);
if (!selected) return <></>;
if (!selected) return <></>;
return (
<div
className={classNames("chat-menu", {
"chat-menu--chat-selected": params.chatId,
})}
>
<div className="chat-menu__header">
<div className="flex flex-row chat-menu__header__r">
{
showingCreateChat && (
<Icon
className="chat-menu__create-icon text-gray-400 hover:text-gray-800 py-2 pl-2"
fa="fas fa-arrow-left"
size={.75}
onClick={() => {
setShowingCreateChat(false);
setSearchParam('');
setSearchResults(null);
}}
/>
)
}
<div
className="text-xs font-bold flex-grow ml-2"
>
{showingCreateChat ? 'Create New Conversation' : 'Conversations' }
</div>
{
!showingCreateChat && (
<Icon
className="chat-menu__create-icon text-gray-400 hover:text-gray-800 py-2 px-2"
fa="fas fa-plus"
size={.75}
onClick={() => {
setShowingCreateChat(true);
// @ts-ignore
onSearchNewChatChange({ target: { value: '' }});
}}
/>
)
}
</div>
<Input
className="border text-sm chat-menu__search"
onChange={showingCreateChat ? onSearchNewChatChange : () => null}
value={searchParam}
placeholder={!showingCreateChat ? 'Search' : 'Search by name'}
>
<Icon className="text-gray-400 mx-2" fa="fas fa-search" size={.75} />
</Input>
</div>
{ !!showingCreateChat && (
searchResults?.length
? searchResults.map((chat) => (
<ChatMenuItem
key={chat.type + chat.receiver}
chatId=""
chat={chat}
selectNewConvo={selectNewConvo}
setCreating={setShowingCreateChat}
isCreating={showingCreateChat}
hideLastChat
/>
))
: (
<div className="text-center text-light text-gray-400 font-semibold my-2">
No conversations found
</div>
)
)}
{ !showingCreateChat && chatIds.map((chatId) => (
<ChatMenuItem
key={chatId}
chatId={chatId}
selectNewConvo={selectNewConvo}
setCreating={setShowingCreateChat}
isCreating={showingCreateChat}
/>
))}
{
selectedNewConvo && (
<CreateChatOptionModal
onClose={() => {
selectNewConvo(null);
setShowingCreateChat(false);
}}
chat={selectedNewConvo}
/>
)
}
return (
<div
className={classNames('chat-menu', {
'chat-menu--chat-selected': params.chatId,
})}>
<div className="chat-menu__header">
<div className="flex flex-row chat-menu__header__r">
{showingCreateChat && (
<Icon
className="chat-menu__create-icon text-gray-400 hover:text-gray-800 py-2 pl-2"
fa="fas fa-arrow-left"
size={0.75}
onClick={() => {
setShowingCreateChat(false);
setSearchParam('');
setSearchResults(null);
}}
/>
)}
<div className="text-xs font-bold flex-grow ml-2">
{showingCreateChat ? 'Create New Conversation' : 'Conversations'}
</div>
{!showingCreateChat && (
<Icon
className="chat-menu__create-icon text-gray-400 hover:text-gray-800 py-2 px-2"
fa="fas fa-plus"
size={0.75}
onClick={() => {
setShowingCreateChat(true);
// @ts-ignore
onSearchNewChatChange({ target: { value: '' } });
}}
/>
)}
</div>
);
};
<Input
className="border text-sm chat-menu__search"
onChange={showingCreateChat ? onSearchNewChatChange : () => null}
value={searchParam}
placeholder={!showingCreateChat ? 'Search' : 'Search by name'}>
<Icon className="text-gray-400 mx-2" fa="fas fa-search" size={0.75} />
</Input>
</div>
{!!showingCreateChat &&
(searchResults?.length ? (
searchResults.map(chat => (
<ChatMenuItem
key={chat.type + chat.receiver}
chatId=""
chat={chat}
selectNewConvo={selectNewConvo}
setCreating={setShowingCreateChat}
isCreating={showingCreateChat}
hideLastChat
/>
))
) : (
<div className="text-center text-light text-gray-400 font-semibold my-2">
No conversations found
</div>
))}
{!showingCreateChat &&
chatIds.map(chatId => (
<ChatMenuItem
key={chatId}
chatId={chatId}
selectNewConvo={selectNewConvo}
setCreating={setShowingCreateChat}
isCreating={showingCreateChat}
/>
))}
{selectedNewConvo && (
<CreateChatOptionModal
onClose={() => {
selectNewConvo(null);
setShowingCreateChat(false);
}}
chat={selectedNewConvo}
/>
)}
</div>
);
}
function CreateChatOptionModal(props: {
chat: Chat;
onClose: () => void;
}): ReactElement {
const selected = useSelectedLocalId();
const user = useUser(selected?.address);
const r_user = useUser(props.chat.receiver);
const history = useHistory();
function CreateChatOptionModal(props: { chat: Chat; onClose: () => void }): ReactElement {
const selected = useSelectedLocalId();
const user = useUser(selected?.address);
const r_user = useUser(props.chat.receiver);
const history = useHistory();
const startChat = useCallback(async (e: MouseEvent<HTMLButtonElement>, isAnon: boolean) => {
e.stopPropagation();
if (!r_user) return;
const chat = await zkchat.createDM(r_user.address, r_user.ecdh, isAnon);
props.onClose();
const chatId = zkchat.deriveChatId(chat);
history.push(`/chat/${chatId}`);
}, [props.chat, r_user]);
const startChat = useCallback(
async (e: MouseEvent<HTMLButtonElement>, isAnon: boolean) => {
e.stopPropagation();
if (!r_user) return;
const chat = await zkchat.createDM(r_user.address, r_user.ecdh, isAnon);
props.onClose();
const chatId = zkchat.deriveChatId(chat);
history.push(`/chat/${chatId}`);
},
[props.chat, r_user]
);
if (!user) return <></>;
if (!user) return <></>;
return (
<Modal
className="w-96"
onClose={props.onClose}
>
<ModalHeader>
{ props.chat.type === 'DIRECT' ? 'Create Direct Message' : 'Create New Group'}
</ModalHeader>
<ModalFooter className="create-chat-options__footer">
<Button
className="mr-1 create-chat-options__create-btn"
onClick={(e) => startChat(e, false)}
>
<Avatar className="w-10 h-10" address={user.address} />
<div className="ml-2">{`Chat as ${getName(user)}`}</div>
</Button>
<Button
className="mr-1 create-chat-options__create-btn create-chat-options__create-btn--anon"
onClick={(e) => startChat(e, true)}
>
<Avatar className="w-10 h-10 bg-black" incognito />
<div className="ml-2">Chat anonymously</div>
</Button>
</ModalFooter>
</Modal>
);
return (
<Modal className="w-96" onClose={props.onClose}>
<ModalHeader>
{props.chat.type === 'DIRECT' ? 'Create Direct Message' : 'Create New Group'}
</ModalHeader>
<ModalFooter className="create-chat-options__footer">
<Button className="mr-1 create-chat-options__create-btn" onClick={e => startChat(e, false)}>
<Avatar className="w-10 h-10" address={user.address} />
<div className="ml-2">{`Chat as ${getName(user)}`}</div>
</Button>
<Button
className="mr-1 create-chat-options__create-btn create-chat-options__create-btn--anon"
onClick={e => startChat(e, true)}>
<Avatar className="w-10 h-10 bg-black" incognito />
<div className="ml-2">Chat anonymously</div>
</Button>
</ModalFooter>
</Modal>
);
}
const ONE_MIN = 60 * 1000;
@@ -205,153 +204,132 @@ const ONE_DAY = 24 * ONE_HOUR;
const ONE_WEEK = 7 * ONE_DAY;
function ChatMenuItem(props: {
chatId: string;
chat?: Chat;
hideLastChat?: boolean;
selectNewConvo: (chat: Chat) => void;
setCreating: (showing: boolean) => void;
isCreating: boolean;
chatId: string;
chat?: Chat;
hideLastChat?: boolean;
selectNewConvo: (chat: Chat) => void;
setCreating: (showing: boolean) => void;
isCreating: boolean;
}): ReactElement {
const selected = useSelectedLocalId();
const zkGroup = useSelectedZKGroup();
let chat = useChatId(props.chatId);
const params = useParams<{chatId: string}>();
const history = useHistory();
const [last] = useLastNMessages(props.chatId, 1);
const theme = useThemeContext();
const selected = useSelectedLocalId();
const zkGroup = useSelectedZKGroup();
let chat = useChatId(props.chatId);
const params = useParams<{ chatId: string }>();
const history = useHistory();
const [last] = useLastNMessages(props.chatId, 1);
const theme = useThemeContext();
const isSelected = props.chatId === params.chatId;
const isSelected = props.chatId === params.chatId;
if (props.chat) {
// @ts-ignore
chat = props.chat;
if (props.chat) {
// @ts-ignore
chat = props.chat;
}
const r_user = useUser(chat?.receiver);
const onClick = useCallback(async () => {
if (!chat) return;
if (!props.isCreating) {
history.push(`/chat/${zkchat.deriveChatId(chat)}`);
} else {
if (!r_user) return;
if (['interrep', 'taz'].includes(selected?.type as string)) {
const newChat = await zkchat.createDM(r_user.address, r_user.ecdh, true);
const chatId = zkchat.deriveChatId(newChat);
props.setCreating(false);
history.push(`/chat/${chatId}`);
} else if (selected?.type === 'gun') {
props.selectNewConvo(chat);
}
}
}, [chat, r_user, selected, props.isCreating]);
const r_user = useUser(chat?.receiver);
const onClick = useCallback(async () => {
if (!chat) return;
if (!props.isCreating) {
history.push(`/chat/${zkchat.deriveChatId(chat)}`);
} else {
if (!r_user) return;
if (['interrep', 'taz'].includes(selected?.type as string)) {
const newChat = await zkchat.createDM(r_user.address, r_user.ecdh, true);
const chatId = zkchat.deriveChatId(newChat);
props.setCreating(false);
history.push(`/chat/${chatId}`);
} else if (selected?.type === 'gun') {
props.selectNewConvo(chat);
}
useEffect(() => {
if (!props.chatId || !chat) return;
(async () => {
await zkchat.fetchMessagesByChat(chat, 1);
if (chat.type === 'DIRECT') {
if (chat.senderHash && chat.senderECDH) {
await sse.updateTopics([`ecdh:${chat.senderECDH}`]);
}
}, [chat, r_user, selected, props.isCreating]);
}
})();
}, [props.chatId, chat]);
useEffect(() => {
if (!props.chatId || !chat) return;
(async () => {
await zkchat.fetchMessagesByChat(chat, 1);
if (chat.type === 'DIRECT') {
if (chat.senderHash && chat.senderECDH) {
await sse.updateTopics([`ecdh:${chat.senderECDH}`]);
}
}
})();
}, [props.chatId, chat]);
if (!chat) return <></>;
if (!chat) return <></>;
return (
<div
className={classNames("flex flex-row chat-menu__item", {
'chat-menu__item--selected': isSelected,
'chat-menu__item--anon': chat.type === 'DIRECT' && chat.senderHash,
return (
<div
className={classNames('flex flex-row chat-menu__item', {
'chat-menu__item--selected': isSelected,
'chat-menu__item--anon': chat.type === 'DIRECT' && chat.senderHash,
})}
onClick={onClick}>
<div className="relative">
<Avatar
className="w-12 h-12 flex-grow-0 flex-shrink-0"
address={chat.receiver || ''}
incognito={!chat.receiver}
group={chat.type === 'DIRECT' ? chat.group : undefined}
/>
{chat.type === 'DIRECT' && chat.senderHash && (
<Avatar
className={classNames('chat-menu__item__anon-marker', {
'bg-gray-800': !zkGroup,
'bg-white': zkGroup,
})}
onClick={onClick}
>
<div className="relative">
<Avatar
className="w-12 h-12 flex-grow-0 flex-shrink-0"
address={chat.receiver || ''}
incognito={!chat.receiver}
group={chat.type === 'DIRECT' ? chat.group : undefined}
/>
{
chat.type === 'DIRECT' && chat.senderHash && (
<Avatar
className={classNames("chat-menu__item__anon-marker", {
'bg-gray-800': !zkGroup,
'bg-white': zkGroup,
})}
incognito
group={zkGroup}
/>
)
}
</div>
<div className="flex flex-col flex-grow flex-shrink mx-4 w-0">
<Nickname
className="font-bold truncate"
address={chat.receiver || ''}
group={chat.type === 'DIRECT' ? chat.group : undefined}
/>
{
!props.hideLastChat && (
<div
className={classNames("text-sm truncate", {
'text-gray-800': theme !== 'dark',
'text-gray-200': theme === 'dark',
})}
>
{last?.content}
</div>
)
}
</div>
{
!props.hideLastChat && (
<div
className={classNames("flex-grow-0 flex-shrink-0 mt-1 text-gray-500")}
>
{
last?.timestamp && (
<FromNow
className="text-xs"
timestamp={last.timestamp}
/>
)
}
</div>
)
}
incognito
group={zkGroup}
/>
)}
</div>
<div className="flex flex-col flex-grow flex-shrink mx-4 w-0">
<Nickname
className="font-bold truncate"
address={chat.receiver || ''}
group={chat.type === 'DIRECT' ? chat.group : undefined}
/>
{!props.hideLastChat && (
<div
className={classNames('text-sm truncate', {
'text-gray-800': theme !== 'dark',
'text-gray-200': theme === 'dark',
})}>
{last?.content}
</div>
)}
</div>
{!props.hideLastChat && (
<div className={classNames('flex-grow-0 flex-shrink-0 mt-1 text-gray-500')}>
{last?.timestamp && <FromNow className="text-xs" timestamp={last.timestamp} />}
</div>
)
)}
</div>
);
}
export function FromNow(props: { timestamp: Date; className?: string }): ReactElement {
const now = new Date();
const past = props.timestamp.getTime();
const diff = now.getTime() - past;
export function FromNow(props: {
timestamp: Date;
className?: string;
}): ReactElement {
const now = new Date();
const past = props.timestamp.getTime();
const diff = now.getTime() - past;
let fromNow = '';
let fromNow = '';
if (diff < ONE_MIN) {
fromNow = 'Now';
} else if (diff < ONE_HOUR) {
fromNow = Math.floor(diff / ONE_MIN) + 'm';
} else if (diff < ONE_DAY) {
fromNow = Math.floor(diff / ONE_HOUR) + 'h';
} else if (diff < ONE_WEEK) {
fromNow = Math.floor(diff / ONE_DAY) + 'd';
} else if (props.timestamp.getFullYear() === now.getFullYear()) {
fromNow = moment(props.timestamp).format('ll').split(',')[0];
} else {
fromNow = moment(props.timestamp).format('ll');
}
if (diff < ONE_MIN) {
fromNow = 'Now';
} else if (diff < ONE_HOUR) {
fromNow = Math.floor(diff/ONE_MIN) + 'm';
} else if (diff < ONE_DAY) {
fromNow = Math.floor(diff/ONE_HOUR) + 'h';
} else if (diff < ONE_WEEK) {
fromNow = Math.floor(diff/ONE_DAY) + 'd';
} else if (props.timestamp.getFullYear() === now.getFullYear()) {
fromNow = moment(props.timestamp).format('ll').split(',')[0];
} else {
fromNow = moment(props.timestamp).format('ll');
}
return <div className={props.className}>{fromNow}</div>;
}
return <div className={props.className}>{fromNow}</div>;
}

View File

@@ -1,14 +1,13 @@
@import "../../util/variable";
@import '../../util/variable';
.dark {
.checkbox {
&__wrapper {
input[type=checkbox]:checked + div {
input[type='checkbox']:checked + div {
border: 1px solid $gray-400;
}
input[type=checkbox]:checked + div:before {
input[type='checkbox']:checked + div:before {
background-color: $gray-200;
}
}
@@ -20,7 +19,7 @@
height: 1rem;
width: 1rem;
input[type=checkbox] {
input[type='checkbox'] {
position: absolute;
cursor: pointer;
top: 0;
@@ -35,21 +34,21 @@
}
}
input[type=checkbox] + div {
input[type='checkbox'] + div {
height: 100%;
width: 100%;
top: 0;
left: 0;
border-radius: .25rem;
border-radius: 0.25rem;
border: 1px solid $gray-200;
position: absolute;
}
input[type=checkbox]:checked + div {
input[type='checkbox']:checked + div {
border: 1px solid $gray-600;
}
input[type=checkbox]:checked + div:before {
input[type='checkbox']:checked + div:before {
content: '';
transform-origin: bottom left;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
@@ -61,7 +60,7 @@
background-color: $gray-800;
}
input[type=checkbox]:disabled {
input[type='checkbox']:disabled {
cursor: default;
&:hover + div {
@@ -69,13 +68,13 @@
}
}
input[type=checkbox]:disabled + div {
opacity: .5;
input[type='checkbox']:disabled + div {
opacity: 0.5;
background-color: $gray-100;
}
}
&__description {
margin-left: .5rem;
margin-left: 0.5rem;
}
}
}

View File

@@ -1,33 +1,22 @@
import React, {InputHTMLAttributes, ReactElement, ReactNode} from "react";
import classNames from "classnames";
import "./checkbox.scss";
import React, { InputHTMLAttributes, ReactElement, ReactNode } from 'react';
import classNames from 'classnames';
import './checkbox.scss';
type Props = {
className?: string;
children?: ReactNode;
} & InputHTMLAttributes<HTMLInputElement>
className?: string;
children?: ReactNode;
} & InputHTMLAttributes<HTMLInputElement>;
export default function Checkbox(props: Props): ReactElement {
const {
className,
children,
...inputProps
} = props;
const { className, children, ...inputProps } = props;
return (
<div
className={classNames(
'flex flex-row flex-nowrap items-center checkbox',
className,
)}>
<div className="checkbox__wrapper">
<input
{...inputProps}
type="checkbox"
/>
<div className="checkbox__el" />
</div>
{ children && <div className="text-sm checkbox__description">{children}</div> }
</div>
)
}
return (
<div className={classNames('flex flex-row flex-nowrap items-center checkbox', className)}>
<div className="checkbox__wrapper">
<input {...inputProps} type="checkbox" />
<div className="checkbox__el" />
</div>
{children && <div className="text-sm checkbox__description">{children}</div>}
</div>
);
}

View File

@@ -1,97 +1,85 @@
import React, {ReactElement, useCallback, useEffect, useState} from "react";
import Button from "../Button";
import Icon from "../Icon";
import config from "../../util/config";
import SpinnerGif from "../../../static/icons/spinner.gif";
import {useCanNonPostMessage, useLoggedIn} from "../../ducks/web3";
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import Button from '../Button';
import Icon from '../Icon';
import config from '../../util/config';
import SpinnerGif from '../../../static/icons/spinner.gif';
import { useCanNonPostMessage, useLoggedIn } from '../../ducks/web3';
export default function ConnectTwitterButton(props: {
className?: string;
redirectUrl: string;
className?: string;
redirectUrl: string;
}): ReactElement {
const [fetching, setFetching] = useState(true);
const [twitterAuth, setTwitterAuth] = useState<{
token: string;
tokenSecret: string;
username: string;
}|null>(null);
const loggedIn = useLoggedIn();
const canPost = useCanNonPostMessage();
const [fetching, setFetching] = useState(true);
const [twitterAuth, setTwitterAuth] = useState<{
token: string;
tokenSecret: string;
username: string;
} | null>(null);
const loggedIn = useLoggedIn();
const canPost = useCanNonPostMessage();
useEffect(() => {
(async function() {
try {
const resp = await fetch(`${config.indexerAPI}/twitter/session`, {
credentials: 'include',
});
const json: any = await resp.json();
useEffect(() => {
(async function () {
try {
const resp = await fetch(`${config.indexerAPI}/twitter/session`, {
credentials: 'include',
});
const json: any = await resp.json();
if (json?.error) {
throw new Error(json?.payload);
}
if (json?.payload) {
setTwitterAuth({
token: json?.payload.user_token,
tokenSecret: json?.payload.user_token_secret,
username: json?.payload.username,
});
}
} catch (e) {
console.error(e);
} finally {
setFetching(false);
}
})();
}, []);
const connectTwitter = useCallback(async () => {
const resp = await fetch(
`${config.indexerAPI}/twitter?redirectUrl=${encodeURI(props.redirectUrl)}`,
{
credentials: 'include',
},
);
const json = await resp.json();
if (!json.error && json.payload) {
window.location.href = json.payload;
if (json?.error) {
throw new Error(json?.payload);
}
}, [props.redirectUrl]);
if (fetching) {
return (
<Button
btnType="primary"
className={props.className}
disabled
>
<Icon url={SpinnerGif} />
</Button>
)
}
if (twitterAuth) {
return (
<Button
btnType="primary"
className={props.className}
disabled={!loggedIn || !canPost}
>
<Icon fa="fab fa-twitter" className="mr-2" />
Connect {twitterAuth.username}
</Button>
)
if (json?.payload) {
setTwitterAuth({
token: json?.payload.user_token,
tokenSecret: json?.payload.user_token_secret,
username: json?.payload.username,
});
}
} catch (e) {
console.error(e);
} finally {
setFetching(false);
}
})();
}, []);
const connectTwitter = useCallback(async () => {
const resp = await fetch(
`${config.indexerAPI}/twitter?redirectUrl=${encodeURI(props.redirectUrl)}`,
{
credentials: 'include',
}
);
const json = await resp.json();
if (!json.error && json.payload) {
window.location.href = json.payload;
}
}, [props.redirectUrl]);
if (fetching) {
return (
<Button
btnType="primary"
className={props.className}
onClick={connectTwitter}
>
<Icon fa="fab fa-twitter" className="mr-2" />
Connect Twitter
</Button>
)
}
<Button btnType="primary" className={props.className} disabled>
<Icon url={SpinnerGif} />
</Button>
);
}
if (twitterAuth) {
return (
<Button btnType="primary" className={props.className} disabled={!loggedIn || !canPost}>
<Icon fa="fab fa-twitter" className="mr-2" />
Connect {twitterAuth.username}
</Button>
);
}
return (
<Button btnType="primary" className={props.className} onClick={connectTwitter}>
<Icon fa="fab fa-twitter" className="mr-2" />
Connect Twitter
</Button>
);
}

View File

@@ -1,50 +1,44 @@
import React, {ReactElement, useEffect, useState} from "react";
import classNames from "classnames";
import Modal, {ModalHeader, ModalContent} from "../Modal";
import {useSelectedLocalId} from "../../ducks/worker";
import config from "../../util/config";
import {UserRow} from "../DiscoverUserPanel";
import React, { ReactElement, useEffect, useState } from 'react';
import classNames from 'classnames';
import Modal, { ModalHeader, ModalContent } from '../Modal';
import { useSelectedLocalId } from '../../ducks/worker';
import config from '../../util/config';
import { UserRow } from '../DiscoverUserPanel';
type Props = {
className?: string;
onClose: () => void;
onChange: (groupId: string) => void;
className?: string;
onClose: () => void;
onChange: (groupId: string) => void;
};
export default function CustomGroupSelectModal(props: Props): ReactElement {
const [groups, setGroups] = useState<string[]>(['zksocial_all']);
const selected = useSelectedLocalId();
const [groups, setGroups] = useState<string[]>(['zksocial_all']);
const selected = useSelectedLocalId();
useEffect(() => {
(async () => {
if (selected?.type !== 'gun') return;
const resp = await fetch(`${config.indexerAPI}/v1/${selected.address}/groups`);
const json = await resp.json();
if (!json.error) {
setGroups([
'zksocial_all',
...json.payload.map((group: any) => 'custom_' + group.address),
]);
}
})();
}, [selected]);
useEffect(() => {
(async () => {
if (selected?.type !== 'gun') return;
const resp = await fetch(`${config.indexerAPI}/v1/${selected.address}/groups`);
const json = await resp.json();
if (!json.error) {
setGroups(['zksocial_all', ...json.payload.map((group: any) => 'custom_' + group.address)]);
}
})();
}, [selected]);
if (selected?.type !== 'gun') return <></>;
if (selected?.type !== 'gun') return <></>;
return (
<Modal
className={classNames('w-96 custom-group-modal', props.className)}
onClose={props.onClose}
>
<ModalHeader onClose={props.onClose}>
You may make a post as...
</ModalHeader>
<ModalContent>
<UserRow name={selected.address} onClick={() => props.onChange('')} />
{groups.map(groupId => (
<UserRow key={groupId} group={groupId} onClick={() => props.onChange(groupId)} />
))}
</ModalContent>
</Modal>
)
}
return (
<Modal
className={classNames('w-96 custom-group-modal', props.className)}
onClose={props.onClose}>
<ModalHeader onClose={props.onClose}>You may make a post as...</ModalHeader>
<ModalContent>
<UserRow name={selected.address} onClick={() => props.onChange('')} />
{groups.map(groupId => (
<UserRow key={groupId} group={groupId} onClick={() => props.onChange(groupId)} />
))}
</ModalContent>
</Modal>
);
}

View File

@@ -1,84 +1,73 @@
import React, {ReactElement, useEffect, useState} from "react";
import classNames from "classnames";
import "./discover-tag.scss";
import Icon from "../Icon";
import SpinnerGIF from "../../../static/icons/spinner.gif";
import {useHistory} from "react-router";
import config from "../../util/config";
import {useThemeContext} from "../ThemeContext";
import React, { ReactElement, useEffect, useState } from 'react';
import classNames from 'classnames';
import './discover-tag.scss';
import Icon from '../Icon';
import SpinnerGIF from '../../../static/icons/spinner.gif';
import { useHistory } from 'react-router';
import config from '../../util/config';
import { useThemeContext } from '../ThemeContext';
export default function DiscoverTagPanel(): ReactElement {
const [tags, setTags] = useState<{ tagName: string; postCount: number }[]>([]);
const [loading, setLoading] = useState(false);
const theme = useThemeContext();
const [tags, setTags] = useState<{ tagName: string; postCount: number }[]>([]);
const [loading, setLoading] = useState(false);
const theme = useThemeContext();
useEffect(() => {
(async function onTagPanelMount() {
setLoading(true);
const resp = await fetch(`${config.indexerAPI}/v1/tags?limit=5`);
const json = await resp.json();
useEffect(() => {
(async function onTagPanelMount() {
setLoading(true);
const resp = await fetch(`${config.indexerAPI}/v1/tags?limit=5`);
const json = await resp.json();
if (!json.error) {
setTags(json.payload);
}
if (!json.error) {
setTags(json.payload);
}
setLoading(false);
})();
}, []);
setLoading(false);
})();
}, []);
return (
<div
className={classNames(
'flex flex-col flex-nowrap flex-grow border border-transparent rounded-xl mt-2',
'meta-group meta-group--alt discover-user',
{
"bg-gray-100": theme !== 'dark',
"bg-gray-900": theme === 'dark',
}
)}
>
<div
className={classNames("px-4 py-2 font-bold text-lg border-b", {
"border-gray-200": theme !== 'dark',
"border-gray-800": theme === 'dark',
})}
>
Discover Tags
</div>
<div className="flex flex-col flex-nowrap py-1">
{ loading && <Icon className="self-center my-4" url={SpinnerGIF} size={3} /> }
{tags.map(({tagName, postCount}) => (
<TagRow
key={tagName}
tagName={tagName}
postCount={postCount}
/>
))}
</div>
</div>
)
return (
<div
className={classNames(
'flex flex-col flex-nowrap flex-grow border border-transparent rounded-xl mt-2',
'meta-group meta-group--alt discover-user',
{
'bg-gray-100': theme !== 'dark',
'bg-gray-900': theme === 'dark',
}
)}>
<div
className={classNames('px-4 py-2 font-bold text-lg border-b', {
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}>
Discover Tags
</div>
<div className="flex flex-col flex-nowrap py-1">
{loading && <Icon className="self-center my-4" url={SpinnerGIF} size={3} />}
{tags.map(({ tagName, postCount }) => (
<TagRow key={tagName} tagName={tagName} postCount={postCount} />
))}
</div>
</div>
);
}
function TagRow(props: { tagName: string; postCount: number }): ReactElement {
const history = useHistory();
const theme = useThemeContext();
const history = useHistory();
const theme = useThemeContext();
return (
<div
className={classNames(
"flex flex-row flex-nowrap px-4 py-2 cursor-pointer",
"items-center",
{
"hover:bg-gray-200": theme !== 'dark',
"hover:bg-gray-800": theme === 'dark',
}
)}
onClick={() => history.push(`/tag/${encodeURIComponent(props.tagName)}/`)}
>
<div className="flex flex-col flex-nowrap justify-center">
<div className="font-bold text-md hover:underline">{props.tagName}</div>
<div className="text-xs text-gray-500">{props.postCount} Posts</div>
</div>
</div>
)
}
return (
<div
className={classNames('flex flex-row flex-nowrap px-4 py-2 cursor-pointer', 'items-center', {
'hover:bg-gray-200': theme !== 'dark',
'hover:bg-gray-800': theme === 'dark',
})}
onClick={() => history.push(`/tag/${encodeURIComponent(props.tagName)}/`)}>
<div className="flex flex-col flex-nowrap justify-center">
<div className="font-bold text-md hover:underline">{props.tagName}</div>
<div className="text-xs text-gray-500">{props.postCount} Posts</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.discover-user {
height: fit-content;
@@ -7,4 +7,4 @@
@media only screen and (max-width: 768px) {
width: 16rem;
}
}
}

View File

@@ -1,119 +1,118 @@
import React, {ReactElement, useCallback, useEffect, useState} from "react";
import classNames from "classnames";
import {useDispatch} from "react-redux";
import {fetchAddressByName, fetchUsers, useUser} from "../../ducks/users";
import Avatar from "../Avatar";
import "./discover-user.scss";
import Icon from "../Icon";
import SpinnerGIF from "../../../static/icons/spinner.gif";
import {useHistory} from "react-router";
import {getName, getHandle} from "../../util/user";
import Web3 from "web3";
import {useThemeContext} from "../ThemeContext";
import Nickname from "../Nickname";
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { fetchAddressByName, fetchUsers, useUser } from '../../ducks/users';
import Avatar from '../Avatar';
import './discover-user.scss';
import Icon from '../Icon';
import SpinnerGIF from '../../../static/icons/spinner.gif';
import { useHistory } from 'react-router';
import { getName, getHandle } from '../../util/user';
import Web3 from 'web3';
import { useThemeContext } from '../ThemeContext';
import Nickname from '../Nickname';
export default function DiscoverUserPanel(): ReactElement {
const [users, setUsers] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const dispatch = useDispatch();
const theme = useThemeContext();
const [users, setUsers] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const dispatch = useDispatch();
const theme = useThemeContext();
useEffect(() => {
(async function onUserPanelMount() {
setLoading(true);
const list = await dispatch(fetchUsers());
setUsers(list as any);
setLoading(false);
})();
}, []);
useEffect(() => {
(async function onUserPanelMount() {
setLoading(true);
const list = await dispatch(fetchUsers());
setUsers(list as any);
setLoading(false);
})();
}, []);
return (
<div
className={classNames(
'flex flex-col flex-nowrap flex-grow border border-transparent rounded-xl mt-2',
'meta-group meta-group--alt discover-user',
{
"bg-gray-100": theme !== 'dark',
"bg-gray-900": theme === 'dark',
}
)}
>
<div
className={classNames("px-4 py-2 font-bold text-lg border-b", {
"border-gray-200": theme !== 'dark',
"border-gray-800": theme === 'dark',
})}
>
Discover Users
</div>
<div className="flex flex-col flex-nowrap py-1">
{ loading && <Icon className="self-center my-4" url={SpinnerGIF} size={3} /> }
{users.map(ens => <UserRow key={ens} name={ens} />)}
</div>
</div>
)
return (
<div
className={classNames(
'flex flex-col flex-nowrap flex-grow border border-transparent rounded-xl mt-2',
'meta-group meta-group--alt discover-user',
{
'bg-gray-100': theme !== 'dark',
'bg-gray-900': theme === 'dark',
}
)}>
<div
className={classNames('px-4 py-2 font-bold text-lg border-b', {
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}>
Discover Users
</div>
<div className="flex flex-col flex-nowrap py-1">
{loading && <Icon className="self-center my-4" url={SpinnerGIF} size={3} />}
{users.map(ens => (
<UserRow key={ens} name={ens} />
))}
</div>
</div>
);
}
export function UserRow(props: {name?: string; group?: string; onClick?: () => void}): ReactElement {
const history = useHistory();
const [username, setUsername] = useState('');
export function UserRow(props: {
name?: string;
group?: string;
onClick?: () => void;
}): ReactElement {
const history = useHistory();
const [username, setUsername] = useState('');
const dispatch = useDispatch();
const user = useUser(username);
const theme = useThemeContext();
const dispatch = useDispatch();
const user = useUser(username);
const theme = useThemeContext();
useEffect(() => {
(async () => {
if (!props.name) return;
useEffect(() => {
(async () => {
if (!props.name) return;
if (!Web3.utils.isAddress(props.name)) {
const address: any = await dispatch(fetchAddressByName(props.name));
setUsername(address);
} else {
setUsername(props.name);
}
})();
}, [props.name]);
if (!Web3.utils.isAddress(props.name)) {
const address: any = await dispatch(fetchAddressByName(props.name));
setUsername(address);
} else {
setUsername(props.name);
}
})();
}, [props.name]);
const onClick = useCallback(() => {
if (user) {
history.push(`/${user.ens || user.address}/`)
const onClick = useCallback(() => {
if (user) {
history.push(`/${user.ens || user.address}/`);
}
}, [user, props.group]);
if (!user && !props.group) return <></>;
return (
<div
className={classNames(
'flex flex-row flex-nowrap px-4 py-2 cursor-pointer',
'items-center transition',
{
'hover:bg-gray-200': theme !== 'dark',
'hover:bg-gray-800': theme === 'dark',
}
}, [user, props.group]);
if (!user && !props.group) return <></>;
return (
<div
className={classNames(
"flex flex-row flex-nowrap px-4 py-2 cursor-pointer",
"items-center transition",
{
"hover:bg-gray-200": theme !== 'dark',
"hover:bg-gray-800": theme === 'dark',
}
)}
onClick={props.onClick || onClick}
>
<Avatar
address={user?.address}
group={props.group}
className="w-10 h-10 mr-3"
incognito={!!props.group}
/>
<div className="flex flex-col flex-nowrap justify-center">
{ props.group && <Nickname group={props.group} className="font-semibold text-sm" /> }
{ user && (
<>
<div className="font-bold text-md hover:underline">
{getName(user, 8, 6)}
</div>
<div className="text-sm text-gray-500">
@{getHandle(user, 8, 6)}
</div>
</>
)}
</div>
</div>
)
}
)}
onClick={props.onClick || onClick}>
<Avatar
address={user?.address}
group={props.group}
className="w-10 h-10 mr-3"
incognito={!!props.group}
/>
<div className="flex flex-col flex-nowrap justify-center">
{props.group && <Nickname group={props.group} className="font-semibold text-sm" />}
{user && (
<>
<div className="font-bold text-md hover:underline">{getName(user, 8, 6)}</div>
<div className="text-sm text-gray-500">@{getHandle(user, 8, 6)}</div>
</>
)}
</div>
</div>
);
}

View File

@@ -1,18 +1,78 @@
@import "../../util/variable";
@import '../../util/variable';
//@import '/node_modules/@draft-js-plugins/mention/lib/plugin.css';
.m6zwb4v, .m6zwb4v:visited, .hngfxw3 {
.m6zwb4v,
.m6zwb4v:visited,
.hngfxw3 {
color: $primary-color;
background: transparent;
}
.mnw6qvm{border:1px solid #eee;position:absolute;min-width:220px;max-width:440px;background:#fff;border-radius:2px;box-shadow:0px 4px 30px 0px rgba(220,220,220,1);cursor:pointer;padding-top:8px;padding-bottom:8px;z-index:2;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;box-sizing:border-box;-webkit-transform:scale(0);-ms-transform:scale(0);transform:scale(0);}
.m1ymsnxd{opacity:0;-webkit-transition:opacity 0.25s cubic-bezier(0.3,1.2,0.2,1);transition:opacity 0.25s cubic-bezier(0.3,1.2,0.2,1);}
.m126ak5t{opacity:1;}
.mtiwdxc{padding:7px 10px 3px 10px;-webkit-transition:background-color 0.4s cubic-bezier(.27,1.27,.48,.56);transition:background-color 0.4s cubic-bezier(.27,1.27,.48,.56);}.mtiwdxc:active{background-color:#cce7ff;}
.myz2dw1{padding:7px 10px 3px 10px;-webkit-transition:background-color 0.4s cubic-bezier(.27,1.27,.48,.56);transition:background-color 0.4s cubic-bezier(.27,1.27,.48,.56);background-color:#e6f3ff;}.myz2dw1:active{background-color:#cce7ff;}
.mpqdcgq{display:inline-block;margin-left:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:368px;font-size:0.9em;margin-bottom:0.2em;}
.m1mfvffo{display:inline-block;width:24px;height:24px;border-radius:12px;}
.mnw6qvm {
border: 1px solid #eee;
position: absolute;
min-width: 220px;
max-width: 440px;
background: #fff;
border-radius: 2px;
box-shadow: 0px 4px 30px 0px rgba(220, 220, 220, 1);
cursor: pointer;
padding-top: 8px;
padding-bottom: 8px;
z-index: 2;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
box-sizing: border-box;
-webkit-transform: scale(0);
-ms-transform: scale(0);
transform: scale(0);
}
.m1ymsnxd {
opacity: 0;
-webkit-transition: opacity 0.25s cubic-bezier(0.3, 1.2, 0.2, 1);
transition: opacity 0.25s cubic-bezier(0.3, 1.2, 0.2, 1);
}
.m126ak5t {
opacity: 1;
}
.mtiwdxc {
padding: 7px 10px 3px 10px;
-webkit-transition: background-color 0.4s cubic-bezier(0.27, 1.27, 0.48, 0.56);
transition: background-color 0.4s cubic-bezier(0.27, 1.27, 0.48, 0.56);
}
.mtiwdxc:active {
background-color: #cce7ff;
}
.myz2dw1 {
padding: 7px 10px 3px 10px;
-webkit-transition: background-color 0.4s cubic-bezier(0.27, 1.27, 0.48, 0.56);
transition: background-color 0.4s cubic-bezier(0.27, 1.27, 0.48, 0.56);
background-color: #e6f3ff;
}
.myz2dw1:active {
background-color: #cce7ff;
}
.mpqdcgq {
display: inline-block;
margin-left: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 368px;
font-size: 0.9em;
margin-bottom: 0.2em;
}
.m1mfvffo {
display: inline-block;
width: 24px;
height: 24px;
border-radius: 12px;
}
.mnw6qvm {
max-height: 12rem;

View File

@@ -1,373 +1,369 @@
import React, {ReactElement, useCallback, useEffect, useMemo, useState} from "react";
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import {
CompositeDecorator, ContentBlock, ContentState, convertFromRaw, convertToRaw,
DefaultDraftBlockRenderMap, EditorState,
} from "draft-js";
import DraftJSPluginEditor, {PluginEditorProps} from "@draft-js-plugins/editor";
CompositeDecorator,
ContentBlock,
ContentState,
convertFromRaw,
convertToRaw,
DefaultDraftBlockRenderMap,
EditorState,
} from 'draft-js';
import DraftJSPluginEditor, { PluginEditorProps } from '@draft-js-plugins/editor';
import createLinkifyPlugin from '@draft-js-plugins/linkify';
import createHashtagPlugin from '@draft-js-plugins/hashtag';
import createMentionPlugin, {
defaultSuggestionsFilter, MentionData,
defaultSuggestionsFilter,
MentionData,
} from '@draft-js-plugins/mention';
const TableUtils = require('draft-js-table');
const { linkify } = require("remarkable/linkify");
const { linkify } = require('remarkable/linkify');
const { markdownToDraft } = require('markdown-draft-js');
import "./draft-js-editor.scss";
import {useHistory} from "react-router";
import {useDispatch} from "react-redux";
import {fetchAddressByName, getUser, searchUsers, useUser} from "../../ducks/users";
import Avatar from "../Avatar";
import classNames from "classnames";
import debounce from "lodash.debounce";
import {getHandle, getName, getUsername} from "../../util/user";
import Web3 from "web3";
import './draft-js-editor.scss';
import { useHistory } from 'react-router';
import { useDispatch } from 'react-redux';
import { fetchAddressByName, getUser, searchUsers, useUser } from '../../ducks/users';
import Avatar from '../Avatar';
import classNames from 'classnames';
import debounce from 'lodash.debounce';
import { getHandle, getName, getUsername } from '../../util/user';
import Web3 from 'web3';
let searchNonce = 0;
let searchTimeout: any = null;
export function DraftEditor(props: PluginEditorProps): ReactElement {
const dispatch = useDispatch();
const dispatch = useDispatch();
const { MentionSuggestions, plugins, blockRenderMap } = useMemo(() => {
const blockRenderMap = DefaultDraftBlockRenderMap.merge(TableUtils.DraftBlockRenderMap);
const linkifyPlugin = createLinkifyPlugin();
const hashtagPlugin = createHashtagPlugin();
const mentionPlugin = createMentionPlugin({
mentionRegExp,
const { MentionSuggestions, plugins, blockRenderMap } = useMemo(() => {
const blockRenderMap = DefaultDraftBlockRenderMap.merge(TableUtils.DraftBlockRenderMap);
const linkifyPlugin = createLinkifyPlugin();
const hashtagPlugin = createHashtagPlugin();
const mentionPlugin = createMentionPlugin({
mentionRegExp,
});
const { MentionSuggestions } = mentionPlugin;
return {
plugins: [linkifyPlugin, hashtagPlugin, mentionPlugin],
MentionSuggestions,
blockRenderMap,
};
}, []);
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState<MentionData[]>([]);
const onOpenChange = useCallback((_open: boolean) => {
setOpen(_open);
}, []);
const onSearchChange = useCallback(async ({ value }: { value: string }) => {
searchNonce++;
const oldNonce = searchNonce;
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
searchTimeout = setTimeout(async () => {
clearTimeout(searchTimeout);
searchTimeout = null;
const result: any = await dispatch(searchUsers(value));
if (oldNonce !== searchNonce) return;
const suggestions = [];
for (let r of result) {
suggestions.push({
name: '@' + (r.ens || r.address),
address: r.address,
profileImage: r.profileImage,
nickname: r.name,
});
const { MentionSuggestions } = mentionPlugin;
return {
plugins: [linkifyPlugin, hashtagPlugin, mentionPlugin],
MentionSuggestions,
blockRenderMap,
};
}, []);
}
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState<MentionData[]>([]);
setSuggestions(suggestions);
}, 500);
}, []);
const onOpenChange = useCallback((_open: boolean) => {
setOpen(_open);
}, []);
useEffect(() => {
return function () {
if (props.onChange) {
props.onChange(EditorState.createEmpty());
}
};
}, []);
const onSearchChange = useCallback(async ({ value }: { value: string }) => {
searchNonce++;
const oldNonce = searchNonce;
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
searchTimeout = setTimeout(async () => {
clearTimeout(searchTimeout);
searchTimeout = null;
const result: any = await dispatch(searchUsers(value));
if (oldNonce !== searchNonce) return;
const suggestions = [];
for (let r of result) {
suggestions.push({
name: '@' + (r.ens || r.address),
address: r.address,
profileImage: r.profileImage,
nickname: r.name,
});
}
setSuggestions(suggestions);
}, 500);
}, []);
useEffect(() => {
return function() {
if (props.onChange) {
props.onChange(EditorState.createEmpty());
}
}
}, []);
return (
<>
<DraftJSPluginEditor
blockRenderMap={blockRenderMap}
customStyleMap={{
CODE: {
backgroundColor: '#f6f6f6',
color: '#1c1e21',
padding: '2px 4px',
margin: '0 2px',
borderRadius: '2px',
fontFamily: 'Roboto Mono, monospace',
},
}}
{...props}
plugins={plugins}
onChange={editorState => {
if (props.onChange && !props.readOnly) {
props.onChange(editorState);
return editorState;
}
}}
/>
<MentionSuggestions
open={open}
onOpenChange={onOpenChange}
suggestions={suggestions}
onSearchChange={onSearchChange}
// @ts-ignore
entryComponent={Entry}
onAddMention={() => {
// get the mention object selected
}}
/>
</>
)
return (
<>
<DraftJSPluginEditor
blockRenderMap={blockRenderMap}
customStyleMap={{
CODE: {
backgroundColor: '#f6f6f6',
color: '#1c1e21',
padding: '2px 4px',
margin: '0 2px',
borderRadius: '2px',
fontFamily: 'Roboto Mono, monospace',
},
}}
{...props}
plugins={plugins}
onChange={editorState => {
if (props.onChange && !props.readOnly) {
props.onChange(editorState);
return editorState;
}
}}
/>
<MentionSuggestions
open={open}
onOpenChange={onOpenChange}
suggestions={suggestions}
onSearchChange={onSearchChange}
// @ts-ignore
entryComponent={Entry}
onAddMention={() => {
// get the mention object selected
}}
/>
</>
);
}
export const decorator = new CompositeDecorator([
{
strategy: findLinkEntities,
component: (props: any) => {
const {url} = props.contentState.getEntity(props.entityKey).getData();
return (
<a
href={url}
target="_blank"
onClick={e => e.stopPropagation()}
>
{props.children}
</a>
);
},
{
strategy: findLinkEntities,
component: (props: any) => {
const { url } = props.contentState.getEntity(props.entityKey).getData();
return (
<a href={url} target="_blank" onClick={e => e.stopPropagation()}>
{props.children}
</a>
);
},
{
strategy: findHashtagEntities,
component: (props: any) => {
const history = useHistory();
return (
<a
className="hashtag"
onClick={e => {
e.stopPropagation();
history.push(`/tag/${encodeURIComponent(props.decoratedText)}`)
}}
>
{props.children}
</a>
);
},
},
{
strategy: findHashtagEntities,
component: (props: any) => {
const history = useHistory();
return (
<a
className="hashtag"
onClick={e => {
e.stopPropagation();
history.push(`/tag/${encodeURIComponent(props.decoratedText)}`);
}}>
{props.children}
</a>
);
},
{
strategy: findMentionEntities,
component: (props: any) => {
const history = useHistory();
const nameOrAddress = props.decoratedText.slice(1);
const [username, setUsername] = useState('');
const dispatch = useDispatch();
},
{
strategy: findMentionEntities,
component: (props: any) => {
const history = useHistory();
const nameOrAddress = props.decoratedText.slice(1);
const [username, setUsername] = useState('');
const dispatch = useDispatch();
useEffect(() => {
(async () => {
if (!Web3.utils.isAddress(nameOrAddress)) {
const address: any = await dispatch(fetchAddressByName(nameOrAddress));
setUsername(address);
dispatch(getUser(address));
} else {
setUsername(nameOrAddress);
dispatch(getUser(nameOrAddress));
}
})();
}, [nameOrAddress]);
useEffect(() => {
(async () => {
if (!Web3.utils.isAddress(nameOrAddress)) {
const address: any = await dispatch(fetchAddressByName(nameOrAddress));
setUsername(address);
dispatch(getUser(address));
} else {
setUsername(nameOrAddress);
dispatch(getUser(nameOrAddress));
}
})();
}, [nameOrAddress]);
const user = useUser(username);
const user = useUser(username);
if (!user?.address || user?.address === "0x0000000000000000000000000000000000000000") {
return <div className="hashtag">@{nameOrAddress}</div>;
}
if (!user?.address || user?.address === '0x0000000000000000000000000000000000000000') {
return <div className="hashtag">@{nameOrAddress}</div>;
}
return (
<a
className="hashtag"
onClick={e => {
e.stopPropagation();
history.push(`/${encodeURIComponent(getUsername(user))}/`)
}}
>
@{getHandle(user)}
</a>
);
},
return (
<a
className="hashtag"
onClick={e => {
e.stopPropagation();
history.push(`/${encodeURIComponent(getUsername(user))}/`);
}}>
@{getHandle(user)}
</a>
);
},
},
]);
function findLinkEntities(contentBlock: any, callback: any, contentState: any) {
contentBlock.findEntityRanges(
(character: any) => {
const entityKey = character.getEntity();
return (
entityKey !== null &&
contentState.getEntity(entityKey).getType() === 'LINK'
);
},
callback
);
contentBlock.findEntityRanges((character: any) => {
const entityKey = character.getEntity();
return entityKey !== null && contentState.getEntity(entityKey).getType() === 'LINK';
}, callback);
}
const mentionRegExp = '[' +
'\\w-' +
// Latin-1 Supplement (letters only) - https://en.wikipedia.org/wiki/List_of_Unicode_characters#Latin-1_Supplement
'\u00C0-\u00D6' +
'\u00D8-\u00F6' +
'\u00F8-\u00FF' +
// Latin Extended-A (without deprecated character) - https://en.wikipedia.org/wiki/List_of_Unicode_characters#Latin_Extended-A
'\u0100-\u0148' +
'\u014A-\u017F' +
// Cyrillic symbols: \u0410-\u044F - https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
'\u0410-\u044F' +
// hiragana (japanese): \u3040-\u309F - https://gist.github.com/ryanmcgrath/982242#file-japaneseregex-js
'\u3040-\u309F' +
// katakana (japanese): \u30A0-\u30FF - https://gist.github.com/ryanmcgrath/982242#file-japaneseregex-js
'\u30A0-\u30FF' +
// For an advanced explaination about Hangul see https://github.com/draft-js-plugins/draft-js-plugins/pull/480#issuecomment-254055437
// Hangul Jamo (korean): \u3130-\u318F - https://en.wikipedia.org/wiki/Korean_language_and_computers#Hangul_in_Unicode
// Hangul Syllables (korean): \uAC00-\uD7A3 - https://en.wikipedia.org/wiki/Korean_language_and_computers#Hangul_in_Unicode
'\u3130-\u318F' +
'\uAC00-\uD7A3' +
// common chinese symbols: \u4e00-\u9eff - http://stackoverflow.com/a/1366113/837709
// extended to \u9fa5 https://github.com/draft-js-plugins/draft-js-plugins/issues/1888
'\u4e00-\u9fa5' +
// Arabic https://en.wikipedia.org/wiki/Arabic_(Unicode_block)
'\u0600-\u06ff' +
// Vietnamese http://vietunicode.sourceforge.net/charset/
'\u00C0-\u1EF9' +
'\u002E'+
']';
const mentionRegExp =
'[' +
'\\w-' +
// Latin-1 Supplement (letters only) - https://en.wikipedia.org/wiki/List_of_Unicode_characters#Latin-1_Supplement
'\u00C0-\u00D6' +
'\u00D8-\u00F6' +
'\u00F8-\u00FF' +
// Latin Extended-A (without deprecated character) - https://en.wikipedia.org/wiki/List_of_Unicode_characters#Latin_Extended-A
'\u0100-\u0148' +
'\u014A-\u017F' +
// Cyrillic symbols: \u0410-\u044F - https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
'\u0410-\u044F' +
// hiragana (japanese): \u3040-\u309F - https://gist.github.com/ryanmcgrath/982242#file-japaneseregex-js
'\u3040-\u309F' +
// katakana (japanese): \u30A0-\u30FF - https://gist.github.com/ryanmcgrath/982242#file-japaneseregex-js
'\u30A0-\u30FF' +
// For an advanced explaination about Hangul see https://github.com/draft-js-plugins/draft-js-plugins/pull/480#issuecomment-254055437
// Hangul Jamo (korean): \u3130-\u318F - https://en.wikipedia.org/wiki/Korean_language_and_computers#Hangul_in_Unicode
// Hangul Syllables (korean): \uAC00-\uD7A3 - https://en.wikipedia.org/wiki/Korean_language_and_computers#Hangul_in_Unicode
'\u3130-\u318F' +
'\uAC00-\uD7A3' +
// common chinese symbols: \u4e00-\u9eff - http://stackoverflow.com/a/1366113/837709
// extended to \u9fa5 https://github.com/draft-js-plugins/draft-js-plugins/issues/1888
'\u4e00-\u9fa5' +
// Arabic https://en.wikipedia.org/wiki/Arabic_(Unicode_block)
'\u0600-\u06ff' +
// Vietnamese http://vietunicode.sourceforge.net/charset/
'\u00C0-\u1EF9' +
'\u002E' +
']';
const HASHTAG_REGEX = /\#[\w\u0590-\u05ff]+/g;
const MENTION_REGEX = new RegExp(`\@${mentionRegExp}+`, 'g');
function findHashtagEntities(contentBlock: ContentBlock, callback: any, contentState: ContentState) {
findWithRegex(HASHTAG_REGEX, contentBlock, callback);
function findHashtagEntities(
contentBlock: ContentBlock,
callback: any,
contentState: ContentState
) {
findWithRegex(HASHTAG_REGEX, contentBlock, callback);
}
function findMentionEntities(contentBlock: ContentBlock, callback: any, contentState: ContentState) {
findWithRegex(MENTION_REGEX, contentBlock, callback);
function findMentionEntities(
contentBlock: ContentBlock,
callback: any,
contentState: ContentState
) {
findWithRegex(MENTION_REGEX, contentBlock, callback);
}
function findWithRegex(regex: any, contentBlock: ContentBlock, callback: any) {
const text = contentBlock.getText();
let matchArr, start;
while ((matchArr = regex.exec(text)) !== null) {
start = matchArr.index;
callback(start, start + matchArr[0].length);
}
const text = contentBlock.getText();
let matchArr, start;
while ((matchArr = regex.exec(text)) !== null) {
start = matchArr.index;
callback(start, start + matchArr[0].length);
}
}
export const markdownConvertOptions = {
preserveNewlines: true,
blockStyles: {
'ins_open': 'UNDERLINE',
'del_open': 'STRIKETHROUGH',
},
styleItems: {
'UNDERLINE': {
open: function () {
return '++';
},
preserveNewlines: true,
blockStyles: {
ins_open: 'UNDERLINE',
del_open: 'STRIKETHROUGH',
},
styleItems: {
UNDERLINE: {
open: function () {
return '++';
},
close: function () {
return '++';
}
},
'STRIKETHROUGH': {
open: function () {
return '~~';
},
close: function () {
return '~~';
}
},
close: function () {
return '++';
},
},
remarkableOptions: {
html: false,
xhtmlOut: false,
breaks: true,
enable: {
inline: ["ins", 'del', 'links', 'autolink'],
core: ['abbr'],
block: ['list', 'table']
},
STRIKETHROUGH: {
open: function () {
return '~~';
},
// highlight: function (str: string, lang: string) {
// if (lang && hljs.getLanguage(lang)) {
// try {
// return hljs.highlight(lang, str).value;
// } catch (err) {
// //
// }
// }
//
// try {
// return hljs.highlightAuto(str).value;
// } catch (err) {
// //
// }
//
// return ''; // use external default escaping
// }
close: function () {
return '~~';
},
},
remarkablePlugins: [linkify],
},
remarkableOptions: {
html: false,
xhtmlOut: false,
breaks: true,
enable: {
inline: ['ins', 'del', 'links', 'autolink'],
core: ['abbr'],
block: ['list', 'table'],
},
// highlight: function (str: string, lang: string) {
// if (lang && hljs.getLanguage(lang)) {
// try {
// return hljs.highlight(lang, str).value;
// } catch (err) {
// //
// }
// }
//
// try {
// return hljs.highlightAuto(str).value;
// } catch (err) {
// //
// }
//
// return ''; // use external default escaping
// }
},
remarkablePlugins: [linkify],
};
export const convertMarkdownToDraft = (md: string) => {
return EditorState.createWithContent(
convertFromRaw(markdownToDraft(md, markdownConvertOptions)),
decorator,
);
}
return EditorState.createWithContent(
convertFromRaw(markdownToDraft(md, markdownConvertOptions)),
decorator
);
};
interface EntryComponentProps {
className?: string;
onMouseDown(event: MouseEvent): void;
onMouseUp(event: MouseEvent): void;
onMouseEnter(event: MouseEvent): void;
role: string;
id: string;
'aria-selected'?: boolean | 'false' | 'true';
mention: MentionData;
isFocused: boolean;
searchValue?: string;
className?: string;
onMouseDown(event: MouseEvent): void;
onMouseUp(event: MouseEvent): void;
onMouseEnter(event: MouseEvent): void;
role: string;
id: string;
'aria-selected'?: boolean | 'false' | 'true';
mention: MentionData;
isFocused: boolean;
searchValue?: string;
}
function Entry(props: EntryComponentProps): ReactElement {
const {
mention,
searchValue, // eslint-disable-line @typescript-eslint/no-unused-vars
isFocused, // eslint-disable-line @typescript-eslint/no-unused-vars
className,
...parentProps
} = props;
const {
mention,
searchValue, // eslint-disable-line @typescript-eslint/no-unused-vars
isFocused, // eslint-disable-line @typescript-eslint/no-unused-vars
className,
...parentProps
} = props;
const address = mention.address;
const address = mention.address;
const user = useUser(address);
const user = useUser(address);
return (
// @ts-ignore
<div
className={classNames('mention-entry', className)}
{...parentProps}
>
<div
className="flex flex-row flex-nowrap p-1 cursor-pointer items-center"
>
<Avatar address={address} className="w-8 h-8 mr-2" />
<div className="flex flex-col flex-nowrap justify-center">
<div className="font-bold text-sm hover:underline">{getName(user)}</div>
<div className="text-xs text-gray-500">@{getHandle(user)}</div>
</div>
</div>
return (
// @ts-ignore
<div className={classNames('mention-entry', className)} {...parentProps}>
<div className="flex flex-row flex-nowrap p-1 cursor-pointer items-center">
<Avatar address={address} className="w-8 h-8 mr-2" />
<div className="flex flex-col flex-nowrap justify-center">
<div className="font-bold text-sm hover:underline">{getName(user)}</div>
<div className="text-xs text-gray-500">@{getHandle(user)}</div>
</div>
);
}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.dark {
.public-DraftEditorPlaceholder-root {
@@ -52,7 +52,7 @@ div.public-DraftEditor-content {
.editor {
div.DraftEditor-root {
padding: .75rem .5rem;
padding: 0.75rem 0.5rem;
cursor: text;
}
@@ -62,7 +62,7 @@ div.public-DraftEditor-content {
.moderation-btn {
//display: none;
margin-bottom: .5rem;
margin-bottom: 0.5rem;
}
//&:focus-within {
@@ -77,7 +77,7 @@ div.public-DraftEditor-content {
//color: $gray-200;
height: 2rem;
cursor: pointer;
font-size: .9375rem;
font-size: 0.9375rem;
align-content: center;
justify-content: center;
border-radius: 50%;
@@ -97,7 +97,7 @@ div.public-DraftEditor-content {
&__submit-btn {
@extend %row-nowrap;
padding: 0;
font-size: .875rem;
font-size: 0.875rem;
height: 2rem;
min-width: 2rem;
@@ -134,7 +134,7 @@ div.public-DraftEditor-content {
justify-content: center;
height: 100%;
padding: 0 0.5rem;
border-left: 1px solid rgba($white, .5);
border-left: 1px solid rgba($white, 0.5);
border-top-right-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
@@ -169,6 +169,5 @@ div.public-DraftEditor-content {
.dark {
.editor {
}
}
}

View File

@@ -1,461 +1,453 @@
import React, {ReactElement, useCallback, useEffect, useState} from "react";
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { convertFromRaw, DraftHandleValue, EditorState, RichUtils } from 'draft-js';
import classNames from 'classnames';
import './editor.scss';
import {
convertFromRaw,
DraftHandleValue,
EditorState,
RichUtils,
} from "draft-js";
import classNames from "classnames";
import "./editor.scss";
import {useAccount, useCanNonPostMessage, useENSName, useGunKey, useLoggedIn, useSemaphoreID} from "../../ducks/web3";
import Avatar from "../Avatar";
import Web3Button from "../Web3Button";
import Button from "../Button";
import {DraftEditor} from "../DraftEditor";
import Icon from "../Icon";
import drafts, {setDraft, setGloabl, setMirror, setModeration, useDraft, useMirror} from "../../ducks/drafts";
import {useDispatch} from "react-redux";
import URLPreview from "../URLPreview";
import SpinnerGif from "../../../static/icons/spinner.gif";
import {useUser} from "../../ducks/users";
import {setPostingGroup, usePostingGroup, useSelectedLocalId, useSelectedZKGroup} from "../../ducks/worker";
import {useHistory} from "react-router";
import Checkbox from "../Checkbox";
import {getSession, verifyTweet} from "../../util/twitter";
import ModerationButton from "../ModerationButton";
import {ModerationMessageSubType} from "../../util/message";
import {usePostModeration} from "../../ducks/mods";
import {useCommentDisabled, useMeta} from "../../ducks/posts";
import Menuable from "../Menuable";
import FileUploadModal from "../FileUploadModal";
import LinkInputModal from "../LinkInputModal";
import {useThemeContext} from "../ThemeContext";
import CustomGroupSelectModal from "../CustomGroupSelectModal";
useAccount,
useCanNonPostMessage,
useENSName,
useGunKey,
useLoggedIn,
useSemaphoreID,
} from '../../ducks/web3';
import Avatar from '../Avatar';
import Web3Button from '../Web3Button';
import Button from '../Button';
import { DraftEditor } from '../DraftEditor';
import Icon from '../Icon';
import drafts, {
setDraft,
setGloabl,
setMirror,
setModeration,
useDraft,
useMirror,
} from '../../ducks/drafts';
import { useDispatch } from 'react-redux';
import URLPreview from '../URLPreview';
import SpinnerGif from '../../../static/icons/spinner.gif';
import { useUser } from '../../ducks/users';
import {
setPostingGroup,
usePostingGroup,
useSelectedLocalId,
useSelectedZKGroup,
} from '../../ducks/worker';
import { useHistory } from 'react-router';
import Checkbox from '../Checkbox';
import { getSession, verifyTweet } from '../../util/twitter';
import ModerationButton from '../ModerationButton';
import { ModerationMessageSubType } from '../../util/message';
import { usePostModeration } from '../../ducks/mods';
import { useCommentDisabled, useMeta } from '../../ducks/posts';
import Menuable from '../Menuable';
import FileUploadModal from '../FileUploadModal';
import LinkInputModal from '../LinkInputModal';
import { useThemeContext } from '../ThemeContext';
import CustomGroupSelectModal from '../CustomGroupSelectModal';
type Props = {
messageId: string;
editorState: EditorState;
className?: string;
disabled?: boolean;
readOnly?: boolean;
loading?: boolean;
onPost?: () => Promise<void>;
}
messageId: string;
editorState: EditorState;
className?: string;
disabled?: boolean;
readOnly?: boolean;
loading?: boolean;
onPost?: () => Promise<void>;
};
export default function Editor(props: Props): ReactElement {
const {
messageId,
editorState,
disabled,
readOnly,
loading,
} = props;
const { messageId, editorState, disabled, readOnly, loading } = props;
const address = useAccount();
const user = useUser(address);
const incognitoGroup = usePostingGroup();
const draft = useDraft(messageId);
const dispatch = useDispatch();
const gun = useGunKey();
const history = useHistory();
const selectedId = useSelectedLocalId();
const isEmpty = !editorState.getCurrentContent().hasText() && !draft.attachment;
const mirror = useMirror();
const selectedZKGroup = useSelectedZKGroup();
const [errorMessage, setErrorMessage] = useState('');
const [verifying, setVerifying] = useState(true);
const [showingImageUploadModal, setShowingImageUploadModal] = useState(false);
const [showingLinkModal, setShowingLinkModal] = useState(false);
const [showingCustomGroupsModal, setShowingCustomGroupsModal] = useState(false);
const [verified, setVerified] = useState(false);
const [verifiedSession, setVerifiedSession] = useState('');
const meta = useMeta(messageId);
const modOverride = usePostModeration(meta?.rootId);
const commentDisabled = useCommentDisabled(meta?.rootId);
const shouldDisplayWarning = !!modOverride?.unmoderated && commentDisabled;
const canNonPostMessage = useCanNonPostMessage();
const theme = useThemeContext();
const bgColor = classNames({
'bg-white': theme !== 'dark',
'bg-dark': theme === 'dark'
});
const address = useAccount();
const user = useUser(address);
const incognitoGroup = usePostingGroup();
const draft = useDraft(messageId);
const dispatch = useDispatch();
const gun = useGunKey();
const history = useHistory();
const selectedId = useSelectedLocalId();
const isEmpty = !editorState.getCurrentContent().hasText() && !draft.attachment;
const mirror = useMirror();
const selectedZKGroup = useSelectedZKGroup();
const [errorMessage, setErrorMessage] = useState('');
const [verifying, setVerifying] = useState(true);
const [showingImageUploadModal, setShowingImageUploadModal] = useState(false);
const [showingLinkModal, setShowingLinkModal] = useState(false);
const [showingCustomGroupsModal, setShowingCustomGroupsModal] = useState(false);
const [verified, setVerified] = useState(false);
const [verifiedSession, setVerifiedSession] = useState('');
const meta = useMeta(messageId);
const modOverride = usePostModeration(meta?.rootId);
const commentDisabled = useCommentDisabled(meta?.rootId);
const shouldDisplayWarning = !!modOverride?.unmoderated && commentDisabled;
const canNonPostMessage = useCanNonPostMessage();
const theme = useThemeContext();
const bgColor = classNames({
'bg-white': theme !== 'dark',
'bg-dark': theme === 'dark',
});
useEffect(() => {
(async function() {
setVerified(false);
setVerifiedSession('');
setVerifying(true);
dispatch(setMirror(false));
useEffect(() => {
(async function () {
setVerified(false);
setVerifiedSession('');
setVerifying(true);
dispatch(setMirror(false));
if (!selectedId) return;
if (!selectedId) return;
let session;
let session;
try {
session = await getSession(selectedId);
try {
session = await getSession(selectedId);
if (session) {
setVerifiedSession(session.username);
dispatch(setMirror(!!localStorage.getItem(`${session.username}_should_mirror`)));
}
} catch (e) {
console.error(e);
}
if (await verifyTweet(user?.address, user?.twitterVerification, session?.username)) {
setVerified(true);
}
setVerifying(false);
})();
}, [selectedId, user]);
const onChange = useCallback((newEditorState: EditorState) => {
if (readOnly) return;
setErrorMessage('');
dispatch(setDraft({
editorState: newEditorState,
reference: messageId,
attachment: draft.attachment,
moderation: draft.moderation,
global: draft.global,
}));
}, [messageId, readOnly, draft]);
const onModerationChange = useCallback((type: ModerationMessageSubType | null) => {
if (readOnly) return;
dispatch(setModeration(messageId || '', type));
}, [messageId]);
const onSetMirror = useCallback(async (e: any) => {
const checked = e.target.checked;
if (!verifiedSession || !verified) {
history.push('/connect/twitter');
return;
if (session) {
setVerifiedSession(session.username);
dispatch(setMirror(!!localStorage.getItem(`${session.username}_should_mirror`)));
}
} catch (e) {
console.error(e);
}
if (verified) {
localStorage.setItem(`${verifiedSession}_should_mirror`, checked ? '1': '');
dispatch(setMirror(checked));
}
}, [verified, verifiedSession]);
if (await verifyTweet(user?.address, user?.twitterVerification, session?.username)) {
setVerified(true);
}
const onChangeGroup = useCallback((groupId: string) => {
dispatch(setPostingGroup(groupId));
setShowingCustomGroupsModal(false);
}, [incognitoGroup]);
setVerifying(false);
})();
}, [selectedId, user]);
const onAddLink = useCallback((url: string) => {
if (readOnly) return;
const onChange = useCallback(
(newEditorState: EditorState) => {
if (readOnly) return;
setErrorMessage('');
dispatch(
setDraft({
editorState: newEditorState,
reference: messageId,
attachment: draft.attachment,
moderation: draft.moderation,
global: draft.global,
})
);
},
[messageId, readOnly, draft]
);
dispatch(setDraft({
editorState: draft.editorState,
reference: draft.reference,
attachment: url,
}));
const onModerationChange = useCallback(
(type: ModerationMessageSubType | null) => {
if (readOnly) return;
dispatch(setModeration(messageId || '', type));
},
[messageId]
);
setShowingImageUploadModal(false);
setShowingLinkModal(false);
}, [messageId, readOnly, draft]);
const onSetMirror = useCallback(
async (e: any) => {
const checked = e.target.checked;
const handleKeyCommand: (command: string) => DraftHandleValue = useCallback((command: string): DraftHandleValue => {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (newState) {
onChange && onChange(newState);
return 'handled';
}
return 'not-handled';
}, [editorState]);
if (!verifiedSession || !verified) {
history.push('/connect/twitter');
return;
}
const onPost = useCallback(async () => {
if (mirror && (!verifiedSession || !verified)) {
history.push('/connect/twitter');
return;
}
setErrorMessage('');
try {
if (props.onPost) {
await props.onPost();
}
} catch (e) {
console.log(e);
setErrorMessage(e.message);
}
}, [props.onPost, verified, verifiedSession, mirror]);
if (verified) {
localStorage.setItem(`${verifiedSession}_should_mirror`, checked ? '1' : '');
dispatch(setMirror(checked));
}
},
[verified, verifiedSession]
);
const onGlobalChange = useCallback((e: any) => {
dispatch(setGloabl(messageId, e.target.checked));
}, [messageId]);
const onChangeGroup = useCallback(
(groupId: string) => {
dispatch(setPostingGroup(groupId));
setShowingCustomGroupsModal(false);
},
[incognitoGroup]
);
const onAddLink = useCallback(
(url: string) => {
if (readOnly) return;
if (!disabled && gun.priv && gun.pub && !gun.joinedTx) {
return (
<div
className={classNames(
'flex flex-col flex-nowrap items-center',
'p-4',
bgColor,
'rounded-xl',
props.className,
)}
>
<Icon className="opacity-50" url={SpinnerGif} size={4} />
</div>
)
dispatch(
setDraft({
editorState: draft.editorState,
reference: draft.reference,
attachment: url,
})
);
setShowingImageUploadModal(false);
setShowingLinkModal(false);
},
[messageId, readOnly, draft]
);
const handleKeyCommand: (command: string) => DraftHandleValue = useCallback(
(command: string): DraftHandleValue => {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (newState) {
onChange && onChange(newState);
return 'handled';
}
return 'not-handled';
},
[editorState]
);
const onPost = useCallback(async () => {
if (mirror && (!verifiedSession || !verified)) {
history.push('/connect/twitter');
return;
}
if (selectedId?.type === 'interrep' && !selectedId.identityPath) {
return (
<div
className={classNames(
'flex flex-col flex-nowrap items-center',
'p-4',
bgColor,
'rounded-xl',
props.className,
)}
>
<div
className={classNames("mt-2 mb-4", {
'text-gray-800': theme !== 'dark',
'text-gray-200': theme === 'dark'
})}
>
Join Interrep to make a post
</div>
<Button btnType="primary" onClick={() => history.push('/onboarding/interrep')}>
Join Interrep
</Button>
</div>
)
setErrorMessage('');
try {
if (props.onPost) {
await props.onPost();
}
} catch (e) {
console.log(e);
setErrorMessage(e.message);
}
}, [props.onPost, verified, verifiedSession, mirror]);
if (!disabled && !selectedId) {
return (
<div
className={classNames(
'flex flex-col flex-nowrap items-center',
'p-4',
bgColor,
'rounded-xl',
props.className,
{
"border-gray-100": theme !== 'dark',
"border-gray-800": theme === 'dark',
}
)}
>
<div
className={classNames("mt-2 mb-4", {
'text-gray-800': theme !== 'dark',
'text-gray-200': theme === 'dark'
})}
>
Connect to a wallet to make a post
</div>
<Web3Button
className={classNames("rounded-xl border", {
"border-gray-100": theme !== 'dark',
"border-gray-800": theme === 'dark',
})}
/>
</div>
)
}
const onGlobalChange = useCallback(
(e: any) => {
dispatch(setGloabl(messageId, e.target.checked));
},
[messageId]
);
if (!disabled && gun.priv && gun.pub && !gun.joinedTx) {
return (
<div
className={classNames(
'flex flex-col flex-nowrap',
'pt-3 pb-2 px-4',
bgColor,
'rounded-xl',
'text-lg',
'editor',
props.className,
)}
>
<div className="flex flex-row flex-nowrap w-full h-full">
<Avatar
className="w-12 h-12 mr-3"
address={address}
group={selectedZKGroup || incognitoGroup}
incognito={
['interrep', 'zkpr_interrep', 'taz'].includes(selectedId?.type as string)
|| !!incognitoGroup
}
/>
<div className="flex flex-row flex-nowrap w-full h-full">
<div className="flex flex-col flex-nowrap w-full h-full editor__wrapper">
{
shouldDisplayWarning && (
<div className="rounded p-2 text-sm bg-yellow-100 text-yellow-500">
You can still submit a reply, but it will be hidden by default due to the OP's reply polilcy.
</div>
)
}
<DraftEditor
editorState={editorState}
onChange={onChange}
handleKeyCommand={handleKeyCommand}
placeholder={readOnly ? '' : "Write here..."}
readOnly={readOnly || disabled}
/>
{
selectedId?.type === 'gun' && !messageId && !incognitoGroup && (
<ModerationButton
onChange={onModerationChange}
currentType={draft.moderation || null}
/>
)
}
{
draft.attachment && (
<div className="editor__attachment py-2">
<URLPreview
url={draft.attachment}
onRemove={() => onAddLink('')}
editable
/>
</div>
)
}
{ errorMessage && <div className="error-message text-xs text-center text-red-500 m-2">{errorMessage}</div> }
</div>
</div>
</div>
<div
className={classNames("flex flex-row flex-nowrap border-t pt-2 ml-15", {
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}
>
<div className="flex-grow pr-4 mr-4 flex flex-row flex-nowrap items-center">
{
selectedId?.type === 'gun' && (
<Icon
className={classNames(
"editor__button text-blue-300 w-8 h-8 relative",
{
'hover:bg-blue-50 hover:text-blue-400': theme !== 'dark',
'hover:bg-blue-900 hover:text-blue-600': theme === 'dark',
}
)}
fa="fas fa-user-secret"
onClick={() => setShowingCustomGroupsModal(true)}
/>
)
}
<Icon
className={classNames(
"editor__button text-blue-300 w-8 h-8 relative",
{
'hover:bg-blue-50 hover:text-blue-400': theme !== 'dark',
'hover:bg-blue-900 hover:text-blue-600': theme === 'dark',
}
)}
fa="fas fa-image"
onClick={() => setShowingImageUploadModal(true)}
/>
<Icon
className={classNames(
"editor__button text-blue-300 w-8 h-8 relative",
{
'hover:bg-blue-50 hover:text-blue-400': theme !== 'dark',
'hover:bg-blue-900 hover:text-blue-600': theme === 'dark',
}
)}
fa="fas fa-link"
onClick={() => setShowingLinkModal(true)}
/>
</div>
<div className="flex-grow flex flex-row flex-nowrap items-center justify-end">
<Button
className="editor__submit-btn"
btnType="primary"
onClick={onPost}
disabled={isEmpty}
loading={loading}
>
<div className="editor__submit-btn__wrapper">
<div className="editor__submit-btn__wrapper__text">
{
!draft?.global && selectedId?.type === 'gun' && !incognitoGroup
? 'Post to my own feed'
: 'Post to global feed'
}
</div>
{
(canNonPostMessage && !incognitoGroup) && (
<Menuable
items={[
{
label: 'Make a public post',
className: 'editor__post-menu',
component: (
<div className="flex flex-col">
<div className="flex flex-row mb-2">
<Checkbox
className="mr-4 text-gray-500"
onChange={onGlobalChange}
checked={!!draft?.global}
>
Post to global timeline
</Checkbox>
</div>
{
!['interrep', 'zkpr_interrep', 'taz'].includes(selectedId?.type as string) && (
<div className="flex flex-row">
<Checkbox
className="mr-4 text-gray-500"
onChange={onSetMirror}
checked={mirror}
disabled={verifying}
>
Mirror to Twitter
</Checkbox>
</div>
)
}
</div>
)
}
]}
>
<Icon fa="fas fa-caret-down" />
</Menuable>
)
}
</div>
</Button>
</div>
</div>
{
showingCustomGroupsModal && (
<CustomGroupSelectModal
onChange={onChangeGroup}
onClose={() => setShowingCustomGroupsModal(false)}
/>
)
}
{
showingImageUploadModal && (
<FileUploadModal
onClose={() => setShowingImageUploadModal(false)}
onAccept={onAddLink}
mustLinkBeImage
/>
)
}
{
showingLinkModal && (
<LinkInputModal
onClose={() => setShowingLinkModal(false)}
onAccept={onAddLink}
/>
)
}
</div>
<div
className={classNames(
'flex flex-col flex-nowrap items-center',
'p-4',
bgColor,
'rounded-xl',
props.className
)}>
<Icon className="opacity-50" url={SpinnerGif} size={4} />
</div>
);
}
if (selectedId?.type === 'interrep' && !selectedId.identityPath) {
return (
<div
className={classNames(
'flex flex-col flex-nowrap items-center',
'p-4',
bgColor,
'rounded-xl',
props.className
)}>
<div
className={classNames('mt-2 mb-4', {
'text-gray-800': theme !== 'dark',
'text-gray-200': theme === 'dark',
})}>
Join Interrep to make a post
</div>
<Button btnType="primary" onClick={() => history.push('/onboarding/interrep')}>
Join Interrep
</Button>
</div>
);
}
if (!disabled && !selectedId) {
return (
<div
className={classNames(
'flex flex-col flex-nowrap items-center',
'p-4',
bgColor,
'rounded-xl',
props.className,
{
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}>
<div
className={classNames('mt-2 mb-4', {
'text-gray-800': theme !== 'dark',
'text-gray-200': theme === 'dark',
})}>
Connect to a wallet to make a post
</div>
<Web3Button
className={classNames('rounded-xl border', {
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}
/>
</div>
);
}
return (
<div
className={classNames(
'flex flex-col flex-nowrap',
'pt-3 pb-2 px-4',
bgColor,
'rounded-xl',
'text-lg',
'editor',
props.className
)}>
<div className="flex flex-row flex-nowrap w-full h-full">
<Avatar
className="w-12 h-12 mr-3"
address={address}
group={selectedZKGroup || incognitoGroup}
incognito={
['interrep', 'zkpr_interrep', 'taz'].includes(selectedId?.type as string) ||
!!incognitoGroup
}
/>
<div className="flex flex-row flex-nowrap w-full h-full">
<div className="flex flex-col flex-nowrap w-full h-full editor__wrapper">
{shouldDisplayWarning && (
<div className="rounded p-2 text-sm bg-yellow-100 text-yellow-500">
You can still submit a reply, but it will be hidden by default due to the OP's reply
polilcy.
</div>
)}
<DraftEditor
editorState={editorState}
onChange={onChange}
handleKeyCommand={handleKeyCommand}
placeholder={readOnly ? '' : 'Write here...'}
readOnly={readOnly || disabled}
/>
{selectedId?.type === 'gun' && !messageId && !incognitoGroup && (
<ModerationButton
onChange={onModerationChange}
currentType={draft.moderation || null}
/>
)}
{draft.attachment && (
<div className="editor__attachment py-2">
<URLPreview url={draft.attachment} onRemove={() => onAddLink('')} editable />
</div>
)}
{errorMessage && (
<div className="error-message text-xs text-center text-red-500 m-2">
{errorMessage}
</div>
)}
</div>
</div>
</div>
<div
className={classNames('flex flex-row flex-nowrap border-t pt-2 ml-15', {
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}>
<div className="flex-grow pr-4 mr-4 flex flex-row flex-nowrap items-center">
{selectedId?.type === 'gun' && (
<Icon
className={classNames('editor__button text-blue-300 w-8 h-8 relative', {
'hover:bg-blue-50 hover:text-blue-400': theme !== 'dark',
'hover:bg-blue-900 hover:text-blue-600': theme === 'dark',
})}
fa="fas fa-user-secret"
onClick={() => setShowingCustomGroupsModal(true)}
/>
)}
<Icon
className={classNames('editor__button text-blue-300 w-8 h-8 relative', {
'hover:bg-blue-50 hover:text-blue-400': theme !== 'dark',
'hover:bg-blue-900 hover:text-blue-600': theme === 'dark',
})}
fa="fas fa-image"
onClick={() => setShowingImageUploadModal(true)}
/>
<Icon
className={classNames('editor__button text-blue-300 w-8 h-8 relative', {
'hover:bg-blue-50 hover:text-blue-400': theme !== 'dark',
'hover:bg-blue-900 hover:text-blue-600': theme === 'dark',
})}
fa="fas fa-link"
onClick={() => setShowingLinkModal(true)}
/>
</div>
<div className="flex-grow flex flex-row flex-nowrap items-center justify-end">
<Button
className="editor__submit-btn"
btnType="primary"
onClick={onPost}
disabled={isEmpty}
loading={loading}>
<div className="editor__submit-btn__wrapper">
<div className="editor__submit-btn__wrapper__text">
{!draft?.global && selectedId?.type === 'gun' && !incognitoGroup
? 'Post to my own feed'
: 'Post to global feed'}
</div>
{canNonPostMessage && !incognitoGroup && (
<Menuable
items={[
{
label: 'Make a public post',
className: 'editor__post-menu',
component: (
<div className="flex flex-col">
<div className="flex flex-row mb-2">
<Checkbox
className="mr-4 text-gray-500"
onChange={onGlobalChange}
checked={!!draft?.global}>
Post to global timeline
</Checkbox>
</div>
{!['interrep', 'zkpr_interrep', 'taz'].includes(
selectedId?.type as string
) && (
<div className="flex flex-row">
<Checkbox
className="mr-4 text-gray-500"
onChange={onSetMirror}
checked={mirror}
disabled={verifying}>
Mirror to Twitter
</Checkbox>
</div>
)}
</div>
),
},
]}>
<Icon fa="fas fa-caret-down" />
</Menuable>
)}
</div>
</Button>
</div>
</div>
{showingCustomGroupsModal && (
<CustomGroupSelectModal
onChange={onChangeGroup}
onClose={() => setShowingCustomGroupsModal(false)}
/>
)}
{showingImageUploadModal && (
<FileUploadModal
onClose={() => setShowingImageUploadModal(false)}
onAccept={onAddLink}
mustLinkBeImage
/>
)}
{showingLinkModal && (
<LinkInputModal onClose={() => setShowingLinkModal(false)} onAccept={onAddLink} />
)}
</div>
);
}

View File

@@ -1,51 +1,48 @@
import React, {ReactElement} from "react";
import Modal, {ModalContent, ModalHeader} from "../Modal";
import {useSelectedLocalId} from "../../ducks/worker";
import React, { ReactElement } from 'react';
import Modal, { ModalContent, ModalHeader } from '../Modal';
import { useSelectedLocalId } from '../../ducks/worker';
import QRCode from 'react-qr-code';
import {Identity} from "../../serviceWorkers/identity";
import { Identity } from '../../serviceWorkers/identity';
type Props = {
onClose: () => void;
}
onClose: () => void;
};
export default function ExportPrivateKeyModal(props: Props): ReactElement {
const selected = useSelectedLocalId();
const selected = useSelectedLocalId();
let data;
let data;
if (selected?.type === 'gun') {
data = {
type: selected.type,
address: selected.address,
nonce: selected.nonce,
publicKey: selected.publicKey,
privateKey: selected.privateKey,
};
}
if (selected?.type === 'gun') {
data = {
type: selected.type,
address: selected.address,
nonce: selected.nonce,
publicKey: selected.publicKey,
privateKey: selected.privateKey,
};
}
if (selected?.type === 'interrep') {
data = {
type: selected.type,
address: selected.address,
serializedIdentity: selected.serializedIdentity,
identityCommitment: selected.identityCommitment,
nonce: selected.nonce,
provider: selected.provider,
name: selected.name,
};
}
if (selected?.type === 'interrep') {
data = {
type: selected.type,
address: selected.address,
serializedIdentity: selected.serializedIdentity,
identityCommitment: selected.identityCommitment,
nonce: selected.nonce,
provider: selected.provider,
name: selected.name,
};
}
return (
<Modal
className="w-96"
onClose={props.onClose}
>
<ModalHeader onClose={props.onClose}>
<b>{`Export Private Key`}</b>
</ModalHeader>
<ModalContent className="p-4 my-4">
<QRCode className="my-0 mx-auto" value={JSON.stringify(data)} />
</ModalContent>
</Modal>
)
}
return (
<Modal className="w-96" onClose={props.onClose}>
<ModalHeader onClose={props.onClose}>
<b>{`Export Private Key`}</b>
</ModalHeader>
<ModalContent className="p-4 my-4">
<QRCode className="my-0 mx-auto" value={JSON.stringify(data)} />
</ModalContent>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable.scss";
@import '../../util/variable.scss';
.file-select-btn {
@extend %col-nowrap;
@@ -6,7 +6,7 @@
justify-content: center;
position: relative;
input[type=file] {
input[type='file'] {
position: absolute;
top: 0;
left: 0;
@@ -15,4 +15,4 @@
opacity: 0;
cursor: pointer;
}
}
}

View File

@@ -1,29 +1,23 @@
import React, {ChangeEvent, EventHandler, ReactElement} from "react";
import "./file-select-btn.scss";
import Button from "../Button";
import Icon from "../Icon";
import classNames from "classnames";
import React, { ChangeEvent, EventHandler, ReactElement } from 'react';
import './file-select-btn.scss';
import Button from '../Button';
import Icon from '../Icon';
import classNames from 'classnames';
type Props = {
className?: string;
accept?: string;
onChange?: EventHandler<ChangeEvent<HTMLInputElement>>;
}
className?: string;
accept?: string;
onChange?: EventHandler<ChangeEvent<HTMLInputElement>>;
};
export default function FileSelectButton(props: Props): ReactElement {
return (
<div className={classNames("file-select-btn", props.className)}>
<Button
btnType="primary"
>
<Icon fa="fas fa-file-upload" className="mr-2" />
<span>Select File</span>
</Button>
<input
type="file"
accept={props.accept}
onChange={props.onChange}
/>
</div>
)
}
return (
<div className={classNames('file-select-btn', props.className)}>
<Button btnType="primary">
<Icon fa="fas fa-file-upload" className="mr-2" />
<span>Select File</span>
</Button>
<input type="file" accept={props.accept} onChange={props.onChange} />
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable.scss";
@import '../../util/variable.scss';
.dark {
.file-upload-modal {
@@ -14,7 +14,7 @@
@extend %col-nowrap;
align-items: center;
justify-content: center;
border-radius: .5rem;
border-radius: 0.5rem;
border: 2px dashed $gray-200;
background-color: $gray-100;
padding: 4rem;
@@ -33,4 +33,4 @@
width: 100%;
}
}
}
}

View File

@@ -1,252 +1,232 @@
import React, {
KeyboardEvent, ChangeEvent, ReactElement,
useCallback, useEffect, useRef, useState,
} from "react";
import Modal, {ModalContent, ModalFooter, ModalHeader} from "../Modal";
import Button from "../Button";
import Icon from "../Icon";
import classNames from "classnames";
import "./file-upload-modal.scss";
import FileSelectButton from "../FileSelectButton";
import Input from "../Input";
import config from "../../util/config";
import URLPreview from "../URLPreview";
import {useDispatch} from "react-redux";
import {ipfsUploadOne} from "../../util/upload";
KeyboardEvent,
ChangeEvent,
ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import Modal, { ModalContent, ModalFooter, ModalHeader } from '../Modal';
import Button from '../Button';
import Icon from '../Icon';
import classNames from 'classnames';
import './file-upload-modal.scss';
import FileSelectButton from '../FileSelectButton';
import Input from '../Input';
import config from '../../util/config';
import URLPreview from '../URLPreview';
import { useDispatch } from 'react-redux';
import { ipfsUploadOne } from '../../util/upload';
type Props = {
className?: string;
onClose: () => void;
onAccept: (url: string) => void;
mustLinkBeImage?: boolean;
skipLinkPreview?: boolean;
}
className?: string;
onClose: () => void;
onAccept: (url: string) => void;
mustLinkBeImage?: boolean;
skipLinkPreview?: boolean;
};
const ONE_MB = 1048576;
const maxFileSize = ONE_MB * 5;
export default function FileUploadModal(props: Props): ReactElement {
const dispatch = useDispatch();
const drop = useRef<HTMLDivElement>(null);
const [err, setError] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [link, setLink] = useState('');
const [file, setFile] = useState<File|null>(null);
const [isUploading, setUploading] = useState(false);
const dispatch = useDispatch();
const drop = useRef<HTMLDivElement>(null);
const [err, setError] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [link, setLink] = useState('');
const [file, setFile] = useState<File | null>(null);
const [isUploading, setUploading] = useState(false);
const handleDrag = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrag = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.dataTransfer) return;
if (!e.dataTransfer) return;
const {files} = e.dataTransfer;
const file = files[0];
const { files } = e.dataTransfer;
const file = files[0];
onFileChange(file);
onFileChange(file);
};
const onFileChange = async (file: File) => {
setError('');
if (!/(^image)(\/)[a-zA-Z0-9_]*/.test(file.type)) {
setError('invalid file type');
return;
}
const onFileChange = async (file: File) => {
setError('');
const url = URL.createObjectURL(file);
if (!(/(^image)(\/)[a-zA-Z0-9_]*/).test(file.type)) {
setError('invalid file type');
return;
}
const isImageValid = await validateImage(url);
const url = URL.createObjectURL(file);
const isImageValid = await validateImage(url);
if (!isImageValid) {
setError('image cannot be previewed');
return;
}
setFile(file);
setLink('');
setPreviewUrl(url);
if (!isImageValid) {
setError('image cannot be previewed');
return;
}
const onLinkEntered = useCallback(async () => {
setError('');
setFile(file);
setLink('');
setPreviewUrl(url);
};
if (props.mustLinkBeImage) {
const isImageValid = await validateImage(link);
const onLinkEntered = useCallback(async () => {
setError('');
if (!isImageValid) {
setError('image cannot be previewed');
return;
}
}
if (props.mustLinkBeImage) {
const isImageValid = await validateImage(link);
if (props.skipLinkPreview) {
props.onAccept(link);
if (!isImageValid) {
setError('image cannot be previewed');
return;
}
}
if (props.skipLinkPreview) {
props.onAccept(link);
} else {
setFile(null);
setPreviewUrl(link);
}
}, [link]);
const onLinkChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setLink(val);
};
const onKeyPress = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onLinkEntered();
}
},
[onLinkEntered]
);
const reset = () => {
setPreviewUrl('');
setLink('');
setError('');
};
const upload = useCallback(async () => {
setUploading(true);
try {
if (file) {
if (file.size > maxFileSize) throw new Error('cannot exceed 5MB');
const json: any = await dispatch(ipfsUploadOne(file));
if (!json.error) {
const { url } = json.payload;
props.onAccept(url);
} else {
setFile(null);
setPreviewUrl(link);
setError(json.payload);
}
}, [link]);
} else if (link) {
props.onAccept(link);
}
} catch (e) {
setError(e.message);
} finally {
setUploading(false);
}
}, [file, link]);
const onLinkChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setLink(val);
useEffect(() => {
if (!drop.current) {
return;
}
const onKeyPress = useCallback(async (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onLinkEntered();
}
}, [onLinkEntered]);
drop.current.addEventListener('dragover', handleDrag);
drop.current.addEventListener('drop', handleDrop);
const reset = () => {
setPreviewUrl('');
setLink('');
setError('');
}
return () => {
if (drop.current) {
drop.current.removeEventListener('dragover', handleDrag);
drop.current.removeEventListener('drop', handleDrop);
}
};
}, [drop.current]);
const upload = useCallback(async () => {
setUploading(true);
try {
if (file) {
if (file.size > maxFileSize) throw new Error('cannot exceed 5MB');
const json: any = await dispatch(ipfsUploadOne(file));
if (!json.error) {
const {url} = json.payload;
props.onAccept(url);
} else {
setError(json.payload);
}
} else if (link) {
props.onAccept(link);
}
} catch (e) {
setError(e.message);
} finally {
setUploading(false);
}
}, [file, link]);
useEffect(() => {
if (!drop.current) {
return;
}
drop.current.addEventListener('dragover', handleDrag);
drop.current.addEventListener('drop', handleDrop);
return () => {
if (drop.current) {
drop.current.removeEventListener('dragover', handleDrag);
drop.current.removeEventListener('drop', handleDrop);
}
}
}, [drop.current]);
return (
<Modal
className={classNames('w-148 file-upload-modal', props.className)}
onClose={props.onClose}
>
<ModalHeader onClose={!previewUrl ? props.onClose : undefined}>
Add a File
</ModalHeader>
<ModalContent>
{
previewUrl
? (
<div className="file-upload-modal__img-preview">
<img
src={previewUrl}
/>
</div>
)
: (
<div className="flex flex-col items-center justify-center p-4">
<div
ref={drop}
className="file-upload-modal__dd mt-4"
>
<div>Drag & Drop</div>
<FileSelectButton
className="my-2"
accept="image/*"
onChange={e => e.target.files && onFileChange(e.target.files[0])}
/>
<small>Maximum image size: 5MB</small>
</div>
<div className="m-4 font-semibold">or</div>
<div className="relative w-full mb-4">
<Input
className="border"
label="Paste a Link"
onChange={onLinkChange}
onKeyPress={onKeyPress}
>
{ validateLink(link) && (
<Icon
className="mx-4 text-blue-300 hover:text-blue-400"
fa="fas fa-arrow-right"
onClick={onLinkEntered}
/>
)}
</Input>
</div>
</div>
)
}
{ err ? <div className="text-xs text-center text-red-500 mb-4">{err}</div> : null }
</ModalContent>
{
previewUrl && (
<ModalFooter>
<Button
btnType="secondary"
onClick={reset}
disabled={isUploading}
>
<Icon fa="fas fa-times" />
</Button>
<Button
btnType="primary"
className="ml-2"
loading={isUploading}
onClick={upload}
>
<Icon
fa="fas fa-check"
/>
</Button>
</ModalFooter>
)
}
</Modal>
)
return (
<Modal
className={classNames('w-148 file-upload-modal', props.className)}
onClose={props.onClose}>
<ModalHeader onClose={!previewUrl ? props.onClose : undefined}>Add a File</ModalHeader>
<ModalContent>
{previewUrl ? (
<div className="file-upload-modal__img-preview">
<img src={previewUrl} />
</div>
) : (
<div className="flex flex-col items-center justify-center p-4">
<div ref={drop} className="file-upload-modal__dd mt-4">
<div>Drag & Drop</div>
<FileSelectButton
className="my-2"
accept="image/*"
onChange={e => e.target.files && onFileChange(e.target.files[0])}
/>
<small>Maximum image size: 5MB</small>
</div>
<div className="m-4 font-semibold">or</div>
<div className="relative w-full mb-4">
<Input
className="border"
label="Paste a Link"
onChange={onLinkChange}
onKeyPress={onKeyPress}>
{validateLink(link) && (
<Icon
className="mx-4 text-blue-300 hover:text-blue-400"
fa="fas fa-arrow-right"
onClick={onLinkEntered}
/>
)}
</Input>
</div>
</div>
)}
{err ? <div className="text-xs text-center text-red-500 mb-4">{err}</div> : null}
</ModalContent>
{previewUrl && (
<ModalFooter>
<Button btnType="secondary" onClick={reset} disabled={isUploading}>
<Icon fa="fas fa-times" />
</Button>
<Button btnType="primary" className="ml-2" loading={isUploading} onClick={upload}>
<Icon fa="fas fa-check" />
</Button>
</ModalFooter>
)}
</Modal>
);
}
const validateLink = (link: string): boolean => {
try {
new URL(link);
return true;
} catch (e) {
return false;
}
}
try {
new URL(link);
return true;
} catch (e) {
return false;
}
};
const validateImage = async (link: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = link;
});
}
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = link;
});
};

View File

@@ -1,104 +1,96 @@
import React, {ReactElement, useEffect, useState} from "react";
import classNames from "classnames";
import Icon from "../Icon";
import SpinnerGIF from "../../../static/icons/spinner.gif";
import SwitchButton from "../SwitchButton";
import {useThemeContext} from "../ThemeContext";
import {UserRow} from "../DiscoverUserPanel";
import Button from "../Button";
import {useParams} from "react-router";
import Web3 from "web3";
import {fetchAddressByName, useUser} from "../../ducks/users";
import {useDispatch} from "react-redux";
import {useSelectedLocalId} from "../../ducks/worker";
import MemberInviteModal from "../MemberInviteModal";
import config from "../../util/config";
import React, { ReactElement, useEffect, useState } from 'react';
import classNames from 'classnames';
import Icon from '../Icon';
import SpinnerGIF from '../../../static/icons/spinner.gif';
import SwitchButton from '../SwitchButton';
import { useThemeContext } from '../ThemeContext';
import { UserRow } from '../DiscoverUserPanel';
import Button from '../Button';
import { useParams } from 'react-router';
import Web3 from 'web3';
import { fetchAddressByName, useUser } from '../../ducks/users';
import { useDispatch } from 'react-redux';
import { useSelectedLocalId } from '../../ducks/worker';
import MemberInviteModal from '../MemberInviteModal';
import config from '../../util/config';
type Props = {
};
type Props = {};
export default function GroupMembersPanel(props: Props): ReactElement {
const theme = useThemeContext();
const dispatch = useDispatch();
const selected = useSelectedLocalId();
const [username, setUsername] = useState('');
const {name} = useParams<{name: string}>();
const [users, setUsers] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [showingInviteModal, showInviteModal] = useState(false);
const isCurrentUser = selected?.address === username;
const user = useUser(username);
const theme = useThemeContext();
const dispatch = useDispatch();
const selected = useSelectedLocalId();
const [username, setUsername] = useState('');
const { name } = useParams<{ name: string }>();
const [users, setUsers] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [showingInviteModal, showInviteModal] = useState(false);
const isCurrentUser = selected?.address === username;
const user = useUser(username);
useEffect(() => {
(async () => {
if (!Web3.utils.isAddress(name)) {
const address: any = await dispatch(fetchAddressByName(name));
setUsername(address);
} else {
setUsername(name);
}
})();
}, [name]);
useEffect(() => {
(async () => {
if (!Web3.utils.isAddress(name)) {
const address: any = await dispatch(fetchAddressByName(name));
setUsername(address);
} else {
setUsername(name);
}
})();
}, [name]);
useEffect(() => {
(async () => {
if (!username || !user?.group) return;
const resp = await fetch(`${config.indexerAPI}/v1/group_members/custom_${username}`);
const json = await resp.json();
useEffect(() => {
(async () => {
if (!username || !user?.group) return;
const resp = await fetch(`${config.indexerAPI}/v1/group_members/custom_${username}`);
const json = await resp.json();
if (!json.error) {
setUsers(json.payload.map((member: any) => member.address));
}
})();
}, [username, user]);
if (!json.error) {
setUsers(json.payload.map((member: any) => member.address));
}
})();
}, [username, user]);
if (!user?.group) return <></>;
if (!user?.group) return <></>;
return (
<div
className={classNames(
'flex flex-col flex-nowrap flex-grow border rounded-xl mt-2',
'meta-group post-mod-panel',
{
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}
>
{ showingInviteModal && <MemberInviteModal onClose={() => showInviteModal(false)} /> }
<div
className={classNames(
"px-4 py-2 font-bold text-lg border-b",
'flex flew-row items-center',
{
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}
>
<p>Members</p>
{
isCurrentUser && (
<a
className="flex flex-row flex-grow justify-end text-xs font-normal cursor-pointer"
onClick={() => showInviteModal(true)}
>
Invite
</a>
)
}
</div>
<div className="flex flex-col flex-nowrap py-1 max-h-80 overflow-y-auto">
{ loading && <Icon className="self-center my-4" url={SpinnerGIF} size={3} /> }
{users.map(address => <UserRow key={address} name={address} />)}
{!users.length && (
<div className="text-light flex flex-row justify-center py-2">
No members yet :(
</div>
)}
</div>
</div>
);
}
return (
<div
className={classNames(
'flex flex-col flex-nowrap flex-grow border rounded-xl mt-2',
'meta-group post-mod-panel',
{
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}>
{showingInviteModal && <MemberInviteModal onClose={() => showInviteModal(false)} />}
<div
className={classNames(
'px-4 py-2 font-bold text-lg border-b',
'flex flew-row items-center',
{
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}>
<p>Members</p>
{isCurrentUser && (
<a
className="flex flex-row flex-grow justify-end text-xs font-normal cursor-pointer"
onClick={() => showInviteModal(true)}>
Invite
</a>
)}
</div>
<div className="flex flex-col flex-nowrap py-1 max-h-80 overflow-y-auto">
{loading && <Icon className="self-center my-4" url={SpinnerGIF} size={3} />}
{users.map(address => (
<UserRow key={address} name={address} />
))}
{!users.length && (
<div className="text-light flex flex-row justify-center py-2">No members yet :(</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
@import "../../util/variable";
@import '../../util/variable';
.icon {
@extend %row-nowrap;
align-items: center;
}
}

View File

@@ -1,44 +1,36 @@
import React, {MouseEventHandler, ReactElement, ReactNode} from "react";
import classNames from "classnames";
import "./icon.scss";
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react';
import classNames from 'classnames';
import './icon.scss';
type Props = {
url?: string;
fa?: string;
className?: string;
size?: number;
onClick?: MouseEventHandler;
children?: ReactNode;
}
url?: string;
fa?: string;
className?: string;
size?: number;
onClick?: MouseEventHandler;
children?: ReactNode;
};
export default function Icon(props: Props): ReactElement {
const {
url,
size = 1,
className = '',
fa,
onClick,
children,
} = props;
const { url, size = 1, className = '', fa, onClick, children } = props;
return (
<div
className={classNames(
'bg-contain bg-center bg-no-repeat icon',
{
'cursor-pointer': onClick,
},
className,
)}
style={{
backgroundImage: url ? `url(${url})` : undefined,
width: !fa ? `${size}rem` : undefined,
height: !fa ? `${size}rem` : undefined,
}}
onClick={onClick}
>
{(!url && !!fa) && <i className={fa} style={{ fontSize: `${size}rem`}}/>}
{children}
</div>
);
}
return (
<div
className={classNames(
'bg-contain bg-center bg-no-repeat icon',
{
'cursor-pointer': onClick,
},
className
)}
style={{
backgroundImage: url ? `url(${url})` : undefined,
width: !fa ? `${size}rem` : undefined,
height: !fa ? `${size}rem` : undefined,
}}
onClick={onClick}>
{!url && !!fa && <i className={fa} style={{ fontSize: `${size}rem` }} />}
{children}
</div>
);
}

View File

@@ -1,63 +1,60 @@
import React, {BaseHTMLAttributes, ReactElement, ReactNode, useCallback, useRef, useState} from "react";
import classNames from "classnames";
import debounce from "lodash.debounce";
import React, {
BaseHTMLAttributes,
ReactElement,
ReactNode,
useCallback,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import debounce from 'lodash.debounce';
type Props = {
className?: string;
onScrolledToBottom?: () => void;
onScrolledToTop?: () => void;
children?: ReactNode;
bottomOffset?: number;
topOffset?: number;
className?: string;
onScrolledToBottom?: () => void;
onScrolledToTop?: () => void;
children?: ReactNode;
bottomOffset?: number;
topOffset?: number;
};
export default function InfiniteScrollable(props: Props): ReactElement {
const {
className,
onScrolledToBottom,
onScrolledToTop,
children,
bottomOffset = 0,
topOffset = 0,
} = props;
const {
className,
onScrolledToBottom,
onScrolledToTop,
children,
bottomOffset = 0,
topOffset = 0,
} = props;
const el = useRef<HTMLDivElement>(null);
const el = useRef<HTMLDivElement>(null);
const onScroll = useCallback(() => {
const current = el.current;
const onScroll = useCallback(() => {
const current = el.current;
if (!current) return;
if (!current) return;
const {
scrollTop,
offsetHeight,
scrollHeight,
} = current;
const { scrollTop, offsetHeight, scrollHeight } = current;
if (onScrolledToBottom) {
if ((scrollTop + offsetHeight) >= scrollHeight - bottomOffset) {
onScrolledToBottom();
}
}
if (onScrolledToBottom) {
if (scrollTop + offsetHeight >= scrollHeight - bottomOffset) {
onScrolledToBottom();
}
}
if (onScrolledToTop) {
if ((scrollTop + scrollHeight) <= offsetHeight + topOffset) {
onScrolledToTop();
}
}
if (onScrolledToTop) {
if (scrollTop + scrollHeight <= offsetHeight + topOffset) {
onScrolledToTop();
}
}
}, [el, onScrolledToBottom, onScrolledToTop]);
const debouncedOnScroll = debounce(onScroll, 100, { leading: true });
}, [el, onScrolledToBottom, onScrolledToTop]);
const debouncedOnScroll = debounce(onScroll, 100, { leading: true })
return (
<div
ref={el}
className={classNames(className)}
onScroll={debouncedOnScroll}
>
{children}
</div>
);
}
return (
<div ref={el} className={classNames(className)} onScroll={debouncedOnScroll}>
{children}
</div>
);
}

View File

@@ -1,52 +1,39 @@
import React, {InputHTMLAttributes, ReactElement, ReactNode} from "react";
import classNames from "classnames";
import "./input.scss";
import {useThemeContext} from "../ThemeContext";
import React, { InputHTMLAttributes, ReactElement, ReactNode } from 'react';
import classNames from 'classnames';
import './input.scss';
import { useThemeContext } from '../ThemeContext';
type Props = {
label?: string;
errorMessage?: string;
children?: ReactNode;
label?: string;
errorMessage?: string;
children?: ReactNode;
} & InputHTMLAttributes<HTMLInputElement>;
export default function Input(props: Props): ReactElement {
const {
label,
errorMessage,
className,
children,
...inputProps
} = props;
const { label, errorMessage, className, children, ...inputProps } = props;
const theme = useThemeContext();
const theme = useThemeContext();
return (
<div
className={classNames(
"rounded-lg input-group",
className,
{
'input-group--readOnly': inputProps.readOnly,
'focus-within:border-gray-400 ': !inputProps.readOnly && theme !== 'dark',
'focus-within:border-gray-600': !inputProps.readOnly && theme === 'dark',
'border-gray-800': theme === 'dark',
}
)}
>
{ label && <div className="input-group__label">{label}</div> }
<div
className="w-full flex flex-row flex-nowrap items-center"
>
<input
className={classNames("bg-transparent rounded-xl flex-grow flex-shrink", {
'cursor-default': inputProps.readOnly,
})}
{...inputProps}
/>
{children}
</div>
return (
<div
className={classNames('rounded-lg input-group', className, {
'input-group--readOnly': inputProps.readOnly,
'focus-within:border-gray-400 ': !inputProps.readOnly && theme !== 'dark',
'focus-within:border-gray-600': !inputProps.readOnly && theme === 'dark',
'border-gray-800': theme === 'dark',
})}>
{label && <div className="input-group__label">{label}</div>}
<div className="w-full flex flex-row flex-nowrap items-center">
<input
className={classNames('bg-transparent rounded-xl flex-grow flex-shrink', {
'cursor-default': inputProps.readOnly,
})}
{...inputProps}
/>
{children}
</div>
{ errorMessage && <small className="error-message text-red-500">{errorMessage}</small> }
</div>
)
{errorMessage && <small className="error-message text-red-500">{errorMessage}</small>}
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.dark {
.input-group {
@@ -24,16 +24,16 @@
background: white;
color: $gray-800;
display: inline-block;
top: -.625rem;
top: -0.625rem;
width: fit-content;
font-size: 12px;
padding: 0 .25rem 0 .1875rem;
margin-left: .625rem;
padding: 0 0.25rem 0 0.1875rem;
margin-left: 0.625rem;
position: absolute;
}
input {
padding: .5rem .75rem;
padding: 0.5rem 0.75rem;
outline: none;
&::placeholder {
@@ -43,6 +43,6 @@
.error-message {
text-align: center;
padding-top: .25rem;
padding-top: 0.25rem;
}
}
}

View File

@@ -1,110 +1,93 @@
import React, {ChangeEvent, KeyboardEvent, ReactElement, useCallback, useState} from "react";
import Modal, {ModalContent, ModalFooter, ModalHeader} from "../Modal";
import classNames from "classnames";
import Input from "../Input";
import Icon from "../Icon";
import Button from "../Button";
import URLPreview from "../URLPreview";
import React, { ChangeEvent, KeyboardEvent, ReactElement, useCallback, useState } from 'react';
import Modal, { ModalContent, ModalFooter, ModalHeader } from '../Modal';
import classNames from 'classnames';
import Input from '../Input';
import Icon from '../Icon';
import Button from '../Button';
import URLPreview from '../URLPreview';
type Props = {
className?: string;
onClose: () => void;
onAccept: (url: string) => void;
}
className?: string;
onClose: () => void;
onAccept: (url: string) => void;
};
export default function LinkInputModal(props: Props): ReactElement {
const [err, setError] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [link, setLink] = useState('');
const [err, setError] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [link, setLink] = useState('');
const onLinkEntered = useCallback(async () => {
setError('');
setPreviewUrl(link);
}, [link]);
const onLinkEntered = useCallback(async () => {
setError('');
setPreviewUrl(link);
}, [link]);
const onLinkChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setLink(val);
};
const onLinkChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setLink(val);
}
const onKeyPress = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
if (validateLink(link) && e.key === 'Enter') {
onLinkEntered();
}
},
[onLinkEntered, link]
);
const onKeyPress = useCallback(async (e: KeyboardEvent<HTMLInputElement>) => {
if (validateLink(link) && e.key === 'Enter') {
onLinkEntered();
}
}, [onLinkEntered, link]);
const reset = () => {
setPreviewUrl('');
setLink('');
setError('');
}
return (
<Modal
className={classNames('w-148 file-upload-modal', props.className)}
onClose={props.onClose}
>
<ModalHeader onClose={!previewUrl ? props.onClose : undefined}>
Add a Link
</ModalHeader>
<ModalContent>
<div className="flex flex-col items-center justify-center p-4">
<div className="relative w-full mb-4">
<Input
className="border"
label="Enter link"
onChange={onLinkChange}
value={link}
onKeyPress={onKeyPress}
placeholder="http(s):// or magnet://..."
>
{ validateLink(link) && (
<Icon
className="mx-4 text-blue-300 hover:text-blue-400"
fa="fas fa-binoculars"
onClick={onLinkEntered}
/>
)}
</Input>
</div>
{
previewUrl && (
<URLPreview className="w-full" url={previewUrl} />
)
}
</div>
{ err ? <div className="text-xs text-center text-red-500 mb-4">{err}</div> : null }
</ModalContent>
{
previewUrl && (
<ModalFooter>
<Button
btnType="secondary"
onClick={reset}
>
<Icon fa="fas fa-times" />
</Button>
<Button
btnType="primary"
className="ml-2"
onClick={() => props.onAccept(link)}
>
<Icon
fa="fas fa-check"
/>
</Button>
</ModalFooter>
)
}
</Modal>
);
const reset = () => {
setPreviewUrl('');
setLink('');
setError('');
};
return (
<Modal
className={classNames('w-148 file-upload-modal', props.className)}
onClose={props.onClose}>
<ModalHeader onClose={!previewUrl ? props.onClose : undefined}>Add a Link</ModalHeader>
<ModalContent>
<div className="flex flex-col items-center justify-center p-4">
<div className="relative w-full mb-4">
<Input
className="border"
label="Enter link"
onChange={onLinkChange}
value={link}
onKeyPress={onKeyPress}
placeholder="http(s):// or magnet://...">
{validateLink(link) && (
<Icon
className="mx-4 text-blue-300 hover:text-blue-400"
fa="fas fa-binoculars"
onClick={onLinkEntered}
/>
)}
</Input>
</div>
{previewUrl && <URLPreview className="w-full" url={previewUrl} />}
</div>
{err ? <div className="text-xs text-center text-red-500 mb-4">{err}</div> : null}
</ModalContent>
{previewUrl && (
<ModalFooter>
<Button btnType="secondary" onClick={reset}>
<Icon fa="fas fa-times" />
</Button>
<Button btnType="primary" className="ml-2" onClick={() => props.onAccept(link)}>
<Icon fa="fas fa-check" />
</Button>
</ModalFooter>
)}
</Modal>
);
}
const validateLink = (link: string): boolean => {
try {
new URL(link);
return true;
} catch (e) {
return false;
}
}
try {
new URL(link);
return true;
} catch (e) {
return false;
}
};

View File

@@ -1,64 +1,64 @@
import React, {ReactElement, useCallback, useState} from "react";
import NotificationBox from "../NotificationBox";
import Button from "../Button";
import {addIdentity, getIdentities, getIdentityStatus, selectIdentity} from "../../serviceWorkers/util";
import {useHasLocal, useLoggedIn} from "../../ducks/web3";
import {useHistory} from "react-router";
import {postWorkerMessage} from "../../util/sw";
import {Identity} from "../../serviceWorkers/identity";
import LoginModal from "../LoginModal";
import {useSelectedLocalId} from "../../ducks/worker";
import React, { ReactElement, useCallback, useState } from 'react';
import NotificationBox from '../NotificationBox';
import Button from '../Button';
import {
addIdentity,
getIdentities,
getIdentityStatus,
selectIdentity,
} from '../../serviceWorkers/util';
import { useHasLocal, useLoggedIn } from '../../ducks/web3';
import { useHistory } from 'react-router';
import { postWorkerMessage } from '../../util/sw';
import { Identity } from '../../serviceWorkers/identity';
import LoginModal from '../LoginModal';
import { useSelectedLocalId } from '../../ducks/worker';
export default function LocalBackupNotification(): ReactElement {
const loggedIn = useLoggedIn();
const hasLocalBackup = useHasLocal();
const history = useHistory();
const selected = useSelectedLocalId();
const [showingLogin, setShowingLogin] = useState(false);
const loggedIn = useLoggedIn();
const hasLocalBackup = useHasLocal();
const history = useHistory();
const selected = useSelectedLocalId();
const [showingLogin, setShowingLogin] = useState(false);
const onAddIdentity = useCallback(async () => {
if (!selected) return;
await postWorkerMessage(addIdentity(selected));
await postWorkerMessage(selectIdentity(
selected.type === 'gun'
? selected.publicKey
: selected.identityCommitment
));
}, [selected]);
const onCreateLocalBackup = useCallback(async () => {
const identities = await postWorkerMessage<Identity[]>(getIdentities());
const {unlocked} = await postWorkerMessage<{unlocked: boolean}>(getIdentityStatus());
if (!identities.length && !unlocked) {
history.push('/create-local-backup');
} else if (unlocked) {
await onAddIdentity();
} else {
setShowingLogin(true);
}
}, [onAddIdentity]);
const onClose = useCallback(async () => {
setShowingLogin(false);
await onAddIdentity();
}, [onAddIdentity])
if (!loggedIn || hasLocalBackup || selected?.type === 'zkpr_interrep') return <></>;
return (
<>
{ showingLogin && <LoginModal onClose={onClose} /> }
<NotificationBox className="text-center mb-2">
You can store a secure backup of your identity locally - next time you login, you won't have to connect to your wallet and sign a message.
<Button
btnType="primary"
className="p-2 mt-4 mx-auto"
onClick={onCreateLocalBackup}
>
Make a local backup
</Button>
</NotificationBox>
</>
const onAddIdentity = useCallback(async () => {
if (!selected) return;
await postWorkerMessage(addIdentity(selected));
await postWorkerMessage(
selectIdentity(selected.type === 'gun' ? selected.publicKey : selected.identityCommitment)
);
}
}, [selected]);
const onCreateLocalBackup = useCallback(async () => {
const identities = await postWorkerMessage<Identity[]>(getIdentities());
const { unlocked } = await postWorkerMessage<{ unlocked: boolean }>(getIdentityStatus());
if (!identities.length && !unlocked) {
history.push('/create-local-backup');
} else if (unlocked) {
await onAddIdentity();
} else {
setShowingLogin(true);
}
}, [onAddIdentity]);
const onClose = useCallback(async () => {
setShowingLogin(false);
await onAddIdentity();
}, [onAddIdentity]);
if (!loggedIn || hasLocalBackup || selected?.type === 'zkpr_interrep') return <></>;
return (
<>
{showingLogin && <LoginModal onClose={onClose} />}
<NotificationBox className="text-center mb-2">
You can store a secure backup of your identity locally - next time you login, you won't have
to connect to your wallet and sign a message.
<Button btnType="primary" className="p-2 mt-4 mx-auto" onClick={onCreateLocalBackup}>
Make a local backup
</Button>
</NotificationBox>
</>
);
}

View File

@@ -1,61 +1,57 @@
import React, {ReactElement, useCallback, useState} from "react";
import Modal, {ModalContent, ModalFooter, ModalHeader} from "../Modal";
import Input from "../Input";
import Button from "../Button";
import {postWorkerMessage} from "../../util/sw";
import {setPassphrase} from "../../serviceWorkers/util";
import "./login-modal.scss";
import React, { ReactElement, useCallback, useState } from 'react';
import Modal, { ModalContent, ModalFooter, ModalHeader } from '../Modal';
import Input from '../Input';
import Button from '../Button';
import { postWorkerMessage } from '../../util/sw';
import { setPassphrase } from '../../serviceWorkers/util';
import './login-modal.scss';
type Props = {
onClose: () => void;
onSuccess?: () => void;
}
onClose: () => void;
onSuccess?: () => void;
};
export default function LoginModal(props: Props): ReactElement {
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const onUnlock = useCallback(async () => {
try {
setErrorMessage('');
await postWorkerMessage(setPassphrase(password));
props.onSuccess && props.onSuccess();
props.onClose();
} catch (e) {
console.log(e);
setErrorMessage(e.message);
}
}, [password]);
const onUnlock = useCallback(async () => {
try {
setErrorMessage('');
await postWorkerMessage(setPassphrase(password));
props.onSuccess && props.onSuccess();
props.onClose();
} catch (e) {
console.log(e);
setErrorMessage(e.message);
}
}, [password]);
return (
<Modal
className="w-96 login-modal"
onClose={props.onClose}
>
<ModalHeader onClose={props.onClose}>
<b>{`Unlock with password`}</b>
</ModalHeader>
<ModalContent className="p-4 my-4">
<Input
className="border relative"
label="Enter password"
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
}}
autoFocus
/>
</ModalContent>
{ errorMessage && <div className="error-message text-xs text-center text-red-500 m-2">{errorMessage}</div> }
<ModalFooter>
<Button
btnType="primary"
onClick={onUnlock}
>
Unlock
</Button>
</ModalFooter>
</Modal>
)
}
return (
<Modal className="w-96 login-modal" onClose={props.onClose}>
<ModalHeader onClose={props.onClose}>
<b>{`Unlock with password`}</b>
</ModalHeader>
<ModalContent className="p-4 my-4">
<Input
className="border relative"
label="Enter password"
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
}}
autoFocus
/>
</ModalContent>
{errorMessage && (
<div className="error-message text-xs text-center text-red-500 m-2">{errorMessage}</div>
)}
<ModalFooter>
<Button btnType="primary" onClick={onUnlock}>
Unlock
</Button>
</ModalFooter>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.login-modal {
@media only screen and (max-width: 768px) {
@@ -17,4 +17,4 @@
max-height: 100% !important;
}
}
}
}

View File

@@ -1,168 +1,166 @@
import React, {ChangeEvent, ReactElement, useCallback, useEffect, useState} from "react";
import Modal, {ModalContent, ModalFooter, ModalHeader} from "../Modal";
import Input from "../Input";
import Button from "../Button";
import "./member-invite-modal.scss";
import {fetchAddressByName, searchUsers, useUser} from "../../ducks/users";
import {useDispatch} from "react-redux";
import {useHistory} from "react-router";
import {useThemeContext} from "../ThemeContext";
import Web3 from "web3";
import classNames from "classnames";
import Avatar from "../Avatar";
import {getHandle, getName} from "../../util/user";
import Icon from "../Icon";
import SpinnerGIF from "../../../static/icons/spinner.gif";
import {submitConnection} from "../../ducks/drafts";
import {ConnectionMessageSubType} from "../../util/message";
import React, { ChangeEvent, ReactElement, useCallback, useEffect, useState } from 'react';
import Modal, { ModalContent, ModalFooter, ModalHeader } from '../Modal';
import Input from '../Input';
import Button from '../Button';
import './member-invite-modal.scss';
import { fetchAddressByName, searchUsers, useUser } from '../../ducks/users';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router';
import { useThemeContext } from '../ThemeContext';
import Web3 from 'web3';
import classNames from 'classnames';
import Avatar from '../Avatar';
import { getHandle, getName } from '../../util/user';
import Icon from '../Icon';
import SpinnerGIF from '../../../static/icons/spinner.gif';
import { submitConnection } from '../../ducks/drafts';
import { ConnectionMessageSubType } from '../../util/message';
type Props = {
onClose: () => void;
onClose: () => void;
};
export default function MemberInviteModal(props: Props): ReactElement {
const [value, setValue] = useState('');
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [results, setResults] = useState<string[]>([]);
const [users, setUsers] = useState<{[address: string]: string}>({});
const [errorMessage, setErrorMessage] = useState('');
const dispatch = useDispatch();
const [value, setValue] = useState('');
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [results, setResults] = useState<string[]>([]);
const [users, setUsers] = useState<{ [address: string]: string }>({});
const [errorMessage, setErrorMessage] = useState('');
const dispatch = useDispatch();
const onInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}, []);
const onInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}, []);
const toggleInvite = useCallback((address: string) => {
const ret = {
...users,
};
const toggleInvite = useCallback(
(address: string) => {
const ret = {
...users,
};
if (ret[address]) {
delete ret[address];
} else {
ret[address] = address;
}
if (ret[address]) {
delete ret[address];
} else {
ret[address] = address;
}
setUsers(ret);
}, [users]);
setUsers(ret);
},
[users]
);
const onSendInvite = useCallback(async () => {
setSubmitting(true);
const onSendInvite = useCallback(async () => {
setSubmitting(true);
const addresses = Object.keys(users);
const addresses = Object.keys(users);
for (let i = 0; i < addresses.length; i++) {
await dispatch(submitConnection(addresses[i], ConnectionMessageSubType.MemberInvite));
}
for (let i = 0; i < addresses.length; i++) {
await dispatch(submitConnection(addresses[i], ConnectionMessageSubType.MemberInvite));
}
setSubmitting(false);
props.onClose();
}, [users]);
setSubmitting(false);
props.onClose();
}, [users]);
useEffect(() => {
(async function () {
setLoading(true);
const list: any = await dispatch(searchUsers(value));
setResults(list.map(({ address }: any) => address));
setLoading(false);
})();
}, [value]);
useEffect(() => {
(async function () {
setLoading(true);
const list: any = await dispatch(searchUsers(value));
setResults(list.map(({ address }: any) => address));
setLoading(false);
})();
}, [value]);
return (
<Modal
className="w-96"
onClose={props.onClose}
>
<ModalHeader onClose={props.onClose}>
<b>Invite Members</b>
</ModalHeader>
<ModalContent className="p-2 my-2">
<div className="flex flex-col items-center w-full">
<Input
className="border relative mb-4 w-full"
type="text"
label="Search by name or address"
onChange={onInputChange}
value={value}
/>
{ loading && <Icon className="self-center my-4 w-full" url={SpinnerGIF} size={3} /> }
{results.map(address => (
<UserRow
key={address}
name={address}
toggleInvite={() => toggleInvite(address)}
selected={!!users[address]}
/>
))}
</div>
</ModalContent>
{ errorMessage && <div className="error-message text-xs text-center text-red-500 m-2">{errorMessage}</div> }
<ModalFooter>
<Button
btnType="primary"
onClick={onSendInvite}
disabled={!Object.keys(users).length}
loading={submitting}
>
{`Send ${Object.keys(users).length} Invitations`}
</Button>
</ModalFooter>
</Modal>
);
return (
<Modal className="w-96" onClose={props.onClose}>
<ModalHeader onClose={props.onClose}>
<b>Invite Members</b>
</ModalHeader>
<ModalContent className="p-2 my-2">
<div className="flex flex-col items-center w-full">
<Input
className="border relative mb-4 w-full"
type="text"
label="Search by name or address"
onChange={onInputChange}
value={value}
/>
{loading && <Icon className="self-center my-4 w-full" url={SpinnerGIF} size={3} />}
{results.map(address => (
<UserRow
key={address}
name={address}
toggleInvite={() => toggleInvite(address)}
selected={!!users[address]}
/>
))}
</div>
</ModalContent>
{errorMessage && (
<div className="error-message text-xs text-center text-red-500 m-2">{errorMessage}</div>
)}
<ModalFooter>
<Button
btnType="primary"
onClick={onSendInvite}
disabled={!Object.keys(users).length}
loading={submitting}>
{`Send ${Object.keys(users).length} Invitations`}
</Button>
</ModalFooter>
</Modal>
);
}
function UserRow(props: {name: string; toggleInvite: () => void; selected: boolean}): ReactElement {
const history = useHistory();
const [username, setUsername] = useState('');
function UserRow(props: {
name: string;
toggleInvite: () => void;
selected: boolean;
}): ReactElement {
const history = useHistory();
const [username, setUsername] = useState('');
const dispatch = useDispatch();
const user = useUser(username);
const theme = useThemeContext();
const dispatch = useDispatch();
const user = useUser(username);
const theme = useThemeContext();
useEffect(() => {
(async () => {
if (!Web3.utils.isAddress(props.name)) {
const address: any = await dispatch(fetchAddressByName(props.name));
setUsername(address);
} else {
setUsername(props.name);
}
})();
}, [props.name]);
useEffect(() => {
(async () => {
if (!Web3.utils.isAddress(props.name)) {
const address: any = await dispatch(fetchAddressByName(props.name));
setUsername(address);
} else {
setUsername(props.name);
}
})();
}, [props.name]);
if (!user) return <></>;
if (!user) return <></>;
return (
<div
className={classNames(
"flex flex-row flex-nowrap px-4 py-2 cursor-pointer",
"items-center transition w-full",
{
"hover:bg-gray-200": theme !== 'dark',
"hover:bg-gray-800": theme === 'dark',
}
)}
onClick={props.toggleInvite}
>
<Avatar address={user.address} className="w-10 h-10 mr-3" />
<div className="flex flex-col flex-nowrap flex-grow justify-center">
<div className="font-bold text-md">
{getName(user, 8, 6)}
</div>
<div className="text-sm text-gray-500">
@{getHandle(user, 8, 6)}
</div>
</div>
<Icon
className={classNames(
{
'opacity-10': !user.meta?.inviteSent && !props.selected,
'opacity-100 text-primary-color': user.meta?.inviteSent || props.selected,
},
)}
fa="fas fa-check"
/>
</div>
)
}
return (
<div
className={classNames(
'flex flex-row flex-nowrap px-4 py-2 cursor-pointer',
'items-center transition w-full',
{
'hover:bg-gray-200': theme !== 'dark',
'hover:bg-gray-800': theme === 'dark',
}
)}
onClick={props.toggleInvite}>
<Avatar address={user.address} className="w-10 h-10 mr-3" />
<div className="flex flex-col flex-nowrap flex-grow justify-center">
<div className="font-bold text-md">{getName(user, 8, 6)}</div>
<div className="text-sm text-gray-500">@{getHandle(user, 8, 6)}</div>
</div>
<Icon
className={classNames({
'opacity-10': !user.meta?.inviteSent && !props.selected,
'opacity-100 text-primary-color': user.meta?.inviteSent || props.selected,
})}
fa="fas fa-check"
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
@import "../../util/variable";
@import '../../util/variable';
.member-invite-modal {
@media only screen and (max-width: 768px) {
height: 24rem;
}
}
}

View File

@@ -1,201 +1,199 @@
import React, {
MouseEvent,
ReactElement,
ReactNode,
ReactNodeArray,
useCallback,
useEffect,
useState
} from "react";
import classNames from "classnames";
import "./menuable.scss";
import Icon from "../Icon";
import {useThemeContext} from "../ThemeContext";
MouseEvent,
ReactElement,
ReactNode,
ReactNodeArray,
useCallback,
useEffect,
useState,
} from 'react';
import classNames from 'classnames';
import './menuable.scss';
import Icon from '../Icon';
import { useThemeContext } from '../ThemeContext';
type MenuableProps = {
items: ItemProps[];
children?: ReactNode;
className?: string;
menuClassName?: string;
onOpen?: () => void;
onClose?: () => void;
opened?: boolean;
}
items: ItemProps[];
children?: ReactNode;
className?: string;
menuClassName?: string;
onOpen?: () => void;
onClose?: () => void;
opened?: boolean;
};
export type ItemProps = {
label: string;
iconUrl?: string;
iconFA?: string;
iconClassName?: string;
className?: string;
onClick?: (e: MouseEvent, reset: () => void) => void;
disabled?: boolean;
children?: ItemProps[];
component?: ReactNode;
}
label: string;
iconUrl?: string;
iconFA?: string;
iconClassName?: string;
className?: string;
onClick?: (e: MouseEvent, reset: () => void) => void;
disabled?: boolean;
children?: ItemProps[];
component?: ReactNode;
};
export default function Menuable(props: MenuableProps): ReactElement {
const {
opened,
} = props;
const { opened } = props;
const [isShowing, setShowing] = useState(!!props.opened);
const [path, setPath] = useState<number[]>([]);
const theme = useThemeContext();
const [isShowing, setShowing] = useState(!!props.opened);
const [path, setPath] = useState<number[]>([]);
const theme = useThemeContext();
useEffect(() => {
if (typeof opened !== 'undefined') {
setShowing(opened);
if (!opened) {
setPath([]);
}
}
}, [opened]);
const onClose = useCallback(() => {
props.onClose && props.onClose();
setShowing(false);
}, []);
const onOpen = useCallback(() => {
props.onOpen && props.onOpen();
setShowing(true);
const cb = () => {
onClose();
window.removeEventListener('click', cb);
};
window.addEventListener('click', cb);
}, [onClose]);
const goBack = useCallback((e: any) => {
e.stopPropagation();
const newPath = [...path];
newPath.pop();
setPath(newPath);
}, [path]);
const onItemClick = useCallback((e: any, item: ItemProps, i: number) => {
e.stopPropagation();
if (item.disabled) return;
if (item.children) {
setPath([...path, i]);
} else if (item.onClick) {
item.onClick(e, () => setPath([]));
}
}, [path]);
let items: ItemProps[] = props.items;
if (path) {
for (const pathIndex of path) {
if (items[pathIndex].children) {
items = items[pathIndex].children as ItemProps[];
}
}
useEffect(() => {
if (typeof opened !== 'undefined') {
setShowing(opened);
if (!opened) {
setPath([]);
}
}
}, [opened]);
return (
const onClose = useCallback(() => {
props.onClose && props.onClose();
setShowing(false);
}, []);
const onOpen = useCallback(() => {
props.onOpen && props.onOpen();
setShowing(true);
const cb = () => {
onClose();
window.removeEventListener('click', cb);
};
window.addEventListener('click', cb);
}, [onClose]);
const goBack = useCallback(
(e: any) => {
e.stopPropagation();
const newPath = [...path];
newPath.pop();
setPath(newPath);
},
[path]
);
const onItemClick = useCallback(
(e: any, item: ItemProps, i: number) => {
e.stopPropagation();
if (item.disabled) return;
if (item.children) {
setPath([...path, i]);
} else if (item.onClick) {
item.onClick(e, () => setPath([]));
}
},
[path]
);
let items: ItemProps[] = props.items;
if (path) {
for (const pathIndex of path) {
if (items[pathIndex].children) {
items = items[pathIndex].children as ItemProps[];
}
}
}
return (
<div
className={classNames(
'menuable',
{
'menuable--active': isShowing,
},
props.className
)}
onClick={e => {
e.stopPropagation();
if (isShowing) return onClose();
onOpen();
}}>
{props.children}
{isShowing && (
<div
className={classNames('menuable', {
'menuable--active': isShowing,
}, props.className)}
onClick={e => {
e.stopPropagation();
if (isShowing) return onClose();
onOpen();
}}
>
{props.children}
className={classNames(
'rounded-xl border menuable__menu',
{
isShowing && (
<div className={classNames(
"rounded-xl border menuable__menu",
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
},
props.menuClassName
)}>
{!!path.length && (
<div
className={classNames(
'text-sm whitespace-nowrap cursor-pointer',
'flex flex-row flex-nowrap items-center',
'menuable__menu__item',
{
'text-gray-500 hover:text-gray-800 hover:bg-gray-50 ': theme !== 'dark',
'text-gray-500 hover:text-gray-200 hover:bg-gray-900 ': theme === 'dark',
}
)}
onClick={goBack}>
<Icon fa="fas fa-caret-left" />
<span className="ml-2">Go back</span>
</div>
)}
{items.map((item, i) => (
<div
key={i}
className={classNames(
'text-sm whitespace-nowrap',
'flex flex-row flex-nowrap items-center',
'menuable__menu__item',
{
'hover:bg-gray-50 ': !item.disabled && theme !== 'dark',
'hover:bg-gray-900 ': !item.disabled && theme === 'dark',
'text-gray-500 hover:text-gray-800':
!item.component && theme !== 'dark' && !item.disabled,
'text-gray-500 hover:text-gray-200':
!item.component && theme === 'dark' && !item.disabled,
},
{
'cursor-pointer': !item.disabled,
'cursor-default': item.disabled,
},
item.className
)}
onClick={e => onItemClick(e, item, i)}>
{item.component ? (
item.component
) : (
<>
<div
className={classNames('menuable__menu__item__label flex-grow', {
'hover:font-semibold': !item.disabled,
'opacity-50': item.disabled,
})}>
{item.label}
</div>
{(item.iconUrl || item.iconFA) && (
<Icon
fa={item.iconFA}
url={item.iconUrl}
className={classNames(
'ml-4',
{
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
'opacity-50': item.disabled,
},
props.menuClassName
)}>
{!!path.length && (
<div
className={classNames(
"text-sm whitespace-nowrap cursor-pointer",
'flex flex-row flex-nowrap items-center',
"menuable__menu__item",
{
'text-gray-500 hover:text-gray-800 hover:bg-gray-50 ': theme !== 'dark',
'text-gray-500 hover:text-gray-200 hover:bg-gray-900 ': theme === 'dark',
}
)}
onClick={goBack}
>
<Icon fa="fas fa-caret-left" />
<span className="ml-2">Go back</span>
</div>
)}
{items.map((item, i) => (
<div
key={i}
className={classNames(
"text-sm whitespace-nowrap",
'flex flex-row flex-nowrap items-center',
"menuable__menu__item",
{
'hover:bg-gray-50 ': !item.disabled && theme !== 'dark',
'hover:bg-gray-900 ': !item.disabled && theme === 'dark',
'text-gray-500 hover:text-gray-800': !item.component && (theme !== 'dark' && !item.disabled),
'text-gray-500 hover:text-gray-200': !item.component && (theme === 'dark' && !item.disabled),
},
{
'cursor-pointer': !item.disabled,
'cursor-default': item.disabled,
},
item.className,
)}
onClick={e => onItemClick(e, item, i)}
>
{
item.component
? item.component
: (
<>
<div
className={classNames(
"menuable__menu__item__label flex-grow",
{
'hover:font-semibold': !item.disabled,
'opacity-50': item.disabled,
},
)}
>
{ item.label }
</div>
{
(item.iconUrl || item.iconFA) && (
<Icon
fa={item.iconFA}
url={item.iconUrl}
className={classNames(
'ml-4',
{
'opacity-50': item.disabled,
},
item.iconClassName,
)}
/>
)
}
</>
)
}
</div>
))}
</div>
)
}
item.iconClassName
)}
/>
)}
</>
)}
</div>
))}
</div>
)
}
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.dark {
.menuable {
@@ -13,18 +13,18 @@
&__menu {
position: absolute;
margin-top: .5rem;
margin-top: 0.5rem;
top: 100%;
left: 0;
background-color: $white;
border-radius: .75rem;
padding: .5rem 0;
border-radius: 0.75rem;
padding: 0.5rem 0;
z-index: 400;
&__item {
@extend %row-nowrap;
align-content: center;
padding: .5rem 1rem;
padding: 0.5rem 1rem;
}
}
@@ -49,4 +49,4 @@
color: $red-800;
}
}
}
}

View File

@@ -1,128 +1,103 @@
import React, {ReactElement} from "react";
import {Route, Switch} from "react-router";
import DiscoverUserPanel from "../DiscoverUserPanel";
import DiscoverTagPanel from "../DiscoverTagPanel";
import PostModerationPanel from "../PostModerationPanel";
import Icon from "../Icon";
import classNames from "classnames";
import GroupMembersPanel from "../GroupMembersPanel";
import React, { ReactElement } from 'react';
import { Route, Switch } from 'react-router';
import DiscoverUserPanel from '../DiscoverUserPanel';
import DiscoverTagPanel from '../DiscoverTagPanel';
import PostModerationPanel from '../PostModerationPanel';
import Icon from '../Icon';
import classNames from 'classnames';
import GroupMembersPanel from '../GroupMembersPanel';
export default function MetaPanel(props: {
className?: string;
}): ReactElement {
return (
<Switch>
<Route path="/explore">
<DefaultMetaPanels className={props.className} />
</Route>
<Route path="/:name/status/:hash">
<PostMetaPanels className={props.className} />
</Route>
<Route path="/post/:hash">
<PostMetaPanels className={props.className} />
</Route>
<Route path="/tag/:tagName">
<DefaultMetaPanels className={props.className} />
</Route>
<Route path="/home">
<DefaultMetaPanels className={props.className} />
</Route>
<Route path="/notifications" />
<Route path="/settings" />
<Route path="/create-local-backup" />
<Route path="/onboarding/interrep" />
<Route path="/connect/twitter" />
<Route path="/signup" />
<Route path="/chat" />
<Route path="/:name">
<ProfileMetaPanels className={props.className} />
</Route>
</Switch>
)
export default function MetaPanel(props: { className?: string }): ReactElement {
return (
<Switch>
<Route path="/explore">
<DefaultMetaPanels className={props.className} />
</Route>
<Route path="/:name/status/:hash">
<PostMetaPanels className={props.className} />
</Route>
<Route path="/post/:hash">
<PostMetaPanels className={props.className} />
</Route>
<Route path="/tag/:tagName">
<DefaultMetaPanels className={props.className} />
</Route>
<Route path="/home">
<DefaultMetaPanels className={props.className} />
</Route>
<Route path="/notifications" />
<Route path="/settings" />
<Route path="/create-local-backup" />
<Route path="/onboarding/interrep" />
<Route path="/connect/twitter" />
<Route path="/signup" />
<Route path="/chat" />
<Route path="/:name">
<ProfileMetaPanels className={props.className} />
</Route>
</Switch>
);
}
function DefaultMetaPanels(props: {
className?: string;
}): ReactElement {
return (
<div className={classNames("app__meta-content", props.className)}>
<DiscoverUserPanel key="discover-user" />
<DiscoverTagPanel key="discover-tag" />
<AppFooter />
</div>
);
function DefaultMetaPanels(props: { className?: string }): ReactElement {
return (
<div className={classNames('app__meta-content', props.className)}>
<DiscoverUserPanel key="discover-user" />
<DiscoverTagPanel key="discover-tag" />
<AppFooter />
</div>
);
}
function ProfileMetaPanels(props: {
className?: string;
}): ReactElement {
return (
<div className={classNames("app__meta-content", props.className)}>
<GroupMembersPanel />
<DiscoverUserPanel key="discover-user" />
<DiscoverTagPanel key="discover-tag" />
<AppFooter />
</div>
);
function ProfileMetaPanels(props: { className?: string }): ReactElement {
return (
<div className={classNames('app__meta-content', props.className)}>
<GroupMembersPanel />
<DiscoverUserPanel key="discover-user" />
<DiscoverTagPanel key="discover-tag" />
<AppFooter />
</div>
);
}
function PostMetaPanels(props: {
className?: string;
}): ReactElement {
return (
<div className={classNames("app__meta-content", props.className)}>
<PostModerationPanel />
<DiscoverUserPanel key="discover-user" />
<DiscoverTagPanel key="discover-tag" />
<AppFooter />
</div>
);
function PostMetaPanels(props: { className?: string }): ReactElement {
return (
<div className={classNames('app__meta-content', props.className)}>
<PostModerationPanel />
<DiscoverUserPanel key="discover-user" />
<DiscoverTagPanel key="discover-tag" />
<AppFooter />
</div>
);
}
function AppFooter(): ReactElement {
return (
<div className="app__meta-content__footer p-2 my-2 flex flex-row">
<div className="text-gray-500 text-xs flex flex-row flex-nowrap mr-4 mb-4 items-center">
<Icon className="mr-2" fa="fas fa-book" />
<a
className="text-gray-500"
href="https://docs.zkitter.com"
target="_blank"
>
Docs
</a>
</div>
<div className="text-gray-500 text-xs flex flex-row flex-nowrap mr-4 mb-4 items-center">
<Icon className="mr-2" fa="fab fa-github" />
<a
className="text-gray-500"
href="https://github.com/zkitter"
target="_blank"
>
Github
</a>
</div>
<div className="text-gray-500 text-xs flex flex-row flex-nowrap mr-4 mb-4 items-center">
<Icon className="mr-2" fa="fab fa-twitter" />
<a
className="text-gray-500"
href="https://twitter.com/zkitterdev"
target="_blank"
>
Twitter
</a>
</div>
<div className="text-gray-500 text-xs flex flex-row flex-nowrap mb-4 items-center">
<Icon className="mr-2" fa="fab fa-discord" />
<a
className="text-gray-500"
href="https://discord.com/invite/GVP9MghwXc"
target="_blank"
>
Discord
</a>
</div>
</div>
);
}
return (
<div className="app__meta-content__footer p-2 my-2 flex flex-row">
<div className="text-gray-500 text-xs flex flex-row flex-nowrap mr-4 mb-4 items-center">
<Icon className="mr-2" fa="fas fa-book" />
<a className="text-gray-500" href="https://docs.zkitter.com" target="_blank">
Docs
</a>
</div>
<div className="text-gray-500 text-xs flex flex-row flex-nowrap mr-4 mb-4 items-center">
<Icon className="mr-2" fa="fab fa-github" />
<a className="text-gray-500" href="https://github.com/zkitter" target="_blank">
Github
</a>
</div>
<div className="text-gray-500 text-xs flex flex-row flex-nowrap mr-4 mb-4 items-center">
<Icon className="mr-2" fa="fab fa-twitter" />
<a className="text-gray-500" href="https://twitter.com/zkitterdev" target="_blank">
Twitter
</a>
</div>
<div className="text-gray-500 text-xs flex flex-row flex-nowrap mb-4 items-center">
<Icon className="mr-2" fa="fab fa-discord" />
<a className="text-gray-500" href="https://discord.com/invite/GVP9MghwXc" target="_blank">
Discord
</a>
</div>
</div>
);
}

View File

@@ -1,130 +1,112 @@
import React, {MouseEventHandler, ReactElement, ReactNode, ReactNodeArray} from 'react';
import React, { MouseEventHandler, ReactElement, ReactNode, ReactNodeArray } from 'react';
import ReactDOM from 'react-dom';
import './modal.scss';
import Icon from "../Icon";
import classNames from "classnames";
import CancelSVG from "../../../static/icons/cancel.svg";
import {useThemeContext} from "../ThemeContext";
import Icon from '../Icon';
import classNames from 'classnames';
import CancelSVG from '../../../static/icons/cancel.svg';
import { useThemeContext } from '../ThemeContext';
type Props = {
className?: string;
onClose: MouseEventHandler;
children: ReactNode | ReactNode[];
}
className?: string;
onClose: MouseEventHandler;
children: ReactNode | ReactNode[];
};
export default function Modal(props: Props): ReactElement {
const { className, onClose, children } = props;
const theme = useThemeContext();
const { className, onClose, children } = props;
const theme = useThemeContext();
const modalRoot = document.querySelector('#modal-root');
const modalRoot = document.querySelector('#modal-root');
if (!modalRoot) return <></>;
if (!modalRoot) return <></>;
// @ts-ignore
return ReactDOM.createPortal(
<div
className={classNames(
'bg-black bg-opacity-80',
"modal__overlay",
theme,
)}
onClick={e => {
e.stopPropagation();
onClose && onClose(e);
}}
>
<div
className={classNames(
`modal__wrapper`,
{
'bg-white': theme !== 'dark',
'bg-dark': theme === 'dark',
},
className,
)}
onClick={e => e.stopPropagation()}
>
{children}
</div>
</div>,
modalRoot,
);
// @ts-ignore
return ReactDOM.createPortal(
<div
className={classNames('bg-black bg-opacity-80', 'modal__overlay', theme)}
onClick={e => {
e.stopPropagation();
onClose && onClose(e);
}}>
<div
className={classNames(
`modal__wrapper`,
{
'bg-white': theme !== 'dark',
'bg-dark': theme === 'dark',
},
className
)}
onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
modalRoot
);
}
type HeaderProps = {
onClose?: () => void;
children: ReactNode;
}
onClose?: () => void;
children: ReactNode;
};
export function ModalHeader(props: HeaderProps): ReactElement {
const theme = useThemeContext();
return (
<div className={classNames("border-b modal__header", {
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}>
<div className="modal__header__title">
{props.children}
</div>
<div className="modal__header__content">
{
props.onClose && (
<div
className={classNames(
"flex flex-row items-center justify-center",
"p-2 rounded-full opacity-50",
"hover:opacity-100",
{
'text-black': theme !== 'dark',
'text-white': theme === 'dark',
}
)}
>
<Icon
fa="fas fa-times"
size={1}
onClick={props.onClose}
/>
</div>
)
}
</div>
</div>
);
const theme = useThemeContext();
return (
<div
className={classNames('border-b modal__header', {
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}>
<div className="modal__header__title">{props.children}</div>
<div className="modal__header__content">
{props.onClose && (
<div
className={classNames(
'flex flex-row items-center justify-center',
'p-2 rounded-full opacity-50',
'hover:opacity-100',
{
'text-black': theme !== 'dark',
'text-white': theme === 'dark',
}
)}>
<Icon fa="fas fa-times" size={1} onClick={props.onClose} />
</div>
)}
</div>
</div>
);
}
type ContentProps = {
children: ReactNode | ReactNodeArray;
className?: string;
}
children: ReactNode | ReactNodeArray;
className?: string;
};
export function ModalContent(props: ContentProps): ReactElement {
return (
<div className={classNames("modal__content", props.className)}>
{props.children}
</div>
);
return <div className={classNames('modal__content', props.className)}>{props.children}</div>;
}
type FooterProps = {
children: ReactNode | ReactNodeArray;
className?: string;
}
children: ReactNode | ReactNodeArray;
className?: string;
};
export function ModalFooter(props: FooterProps): ReactElement {
const theme = useThemeContext();
const theme = useThemeContext();
return (
<div className={classNames(
"border-t modal__footer",
{
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
},
props.className
)}>
{props.children}
</div>
);
}
return (
<div
className={classNames(
'border-t modal__footer',
{
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
},
props.className
)}>
{props.children}
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.modal {
@extend %col-nowrap;
@@ -15,7 +15,7 @@
&__wrapper {
margin: 3rem auto;
border-radius: .5rem;
border-radius: 0.5rem;
z-index: 200;
overflow: hidden;
@@ -28,7 +28,7 @@
@extend %row-nowrap;
flex: 0 0 auto;
align-items: center;
padding: .5rem 1rem;
padding: 0.5rem 1rem;
&__title {
font-weight: 500;
@@ -55,7 +55,7 @@
}
.error-message {
font-size: .8125rem;
font-size: 0.8125rem;
text-align: center;
margin-top: 1rem;
}
@@ -68,4 +68,4 @@
flex: 0 0 auto;
padding: 1rem 1.25rem;
}
}
}

View File

@@ -1,139 +1,115 @@
import React, {ReactElement, ReactNode, useEffect, useState} from "react";
import Icon from "../Icon";
import "./moderation-btn.scss";
import classNames from "classnames";
import Modal, {ModalContent, ModalFooter, ModalHeader} from "../Modal";
import Button from "../Button";
import {ModerationMessageSubType} from "../../util/message";
import {useThemeContext} from "../ThemeContext";
import React, { ReactElement, ReactNode, useEffect, useState } from 'react';
import Icon from '../Icon';
import './moderation-btn.scss';
import classNames from 'classnames';
import Modal, { ModalContent, ModalFooter, ModalHeader } from '../Modal';
import Button from '../Button';
import { ModerationMessageSubType } from '../../util/message';
import { useThemeContext } from '../ThemeContext';
type Props = {
className?: string;
onChange?: (type: ModerationMessageSubType|null) => void;
currentType?: ModerationMessageSubType|null;
}
className?: string;
onChange?: (type: ModerationMessageSubType | null) => void;
currentType?: ModerationMessageSubType | null;
};
export default function ModerationButton(props: Props): ReactElement {
const {
className = '',
currentType = null,
onChange,
} = props;
const { className = '', currentType = null, onChange } = props;
const [showingModal, showModal] = useState(false);
const [replyType, setReplyType] = useState<ModerationMessageSubType|null>(currentType);
const theme = useThemeContext();
const [showingModal, showModal] = useState(false);
const [replyType, setReplyType] = useState<ModerationMessageSubType | null>(currentType);
const theme = useThemeContext();
useEffect(() => {
if (onChange) onChange(replyType);
}, [replyType]);
useEffect(() => {
if (onChange) onChange(replyType);
}, [replyType]);
return (
<>
{ showingModal && (
<Modal className="moderation-modal" onClose={() => showModal(false)}>
<ModalHeader
onClose={() => showModal(false)}
>
<b>Thread Moderation</b>
</ModalHeader>
<ModalContent className="p-4">
<div className="flex flex-col justify-center">
{renderReplyOption(null, replyType, setReplyType)}
{renderReplyOption(ModerationMessageSubType.ThreadBlock, replyType, setReplyType)}
{renderReplyOption(ModerationMessageSubType.ThreadFollow, replyType, setReplyType)}
{renderReplyOption(ModerationMessageSubType.ThreadMention, replyType, setReplyType)}
</div>
</ModalContent>
</Modal>
)}
<button
className={classNames(
"flex flex-row items-center text-blue-300 font-bold",
{
'hover:bg-blue-50 hover:text-blue-400': theme !== 'dark',
'hover:bg-blue-900 hover:text-blue-600': theme === 'dark',
},
"moderation-btn",
className,
)}
onClick={() => showModal(true)}
>
<Icon
fa={getFA(replyType)}
size={.875}
/>
<div
className="text-sm ml-2 moderation-btn__label"
>
{getLabel(replyType)}
</div>
</button>
</>
)
return (
<>
{showingModal && (
<Modal className="moderation-modal" onClose={() => showModal(false)}>
<ModalHeader onClose={() => showModal(false)}>
<b>Thread Moderation</b>
</ModalHeader>
<ModalContent className="p-4">
<div className="flex flex-col justify-center">
{renderReplyOption(null, replyType, setReplyType)}
{renderReplyOption(ModerationMessageSubType.ThreadBlock, replyType, setReplyType)}
{renderReplyOption(ModerationMessageSubType.ThreadFollow, replyType, setReplyType)}
{renderReplyOption(ModerationMessageSubType.ThreadMention, replyType, setReplyType)}
</div>
</ModalContent>
</Modal>
)}
<button
className={classNames(
'flex flex-row items-center text-blue-300 font-bold',
{
'hover:bg-blue-50 hover:text-blue-400': theme !== 'dark',
'hover:bg-blue-900 hover:text-blue-600': theme === 'dark',
},
'moderation-btn',
className
)}
onClick={() => showModal(true)}>
<Icon fa={getFA(replyType)} size={0.875} />
<div className="text-sm ml-2 moderation-btn__label">{getLabel(replyType)}</div>
</button>
</>
);
}
function renderReplyOption(
replyType: ModerationMessageSubType|null,
active: ModerationMessageSubType|null,
setReplyType: (replyType: ModerationMessageSubType|null) => void,
replyType: ModerationMessageSubType | null,
active: ModerationMessageSubType | null,
setReplyType: (replyType: ModerationMessageSubType | null) => void
): ReactNode {
const fa = getFA(replyType);
const label = getLabel(replyType);
const fa = getFA(replyType);
const label = getLabel(replyType);
return (
<div
className={classNames(
"flex flex-row items-center p-2 moderation-btn__reply-option",
{
'moderation-btn__reply-option--active': active === replyType,
'hover:text-blue-400': active !== replyType,
}
)}
onClick={() => setReplyType(replyType)}
>
<Icon
className={classNames(
"p-2 rounded-full border border-2",
{
'border-gray-200 text-gray-400 hover:border-blue-400 hover:text-blue-400': active !== replyType,
'border-blue-400 bg-blue-400 text-white': active === replyType,
}
)}
fa={fa}
size={.875}
/>
<div
className="text-light ml-4"
>
{label}
</div>
</div>
)
return (
<div
className={classNames('flex flex-row items-center p-2 moderation-btn__reply-option', {
'moderation-btn__reply-option--active': active === replyType,
'hover:text-blue-400': active !== replyType,
})}
onClick={() => setReplyType(replyType)}>
<Icon
className={classNames('p-2 rounded-full border border-2', {
'border-gray-200 text-gray-400 hover:border-blue-400 hover:text-blue-400':
active !== replyType,
'border-blue-400 bg-blue-400 text-white': active === replyType,
})}
fa={fa}
size={0.875}
/>
<div className="text-light ml-4">{label}</div>
</div>
);
}
function getFA(replyType: ModerationMessageSubType | null): string {
switch (replyType) {
case ModerationMessageSubType.ThreadBlock:
return 'fas fa-shield-alt';
case ModerationMessageSubType.ThreadFollow:
return 'fas fa-user-check';
case ModerationMessageSubType.ThreadMention:
return 'fas fa-at';
default:
return 'fas fa-globe';
}
switch (replyType) {
case ModerationMessageSubType.ThreadBlock:
return 'fas fa-shield-alt';
case ModerationMessageSubType.ThreadFollow:
return 'fas fa-user-check';
case ModerationMessageSubType.ThreadMention:
return 'fas fa-at';
default:
return 'fas fa-globe';
}
}
function getLabel(replyType: ModerationMessageSubType | null): string {
switch (replyType) {
case ModerationMessageSubType.ThreadBlock:
return 'Hide replies that you blocked';
case ModerationMessageSubType.ThreadFollow:
return 'Show replies that you followed or liked';
case ModerationMessageSubType.ThreadMention:
return 'Show replies from people you mentioned';
default:
return 'Show reply from everyone';
}
switch (replyType) {
case ModerationMessageSubType.ThreadBlock:
return 'Hide replies that you blocked';
case ModerationMessageSubType.ThreadFollow:
return 'Show replies that you followed or liked';
case ModerationMessageSubType.ThreadMention:
return 'Show replies from people you mentioned';
default:
return 'Show reply from everyone';
}
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.moderation-btn {
border-radius: 1rem;
@@ -49,4 +49,4 @@
max-height: 100% !important;
}
}
}
}

View File

@@ -1,154 +1,138 @@
import React, {ReactElement, useEffect, useState} from "react";
import {getUser, useUser} from "../../ducks/users";
import {useDispatch} from "react-redux";
import {getName} from "../../util/user";
import Icon from "../Icon";
import TwitterPaper from "../../../static/icons/twitter-paper.png";
import TwitterBronze from "../../../static/icons/twitter-bronze.png";
import TwitterSilver from "../../../static/icons/twitter-silver.png";
import TwitterGold from "../../../static/icons/twitter-gold.png";
import Popoverable from "../Popoverable";
import React, { ReactElement, useEffect, useState } from 'react';
import { getUser, useUser } from '../../ducks/users';
import { useDispatch } from 'react-redux';
import { getName } from '../../util/user';
import Icon from '../Icon';
import TwitterPaper from '../../../static/icons/twitter-paper.png';
import TwitterBronze from '../../../static/icons/twitter-bronze.png';
import TwitterSilver from '../../../static/icons/twitter-silver.png';
import TwitterGold from '../../../static/icons/twitter-gold.png';
import Popoverable from '../Popoverable';
type Props = {
className?: string;
address?: string;
interepProvider?: string;
interepGroup?: string;
group?: string | null;
className?: string;
address?: string;
interepProvider?: string;
interepGroup?: string;
group?: string | null;
};
const GROUP_TO_NICKNAME: {
[group: string]: string;
[group: string]: string;
} = {
'zksocial_all': 'Anonymous',
'semaphore_taz_members': 'A TAZ Member',
'interrep_twitter_unrated': 'A Twitter user',
'interrep_twitter_bronze': 'A Twitter user with 500+ followers',
'interrep_twitter_silver': 'A Twitter user with 2k+ followers',
'interrep_twitter_gold': 'A Twitter user with 7k+ followers',
}
zksocial_all: 'Anonymous',
semaphore_taz_members: 'A TAZ Member',
interrep_twitter_unrated: 'A Twitter user',
interrep_twitter_bronze: 'A Twitter user with 500+ followers',
interrep_twitter_silver: 'A Twitter user with 2k+ followers',
interrep_twitter_gold: 'A Twitter user with 7k+ followers',
};
export default function Nickname(props: Props): ReactElement {
const { address, interepProvider, interepGroup, className = '', group } = props;
const [username, setUsername] = useState('');
const user = useUser(username);
const dispatch = useDispatch();
const { address, interepProvider, interepGroup, className = '', group } = props;
const [username, setUsername] = useState('');
const user = useUser(username);
const dispatch = useDispatch();
const badges = [];
const [protocol, groupName] = props.group?.split('_') || [];
const badges = [];
const [protocol, groupName] = props.group?.split('_') || [];
useEffect(() => {
if (!user && address) {
dispatch(getUser(address));
} else if (!user && protocol === 'custom') {
dispatch(getUser(groupName));
}
}, [user, address, groupName, protocol]);
useEffect(() => {
if (protocol === 'custom') {
setUsername(groupName);
} else if (address) {
setUsername(address);
}
}, [address, groupName, protocol]);
if (user) {
return (
<div className={`flex flex-row flex-nowrap items-center ${className}`}>
{getName(user)}
</div>
)
useEffect(() => {
if (!user && address) {
dispatch(getUser(address));
} else if (!user && protocol === 'custom') {
dispatch(getUser(groupName));
}
}, [user, address, groupName, protocol]);
useEffect(() => {
if (protocol === 'custom') {
return (
<div className={`flex flex-row flex-nowrap items-center ${className}`}>
{getName(user)}
</div>
)
setUsername(groupName);
} else if (address) {
setUsername(address);
}
}, [address, groupName, protocol]);
if (group) {
return (
<div className={`flex flex-row flex-nowrap items-center text-sm ${className}`}>
{GROUP_TO_NICKNAME[group] || 'Anonymous'}
</div>
)
}
if (user) {
return (
<div className={`flex flex-row flex-nowrap items-center ${className}`}>{getName(user)}</div>
);
}
if (interepProvider && interepGroup) {
if (/twitter/i.test(interepProvider)) {
if (/unrated/i.test(interepGroup)) {
badges.push(
<Badge
key={interepProvider + '_' + interepGroup}
label="<500 Twitter followers"
url={TwitterPaper}
/>
);
}
if (protocol === 'custom') {
return (
<div className={`flex flex-row flex-nowrap items-center ${className}`}>{getName(user)}</div>
);
}
if (/bronze/i.test(interepGroup)) {
badges.push(
<Badge
key={interepProvider + '_' + interepGroup}
label="500+ Twitter followers"
url={TwitterBronze}
/>
);
}
if (group) {
return (
<div className={`flex flex-row flex-nowrap items-center text-sm ${className}`}>
{GROUP_TO_NICKNAME[group] || 'Anonymous'}
</div>
);
}
if (/silver/i.test(interepGroup)) {
badges.push(
<Badge
key={interepProvider + '_' + interepGroup}
label="2000+ Twitter followers"
url={TwitterSilver}
/>
);
}
if (interepProvider && interepGroup) {
if (/twitter/i.test(interepProvider)) {
if (/unrated/i.test(interepGroup)) {
badges.push(
<Badge
key={interepProvider + '_' + interepGroup}
label="<500 Twitter followers"
url={TwitterPaper}
/>
);
}
if (/gold/i.test(interepGroup)) {
badges.push(
<Badge
key={interepProvider + '_' + interepGroup}
label="7000+ Twitter followers"
url={TwitterGold}
/>
);
}
}
if (/bronze/i.test(interepGroup)) {
badges.push(
<Badge
key={interepProvider + '_' + interepGroup}
label="500+ Twitter followers"
url={TwitterBronze}
/>
);
}
return (
<div className={`flex flex-row flex-nowrap items-center text-sm ${className}`}>
Anonymous
<div className="flex flex-row flex-nowrap items-center ml-2">
{ badges }
</div>
</div>
)
if (/silver/i.test(interepGroup)) {
badges.push(
<Badge
key={interepProvider + '_' + interepGroup}
label="2000+ Twitter followers"
url={TwitterSilver}
/>
);
}
if (/gold/i.test(interepGroup)) {
badges.push(
<Badge
key={interepProvider + '_' + interepGroup}
label="7000+ Twitter followers"
url={TwitterGold}
/>
);
}
}
return (
<div
className={`flex flex-row flex-nowrap items-center text-sm ${className}`}
>
Anonymous
</div>
<div className={`flex flex-row flex-nowrap items-center text-sm ${className}`}>
Anonymous
<div className="flex flex-row flex-nowrap items-center ml-2">{badges}</div>
</div>
);
}
return (
<div className={`flex flex-row flex-nowrap items-center text-sm ${className}`}>Anonymous</div>
);
}
function Badge(props: { url: string; label: string }): ReactElement {
return (
<Popoverable label={props.label}>
<Icon
className="shadow rounded-full"
url={props.url}
size={1}
/>
</Popoverable>
)
}
return (
<Popoverable label={props.label}>
<Icon className="shadow rounded-full" url={props.url} size={1} />
</Popoverable>
);
}

View File

@@ -1,23 +1,26 @@
import React, {ReactElement, ReactNode, ReactNodeArray} from "react";
import classNames from "classnames";
import "./notif-box.scss";
import React, { ReactElement, ReactNode, ReactNodeArray } from 'react';
import classNames from 'classnames';
import './notif-box.scss';
type Props = {
type?: 'warning' | 'info' | 'error';
children?: ReactNode | ReactNodeArray;
className?: string;
type?: 'warning' | 'info' | 'error';
children?: ReactNode | ReactNodeArray;
className?: string;
};
export default function NotificationBox(props: Props): ReactElement {
return (
<div
className={classNames('notif-box', {
'notif-box--info': props.type === 'info',
'notif-box--warning': props.type === 'warning',
'notif-box--error': props.type === 'error',
}, props.className)}
>
{props.children}
</div>
)
}
return (
<div
className={classNames(
'notif-box',
{
'notif-box--info': props.type === 'info',
'notif-box--warning': props.type === 'warning',
'notif-box--error': props.type === 'error',
},
props.className
)}>
{props.children}
</div>
);
}

View File

@@ -1,10 +1,10 @@
@import "../../util/variable";
@import '../../util/variable';
.notif-box {
background-color: rgba($primary-color, .1);
border: 1px solid rgba($primary-color, .5);
background-color: rgba($primary-color, 0.1);
border: 1px solid rgba($primary-color, 0.5);
color: darken($primary-color, 10);
border-radius: .75rem;
font-size: .875rem;
padding: .75rem 1rem;
}
border-radius: 0.75rem;
font-size: 0.875rem;
padding: 0.75rem 1rem;
}

View File

@@ -1,64 +1,63 @@
import React, {MouseEventHandler, ReactElement, useEffect} from "react";
import {fetchMeta, useGoToPost, usePost} from "../../ducks/posts";
import Post from "../Post";
import {Post as PostMessage, PostMessageSubType} from "../../util/message";
import classNames from "classnames";
import {useDispatch} from "react-redux";
import {useLoggedIn} from "../../ducks/web3";
import {useThemeContext} from "../ThemeContext";
import React, { MouseEventHandler, ReactElement, useEffect } from 'react';
import { fetchMeta, useGoToPost, usePost } from '../../ducks/posts';
import Post from '../Post';
import { Post as PostMessage, PostMessageSubType } from '../../util/message';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { useLoggedIn } from '../../ducks/web3';
import { useThemeContext } from '../ThemeContext';
type Props = {
level?: number;
messageId: string;
className?: string;
postClassName?: string;
onClick?: MouseEventHandler;
onSuccessPost?: (post: PostMessage) => void;
expand?: boolean;
level?: number;
messageId: string;
className?: string;
postClassName?: string;
onClick?: MouseEventHandler;
onSuccessPost?: (post: PostMessage) => void;
expand?: boolean;
};
export default function ParentThread(props: Props): ReactElement {
const post = usePost(props.messageId);
// @ts-ignore
const parent = [PostMessageSubType.Reply, PostMessageSubType.MirrorReply].includes(post?.subtype)
? post?.payload.reference
: '';
const gotoPost = useGoToPost();
const dispatch = useDispatch();
const loggedIn = useLoggedIn();
const theme = useThemeContext();
const post = usePost(props.messageId);
// @ts-ignore
const parent = [PostMessageSubType.Reply, PostMessageSubType.MirrorReply].includes(post?.subtype)
? post?.payload.reference
: '';
const gotoPost = useGoToPost();
const dispatch = useDispatch();
const loggedIn = useLoggedIn();
const theme = useThemeContext();
useEffect(() => {
(async function onPostViewMount() {
if (!parent) return;
await dispatch(fetchMeta(parent));
})();
useEffect(() => {
(async function onPostViewMount() {
if (!parent) return;
await dispatch(fetchMeta(parent));
})();
}, [loggedIn, parent]);
}, [loggedIn, parent]);
if (!parent) return <></>;
if (!parent) return <></>;
return (
<>
<ParentThread
className={props.className}
messageId={parent}
onSuccessPost={props.onSuccessPost}
/>
<Post
messageId={parent}
className={classNames(
"cursor-pointer hover:bg-gray-50 parent-post",
{
'hover:bg-gray-50 ': theme !== 'dark',
'hover:bg-gray-900 ': theme === 'dark',
},
props.className,
)}
onClick={() => gotoPost(parent)}
onSuccessPost={props.onSuccessPost}
isParent
/>
</>
);
}
return (
<>
<ParentThread
className={props.className}
messageId={parent}
onSuccessPost={props.onSuccessPost}
/>
<Post
messageId={parent}
className={classNames(
'cursor-pointer hover:bg-gray-50 parent-post',
{
'hover:bg-gray-50 ': theme !== 'dark',
'hover:bg-gray-900 ': theme === 'dark',
},
props.className
)}
onClick={() => gotoPost(parent)}
onSuccessPost={props.onSuccessPost}
isParent
/>
</>
);
}

View File

@@ -1,18 +1,16 @@
import React, {ReactElement, ReactNode} from "react";
import "./popoverable.scss";
import React, { ReactElement, ReactNode } from 'react';
import './popoverable.scss';
type Props = {
label: string;
children: ReactNode;
}
label: string;
children: ReactNode;
};
export default function Popoverable(props: Props): ReactElement {
return (
<div className="popoverable" title={props.label}>
{/*<div className="popoverable__label">{props.label}</div>*/}
<>
{props.children}
</>
</div>
)
}
return (
<div className="popoverable" title={props.label}>
{/*<div className="popoverable__label">{props.label}</div>*/}
<>{props.children}</>
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.popoverable {
position: relative;
@@ -11,12 +11,12 @@
left: 0;
right: 0;
width: fit-content;
background-color: rgba($black, .8);
color: rgba($white, .9);
background-color: rgba($black, 0.8);
color: rgba($white, 0.9);
white-space: nowrap;
font-size: .75rem;
padding: 0 .5rem;
border-radius: .125rem;
font-size: 0.75rem;
padding: 0 0.5rem;
border-radius: 0.125rem;
font-weight: 400;
transition: opacity 200ms ease-in-out;
transition-delay: 500ms;
@@ -31,4 +31,4 @@
max-height: 100rem;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,11 @@
@import "../../util/variable";
@import '../../util/variable';
.post {
position: relative;
transition: border-color 150ms ease-in,
background-color 150ms ease-in-out;
transition: border-color 150ms ease-in, background-color 150ms ease-in-out;
&__footer {
margin-left: -.3125rem;
margin-left: -0.3125rem;
}
&__parent-line {
@@ -34,14 +33,14 @@
.dark {
.post-button {
color: rgba($white, .5);
color: rgba($white, 0.5);
}
}
.post-button {
color: rgba($black, .5);
color: rgba($black, 0.5);
height: 2rem;
font-size: .9375rem;
font-size: 0.9375rem;
.icon {
@extend %row-nowrap;
@@ -86,9 +85,9 @@
border-color: $gray-800;
}
.thread__content{
.thread__content {
.thread > .post {
border-left: .25rem solid $gray-800;
border-left: 0.25rem solid $gray-800;
}
}
@@ -129,10 +128,9 @@
border-top: 1px solid $gray-100;
}
.thread__content{
.thread__content {
.thread > .post {
border-left: .25rem solid $gray-100;
border-left: 0.25rem solid $gray-100;
&:hover {
border-color: $gray-400;
@@ -168,4 +166,4 @@
max-height: 100% !important;
}
}
}
}

View File

@@ -1,38 +1,38 @@
import React from "react";
import ReactDOM from "react-dom";
import {act} from "react-dom/test-utils";
import {Provider} from "react-redux";
import Post from "./index";
import {Post as PostMessage} from "../../util/message";
import {dispatchSpy, ducks, gunStub, store} from "../../util/testUtils";
import {MessageType, PostMessageSubType} from "../../util/message";
import React from 'react';
import ReactDOM from 'react-dom';
import { act } from 'react-dom/test-utils';
import { Provider } from 'react-redux';
import Post from './index';
import { Post as PostMessage } from '../../util/message';
import { dispatchSpy, ducks, gunStub, store } from '../../util/testUtils';
import { MessageType, PostMessageSubType } from '../../util/message';
describe('<Post>', () => {
const root = document.createElement('div');
const post = new PostMessage({
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: {
content: 'hello',
},
});
const json = post.toJSON();
const root = document.createElement('div');
const post = new PostMessage({
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: {
content: 'hello',
},
});
const json = post.toJSON();
it ('should mount', async () => {
act(() => {
ReactDOM.render(
<Provider store={store}>
<Post messageId={json.messageId} />
</Provider>,
root,
)
});
expect(root.innerHTML).toBeTruthy();
it('should mount', async () => {
act(() => {
ReactDOM.render(
<Provider store={store}>
<Post messageId={json.messageId} />
</Provider>,
root
);
});
it ('should update', async () => {
store.dispatch(ducks.posts.setPost(post))
expect(root.textContent).toBe('Anonymous•a few secondshello000');
});
expect(root.innerHTML).toBeTruthy();
});
it('should update', async () => {
store.dispatch(ducks.posts.setPost(post));
expect(root.textContent).toBe('Anonymous•a few secondshello000');
});
});

View File

@@ -1,167 +1,159 @@
import React, {ReactElement, useCallback, useEffect, useState} from "react";
import classNames from "classnames";
import {useDispatch} from "react-redux";
import {useUser} from "../../ducks/users";
import Icon from "../Icon";
import SpinnerGIF from "../../../static/icons/spinner.gif";
import {useHistory, useParams} from "react-router";
import {getHandle, getUsername} from "../../util/user";
import "./post-mod-panel.scss";
import {useMeta, usePost} from "../../ducks/posts";
import {ModerationMessageSubType, PostMessageSubType} from "../../util/message";
import SwitchButton from "../SwitchButton";
import {unmoderate, usePostModeration} from "../../ducks/mods";
import {useThemeContext} from "../ThemeContext";
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { useUser } from '../../ducks/users';
import Icon from '../Icon';
import SpinnerGIF from '../../../static/icons/spinner.gif';
import { useHistory, useParams } from 'react-router';
import { getHandle, getUsername } from '../../util/user';
import './post-mod-panel.scss';
import { useMeta, usePost } from '../../ducks/posts';
import { ModerationMessageSubType, PostMessageSubType } from '../../util/message';
import SwitchButton from '../SwitchButton';
import { unmoderate, usePostModeration } from '../../ducks/mods';
import { useThemeContext } from '../ThemeContext';
export default function PostModerationPanel(): ReactElement {
const [loading, setLoading] = useState(false);
const {name, hash} = useParams<{name?: string; hash: string}>();
const dispatch = useDispatch();
const messageId = [name, hash].join('/');
const originPost = usePost(messageId);
const meta = useMeta(originPost?.subtype === PostMessageSubType.Repost ? originPost.payload.reference : messageId);
const threadmod = usePostModeration(meta?.rootId);
const [unmoderated, setUnmoderated] = useState(false);
const theme = useThemeContext();
const [loading, setLoading] = useState(false);
const { name, hash } = useParams<{ name?: string; hash: string }>();
const dispatch = useDispatch();
const messageId = [name, hash].join('/');
const originPost = usePost(messageId);
const meta = useMeta(
originPost?.subtype === PostMessageSubType.Repost ? originPost.payload.reference : messageId
);
const threadmod = usePostModeration(meta?.rootId);
const [unmoderated, setUnmoderated] = useState(false);
const theme = useThemeContext();
useEffect(() => {
(async function onPostModerationPanelMount() {
setUnmoderated(!!threadmod?.unmoderated);
})();
}, [threadmod]);
useEffect(() => {
(async function onPostModerationPanelMount() {
setUnmoderated(!!threadmod?.unmoderated);
})();
}, [threadmod]);
const toggleModeration = useCallback(() => {
if (!meta?.rootId) return;
dispatch(unmoderate(meta?.rootId, !unmoderated));
setUnmoderated(!unmoderated);
}, [unmoderated, meta?.rootId]);
const toggleModeration = useCallback(() => {
if (!meta?.rootId) return;
dispatch(unmoderate(meta?.rootId, !unmoderated));
setUnmoderated(!unmoderated);
}, [unmoderated, meta?.rootId]);
return (
<div
return (
<div
className={classNames(
'flex flex-col flex-nowrap flex-grow border rounded-xl mt-2',
'meta-group post-mod-panel',
{
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}>
<div
className={classNames('px-4 py-2 font-bold text-lg border-b', {
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}>
Moderation
</div>
<div className="flex flex-col flex-nowrap py-1">
{loading && <Icon className="self-center my-4" url={SpinnerGIF} size={3} />}
<div className="flex flex-row items-center justify-center px-4 py-2">
<Icon
className={classNames(
'flex flex-col flex-nowrap flex-grow border rounded-xl mt-2',
'meta-group post-mod-panel',
{
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
'flex flex-row items-center justify-center flex-shrink-0 flex-grow-0',
'h-9 w-9 p-2 rounded-full border-2',
{
'border-black text-black': theme !== 'dark',
'border-white text-white': theme === 'dark',
}
)}
>
<div className={classNames("px-4 py-2 font-bold text-lg border-b", {
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}>
Moderation
</div>
<div className="flex flex-col flex-nowrap py-1">
{ loading && <Icon className="self-center my-4" url={SpinnerGIF} size={3} /> }
<div className="flex flex-row items-center justify-center px-4 py-2">
<Icon
className={classNames(
"flex flex-row items-center justify-center flex-shrink-0 flex-grow-0",
"h-9 w-9 p-2 rounded-full border-2",
{
"border-black text-black": theme !== 'dark',
"border-white text-white": theme === 'dark',
}
)}
fa={getFA(meta?.moderation)}
size={.875}
/>
<div className="flex-grow flex-shrink w-0 text-light ml-2">
<PanelTextContent />
</div>
</div>
</div>
{
meta?.moderation && (
<div
className={classNames(
"flex flex-row items-center text-sm",
"px-4 py-2",
{
"bg-gray-100 text-gray-500": theme !== 'dark',
"bg-gray-900 text-gray-500": theme === 'dark',
}
)}
>
<div className="flex-grow flex-shrink w-0 mr-2">
You can switch off moderation for this post temporarily
</div>
<SwitchButton
className="flex-grow-0 flex-shrink-0 ml-4"
checked={!unmoderated}
onChange={toggleModeration}
/>
</div>
)
}
fa={getFA(meta?.moderation)}
size={0.875}
/>
<div className="flex-grow flex-shrink w-0 text-light ml-2">
<PanelTextContent />
</div>
</div>
)
</div>
{meta?.moderation && (
<div
className={classNames('flex flex-row items-center text-sm', 'px-4 py-2', {
'bg-gray-100 text-gray-500': theme !== 'dark',
'bg-gray-900 text-gray-500': theme === 'dark',
})}>
<div className="flex-grow flex-shrink w-0 mr-2">
You can switch off moderation for this post temporarily
</div>
<SwitchButton
className="flex-grow-0 flex-shrink-0 ml-4"
checked={!unmoderated}
onChange={toggleModeration}
/>
</div>
)}
</div>
);
}
function getFA(moderation?: ModerationMessageSubType | null): string {
switch(moderation) {
case ModerationMessageSubType.ThreadBlock:
return 'fas fa-shield-alt';
case ModerationMessageSubType.ThreadFollow:
return 'fas fa-user-check';
case ModerationMessageSubType.ThreadMention:
return 'fas fa-at';
default:
return 'fas fa-globe';
}
switch (moderation) {
case ModerationMessageSubType.ThreadBlock:
return 'fas fa-shield-alt';
case ModerationMessageSubType.ThreadFollow:
return 'fas fa-user-check';
case ModerationMessageSubType.ThreadMention:
return 'fas fa-at';
default:
return 'fas fa-globe';
}
}
function PanelTextContent (): ReactElement {
const {name, hash} = useParams<{name?: string; hash: string}>();
const messageId = [name, hash].join('/');
const originPost = usePost(messageId);
const meta = useMeta(originPost?.subtype === PostMessageSubType.Repost ? originPost.payload.reference : messageId);
const root = usePost(meta?.rootId || undefined);
const op = useUser(root?.creator);
const history = useHistory();
function PanelTextContent(): ReactElement {
const { name, hash } = useParams<{ name?: string; hash: string }>();
const messageId = [name, hash].join('/');
const originPost = usePost(messageId);
const meta = useMeta(
originPost?.subtype === PostMessageSubType.Repost ? originPost.payload.reference : messageId
);
const root = usePost(meta?.rootId || undefined);
const op = useUser(root?.creator);
const history = useHistory();
const handle = getHandle(op);
const mention = (
<a
className="hashtag cursor-pointer ml-1"
onClick={e => {
e.stopPropagation();
history.push(`/${encodeURIComponent(getUsername(op))}/`)
}}
>
@{handle}
</a>
);
const handle = getHandle(op);
const mention = (
<a
className="hashtag cursor-pointer ml-1"
onClick={e => {
e.stopPropagation();
history.push(`/${encodeURIComponent(getUsername(op))}/`);
}}>
@{handle}
</a>
);
switch(meta?.moderation) {
case ModerationMessageSubType.ThreadBlock:
return (
<div>
Hide replies blocked by
{mention}
</div>
);
case ModerationMessageSubType.ThreadFollow:
return (
<div>
Show only replies liked or followed
{mention}
</div>
);
case ModerationMessageSubType.ThreadMention:
return (
<div>
Show only replies from users mentioned by
{mention}
</div>
);
default:
return (
<div>
Show all contents
</div>
);
}
}
switch (meta?.moderation) {
case ModerationMessageSubType.ThreadBlock:
return (
<div>
Hide replies blocked by
{mention}
</div>
);
case ModerationMessageSubType.ThreadFollow:
return (
<div>
Show only replies liked or followed
{mention}
</div>
);
case ModerationMessageSubType.ThreadMention:
return (
<div>
Show only replies from users mentioned by
{mention}
</div>
);
default:
return <div>Show all contents</div>;
}
}

View File

@@ -1,5 +1,5 @@
@import "../../util/variable";
@import '../../util/variable';
.post-mod-panel {
overflow: hidden;
}
}

View File

@@ -1,48 +1,44 @@
import React, {ReactElement, useCallback, useState} from "react";
import QrReader from "react-qr-reader";
import {Identity} from "../../serviceWorkers/identity";
import {postWorkerMessage} from "../../util/sw";
import {addIdentity, selectIdentity, setIdentity} from "../../serviceWorkers/util";
import React, { ReactElement, useCallback, useState } from 'react';
import QrReader from 'react-qr-reader';
import { Identity } from '../../serviceWorkers/identity';
import { postWorkerMessage } from '../../util/sw';
import { addIdentity, selectIdentity, setIdentity } from '../../serviceWorkers/util';
export default function QRScanner(props: {
onSuccess?: () => void;
}): ReactElement {
const [errorMessage, setErrorMessage] = useState('');
const [scannedData, setScannedData] = useState('');
export default function QRScanner(props: { onSuccess?: () => void }): ReactElement {
const [errorMessage, setErrorMessage] = useState('');
const [scannedData, setScannedData] = useState('');
const onScan = useCallback(async (data: string | null) => {
if (!data) return;
const onScan = useCallback(async (data: string | null) => {
if (!data) return;
setScannedData(data);
setScannedData(data);
try {
const identity: Identity = JSON.parse(data);
if (identity.type === 'gun' && !identity.privateKey) return;
if (identity.type === 'interrep' && !identity.serializedIdentity) return;
await postWorkerMessage(setIdentity(identity));
// await postWorkerMessage(selectIdentity(identity.publicKey));
if (props.onSuccess) props.onSuccess();
} catch (e) {
setErrorMessage(e.message);
}
}, [])
try {
const identity: Identity = JSON.parse(data);
if (identity.type === 'gun' && !identity.privateKey) return;
if (identity.type === 'interrep' && !identity.serializedIdentity) return;
await postWorkerMessage(setIdentity(identity));
// await postWorkerMessage(selectIdentity(identity.publicKey));
if (props.onSuccess) props.onSuccess();
} catch (e) {
setErrorMessage(e.message);
}
}, []);
const onError = useCallback((err: string) => {
setErrorMessage(err);
}, [])
const onError = useCallback((err: string) => {
setErrorMessage(err);
}, []);
return (
<div className="qr-scanner">
<div className="text-light text-center px-3 py-2 font-semibold">
On desktop, you can export your private key to QR code by logging in and clicking "Export Private Key"
</div>
<QrReader
delay={300}
onScan={onScan}
onError={onError}
style={{ width: '100%' }}
/>
{ errorMessage && <div className="error-message text-xs text-center text-red-500 m-2">{errorMessage}</div> }
</div>
)
}
return (
<div className="qr-scanner">
<div className="text-light text-center px-3 py-2 font-semibold">
On desktop, you can export your private key to QR code by logging in and clicking "Export
Private Key"
</div>
<QrReader delay={300} onScan={onScan} onError={onError} style={{ width: '100%' }} />
{errorMessage && (
<div className="error-message text-xs text-center text-red-500 m-2">{errorMessage}</div>
)}
</div>
);
}

View File

@@ -1,18 +1,18 @@
import React, {ChangeEventHandler, ReactElement} from "react";
import "./switch-button.scss";
import classNames from "classnames";
import React, { ChangeEventHandler, ReactElement } from 'react';
import './switch-button.scss';
import classNames from 'classnames';
type Props = {
checked?: boolean;
onChange?: ChangeEventHandler<HTMLInputElement>;
className?: string;
}
checked?: boolean;
onChange?: ChangeEventHandler<HTMLInputElement>;
className?: string;
};
export default function SwitchButton(props: Props): ReactElement {
return (
<div className={classNames("switch-button", props.className)}>
<input type="checkbox" onChange={props.onChange} checked={props.checked}/>
<span className="slider round" />
</div>
);
}
return (
<div className={classNames('switch-button', props.className)}>
<input type="checkbox" onChange={props.onChange} checked={props.checked} />
<span className="slider round" />
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.switch-button {
cursor: pointer;
@@ -31,20 +31,20 @@
right: 0;
bottom: 0;
background-color: $gray-200;
-webkit-transition: .4s;
transition: .4s;
-webkit-transition: 0.4s;
transition: 0.4s;
}
.slider:before {
position: absolute;
content: "";
content: '';
height: 1rem;
width: 1rem;
left: 2px;
bottom: 2px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
-webkit-transition: 0.4s;
transition: 0.4s;
}
input:checked + .slider {
@@ -68,4 +68,4 @@ input:checked + .slider:before {
.slider.round:before {
border-radius: 50%;
}
}

View File

@@ -1,95 +1,86 @@
import React, {ReactElement, useCallback, useEffect, useState} from "react";
import classNames from "classnames";
import Post from "../Post";
import {useDispatch} from "react-redux";
import {fetchHomeFeed, fetchTagFeed, useGoToPost} from "../../ducks/posts";
import "./tag-feed.scss";
import {useLoggedIn} from "../../ducks/web3";
import {useHistory, useParams} from "react-router";
import InfiniteScrollable from "../InfiniteScrollable";
import {useSelectedLocalId} from "../../ducks/worker";
import {useThemeContext} from "../ThemeContext";
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import Post from '../Post';
import { useDispatch } from 'react-redux';
import { fetchHomeFeed, fetchTagFeed, useGoToPost } from '../../ducks/posts';
import './tag-feed.scss';
import { useLoggedIn } from '../../ducks/web3';
import { useHistory, useParams } from 'react-router';
import InfiniteScrollable from '../InfiniteScrollable';
import { useSelectedLocalId } from '../../ducks/worker';
import { useThemeContext } from '../ThemeContext';
export default function TagFeed(): ReactElement {
const {tagName} = useParams<{tagName: string}>();
const [limit, setLimit] = useState(20);
const [offset, setOffset] = useState(0);
const [order, setOrder] = useState<string[]>([]);
const [fetching, setFetching] = useState(false);
const dispatch = useDispatch();
const history = useHistory();
const loggedIn = useLoggedIn();
const tag = decodeURIComponent(tagName);
const selected = useSelectedLocalId();
const theme = useThemeContext();
const { tagName } = useParams<{ tagName: string }>();
const [limit, setLimit] = useState(20);
const [offset, setOffset] = useState(0);
const [order, setOrder] = useState<string[]>([]);
const [fetching, setFetching] = useState(false);
const dispatch = useDispatch();
const history = useHistory();
const loggedIn = useLoggedIn();
const tag = decodeURIComponent(tagName);
const selected = useSelectedLocalId();
const theme = useThemeContext();
useEffect(() => {
(async function onTagFeedMount() {
setFetching(true);
try {
setOrder([]);
setOffset(0);
await fetchMore(true);
} finally {
setFetching(false);
}
})();
}, [loggedIn, tag, selected]);
useEffect(() => {
(async function onTagFeedMount() {
setFetching(true);
try {
setOrder([]);
setOffset(0);
await fetchMore(true);
} finally {
setFetching(false);
}
})();
}, [loggedIn, tag, selected]);
const fetchMore = useCallback(async (reset = false) => {
if (reset) {
const messageIds: any = await dispatch(fetchTagFeed(tag, 20, 0));
setOffset(20);
setOrder(messageIds);
} else {
if (order.length % limit) return;
const messageIds: any = await dispatch(fetchTagFeed(tag, limit, offset));
setOffset(offset + limit);
setOrder(order.concat(messageIds));
}
}, [limit, offset, order, tag]);
const fetchMore = useCallback(
async (reset = false) => {
if (reset) {
const messageIds: any = await dispatch(fetchTagFeed(tag, 20, 0));
setOffset(20);
setOrder(messageIds);
} else {
if (order.length % limit) return;
const messageIds: any = await dispatch(fetchTagFeed(tag, limit, offset));
setOffset(offset + limit);
setOrder(order.concat(messageIds));
}
},
[limit, offset, order, tag]
);
const gotoPost = useGoToPost();
const gotoPost = useGoToPost();
return (
<InfiniteScrollable
className={classNames('flex-grow home-feed',
'mx-4 py-2',
{},
)}
bottomOffset={128}
onScrolledToBottom={fetchMore}
>
{
!order.length && !fetching && (
<div
className={classNames(
'flex flex-row flex-nowrap items-center justify-center',
'py-6 px-4 border border-gray-200 rounded-xl text-sm text-gray-300',
)}
>
Nothing to see here yet
</div>
)
}
{
order.map((messageId, i) => {
return (
<Post
key={messageId}
className={classNames(
"rounded-xl transition-colors mb-1 cursor-pointer border",
{
"hover:border-gray-300 border-gray-200": theme !== 'dark',
"hover:border-gray-700 border-gray-800": theme === 'dark',
},
)}
messageId={messageId}
onClick={() => gotoPost(messageId)}
/>
);
})
}
</InfiniteScrollable>
);
}
return (
<InfiniteScrollable
className={classNames('flex-grow home-feed', 'mx-4 py-2', {})}
bottomOffset={128}
onScrolledToBottom={fetchMore}>
{!order.length && !fetching && (
<div
className={classNames(
'flex flex-row flex-nowrap items-center justify-center',
'py-6 px-4 border border-gray-200 rounded-xl text-sm text-gray-300'
)}>
Nothing to see here yet
</div>
)}
{order.map((messageId, i) => {
return (
<Post
key={messageId}
className={classNames('rounded-xl transition-colors mb-1 cursor-pointer border', {
'hover:border-gray-300 border-gray-200': theme !== 'dark',
'hover:border-gray-700 border-gray-800': theme === 'dark',
})}
messageId={messageId}
onClick={() => gotoPost(messageId)}
/>
);
})}
</InfiniteScrollable>
);
}

View File

@@ -3,7 +3,7 @@
overflow-y: auto;
&::-webkit-scrollbar {
width: 0; /* Remove scrollbar space */
background: transparent; /* Optional: just make scrollbar invisible */
width: 0; /* Remove scrollbar space */
background: transparent; /* Optional: just make scrollbar invisible */
}
}
}

View File

@@ -1,96 +1,91 @@
import React, {ReactElement, useEffect, useState} from "react";
import Modal, {ModalContent, ModalFooter, ModalHeader} from "../Modal";
import TazHero from "../../../static/icons/taz_hero.png";
import "./taz-modal.scss";
import Button from "../Button";
import {useLocation} from "react-router";
import {Identity} from "@semaphore-protocol/identity";
import {findProof} from "../../util/merkle";
import {postWorkerMessage} from "../../util/sw";
import {setIdentity} from "../../serviceWorkers/util";
import {ViewType} from "../../pages/SignupView";
import React, { ReactElement, useEffect, useState } from 'react';
import Modal, { ModalContent, ModalFooter, ModalHeader } from '../Modal';
import TazHero from '../../../static/icons/taz_hero.png';
import './taz-modal.scss';
import Button from '../Button';
import { useLocation } from 'react-router';
import { Identity } from '@semaphore-protocol/identity';
import { findProof } from '../../util/merkle';
import { postWorkerMessage } from '../../util/sw';
import { setIdentity } from '../../serviceWorkers/util';
import { ViewType } from '../../pages/SignupView';
type Props = {
onClose: () => void;
tazIdentity: string[] | null
onClose: () => void;
tazIdentity: string[] | null;
};
export default function TazModal(props: Props): ReactElement {
const { tazIdentity } = props;
const [errorMessage, setErrorMessage] = useState('');
const { tazIdentity } = props;
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
(async () => {
try {
const serializedIdentity = JSON.stringify(tazIdentity);
const zkIdentity = new Identity(serializedIdentity);
const idCommitmentBigInt = zkIdentity.generateCommitment();
const pathData = await findProof('semaphore_taz_members', idCommitmentBigInt.toString(16));
if (pathData) {
await postWorkerMessage(setIdentity({
type: 'taz',
identityCommitment: idCommitmentBigInt.toString(),
serializedIdentity: serializedIdentity,
identityPath: {
path_index: pathData.pathIndices,
path_elements: pathData.siblings,
root: pathData.root,
},
}));
} else {
setErrorMessage(`Cannot find ${idCommitmentBigInt.toString()} in TAZ Group`);
}
} catch (e) {
setErrorMessage(e.message);
}
})();
}, [tazIdentity]);
if (errorMessage) {
return (
<Modal className="w-148 taz-modal" onClose={props.onClose}>
<ModalHeader>
Invalid Identity
</ModalHeader>
<ModalContent className="p-4">
<div className="my-2 text-light">
Something went wrong while importing your identity :(
<div className="mt-4">Please try again in a few minutes.</div>
</div>
</ModalContent>
<ModalFooter>
<Button btnType="primary" onClick={props.onClose}>
Close
</Button>
</ModalFooter>
</Modal>
);
}
useEffect(() => {
(async () => {
try {
const serializedIdentity = JSON.stringify(tazIdentity);
const zkIdentity = new Identity(serializedIdentity);
const idCommitmentBigInt = zkIdentity.generateCommitment();
const pathData = await findProof('semaphore_taz_members', idCommitmentBigInt.toString(16));
if (pathData) {
await postWorkerMessage(
setIdentity({
type: 'taz',
identityCommitment: idCommitmentBigInt.toString(),
serializedIdentity: serializedIdentity,
identityPath: {
path_index: pathData.pathIndices,
path_elements: pathData.siblings,
root: pathData.root,
},
})
);
} else {
setErrorMessage(`Cannot find ${idCommitmentBigInt.toString()} in TAZ Group`);
}
} catch (e) {
setErrorMessage(e.message);
}
})();
}, [tazIdentity]);
if (errorMessage) {
return (
<Modal className="w-148 taz-modal" onClose={() => null}>
<ModalHeader>
Your TAZ identity is imported successfully!
</ModalHeader>
<ModalContent className="p-4">
<div
className="taz-modal__hero"
style={{ backgroundImage: `url(${TazHero})` }}
/>
<div className="my-2 text-light">
The Temporary Anonymous Zone is a community hub at Devcon VI. Visitors can play with apps using an anonymous identity that uses the zero-knowledge protocol, Semaphore.
<br />
<div className="mt-2">
You can start posting or chatting with other users anonymously.
</div>
</div>
</ModalContent>
<ModalFooter>
<Button btnType="primary" onClick={props.onClose}>
Experience Zero-Knowledge
</Button>
</ModalFooter>
</Modal>
)
}
<Modal className="w-148 taz-modal" onClose={props.onClose}>
<ModalHeader>Invalid Identity</ModalHeader>
<ModalContent className="p-4">
<div className="my-2 text-light">
Something went wrong while importing your identity :(
<div className="mt-4">Please try again in a few minutes.</div>
</div>
</ModalContent>
<ModalFooter>
<Button btnType="primary" onClick={props.onClose}>
Close
</Button>
</ModalFooter>
</Modal>
);
}
return (
<Modal className="w-148 taz-modal" onClose={() => null}>
<ModalHeader>Your TAZ identity is imported successfully!</ModalHeader>
<ModalContent className="p-4">
<div className="taz-modal__hero" style={{ backgroundImage: `url(${TazHero})` }} />
<div className="my-2 text-light">
The Temporary Anonymous Zone is a community hub at Devcon VI. Visitors can play with apps
using an anonymous identity that uses the zero-knowledge protocol, Semaphore.
<br />
<div className="mt-2">
You can start posting or chatting with other users anonymously.
</div>
</div>
</ModalContent>
<ModalFooter>
<Button btnType="primary" onClick={props.onClose}>
Experience Zero-Knowledge
</Button>
</ModalFooter>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.taz-modal {
&__hero {
@@ -8,4 +8,4 @@
background-repeat: no-repeat;
background-position: center;
}
}
}

View File

@@ -1,43 +1,30 @@
import React, {TextareaHTMLAttributes, ReactElement, LegacyRef} from "react";
import classNames from "classnames";
import "./textarea.scss";
import {useThemeContext} from "../ThemeContext";
import React, { TextareaHTMLAttributes, ReactElement, LegacyRef } from 'react';
import classNames from 'classnames';
import './textarea.scss';
import { useThemeContext } from '../ThemeContext';
type Props = {
ref?: LegacyRef<HTMLTextAreaElement>;
label?: string;
errorMessage?: string;
ref?: LegacyRef<HTMLTextAreaElement>;
label?: string;
errorMessage?: string;
} & TextareaHTMLAttributes<HTMLTextAreaElement>;
export default function Textarea(props: Props): ReactElement {
const {
label,
errorMessage,
className,
...textareaProps
} = props;
const { label, errorMessage, className, ...textareaProps } = props;
const theme = useThemeContext();
const theme = useThemeContext();
return (
<div
className={classNames(
"rounded-lg textarea-group",
className,
{
'bg-gray-100 text-gray-300': props.disabled && theme !== 'dark',
'bg-gray-900 text-gray-600': props.disabled && theme === 'dark',
'focus-within:border-gray-400 ': !textareaProps.readOnly && theme !== 'dark',
'focus-within:border-gray-600 border-gray-800': !textareaProps.readOnly && theme === 'dark',
}
)}
>
{ label && <div className="textarea-group__label">{label}</div> }
<textarea
ref={props.ref}
{...textareaProps}
/>
{ errorMessage && <small className="error-message">{errorMessage}</small> }
</div>
)
return (
<div
className={classNames('rounded-lg textarea-group', className, {
'bg-gray-100 text-gray-300': props.disabled && theme !== 'dark',
'bg-gray-900 text-gray-600': props.disabled && theme === 'dark',
'focus-within:border-gray-400 ': !textareaProps.readOnly && theme !== 'dark',
'focus-within:border-gray-600 border-gray-800': !textareaProps.readOnly && theme === 'dark',
})}>
{label && <div className="textarea-group__label">{label}</div>}
<textarea ref={props.ref} {...textareaProps} />
{errorMessage && <small className="error-message">{errorMessage}</small>}
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.dark {
.textarea-group {
@@ -16,11 +16,11 @@
&__label {
background: white;
display: inline-block;
top: -.625rem;
top: -0.625rem;
width: fit-content;
font-size: 12px;
padding: 0 .25rem 0 .1875rem;
margin-left: .625rem;
padding: 0 0.25rem 0 0.1875rem;
margin-left: 0.625rem;
position: absolute;
}
@@ -28,7 +28,7 @@
width: 100%;
height: 100%;
resize: none;
padding: .5rem .75rem;
padding: 0.5rem 0.75rem;
outline: none;
background-color: transparent;
@@ -36,4 +36,4 @@
color: $gray-200;
}
}
}
}

View File

@@ -1,16 +1,12 @@
import React, {ReactElement, ReactNode, useContext} from "react";
import {useSetting} from "../../ducks/app";
import React, { ReactElement, ReactNode, useContext } from 'react';
import { useSetting } from '../../ducks/app';
const ThemeContext = React.createContext('light');
export const ThemeProvider = (props: { children: ReactNode }): ReactElement => {
const setting = useSetting();
const setting = useSetting();
return (
<ThemeContext.Provider value={setting.theme}>
{props.children}
</ThemeContext.Provider>
);
return <ThemeContext.Provider value={setting.theme}>{props.children}</ThemeContext.Provider>;
};
export const useThemeContext = () => useContext(ThemeContext);

View File

@@ -1,131 +1,118 @@
import React, {MouseEventHandler, ReactElement, useCallback, useEffect, useState} from "react";
import {useDispatch} from "react-redux";
import {useHistory, useParams} from "react-router";
import {fetchPost, fetchReplies, useGoToPost, useMeta} from "../../ducks/posts";
import classNames from "classnames";
import Post from "../Post";
import {parseMessageId, Post as PostMessage} from "../../util/message";
import {usePostModeration} from "../../ducks/mods";
import {useThemeContext} from "../ThemeContext";
import React, { MouseEventHandler, ReactElement, useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory, useParams } from 'react-router';
import { fetchPost, fetchReplies, useGoToPost, useMeta } from '../../ducks/posts';
import classNames from 'classnames';
import Post from '../Post';
import { parseMessageId, Post as PostMessage } from '../../util/message';
import { usePostModeration } from '../../ducks/mods';
import { useThemeContext } from '../ThemeContext';
type Props = {
level?: number;
messageId: string;
className?: string;
postClassName?: string;
onClick?: MouseEventHandler;
clearObserver?: () => void;
expand?: boolean;
onSuccessPost?: (post: PostMessage) => void;
level?: number;
messageId: string;
className?: string;
postClassName?: string;
onClick?: MouseEventHandler;
clearObserver?: () => void;
expand?: boolean;
onSuccessPost?: (post: PostMessage) => void;
};
export default function Thread(props: Props): ReactElement {
const { messageId, level = 0 } = props;
const [limit, setLimit] = useState(20);
const [offset, setOffset] = useState(0);
const [order, setOrder] = useState<string[]>([]);
const dispatch = useDispatch();
const meta = useMeta(messageId);
const modOverride = usePostModeration(meta?.rootId);
const [end, setEnd] = useState(false);
const theme = useThemeContext();
const { messageId, level = 0 } = props;
const [limit, setLimit] = useState(20);
const [offset, setOffset] = useState(0);
const [order, setOrder] = useState<string[]>([]);
const dispatch = useDispatch();
const meta = useMeta(messageId);
const modOverride = usePostModeration(meta?.rootId);
const [end, setEnd] = useState(false);
const theme = useThemeContext();
const fetchMore = useCallback(async (reset = false) => {
let messageIds: any;
if (reset) {
messageIds = await dispatch(fetchReplies(messageId, 20, 0));
setOffset(messageIds.length);
setOrder(messageIds);
} else {
messageIds = await dispatch(fetchReplies(messageId, limit, offset));
setOffset(offset + messageIds.length);
setOrder(order.concat(messageIds));
}
const fetchMore = useCallback(
async (reset = false) => {
let messageIds: any;
if (reset) {
messageIds = await dispatch(fetchReplies(messageId, 20, 0));
setOffset(messageIds.length);
setOrder(messageIds);
} else {
messageIds = await dispatch(fetchReplies(messageId, limit, offset));
setOffset(offset + messageIds.length);
setOrder(order.concat(messageIds));
}
if (!messageIds.length) {
setEnd(true);
} else {
setEnd(false);
}
}, [limit, offset, order, messageId]);
if (!messageIds.length) {
setEnd(true);
} else {
setEnd(false);
}
},
[limit, offset, order, messageId]
);
const showMore = useCallback(async () => {
props.clearObserver && props.clearObserver();
setOrder([]);
await fetchMore();
}, [fetchMore, messageId]);
const showMore = useCallback(async () => {
props.clearObserver && props.clearObserver();
setOrder([]);
await fetchMore();
}, [fetchMore, messageId]);
const gotoPost = useGoToPost();
const gotoPost = useGoToPost();
useEffect(() => {
(async function onThreadMount() {
if (!messageId || level >= 3) return;
await fetchMore(true);
})();
}, [messageId, level, modOverride?.unmoderated]);
useEffect(() => {
(async function onThreadMount() {
if (!messageId || level >= 3) return;
await fetchMore(true);
})();
}, [messageId, level, modOverride?.unmoderated]);
return (
<div
className={classNames('thread', props.className)}
>
<Post
className={classNames("mb-0.5", props.postClassName)}
messageId={messageId}
onClick={props.onClick}
onSuccessPost={props.onSuccessPost}
/>
return (
<div className={classNames('thread', props.className)}>
<Post
className={classNames('mb-0.5', props.postClassName)}
messageId={messageId}
onClick={props.onClick}
onSuccessPost={props.onSuccessPost}
/>
<div className={classNames('pl-4', 'thread__content')}>
{order.map(messageId => {
return (
<div key={messageId} className="pt-1">
<Thread
key={messageId}
level={level + 1}
postClassName={classNames('transition-colors cursor-pointer', 'border-l-4 mr-1', {
'hover:border-gray-300 border-gray-200 bg-gray-50 ': theme !== 'dark',
'hover:border-gray-700 border-gray-800 bg-gray-900': theme === 'dark',
})}
messageId={messageId}
onClick={e => {
e.stopPropagation();
gotoPost(messageId);
}}
clearObserver={props.clearObserver}
onSuccessPost={props.onSuccessPost}
/>
</div>
);
})}
{!end && order.length < meta.replyCount && (
<div
className={classNames(
'pl-4',
'thread__content',
)}
>
className={classNames(
'flex flex-row flex-nowrap items-center justify-center',
'p-4 text-blue-400 hover:text-blue-300 cursor-pointer hover:underline',
'border-t',
{
order.map(messageId => {
return (
<div key={messageId} className="pt-1">
<Thread
key={messageId}
level={level + 1}
postClassName={classNames(
"transition-colors cursor-pointer",
"border-l-4 mr-1",
{
"hover:border-gray-300 border-gray-200 bg-gray-50 ": theme !== 'dark',
"hover:border-gray-700 border-gray-800 bg-gray-900": theme === 'dark',
},
)}
messageId={messageId}
onClick={e => {
e.stopPropagation();
gotoPost(messageId);
}}
clearObserver={props.clearObserver}
onSuccessPost={props.onSuccessPost}
/>
</div>
);
})
}
{
!end && order.length < meta.replyCount && (
<div
className={classNames(
"flex flex-row flex-nowrap items-center justify-center",
"p-4 text-blue-400 hover:text-blue-300 cursor-pointer hover:underline",
"border-t",
{
"border-gray-200 bg-white": theme !== 'dark',
"border-gray-800 bg-dark": theme === 'dark',
},
)}
onClick={showMore}
>
Show More
</div>
)
'border-gray-200 bg-white': theme !== 'dark',
'border-gray-800 bg-dark': theme === 'dark',
}
)}
onClick={showMore}>
Show More
</div>
)}
</div>
);
</div>
);
}

View File

@@ -1,437 +1,387 @@
import React, {ReactElement, useCallback, useContext, useEffect, useState} from "react";
import Icon from "../Icon";
import classNames from "classnames";
import "./top-nav.scss"
import {Route, Switch, useHistory, useLocation, useParams} from "react-router";
import Web3Button from "../Web3Button";
import {
useAccount,
useGunLoggedIn,
useSemaphoreID,
} from "../../ducks/web3";
import {useDispatch} from "react-redux";
import {fetchAddressByName, useUser} from "../../ducks/users";
import Web3 from "web3";
import {getName} from "../../util/user";
import {useSelectedLocalId} from "../../ducks/worker";
import {fetchNameByAddress} from "../../util/web3";
import React, { ReactElement, useCallback, useContext, useEffect, useState } from 'react';
import Icon from '../Icon';
import classNames from 'classnames';
import './top-nav.scss';
import { Route, Switch, useHistory, useLocation, useParams } from 'react-router';
import Web3Button from '../Web3Button';
import { useAccount, useGunLoggedIn, useSemaphoreID } from '../../ducks/web3';
import { useDispatch } from 'react-redux';
import { fetchAddressByName, useUser } from '../../ducks/users';
import Web3 from 'web3';
import { getName } from '../../util/user';
import { useSelectedLocalId } from '../../ducks/worker';
import { fetchNameByAddress } from '../../util/web3';
// import Logo from "../../../static/icons/applogo.svg";
import Modal from "../Modal";
import MetaPanel from "../MetaPanel";
import {useThemeContext} from "../ThemeContext";
import config from "../../util/config";
import Modal from '../Modal';
import MetaPanel from '../MetaPanel';
import { useThemeContext } from '../ThemeContext';
import config from '../../util/config';
export default function TopNav(): ReactElement {
const theme = useThemeContext();
const theme = useThemeContext();
return (
<div
className={classNames(
'flex-shrink-0',
'flex', 'flex-row', 'flex-nowrap', 'items-center',
'border-b',
'top-nav',
{
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}
>
<div
className={classNames(
"flex flex-row flex-nowrap items-center flex-grow flex-shrink-0",
)}
>
<Switch>
<Route path="/explore" component={GlobalHeaderGroup} />
<Route path="/home" component={GlobalHeaderGroup} />
<Route path="/tag/:tagName" component={TagHeaderGroup} />
<Route path="/:name/status/:hash" component={PostHeaderGroup} />
<Route path="/post/:hash" component={PostHeaderGroup} />
<Route path="/create-local-backup" component={DefaultHeaderGroup} />
<Route path="/onboarding/interrep" component={DefaultHeaderGroup} />
<Route path="/connect/twitter" component={DefaultHeaderGroup} />
<Route path="/signup" component={DefaultHeaderGroup} />
<Route path="/notification" component={DefaultHeaderGroup} />
<Route path="/chat/:chatId?" component={ChatHeaderGroup} />
<Route path="/settings" component={SettingHeaderGroup} />
<Route path="/:name" component={UserProfileHeaderGroup} />
<Route>
<DefaultHeaderGroup />
</Route>
</Switch>
</div>
<div
className="flex flex-row flex-nowrap items-center flex-grow-0 flex-shrink-0 mx-4 h-20 mobile-hidden"
>
<NavIconRow />
<Web3Button
className={classNames("rounded-xl top-nav__web3-btn border", {
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}
/>
</div>
</div>
);
return (
<div
className={classNames(
'flex-shrink-0',
'flex',
'flex-row',
'flex-nowrap',
'items-center',
'border-b',
'top-nav',
{
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}>
<div className={classNames('flex flex-row flex-nowrap items-center flex-grow flex-shrink-0')}>
<Switch>
<Route path="/explore" component={GlobalHeaderGroup} />
<Route path="/home" component={GlobalHeaderGroup} />
<Route path="/tag/:tagName" component={TagHeaderGroup} />
<Route path="/:name/status/:hash" component={PostHeaderGroup} />
<Route path="/post/:hash" component={PostHeaderGroup} />
<Route path="/create-local-backup" component={DefaultHeaderGroup} />
<Route path="/onboarding/interrep" component={DefaultHeaderGroup} />
<Route path="/connect/twitter" component={DefaultHeaderGroup} />
<Route path="/signup" component={DefaultHeaderGroup} />
<Route path="/notification" component={DefaultHeaderGroup} />
<Route path="/chat/:chatId?" component={ChatHeaderGroup} />
<Route path="/settings" component={SettingHeaderGroup} />
<Route path="/:name" component={UserProfileHeaderGroup} />
<Route>
<DefaultHeaderGroup />
</Route>
</Switch>
</div>
<div className="flex flex-row flex-nowrap items-center flex-grow-0 flex-shrink-0 mx-4 h-20 mobile-hidden">
<NavIconRow />
<Web3Button
className={classNames('rounded-xl top-nav__web3-btn border', {
'border-gray-200': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}
/>
</div>
</div>
);
}
function NavIconRow() {
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const [ensName, setEnsName] = useState('');
const theme = useThemeContext();
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const [ensName, setEnsName] = useState('');
const theme = useThemeContext();
let address = '';
let address = '';
if (loggedIn) {
address = selectedLocalId?.address || account;
}
if (loggedIn) {
address = selectedLocalId?.address || account;
}
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
return (
<div
className={classNames(
"flex flex-row flex-nowrap items-center flex-shrink-0",
"rounded-xl border",
"p-1 mx-4 overflow-hidden",
'mobile-hidden',
{
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}
>
<TopNavIcon fa="fas fa-home" pathname="/home" disabled={!loggedIn} />
<TopNavIcon fa="fas fa-envelope" pathname={`/chat`} disabled={!selectedLocalId} />
<TopNavIcon fa="fas fa-globe-asia" pathname="/explore" />
{/*<TopNavIcon fa="fas fa-bell" pathname="/notifications" />*/}
</div>
)
return (
<div
className={classNames(
'flex flex-row flex-nowrap items-center flex-shrink-0',
'rounded-xl border',
'p-1 mx-4 overflow-hidden',
'mobile-hidden',
{
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
}
)}>
<TopNavIcon fa="fas fa-home" pathname="/home" disabled={!loggedIn} />
<TopNavIcon fa="fas fa-envelope" pathname={`/chat`} disabled={!selectedLocalId} />
<TopNavIcon fa="fas fa-globe-asia" pathname="/explore" />
{/*<TopNavIcon fa="fas fa-bell" pathname="/notifications" />*/}
</div>
);
}
function DefaultHeaderGroup() {
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const [ensName, setEnsName] = useState('');
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const [ensName, setEnsName] = useState('');
let address = '';
let address = '';
if (loggedIn) {
address = selectedLocalId?.address || account;
}
if (loggedIn) {
address = selectedLocalId?.address || account;
}
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
return (
<div
className={classNames(
"flex flex-row flex-nowrap items-center flex-shrink-0",
"p-1 mx-4 overflow-hidden",
)}
>
<Icon
url="/applogo.svg"
size={2}
/>
</div>
)
return (
<div
className={classNames(
'flex flex-row flex-nowrap items-center flex-shrink-0',
'p-1 mx-4 overflow-hidden'
)}>
<Icon url="/applogo.svg" size={2} />
</div>
);
}
function GlobalHeaderGroup() {
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const [ensName, setEnsName] = useState('');
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const [ensName, setEnsName] = useState('');
let address = '';
let address = '';
if (loggedIn) {
address = selectedLocalId?.address || account;
}
if (loggedIn) {
address = selectedLocalId?.address || account;
}
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
return (
<div
className={classNames(
"flex flex-row flex-nowrap flex-grow items-center flex-shrink-0",
"p-1 mx-4 overflow-hidden",
)}
>
<Icon
url="/applogo.svg"
size={2}
/>
<TopNavContextButton />
</div>
)
return (
<div
className={classNames(
'flex flex-row flex-nowrap flex-grow items-center flex-shrink-0',
'p-1 mx-4 overflow-hidden'
)}>
<Icon url="/applogo.svg" size={2} />
<TopNavContextButton />
</div>
);
}
function TopNavContextButton(): ReactElement {
const [showing, showModal] = useState(false);
const [showing, showModal] = useState(false);
return (
<>
{showing && (
<Modal
className="meta-modal"
onClose={() => showModal(false)}
>
<MetaPanel className="mobile-only" />
</Modal>
)}
<div className="felx flex-row flex-nowrap flex-grow justify-end items-center mobile-only">
<Icon
className="justify-end text-gray-200 hover:text-blue-400"
fa="fas fa-binoculars"
onClick={() => showModal(true)}
size={1.25}
/>
</div>
</>
)
return (
<>
{showing && (
<Modal className="meta-modal" onClose={() => showModal(false)}>
<MetaPanel className="mobile-only" />
</Modal>
)}
<div className="felx flex-row flex-nowrap flex-grow justify-end items-center mobile-only">
<Icon
className="justify-end text-gray-200 hover:text-blue-400"
fa="fas fa-binoculars"
onClick={() => showModal(true)}
size={1.25}
/>
</div>
</>
);
}
function SettingHeaderGroup() {
const history = useHistory();
const history = useHistory();
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
return (
<div
className={classNames(
"flex flex-row flex-nowrap items-center flex-shrink-0",
"rounded-xl p-1 mx-4 overflow-hidden",
"profile-header-group",
)}
>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div
className="flex flex-row flex-nowrap items-center px-2 py-2 profile-header-group__title-group"
>
<div
className="flex flex-col flex-nowrap justify-center ml-2 font-bold text-lg "
>
Settings
</div>
</div>
return (
<div
className={classNames(
'flex flex-row flex-nowrap items-center flex-shrink-0',
'rounded-xl p-1 mx-4 overflow-hidden',
'profile-header-group'
)}>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div className="flex flex-row flex-nowrap items-center px-2 py-2 profile-header-group__title-group">
<div className="flex flex-col flex-nowrap justify-center ml-2 font-bold text-lg ">
Settings
</div>
)
</div>
</div>
);
}
function ChatHeaderGroup() {
const history = useHistory();
const {chatId} = useParams<{chatId: string}>();
const history = useHistory();
const { chatId } = useParams<{ chatId: string }>();
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history, chatId]);
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history, chatId]);
return (
<div
className={classNames(
"flex flex-row flex-nowrap items-center flex-shrink-0",
"rounded-xl p-1 mx-4 overflow-hidden",
"profile-header-group",
)}
>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div
className="flex flex-row flex-nowrap items-center px-2 py-2 profile-header-group__title-group"
>
<div
className="flex flex-col flex-nowrap justify-center ml-2 font-bold text-lg "
>
Chat
</div>
</div>
</div>
)
return (
<div
className={classNames(
'flex flex-row flex-nowrap items-center flex-shrink-0',
'rounded-xl p-1 mx-4 overflow-hidden',
'profile-header-group'
)}>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div className="flex flex-row flex-nowrap items-center px-2 py-2 profile-header-group__title-group">
<div className="flex flex-col flex-nowrap justify-center ml-2 font-bold text-lg ">Chat</div>
</div>
</div>
);
}
function UserProfileHeaderGroup() {
const {name} = useParams<{ name: string }>();
const [username, setUsername] = useState('');
const { name } = useParams<{ name: string }>();
const [username, setUsername] = useState('');
const dispatch = useDispatch();
const user = useUser(username);
const dispatch = useDispatch();
const user = useUser(username);
useEffect(() => {
(async () => {
if (!Web3.utils.isAddress(name)) {
const address: any = await dispatch(fetchAddressByName(name));
setUsername(address);
} else {
setUsername(name);
}
})();
}, [name]);
const history = useHistory();
useEffect(() => {
(async () => {
if (!Web3.utils.isAddress(name)) {
const address: any = await dispatch(fetchAddressByName(name));
setUsername(address);
} else {
setUsername(name);
}
})();
}, [name]);
const history = useHistory();
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
return (
<div
className={classNames(
"flex flex-row flex-nowrap flex-grow items-center flex-shrink-0",
"rounded-xl p-1 mx-4 overflow-hidden",
"profile-header-group",
)}
>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div
className="flex flex-row flex-nowrap items-center px-2 py-2 profile-header-group__title-group"
>
<div
className="flex flex-col flex-nowrap justify-center ml-2"
>
<div className="font-bold text-lg profile-header-group__title">
{getName(user)}
</div>
<div className="text-xs text-gray-500 profile-header-group__subtitle">
{user?.meta?.postingCount || 0} Posts
</div>
</div>
</div>
<TopNavContextButton />
return (
<div
className={classNames(
'flex flex-row flex-nowrap flex-grow items-center flex-shrink-0',
'rounded-xl p-1 mx-4 overflow-hidden',
'profile-header-group'
)}>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div className="flex flex-row flex-nowrap items-center px-2 py-2 profile-header-group__title-group">
<div className="flex flex-col flex-nowrap justify-center ml-2">
<div className="font-bold text-lg profile-header-group__title">{getName(user)}</div>
<div className="text-xs text-gray-500 profile-header-group__subtitle">
{user?.meta?.postingCount || 0} Posts
</div>
</div>
)
</div>
<TopNavContextButton />
</div>
);
}
function TagHeaderGroup() {
const history = useHistory();
const {tagName} = useParams<{ tagName: string }>();
const tag = decodeURIComponent(tagName);
const history = useHistory();
const { tagName } = useParams<{ tagName: string }>();
const tag = decodeURIComponent(tagName);
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
return (
<div
className={classNames(
"flex flex-row flex-grow flex-nowrap items-center flex-shrink-0",
"rounded-xl p-1 mx-4 overflow-hidden",
"tag-header-group",
)}
>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div
className="flex flex-row flex-nowrap items-center px-2 py-2"
>
<div className="flex flex-col flex-nowrap justify-center ml-2">
<div className="font-bold text-xl tag-header-group__tag-text">
{tag}
</div>
</div>
</div>
<TopNavContextButton />
return (
<div
className={classNames(
'flex flex-row flex-grow flex-nowrap items-center flex-shrink-0',
'rounded-xl p-1 mx-4 overflow-hidden',
'tag-header-group'
)}>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div className="flex flex-row flex-nowrap items-center px-2 py-2">
<div className="flex flex-col flex-nowrap justify-center ml-2">
<div className="font-bold text-xl tag-header-group__tag-text">{tag}</div>
</div>
)
</div>
<TopNavContextButton />
</div>
);
}
function PostHeaderGroup() {
const history = useHistory();
const history = useHistory();
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
return (
<div
className={classNames(
"flex flex-row flex-nowrap flex-grow items-center flex-shrink-0",
"rounded-xl p-1 mx-4 overflow-hidden",
"post-header-group",
)}
>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div
className="flex flex-row flex-nowrap items-center px-2 py-2"
>
<div className="flex flex-col flex-nowrap justify-center ml-2">
<div className="font-bold text-xl top-nav__text-title">
Post
</div>
</div>
</div>
<TopNavContextButton />
return (
<div
className={classNames(
'flex flex-row flex-nowrap flex-grow items-center flex-shrink-0',
'rounded-xl p-1 mx-4 overflow-hidden',
'post-header-group'
)}>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<div className="flex flex-row flex-nowrap items-center px-2 py-2">
<div className="flex flex-col flex-nowrap justify-center ml-2">
<div className="font-bold text-xl top-nav__text-title">Post</div>
</div>
)
</div>
<TopNavContextButton />
</div>
);
}
type TopNavIconProps = {
fa: string;
pathname: string;
disabled?: boolean;
}
fa: string;
pathname: string;
disabled?: boolean;
};
function TopNavIcon(props: TopNavIconProps): ReactElement {
const history = useHistory();
const {pathname} = useLocation();
const history = useHistory();
const { pathname } = useLocation();
return (
<Icon
className={classNames(
'flex', 'flex-row', 'items-center', 'justify-center',
'top-nav__icon',
{
'top-nav__icon--selected': pathname === props.pathname,
'top-nav__icon--disabled': props.disabled,
}
)}
onClick={(pathname !== props.pathname && !props.disabled) ? () => history.push(props.pathname) : undefined}
fa={props.fa}
size={1.125}
/>
)
}
return (
<Icon
className={classNames('flex', 'flex-row', 'items-center', 'justify-center', 'top-nav__icon', {
'top-nav__icon--selected': pathname === props.pathname,
'top-nav__icon--disabled': props.disabled,
})}
onClick={
pathname !== props.pathname && !props.disabled
? () => history.push(props.pathname)
: undefined
}
fa={props.fa}
size={1.125}
/>
);
}

View File

@@ -1,12 +1,11 @@
@import "../../util/variable";
@import '../../util/variable';
.top-nav {
&__icon {
height: 2rem;
width: 2rem;
border-radius: .5rem;
transition: background-color 100ms ease-in-out,
color 100ms ease-in-out;
border-radius: 0.5rem;
transition: background-color 100ms ease-in-out, color 100ms ease-in-out;
&:hover {
//background: rgba($primary-color, .15);
@@ -30,18 +29,17 @@
}
&__icon + &__icon {
margin-left: .25rem;
margin-left: 0.25rem;
}
&__back-icon {
height: 2.5rem;
width: 2.5rem;
border-radius: 50%;
transition: background-color 100ms ease-in-out,
color 100ms ease-in-out;
transition: background-color 100ms ease-in-out, color 100ms ease-in-out;
&:hover {
background: rgba($primary-color, .15);
background: rgba($primary-color, 0.15);
color: $primary-color;
}
}
@@ -50,13 +48,12 @@
.web3-button__unlock-menu {
width: 16rem;
position: fixed !important;
margin-top: .5rem !important;
margin-top: 0.5rem !important;
top: 58px !important;
right: 16px !important;
left: auto !important;
padding-top: 0 !important;
}
}
}
@@ -88,7 +85,7 @@
.top-nav {
&__back-icon {
i {
font-size: .75rem !important;
font-size: 0.75rem !important;
}
}
@@ -105,14 +102,14 @@
.top-nav {
&__back-icon {
i {
font-size: .75rem !important;
font-size: 0.75rem !important;
}
}
}
&__title-group {
padding-top: .25rem;
padding-bottom: .25rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
&__title {
@@ -121,7 +118,7 @@
}
&__subtitle {
font-size: .75rem;
font-size: 0.75rem;
}
}
@@ -132,7 +129,7 @@
.top-nav {
&__back-icon {
i {
font-size: .75rem !important;
font-size: 0.75rem !important;
}
}
}
@@ -167,4 +164,4 @@
}
}
}
}
}

View File

@@ -1,242 +1,237 @@
import React, {ReactElement, useCallback, useEffect, useState} from "react";
import "./url-preview.scss";
import Icon from "../Icon";
import classNames from "classnames";
import config from "../../util/config";
import {shouldBlurImage} from "../../pages/SettingView";
import SpinnerGif from "../../../static/icons/spinner.gif";
import WebTorrentViewer from "../WebTorrentViewer";
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import './url-preview.scss';
import Icon from '../Icon';
import classNames from 'classnames';
import config from '../../util/config';
import { shouldBlurImage } from '../../pages/SettingView';
import SpinnerGif from '../../../static/icons/spinner.gif';
import WebTorrentViewer from '../WebTorrentViewer';
type Props = {
url?: string;
editable?: boolean;
showAll?: boolean;
onRemove?: () => void;
className?: string;
url?: string;
editable?: boolean;
showAll?: boolean;
onRemove?: () => void;
className?: string;
};
type Preview = {
link: string;
title: string;
description: string;
image: string;
mediaType: string;
contentType: string;
favicon: string;
}
link: string;
title: string;
description: string;
image: string;
mediaType: string;
contentType: string;
favicon: string;
};
function parseURL(url = ''): URL | null {
try {
return new URL(url);
} catch (e) {
return null;
}
try {
return new URL(url);
} catch (e) {
return null;
}
}
export default function URLPreview(props: Props): ReactElement {
const {url, editable} = props;
const [imageSrc, setImageSrc] = useState('');
const [preview, setPreview] = useState<Preview|null>(null);
const [isBlur, setBlur] = useState(shouldBlurImage());
const [loading, setLoading] = useState(true);
const { url, editable } = props;
const [imageSrc, setImageSrc] = useState('');
const [preview, setPreview] = useState<Preview | null>(null);
const [isBlur, setBlur] = useState(shouldBlurImage());
const [loading, setLoading] = useState(true);
const urlParams = parseURL(url);
const isMagnet = urlParams?.protocol === 'magnet:';
const urlParams = parseURL(url);
const isMagnet = urlParams?.protocol === 'magnet:';
useEffect(() => {
(async function onURLPreviewLoad() {
setPreview(null);
setImageSrc('');
useEffect(() => {
(async function onURLPreviewLoad() {
setPreview(null);
setImageSrc('');
if (!url) {
return;
}
if (!url) {
return;
}
try {
setLoading(true);
try {
setLoading(true);
if (isMagnet) {
return;
}
if (isMagnet) {
return;
}
if (await testImage(url)) {
setImageSrc(url);
return;
}
if (await testImage(url)) {
setImageSrc(url);
return;
}
const resp = await fetch(`${config.indexerAPI}/preview?link=${encodeURI(url)}`);
const json = await resp.json();
const resp = await fetch(`${config.indexerAPI}/preview?link=${encodeURI(url)}`);
const json = await resp.json();
if (!json.payload.error) {
const {
link,
title = '',
description = '',
image = '',
mediaType = '',
contentType = '',
favicon = '',
} = json.payload;
if (!json.payload.error) {
const {
link,
title = '',
description = '',
image = '',
mediaType = '',
contentType = '',
favicon = '',
} = json.payload;
setPreview({
link,
title,
description,
image,
mediaType,
contentType,
favicon,
});
setPreview({
link,
title,
description,
image,
mediaType,
contentType,
favicon,
});
return;
}
} catch (e) {
setImageSrc('');
setPreview(null);
} finally {
setLoading(false);
}
})();
}, [url]);
return;
}
} catch (e) {
setImageSrc('');
setPreview(null);
} finally {
setLoading(false);
}
})();
}, [url]);
const openImageLink = useCallback(
(e: any) => {
e.stopPropagation();
window.open(imageSrc, '_blank');
},
[imageSrc]
);
const openImageLink = useCallback((e: any) => {
e.stopPropagation();
window.open(imageSrc, '_blank');
}, [imageSrc]);
const openLink = useCallback((e: any) => {
if (!preview?.link) return;
e.stopPropagation();
window.open(preview?.link, '_blank');
}, [preview?.link]);
if (loading) {
return (
<div className={classNames("url-preview", props.className)}>
<div className="flex flex-row items-center">
<Icon url={SpinnerGif} size={2.5} />
<small className="text-gray-500 font-semibold text-xs">Loading...</small>
</div>
</div>
);
}
if (!imageSrc && !preview && !isMagnet) {
return <></>;
}
const openLink = useCallback(
(e: any) => {
if (!preview?.link) return;
e.stopPropagation();
window.open(preview?.link, '_blank');
},
[preview?.link]
);
if (loading) {
return (
<div
className={classNames("url-preview", props.className, {
'url-preview--wt': urlParams?.protocol === 'magnet:',
})}
>
{ urlParams?.protocol === 'magnet:' && (
<WebTorrentViewer
url={urlParams.href}
/>
)}
{ imageSrc && (
<div
className={classNames("url-preview__img-container", {
'url-preview__img-container--showAll': props.showAll,
'blurred-image': !props.editable && isBlur,
'unblurred-image': props.editable || !isBlur,
})}
>
<img
className={classNames("url-preview__img", {
'cursor-pointer': !props.editable,
})}
src={imageSrc}
onClick={!props.editable ? openImageLink : undefined}
/>
</div>
) }
{ preview && (
<div
className={classNames("url-preview__link-container", {
'cursor-pointer': !props.editable,
})}
onClick={!props.editable ? openLink : undefined}
>
{
preview.image && (
<div
className={classNames("url-preview__link-image", {
'blurred-image': !props.editable && isBlur,
'unblurred-image': props.editable || !isBlur,
})}
>
<img src={preview.image} />
</div>
)
}
{
(preview.title || preview.description)
? (
<div className="url-preview__link-content">
<div className="url-preview__link-title">{preview.title}</div>
<div className="url-preview__link-desc">{preview.description}</div>
</div>
)
: (
<a className="px-4 py-2 text-light text-ellipsis overflow-hidden" href={url} target="_blank">{url}</a>
)
}
</div>
) }
{ editable && (
<Icon
className="url-preview__close bg-black bg-opacity-80 text-white absolute top-4 left-4 w-8 h-8"
fa="fas fa-times"
onClick={e => {
e.stopPropagation();
props.onRemove && props.onRemove();
}}
/>
) }
{ !editable && !isMagnet && !!(imageSrc || preview?.image) && (
<Icon
className="url-preview__close bg-black bg-opacity-80 text-white absolute top-4 left-4 w-8 h-8"
fa={isBlur ? "fas fa-eye-slash" : "fas fa-eye"}
onClick={e => {
e.stopPropagation();
setBlur(!isBlur);
}}
/>
) }
<div className={classNames('url-preview', props.className)}>
<div className="flex flex-row items-center">
<Icon url={SpinnerGif} size={2.5} />
<small className="text-gray-500 font-semibold text-xs">Loading...</small>
</div>
</div>
);
};
}
if (!imageSrc && !preview && !isMagnet) {
return <></>;
}
return (
<div
className={classNames('url-preview', props.className, {
'url-preview--wt': urlParams?.protocol === 'magnet:',
})}>
{urlParams?.protocol === 'magnet:' && <WebTorrentViewer url={urlParams.href} />}
{imageSrc && (
<div
className={classNames('url-preview__img-container', {
'url-preview__img-container--showAll': props.showAll,
'blurred-image': !props.editable && isBlur,
'unblurred-image': props.editable || !isBlur,
})}>
<img
className={classNames('url-preview__img', {
'cursor-pointer': !props.editable,
})}
src={imageSrc}
onClick={!props.editable ? openImageLink : undefined}
/>
</div>
)}
{preview && (
<div
className={classNames('url-preview__link-container', {
'cursor-pointer': !props.editable,
})}
onClick={!props.editable ? openLink : undefined}>
{preview.image && (
<div
className={classNames('url-preview__link-image', {
'blurred-image': !props.editable && isBlur,
'unblurred-image': props.editable || !isBlur,
})}>
<img src={preview.image} />
</div>
)}
{preview.title || preview.description ? (
<div className="url-preview__link-content">
<div className="url-preview__link-title">{preview.title}</div>
<div className="url-preview__link-desc">{preview.description}</div>
</div>
) : (
<a
className="px-4 py-2 text-light text-ellipsis overflow-hidden"
href={url}
target="_blank">
{url}
</a>
)}
</div>
)}
{editable && (
<Icon
className="url-preview__close bg-black bg-opacity-80 text-white absolute top-4 left-4 w-8 h-8"
fa="fas fa-times"
onClick={e => {
e.stopPropagation();
props.onRemove && props.onRemove();
}}
/>
)}
{!editable && !isMagnet && !!(imageSrc || preview?.image) && (
<Icon
className="url-preview__close bg-black bg-opacity-80 text-white absolute top-4 left-4 w-8 h-8"
fa={isBlur ? 'fas fa-eye-slash' : 'fas fa-eye'}
onClick={e => {
e.stopPropagation();
setBlur(!isBlur);
}}
/>
)}
</div>
);
}
function testImage(url: string) {
return new Promise(function (resolve, reject) {
const timer = setTimeout(function () {
// reset .src to invalid URL so it stops previous
// loading, but doesn't trigger new load
img.src = "//!!!!/test.jpg";
resolve(false);
}, 60000);
return new Promise(function (resolve, reject) {
const timer = setTimeout(function () {
// reset .src to invalid URL so it stops previous
// loading, but doesn't trigger new load
img.src = '//!!!!/test.jpg';
resolve(false);
}, 60000);
const img = new Image();
const img = new Image();
img.onerror = img.onabort = function () {
clearTimeout(timer);
resolve(false);
};
img.onerror = img.onabort = function () {
clearTimeout(timer);
resolve(false);
};
img.onload = function () {
clearTimeout(timer);
resolve(true);
};
img.onload = function () {
clearTimeout(timer);
resolve(true);
};
img.src = url;
});
}
img.src = url;
});
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable";
@import '../../util/variable';
.dark {
.url-preview {
@@ -73,14 +73,14 @@
&__link-content {
@extend %col-nowrap;
padding: .75rem 1rem;
font-size: .9375rem;
padding: 0.75rem 1rem;
font-size: 0.9375rem;
line-height: 1.35rem;
}
&__link-title {
font-weight: 600;
margin-bottom: .25rem;
margin-bottom: 0.25rem;
text-overflow: ellipsis;
overflow: hidden;
max-height: 2.7rem;
@@ -92,4 +92,4 @@
text-overflow: ellipsis;
overflow: hidden;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
@import "../../util/variable";
@import '../../util/variable';
.web3-button {
background-color: rgba($primary-color, .1);
background-color: rgba($primary-color, 0.1);
color: $primary-color;
font-family: Inter, sans-serif;
&:hover {
box-shadow: 0 0 0 1px rgba($primary-color, .5);
box-shadow: 0 0 0 1px rgba($primary-color, 0.5);
}
&__alt-action {
@@ -22,7 +22,7 @@
height: 2rem;
width: 2rem;
border-radius: 50%;
margin: 0 .375rem;
margin: 0 0.375rem;
&:hover {
//color: $primary-color;
@@ -81,7 +81,7 @@
&__item {
width: 100%;
padding: .5rem 1rem;
padding: 0.5rem 1rem;
}
&__selected-item {

View File

@@ -1,65 +1,69 @@
import React from "react";
import ReactDOM from "react-dom";
import {act} from "react-dom/test-utils";
import {Provider} from "react-redux";
import {ducks, store} from "../../util/testUtils";
import Web3Button from "./index";
import {setSelectedId} from "../../ducks/worker";
import {ZkIdentity} from "@zk-kit/identity";
import React from 'react';
import ReactDOM from 'react-dom';
import { act } from 'react-dom/test-utils';
import { Provider } from 'react-redux';
import { ducks, store } from '../../util/testUtils';
import Web3Button from './index';
import { setSelectedId } from '../../ducks/worker';
import { ZkIdentity } from '@zk-kit/identity';
describe('<Web3Button>', () => {
const root = document.createElement('div');
const root = document.createElement('div');
it ('should mount', async () => {
act(() => {
ReactDOM.render(
<Provider store={store}>
<Web3Button />
</Provider>,
root,
)
});
expect(root.textContent).toBe('Add a user');
it('should mount', async () => {
act(() => {
ReactDOM.render(
<Provider store={store}>
<Web3Button />
</Provider>,
root
);
});
it ('should render gun identity', async () => {
await store.dispatch(ducks.worker.setSelectedId({
type: "gun",
address: '0xgunuser',
privateKey: '0xpriv',
publicKey: '0xpub',
nonce: 1,
}));
expect(root.textContent).toBe('0xgunu...user');
});
expect(root.textContent).toBe('Add a user');
});
it ('should render gun identity', async () => {
const id = new ZkIdentity();
await store.dispatch(ducks.worker.setSelectedId({
type: "interrep",
nonce: 0,
address: '0xinterrep',
identityCommitment: id.genIdentityCommitment().toString(16),
serializedIdentity: id.serializeIdentity(),
provider: 'twitter',
name: 'gold',
identityPath: null,
}));
expect(root.textContent).toBe('Incognito');
});
it ('should render zkpr identity', async () => {
const id = new ZkIdentity();
await store.dispatch(ducks.worker.setSelectedId({
type: "zkpr_interrep",
identityCommitment: id.genIdentityCommitment().toString(16),
provider: 'twitter',
name: 'gold',
identityPath: null,
}));
expect(root.textContent).toBe('Connected to ZKPR');
});
it('should render gun identity', async () => {
await store.dispatch(
ducks.worker.setSelectedId({
type: 'gun',
address: '0xgunuser',
privateKey: '0xpriv',
publicKey: '0xpub',
nonce: 1,
})
);
expect(root.textContent).toBe('0xgunu...user');
});
it('should render gun identity', async () => {
const id = new ZkIdentity();
await store.dispatch(
ducks.worker.setSelectedId({
type: 'interrep',
nonce: 0,
address: '0xinterrep',
identityCommitment: id.genIdentityCommitment().toString(16),
serializedIdentity: id.serializeIdentity(),
provider: 'twitter',
name: 'gold',
identityPath: null,
})
);
expect(root.textContent).toBe('Incognito');
});
it('should render zkpr identity', async () => {
const id = new ZkIdentity();
await store.dispatch(
ducks.worker.setSelectedId({
type: 'zkpr_interrep',
identityCommitment: id.genIdentityCommitment().toString(16),
provider: 'twitter',
name: 'gold',
identityPath: null,
})
);
expect(root.textContent).toBe('Connected to ZKPR');
});
});

View File

@@ -1,297 +1,306 @@
import React, {ReactElement, useCallback, useEffect, useRef, useState} from "react";
import classNames from "classnames";
import {addMagnetURL, getInfoHashFromMagnet, getWebtorrentClient, removeMagnetURL} from "../../util/webtorrent";
import Icon from "../Icon";
import SpinnerGif from "../../../static/icons/spinner.gif";
import {Torrent, TorrentFile} from "webtorrent";
import React, { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import {
addMagnetURL,
getInfoHashFromMagnet,
getWebtorrentClient,
removeMagnetURL,
} from '../../util/webtorrent';
import Icon from '../Icon';
import SpinnerGif from '../../../static/icons/spinner.gif';
import { Torrent, TorrentFile } from 'webtorrent';
import mime from 'mime-types';
import prettyBytes from "pretty-bytes";
import WTIcon from "../../../static/icons/webtorrent-small.png";
import "./wt-viewer.scss";
import prettyBytes from 'pretty-bytes';
import WTIcon from '../../../static/icons/webtorrent-small.png';
import './wt-viewer.scss';
type Props = {
className?: string;
url: string;
}
className?: string;
url: string;
};
export default function WebTorrentViewer(props: Props): ReactElement {
const viewer = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(false);
const [files, setFiles] = useState<{name: string; length: number; progress: number}[]>([]);
const [selectedFile, selectFile] = useState<TorrentFile|null>(null);
const [currentTorrent, setTorrent] = useState<Torrent|null>(null);
const [isDownloading, setDownloading] = useState(false);
const [isSeeding, setSeeding] = useState(false);
const [progress, setProgress] = useState(0);
const viewer = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(false);
const [files, setFiles] = useState<{ name: string; length: number; progress: number }[]>([]);
const [selectedFile, selectFile] = useState<TorrentFile | null>(null);
const [currentTorrent, setTorrent] = useState<Torrent | null>(null);
const [isDownloading, setDownloading] = useState(false);
const [isSeeding, setSeeding] = useState(false);
const [progress, setProgress] = useState(0);
const onFileClick = useCallback(async (fileIndex: number) => {
const client = getWebtorrentClient();
const torrent = client.get(getInfoHashFromMagnet(props.url)) || await addMagnetURL(props.url);
setTorrent(torrent);
const file = torrent.files[fileIndex];
const onFileClick = useCallback(
async (fileIndex: number) => {
const client = getWebtorrentClient();
const torrent =
client.get(getInfoHashFromMagnet(props.url)) || (await addMagnetURL(props.url));
setTorrent(torrent);
const file = torrent.files[fileIndex];
const type = mime.lookup(file.name) || '';
const isImage = /image/.test(type);
const isVideo = /video/.test(type);
const isAudio = /audio/.test(type);
const type = mime.lookup(file.name) || '';
const isImage = /image/.test(type);
const isVideo = /video/.test(type);
const isAudio = /audio/.test(type);
const onFileReady = () => {
setLoading(true);
if (isImage) {
if (file.progress === 1) {
if (file.progress) {
torrent.off('download', onFileReady);
setLoading(false);
}
} else {
selectFile(file);
}
} else {
if (file.progress) {
selectFile(file);
torrent.off('download', onFileReady);
setLoading(false);
}
}
}
const onDownload = () => {
setFiles(torrent.files.map(({ name, length, progress }) => ({ name, length, progress })));
setProgress(torrent.progress);
if (torrent.progress === 1) {
torrent.off('download', onDownload);
setDownloading(false);
setSeeding(true);
} else {
setDownloading(true);
}
}
if (file.progress === 1) {
selectFile(file);
} else {
torrent.on('download', onFileReady);
torrent.on('download', onDownload);
torrent.once('done', () => {
onFileReady();
onDownload();
});
}
}, [props.url]);
const onDownload = useCallback(async (fileIndex: number) => {
const client = getWebtorrentClient();
const torrent = client.get(getInfoHashFromMagnet(props.url)) || await addMagnetURL(props.url);
setTorrent(torrent);
const file = torrent.files[fileIndex];
const onDownload = () => {
setFiles(torrent.files.map(({ name, length, progress }) => ({ name, length, progress })));
setProgress(torrent.progress);
setDownloading(torrent.progress !== 1);
}
if (torrent.progress !== 1) {
torrent.on('download', onDownload);
torrent.once('done', () => {
onDownload();
});
}
return new Promise((resolve) => {
file.getBlobURL((err, url) => {
if (err || !url) throw err
const a = document.createElement('a')
a.download = file.name;
a.href = url;
a.click();
resolve();
});
});
}, [props.url]);
useEffect(() => {
(async function onFileChanged () {
if (!selectedFile || !viewer.current) {
return;
}
const current = viewer.current;
const type = mime.lookup(selectedFile.name) || '';
const isImage = /image/.test(type);
const isVideo = /video/.test(type);
const isAudio = /audio/.test(type);
if (!isImage && !isVideo && !isAudio) {
return;
}
current.innerHTML = '';
let el: any;
if (isImage) {
el = document.createElement('img');
}
if (isVideo) {
el = document.createElement('video');
}
if (isAudio) {
el = document.createElement('audio');
}
current.appendChild(el);
selectedFile.renderTo(el);
if (el.play) el.play();
})();
}, [selectedFile, viewer.current, props.url]);
useEffect(() => {
const onFileReady = () => {
setLoading(true);
(async function onMount() {
const client = getWebtorrentClient();
const t = client.get(getInfoHashFromMagnet(props.url)) || await addMagnetURL(props.url);
setTorrent(t);
setFiles(t.files.map(({ name, length, progress }) => ({ name, length, progress })));
await new Promise(r => t.destroy({ destroyStore: false }, r));
if (isImage) {
if (file.progress === 1) {
if (file.progress) {
torrent.off('download', onFileReady);
setLoading(false);
}
} else {
selectFile(file);
}
} else {
if (file.progress) {
selectFile(file);
torrent.off('download', onFileReady);
setLoading(false);
})();
}, [props.url]);
useEffect(() => {
return () => {
const client = getWebtorrentClient();
const t = client.get(getInfoHashFromMagnet(props.url));
if (t) {
t.destroy({destroyStore: false});
}
}
}
}, [props.url]);
};
return (
<div
className={classNames('wt-viewer', props.className)}
>
{loading && (
<div className="flex flex-row items-center">
<Icon url={SpinnerGif} size={2.5} />
<small className="text-gray-500 font-semibold text-xs">Loading...</small>
</div>
)}
<div className="wt-viewer__viewer" ref={viewer} />
{!!files.length && (
<div className="wt-viewer__files">
{files.map((file, i) => {
return (
<FileRow
key={i}
name={file.name}
length={file.length}
progress={file.progress}
onFileClick={() => onFileClick(i)}
onDownload={() => onDownload(i)}
/>
);
})}
</div>
)}
{
currentTorrent && (
<div
className="flex flex-row items-center px-4 py-2 bg-gray-200"
onClick={e => e.stopPropagation()}
>
<Icon
className="text-gray-400 mr-2 cursor-pointer"
fa={(isDownloading || isSeeding) ? 'fas fa-stop' : undefined}
url={(isDownloading || isSeeding) ? undefined : WTIcon}
size={(isDownloading || isSeeding) ? .75 : 1.25}
onClick={async (e) => {
const client = getWebtorrentClient();
const torrent = client.get(getInfoHashFromMagnet(props.url));
if (torrent) {
await new Promise(r => torrent.destroy({ destroyStore: false }, r));
setDownloading(false);
setSeeding(false);
}
}}
/>
<div className="text-xs text-gray-400">
{
isDownloading
? `${prettyBytes(currentTorrent.downloadSpeed)}/s - Downloading ${currentTorrent.name}`
: isSeeding
? `${prettyBytes(currentTorrent.uploadSpeed)}/s - Sharing with ${currentTorrent.numPeers} peers`
: 'WebTorrent'
}
</div>
</div>
)
}
const onDownload = () => {
setFiles(torrent.files.map(({ name, length, progress }) => ({ name, length, progress })));
setProgress(torrent.progress);
if (torrent.progress === 1) {
torrent.off('download', onDownload);
setDownloading(false);
setSeeding(true);
} else {
setDownloading(true);
}
};
if (file.progress === 1) {
selectFile(file);
} else {
torrent.on('download', onFileReady);
torrent.on('download', onDownload);
torrent.once('done', () => {
onFileReady();
onDownload();
});
}
},
[props.url]
);
const onDownload = useCallback(
async (fileIndex: number) => {
const client = getWebtorrentClient();
const torrent =
client.get(getInfoHashFromMagnet(props.url)) || (await addMagnetURL(props.url));
setTorrent(torrent);
const file = torrent.files[fileIndex];
const onDownload = () => {
setFiles(torrent.files.map(({ name, length, progress }) => ({ name, length, progress })));
setProgress(torrent.progress);
setDownloading(torrent.progress !== 1);
};
if (torrent.progress !== 1) {
torrent.on('download', onDownload);
torrent.once('done', () => {
onDownload();
});
}
return new Promise(resolve => {
file.getBlobURL((err, url) => {
if (err || !url) throw err;
const a = document.createElement('a');
a.download = file.name;
a.href = url;
a.click();
resolve();
});
});
},
[props.url]
);
useEffect(() => {
(async function onFileChanged() {
if (!selectedFile || !viewer.current) {
return;
}
const current = viewer.current;
const type = mime.lookup(selectedFile.name) || '';
const isImage = /image/.test(type);
const isVideo = /video/.test(type);
const isAudio = /audio/.test(type);
if (!isImage && !isVideo && !isAudio) {
return;
}
current.innerHTML = '';
let el: any;
if (isImage) {
el = document.createElement('img');
}
if (isVideo) {
el = document.createElement('video');
}
if (isAudio) {
el = document.createElement('audio');
}
current.appendChild(el);
selectedFile.renderTo(el);
if (el.play) el.play();
})();
}, [selectedFile, viewer.current, props.url]);
useEffect(() => {
setLoading(true);
(async function onMount() {
const client = getWebtorrentClient();
const t = client.get(getInfoHashFromMagnet(props.url)) || (await addMagnetURL(props.url));
setTorrent(t);
setFiles(t.files.map(({ name, length, progress }) => ({ name, length, progress })));
await new Promise(r => t.destroy({ destroyStore: false }, r));
setLoading(false);
})();
}, [props.url]);
useEffect(() => {
return () => {
const client = getWebtorrentClient();
const t = client.get(getInfoHashFromMagnet(props.url));
if (t) {
t.destroy({ destroyStore: false });
}
};
}, [props.url]);
return (
<div className={classNames('wt-viewer', props.className)}>
{loading && (
<div className="flex flex-row items-center">
<Icon url={SpinnerGif} size={2.5} />
<small className="text-gray-500 font-semibold text-xs">Loading...</small>
</div>
)
)}
<div className="wt-viewer__viewer" ref={viewer} />
{!!files.length && (
<div className="wt-viewer__files">
{files.map((file, i) => {
return (
<FileRow
key={i}
name={file.name}
length={file.length}
progress={file.progress}
onFileClick={() => onFileClick(i)}
onDownload={() => onDownload(i)}
/>
);
})}
</div>
)}
{currentTorrent && (
<div
className="flex flex-row items-center px-4 py-2 bg-gray-200"
onClick={e => e.stopPropagation()}>
<Icon
className="text-gray-400 mr-2 cursor-pointer"
fa={isDownloading || isSeeding ? 'fas fa-stop' : undefined}
url={isDownloading || isSeeding ? undefined : WTIcon}
size={isDownloading || isSeeding ? 0.75 : 1.25}
onClick={async e => {
const client = getWebtorrentClient();
const torrent = client.get(getInfoHashFromMagnet(props.url));
if (torrent) {
await new Promise(r => torrent.destroy({ destroyStore: false }, r));
setDownloading(false);
setSeeding(false);
}
}}
/>
<div className="text-xs text-gray-400">
{isDownloading
? `${prettyBytes(currentTorrent.downloadSpeed)}/s - Downloading ${
currentTorrent.name
}`
: isSeeding
? `${prettyBytes(currentTorrent.uploadSpeed)}/s - Sharing with ${
currentTorrent.numPeers
} peers`
: 'WebTorrent'}
</div>
</div>
)}
</div>
);
}
function FileRow(props: {
name: string;
length: number;
onFileClick: () => void;
onDownload: () => void;
progress: number;
name: string;
length: number;
onFileClick: () => void;
onDownload: () => void;
progress: number;
}): ReactElement {
const type = mime.lookup(props.name) || '';
const isImage = /image/.test(type);
const isVideo = /video/.test(type);
const isAudio = /audio/.test(type);
const [downloading, setDownloading] = useState(false);
const type = mime.lookup(props.name) || '';
const isImage = /image/.test(type);
const isVideo = /video/.test(type);
const isAudio = /audio/.test(type);
const [downloading, setDownloading] = useState(false);
const onDownload = async () => {
setDownloading(true);
await props.onDownload();
setDownloading(false);
};
const onDownload = async () => {
setDownloading(true);
await props.onDownload();
setDownloading(false);
};
return (
<div
className="wt-viewer__file"
onClick={e => {
e.stopPropagation();
props.onFileClick();
}}
>
<Icon
className="wt-viewer__file__icon"
fa={classNames({
'fas fa-file-image': isImage,
'fas fa-file-video': isVideo,
'fas fa-file-audio': isAudio,
'fas fa-file': !isImage && !isVideo && !isAudio,
})}
// url={props.loading ? SpinnerGif : undefined}
size={1.5}
/>
<div className="wt-viewer__file__name text-light mx-4">
<div>{props.name}</div>
<small>{`${(props.progress * 100).toFixed(0)}% - ${prettyBytes(props.length * props.progress)} / ${prettyBytes(props.length)}`}</small>
</div>
<Icon
className="wt-viewer__file__download-btn"
fa={downloading ? undefined : "fas fa-download"}
url={downloading ? SpinnerGif : undefined}
size={downloading ? 2 : 1.25}
onClick={e => {
if (downloading) return;
e.stopPropagation();
onDownload();
}}
/>
</div>
)
}
return (
<div
className="wt-viewer__file"
onClick={e => {
e.stopPropagation();
props.onFileClick();
}}>
<Icon
className="wt-viewer__file__icon"
fa={classNames({
'fas fa-file-image': isImage,
'fas fa-file-video': isVideo,
'fas fa-file-audio': isAudio,
'fas fa-file': !isImage && !isVideo && !isAudio,
})}
// url={props.loading ? SpinnerGif : undefined}
size={1.5}
/>
<div className="wt-viewer__file__name text-light mx-4">
<div>{props.name}</div>
<small>{`${(props.progress * 100).toFixed(0)}% - ${prettyBytes(
props.length * props.progress
)} / ${prettyBytes(props.length)}`}</small>
</div>
<Icon
className="wt-viewer__file__download-btn"
fa={downloading ? undefined : 'fas fa-download'}
url={downloading ? SpinnerGif : undefined}
size={downloading ? 2 : 1.25}
onClick={e => {
if (downloading) return;
e.stopPropagation();
onDownload();
}}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable.scss";
@import '../../util/variable.scss';
.wt-viewer {
&__files {
@@ -9,7 +9,7 @@
&__file {
@extend %row-nowrap;
padding: .5rem 1rem;
padding: 0.5rem 1rem;
cursor: pointer;
.icon {
@@ -48,7 +48,7 @@
&__viewer {
@extend %row-nowrap;
align-items: center;;
align-items: center;
justify-content: center;
background-color: black;
@@ -67,4 +67,4 @@
margin: 1rem 0;
}
}
}
}

20
src/custom.d.ts vendored
View File

@@ -1,16 +1,16 @@
declare module "*.svg" {
const content: string;
export default content;
declare module '*.svg' {
const content: string;
export default content;
}
declare module "*.png" {
const content: string;
export default content;
declare module '*.png' {
const content: string;
export default content;
}
declare module "*.gif" {
const content: string;
export default content;
declare module '*.gif' {
const content: string;
export default content;
}
declare const self: ServiceWorkerGlobalScope;
declare const self: ServiceWorkerGlobalScope;

View File

@@ -1,59 +1,59 @@
import {useSelector} from "react-redux";
import {AppRootState} from "../store/configureAppStore";
import deepEqual from "fast-deep-equal";
import { useSelector } from 'react-redux';
import { AppRootState } from '../store/configureAppStore';
import deepEqual from 'fast-deep-equal';
const THEME_LS_KEY = 'theme';
enum ActionTypes {
SET_THEME = 'app/setTheme',
SET_THEME = 'app/setTheme',
}
type Action<payload> = {
type: ActionTypes;
payload: payload;
meta?: any;
error?: boolean;
}
type: ActionTypes;
payload: payload;
meta?: any;
error?: boolean;
};
type State = {
theme: string;
theme: string;
};
const getTheme = () => {
let theme = localStorage.getItem(THEME_LS_KEY);
if (theme === 'dark' || theme === 'light') return theme;
if (window.matchMedia("(prefers-color-scheme: dark)")) return 'dark';
return 'light';
}
let theme = localStorage.getItem(THEME_LS_KEY);
if (theme === 'dark' || theme === 'light') return theme;
if (window.matchMedia('(prefers-color-scheme: dark)')) return 'dark';
return 'light';
};
const initialState = {
theme: getTheme(),
theme: getTheme(),
};
export const setTheme = (theme: 'dark' | 'light') => {
localStorage.setItem(THEME_LS_KEY, theme);
return {
type: ActionTypes.SET_THEME,
payload: theme,
};
}
localStorage.setItem(THEME_LS_KEY, theme);
return {
type: ActionTypes.SET_THEME,
payload: theme,
};
};
export default function app(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionTypes.SET_THEME:
return {
...state,
theme: action.payload,
};
default:
return state;
}
switch (action.type) {
case ActionTypes.SET_THEME:
return {
...state,
theme: action.payload,
};
default:
return state;
}
}
export const useSetting = () => {
return useSelector((state: AppRootState) => {
return {
theme: state.app.theme,
};
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return {
theme: state.app.theme,
};
}, deepEqual);
};

View File

@@ -1,285 +1,288 @@
import {Chat, ChatMessage, ZKChatClient} from "../util/zkchat";
import {Dispatch} from "redux";
import store, {AppRootState} from "../store/configureAppStore";
import config from "../util/config";
import {useSelector} from "react-redux";
import deepEqual from "fast-deep-equal";
import sse from "../util/sse";
import { Chat, ChatMessage, ZKChatClient } from '../util/zkchat';
import { Dispatch } from 'redux';
import store, { AppRootState } from '../store/configureAppStore';
import config from '../util/config';
import { useSelector } from 'react-redux';
import deepEqual from 'fast-deep-equal';
import sse from '../util/sse';
const EVENTS = ZKChatClient.EVENTS;
export const zkchat = new ZKChatClient({
api: `${config.indexerAPI}/v1/zkchat`,
api: `${config.indexerAPI}/v1/zkchat`,
});
sse.on('NEW_CHAT_MESSAGE', async (payload: any) => {
const message = await zkchat.inflateMessage(payload);
zkchat.prependMessage(message);
const message = await zkchat.inflateMessage(payload);
zkchat.prependMessage(message);
});
const onNewMessage = (message: ChatMessage) => {
const {
receiver,
sender,
} = message;
const chatId = zkchat.deriveChatId({
type: 'DIRECT',
receiver: '',
receiverECDH: receiver.ecdh!,
senderECDH: sender.ecdh!,
});
store.dispatch(setMessage(message));
store.dispatch(setMessagesForChat(chatId, zkchat.activeChats[chatId].messages));
const { receiver, sender } = message;
const chatId = zkchat.deriveChatId({
type: 'DIRECT',
receiver: '',
receiverECDH: receiver.ecdh!,
senderECDH: sender.ecdh!,
});
store.dispatch(setMessage(message));
store.dispatch(setMessagesForChat(chatId, zkchat.activeChats[chatId].messages));
};
zkchat.on(EVENTS.MESSAGE_APPENDED, onNewMessage);
zkchat.on(EVENTS.MESSAGE_PREPENDED, onNewMessage);
zkchat.on(EVENTS.CHAT_CREATED, (chat: Chat) => {
store.dispatch(addChat(chat));
store.dispatch(addChat(chat));
});
enum ActionTypes {
SET_CHATS = 'chats/setChats',
SET_MESSAGES_FOR_CHAT = 'chats/setMessagesForChat',
ADD_CHAT = 'chats/addChat',
SET_CHAT_NICKNAME = 'chats/setChatNickname',
SET_MESSAGE = 'chats/SET_MESSAGE',
SET_CHATS = 'chats/setChats',
SET_MESSAGES_FOR_CHAT = 'chats/setMessagesForChat',
ADD_CHAT = 'chats/addChat',
SET_CHAT_NICKNAME = 'chats/setChatNickname',
SET_MESSAGE = 'chats/SET_MESSAGE',
}
type Action<payload> = {
type: ActionTypes;
payload?: payload;
meta?: any;
error?: boolean;
}
type: ActionTypes;
payload?: payload;
meta?: any;
error?: boolean;
};
type State = {
chats: {
order: string[];
map: {
[chatId: string]: InflatedChat;
};
chats: {
order: string[];
map: {
[chatId: string]: InflatedChat;
};
messages: {
[messageId: string]: ChatMessage;
};
}
};
messages: {
[messageId: string]: ChatMessage;
};
};
export type InflatedChat = Chat & {
messages: string[];
nickname?: string;
}
messages: string[];
nickname?: string;
};
const initialState: State = {
chats: {
order: [],
map: {},
},
messages: {},
chats: {
order: [],
map: {},
},
messages: {},
};
export const setChats = (chats: {
[chatId: string]: Chat;
[chatId: string]: Chat;
}): Action<{
[chatId: string]: Chat;
[chatId: string]: Chat;
}> => ({
type: ActionTypes.SET_CHATS,
payload: chats,
type: ActionTypes.SET_CHATS,
payload: chats,
});
const addChat = (chat: Chat): Action<Chat> => ({
type: ActionTypes.ADD_CHAT,
payload: chat,
type: ActionTypes.ADD_CHAT,
payload: chat,
});
const setNickname = (chat: Chat, nickname: string): Action<{
chat: Chat,
nickname: string,
const setNickname = (
chat: Chat,
nickname: string
): Action<{
chat: Chat;
nickname: string;
}> => ({
type: ActionTypes.SET_CHAT_NICKNAME,
payload: {chat, nickname},
type: ActionTypes.SET_CHAT_NICKNAME,
payload: { chat, nickname },
});
const setMessage = (msg: ChatMessage): Action<ChatMessage> => ({
type: ActionTypes.SET_MESSAGE,
payload: msg,
type: ActionTypes.SET_MESSAGE,
payload: msg,
});
const setMessagesForChat = (chatId: string, messages: string[]): Action<{ chatId: string; messages: string[] }> => ({
type: ActionTypes.SET_MESSAGES_FOR_CHAT,
payload: { chatId, messages },
const setMessagesForChat = (
chatId: string,
messages: string[]
): Action<{ chatId: string; messages: string[] }> => ({
type: ActionTypes.SET_MESSAGES_FOR_CHAT,
payload: { chatId, messages },
});
export const fetchChats = (address: string) => async (dispatch: Dispatch, getState: () => AppRootState) => {
export const fetchChats =
(address: string) => async (dispatch: Dispatch, getState: () => AppRootState) => {
await zkchat.fetchActiveChats(address);
dispatch(setChats(zkchat.activeChats));
}
};
export default function chats(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionTypes.SET_CHATS:
return handleSetChats(state, action);
case ActionTypes.ADD_CHAT:
return handeAddChat(state, action);
case ActionTypes.SET_MESSAGES_FOR_CHAT:
return handleSetMessagesForChats(state, action);
case ActionTypes.SET_CHAT_NICKNAME:
return handeSetNickname(state, action);
case ActionTypes.SET_MESSAGE:
return {
...state,
messages: {
...state.messages,
[action.payload.messageId]: action.payload,
},
};
default:
return state;
}
switch (action.type) {
case ActionTypes.SET_CHATS:
return handleSetChats(state, action);
case ActionTypes.ADD_CHAT:
return handeAddChat(state, action);
case ActionTypes.SET_MESSAGES_FOR_CHAT:
return handleSetMessagesForChats(state, action);
case ActionTypes.SET_CHAT_NICKNAME:
return handeSetNickname(state, action);
case ActionTypes.SET_MESSAGE:
return {
...state,
messages: {
...state.messages,
[action.payload.messageId]: action.payload,
},
};
default:
return state;
}
}
function handeSetNickname(state: State, action: Action<{ chat: Chat, nickname: string }>) {
const chatId = zkchat.deriveChatId(action.payload!.chat);
const chats = state.chats;
const { map } = chats;
const chat = map[chatId];
function handeSetNickname(state: State, action: Action<{ chat: Chat; nickname: string }>) {
const chatId = zkchat.deriveChatId(action.payload!.chat);
const chats = state.chats;
const { map } = chats;
const chat = map[chatId];
if (!chat || chat.nickname === action.payload!.nickname) {
return state;
}
if (!chat || chat.nickname === action.payload!.nickname) {
return state;
}
return {
...state,
chats: {
...chats,
map: {
...map,
[chatId]: {
...chat,
nickname: action.payload!.nickname,
},
},
return {
...state,
chats: {
...chats,
map: {
...map,
[chatId]: {
...chat,
nickname: action.payload!.nickname,
},
}
},
},
};
}
function handeAddChat(state: State, action: Action<Chat>) {
const chatId = zkchat.deriveChatId(action.payload!);
const chats = state.chats;
const chatId = zkchat.deriveChatId(action.payload!);
const chats = state.chats;
const { order, map } = chats;
const { order, map } = chats;
if (map[chatId]) {
return state;
}
if (map[chatId]) {
return state;
}
const newOrder = [...order, chatId];
const newMap = {
...map,
[chatId]: {
...action.payload!,
messages: [],
},
};
const newOrder = [...order, chatId];
const newMap = {
...map,
[chatId]: {
...action.payload!,
messages: [],
},
};
return {
...state,
chats: {
order: newOrder,
map: newMap,
},
}
return {
...state,
chats: {
order: newOrder,
map: newMap,
},
};
}
function handleSetChats(state: State, action: Action<{
function handleSetChats(
state: State,
action: Action<{
[chatId: string]: Chat & {
messages: string[];
};
}>) {
const chats = action.payload!;
const order = Object.keys(chats);
const map = {
...chats,
messages: string[];
};
}>
) {
const chats = action.payload!;
const order = Object.keys(chats);
const map = {
...chats,
};
return {
...state,
chats: {
order,
map,
},
}
return {
...state,
chats: {
order,
map,
},
};
}
function handleSetMessagesForChats(state: State, action: Action<{
function handleSetMessagesForChats(
state: State,
action: Action<{
chatId: string;
messages: string[]
}>) {
const { chatId, messages } = action.payload!;
const chat = state.chats.map[chatId];
messages: string[];
}>
) {
const { chatId, messages } = action.payload!;
const chat = state.chats.map[chatId];
if (!chat) return state;
if (!chat) return state;
return {
...state,
chats: {
...state.chats,
map: {
...state.chats.map,
[chatId]: {
...chat,
messages: messages,
}
}
return {
...state,
chats: {
...state.chats,
map: {
...state.chats.map,
[chatId]: {
...chat,
messages: messages,
},
}
},
},
};
}
export const useChatIds = () => {
return useSelector((state: AppRootState) => {
return state.chats.chats.order;
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return state.chats.chats.order;
}, deepEqual);
};
export const useChatId = (chatId: string) => {
return useSelector((state: AppRootState) => {
const chat = state.chats.chats.map[chatId];
return useSelector((state: AppRootState) => {
const chat = state.chats.chats.map[chatId];
if (!chat) return;
if (!chat) return;
const {
messages,
nickname,
...rest
} = chat;
const { messages, nickname, ...rest } = chat;
return rest;
}, deepEqual);
}
return rest;
}, deepEqual);
};
export const useMessagesByChatId = (chatId: string) => {
return useSelector((state: AppRootState) => {
return state.chats.chats.map[chatId]?.messages || [];
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return state.chats.chats.map[chatId]?.messages || [];
}, deepEqual);
};
export const useLastNMessages = (chatId: string, n = 1): ChatMessage[] => {
return useSelector((state: AppRootState) => {
const {
chats: {
chats: {
map,
},
messages,
}
} = state;
const ids = state.chats.chats.map[chatId]?.messages || [];
return ids.slice(0, n).map(messageId => messages[messageId]);
}, deepEqual);
}
return useSelector((state: AppRootState) => {
const {
chats: {
chats: { map },
messages,
},
} = state;
const ids = state.chats.chats.map[chatId]?.messages || [];
return ids.slice(0, n).map(messageId => messages[messageId]);
}, deepEqual);
};
export const useChatMessage = (messageId: string) => {
return useSelector((state: AppRootState) => {
return state.chats.messages[messageId];
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return state.chats.messages[messageId];
}, deepEqual);
};

View File

@@ -1,188 +1,209 @@
import {ducks, fetchReset, fetchStub, store} from "../util/testUtils";
import {createEditorStateWithText} from "@draft-js-plugins/editor";
import {ZkIdentity} from "@zk-kit/identity";
import { ducks, fetchReset, fetchStub, store } from '../util/testUtils';
import { createEditorStateWithText } from '@draft-js-plugins/editor';
import { ZkIdentity } from '@zk-kit/identity';
const {
setMirror,
submitConnection,
submitModeration,
submitPost,
submitProfile,
submitRepost,
setDraft,
setMirror,
submitConnection,
submitModeration,
submitPost,
submitProfile,
submitRepost,
setDraft,
} = ducks.drafts;
const {
setSelectedId,
} = ducks.worker;
const { setSelectedId } = ducks.worker;
describe('Drafts Duck', () => {
it('should initialize state', () => {
expect(store.getState().drafts).toStrictEqual({ map: {}, mirror: false, submitting: false });
});
it('should initialize state', () => {
expect(store.getState().drafts).toStrictEqual({ map: {}, mirror: false, submitting: false });
});
it('should submit post', async () => {
store.dispatch(setDraft({
reference: '',
editorState: createEditorStateWithText('hello world!'),
}));
store.dispatch(setMirror(true));
// @ts-ignore
const post: any = await store.dispatch(submitPost(''));
expect(post.payload).toStrictEqual({
"attachment": "",
"content": "hello world!",
"reference": "",
"title": "",
"topic": "ok",
});
expect(fetchStub.args[0]).toStrictEqual(["http://127.0.0.1:3000/twitter/update", {"body": "{\"status\":\"hello world!\"}", "credentials": "include", "headers": {"Content-Type": "application/json"}, "method": "POST"}]);
fetchReset();
it('should submit post', async () => {
store.dispatch(
setDraft({
reference: '',
editorState: createEditorStateWithText('hello world!'),
})
);
store.dispatch(setMirror(true));
// @ts-ignore
const post: any = await store.dispatch(submitPost(''));
expect(post.payload).toStrictEqual({
attachment: '',
content: 'hello world!',
reference: '',
title: '',
topic: 'ok',
});
expect(fetchStub.args[0]).toStrictEqual([
'http://127.0.0.1:3000/twitter/update',
{
body: '{"status":"hello world!"}',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
},
]);
fetchReset();
});
it('should submit semaphore post', async () => {
const zkIdentity = new ZkIdentity();
const identityCommitment = '0x' + zkIdentity.genIdentityCommitment().toString(16);
store.dispatch(setSelectedId({
type: 'interrep',
address: '0xmyaddress',
nonce: 0,
provider: 'autism',
name: 'diamond',
identityPath: {
path_elements: ['0x0', '0x1'],
path_index: [0, 1],
root: '0xroothash',
it('should submit semaphore post', async () => {
const zkIdentity = new ZkIdentity();
const identityCommitment = '0x' + zkIdentity.genIdentityCommitment().toString(16);
store.dispatch(
setSelectedId({
type: 'interrep',
address: '0xmyaddress',
nonce: 0,
provider: 'autism',
name: 'diamond',
identityPath: {
path_elements: ['0x0', '0x1'],
path_index: [0, 1],
root: '0xroothash',
},
identityCommitment,
serializedIdentity: zkIdentity.serializeIdentity(),
})
);
store.dispatch(
setDraft({
reference: '0xparenthash',
editorState: createEditorStateWithText('reply world!'),
})
);
store.dispatch(setMirror(false));
// @ts-ignore
fetchStub.returns(
Promise.resolve({
status: 200,
json: async () => ({
payload: {
data: {
siblings: ['0x00', '0x01'],
pathIndices: [0, 1],
root: '0x00',
},
identityCommitment,
serializedIdentity: zkIdentity.serializeIdentity(),
}))
store.dispatch(setDraft({
reference: '0xparenthash',
editorState: createEditorStateWithText('reply world!'),
}));
store.dispatch(setMirror(false));
// @ts-ignore
fetchStub.returns(Promise.resolve({
status: 200,
json: async () => ({
payload: {
data: {
siblings: ['0x00', '0x01'],
pathIndices: [0, 1],
root: '0x00',
},
name: '',
provider: '',
},
}),
}));
// @ts-ignore
const post: any = await store.dispatch(submitPost('0xparenthash'));
expect(post.payload).toStrictEqual({
"attachment": "",
"content": "reply world!",
"reference": "0xparenthash",
"title": "",
"topic": "",
});
expect(fetchStub.args[0][0]).toBe(`http://127.0.0.1:3000/interrep/${identityCommitment}`);
fetchReset();
name: '',
provider: '',
},
}),
})
);
// @ts-ignore
const post: any = await store.dispatch(submitPost('0xparenthash'));
expect(post.payload).toStrictEqual({
attachment: '',
content: 'reply world!',
reference: '0xparenthash',
title: '',
topic: '',
});
expect(fetchStub.args[0][0]).toBe(`http://127.0.0.1:3000/interrep/${identityCommitment}`);
fetchReset();
});
it('should submit zkpr semaphore post', async () => {
const zkIdentity = new ZkIdentity();
const identityCommitment = '0x' + zkIdentity.genIdentityCommitment().toString(16);
store.dispatch(setSelectedId({
type: 'zkpr_interrep',
provider: 'autism',
name: 'diamond',
identityPath: {
path_elements: ['0x0', '0x1'],
path_index: [0, 1],
root: '0xroothash',
it('should submit zkpr semaphore post', async () => {
const zkIdentity = new ZkIdentity();
const identityCommitment = '0x' + zkIdentity.genIdentityCommitment().toString(16);
store.dispatch(
setSelectedId({
type: 'zkpr_interrep',
provider: 'autism',
name: 'diamond',
identityPath: {
path_elements: ['0x0', '0x1'],
path_index: [0, 1],
root: '0xroothash',
},
identityCommitment,
})
);
store.dispatch(
setDraft({
reference: '0xparenthash1',
editorState: createEditorStateWithText('reply world!!'),
})
);
store.dispatch(setMirror(false));
// @ts-ignore
fetchStub.returns(
Promise.resolve({
status: 200,
json: async () => ({
payload: {
data: {
siblings: ['0x00', '0x01'],
pathIndices: [0, 1],
root: '0x00',
},
identityCommitment,
}))
store.dispatch(setDraft({
reference: '0xparenthash1',
editorState: createEditorStateWithText('reply world!!'),
}));
store.dispatch(setMirror(false));
// @ts-ignore
fetchStub.returns(Promise.resolve({
status: 200,
json: async () => ({
payload: {
data: {
siblings: ['0x00', '0x01'],
pathIndices: [0, 1],
root: '0x00',
},
name: '',
provider: '',
},
}),
}));
// @ts-ignore
const post: any = await store.dispatch(submitPost('0xparenthash1'));
expect(post.payload).toStrictEqual({
"attachment": "",
"content": "reply world!!",
"reference": "0xparenthash1",
"title": "",
"topic": "",
});
expect(fetchStub.args[0][0]).toBe(`http://127.0.0.1:3000/interrep/${identityCommitment}`);
fetchReset();
store.dispatch(setSelectedId({
type: 'gun',
address: '0xmyaddress',
nonce: 0,
publicKey: '0xpubkey',
privateKey: '0xprivkey',
}));
name: '',
provider: '',
},
}),
})
);
// @ts-ignore
const post: any = await store.dispatch(submitPost('0xparenthash1'));
expect(post.payload).toStrictEqual({
attachment: '',
content: 'reply world!!',
reference: '0xparenthash1',
title: '',
topic: '',
});
expect(fetchStub.args[0][0]).toBe(`http://127.0.0.1:3000/interrep/${identityCommitment}`);
fetchReset();
store.dispatch(
setSelectedId({
type: 'gun',
address: '0xmyaddress',
nonce: 0,
publicKey: '0xpubkey',
privateKey: '0xprivkey',
})
);
});
it('should submit repost', async () => {
// @ts-ignore
const post: any = await store.dispatch(submitRepost('0xreposthash'));
expect(post.subtype).toBe('REPOST');
expect(post.payload).toStrictEqual({
"attachment": "",
"content": "",
"reference": "0xreposthash",
"title": "",
"topic": "",
});
it('should submit repost', async () => {
// @ts-ignore
const post: any = await store.dispatch(submitRepost('0xreposthash'));
expect(post.subtype).toBe('REPOST');
expect(post.payload).toStrictEqual({
attachment: '',
content: '',
reference: '0xreposthash',
title: '',
topic: '',
});
});
it('should submit moderation', async () => {
// @ts-ignore
const mod: any = await store.dispatch(submitModeration('0xmodhash', 'LIKE'));
expect(mod.subtype).toBe('LIKE');
expect(mod.payload).toStrictEqual({
"reference": "0xmodhash",
});
it('should submit moderation', async () => {
// @ts-ignore
const mod: any = await store.dispatch(submitModeration('0xmodhash', 'LIKE'));
expect(mod.subtype).toBe('LIKE');
expect(mod.payload).toStrictEqual({
reference: '0xmodhash',
});
});
it('should submit connection', async () => {
// @ts-ignore
const conn: any = await store.dispatch(submitConnection('0xotheruser', 'BLOCK'));
expect(conn.subtype).toBe('BLOCK');
expect(conn.payload).toStrictEqual({
"name": "0xotheruser",
});
it('should submit connection', async () => {
// @ts-ignore
const conn: any = await store.dispatch(submitConnection('0xotheruser', 'BLOCK'));
expect(conn.subtype).toBe('BLOCK');
expect(conn.payload).toStrictEqual({
name: '0xotheruser',
});
});
it('should submit profile', async () => {
// @ts-ignore
const pf: any = await store.dispatch(submitProfile('PROFILE_IMAGE', 'image'));
expect(pf.subtype).toBe('PROFILE_IMAGE');
expect(pf.payload).toStrictEqual({
key: "",
value: "image",
});
it('should submit profile', async () => {
// @ts-ignore
const pf: any = await store.dispatch(submitProfile('PROFILE_IMAGE', 'image'));
expect(pf.subtype).toBe('PROFILE_IMAGE');
expect(pf.payload).toStrictEqual({
key: '',
value: 'image',
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,64 +1,67 @@
import {useSelector} from "react-redux";
import {AppRootState} from "../store/configureAppStore";
import deepEqual from "fast-deep-equal";
import { useSelector } from 'react-redux';
import { AppRootState } from '../store/configureAppStore';
import deepEqual from 'fast-deep-equal';
enum ActionTypes {
UNMODERATE = 'mods/unmoderate',
UNMODERATE = 'mods/unmoderate',
}
type Action<payload> = {
type: ActionTypes;
payload?: payload;
meta?: any;
error?: boolean;
}
type ModSetting = {
unmoderated: boolean;
}
type State = {
posts: {
[messageId: string]: ModSetting;
};
}
const initialState: State = {
posts: {},
type: ActionTypes;
payload?: payload;
meta?: any;
error?: boolean;
};
export const unmoderate = (messageId: string, unmoderated: boolean): Action<{
messageId: string;
unmoderated: boolean;
type ModSetting = {
unmoderated: boolean;
};
type State = {
posts: {
[messageId: string]: ModSetting;
};
};
const initialState: State = {
posts: {},
};
export const unmoderate = (
messageId: string,
unmoderated: boolean
): Action<{
messageId: string;
unmoderated: boolean;
}> => ({
type: ActionTypes.UNMODERATE,
payload: {
messageId,
unmoderated,
},
type: ActionTypes.UNMODERATE,
payload: {
messageId,
unmoderated,
},
});
export default function mods(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionTypes.UNMODERATE:
return {
...state,
posts: {
...state.posts,
[action.payload.messageId]: {
...state.posts[action.payload.messageId],
unmoderated: action.payload.unmoderated,
}
}
}
default:
return state;
}
switch (action.type) {
case ActionTypes.UNMODERATE:
return {
...state,
posts: {
...state.posts,
[action.payload.messageId]: {
...state.posts[action.payload.messageId],
unmoderated: action.payload.unmoderated,
},
},
};
default:
return state;
}
}
export const usePostModeration = (messageId?: string | null): ModSetting | null => {
return useSelector((state: AppRootState) => {
if (!messageId) return null;
return state.mods.posts[messageId] || null;
}, deepEqual);
}
return useSelector((state: AppRootState) => {
if (!messageId) return null;
return state.mods.posts[messageId] || null;
}, deepEqual);
};

View File

@@ -1,251 +1,360 @@
import {ducks, fetchStub, gunStub, store} from '../util/testUtils';
import {MessageType, Post, PostMessageSubType} from "../util/message";
import { ducks, fetchStub, gunStub, store } from '../util/testUtils';
import { MessageType, Post, PostMessageSubType } from '../util/message';
const {
posts: {
fetchPosts,
fetchMeta,
fetchPost,
fetchReplies,
fetchTagFeed,
fetchHomeFeed,
fetchLikedBy,
fetchRepliedBy,
setPost,
setLiked,
setReposted,
setBlockedPost,
incrementRepost,
incrementReply,
incrementLike,
decrementRepost,
decrementLike,
unsetPost,
}
posts: {
fetchPosts,
fetchMeta,
fetchPost,
fetchReplies,
fetchTagFeed,
fetchHomeFeed,
fetchLikedBy,
fetchRepliedBy,
setPost,
setLiked,
setReposted,
setBlockedPost,
incrementRepost,
incrementReply,
incrementLike,
decrementRepost,
decrementLike,
unsetPost,
},
} = ducks;
describe('Posts Duck', () => {
it('should return initial state', async () => {
expect(store.getState().posts).toStrictEqual({ map: {}, meta: {}});
it('should return initial state', async () => {
expect(store.getState().posts).toStrictEqual({ map: {}, meta: {} });
});
it('should fetch meta', async () => {
const messageId = '0xmeta/0000000000000000000000000000000000000000000000000000000000000000';
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: {
messageId: messageId,
meta: {
likeCounts: 11,
},
},
}),
})
);
// @ts-ignore
await store.dispatch(fetchMeta(messageId));
expect(fetchStub.args[0][0]).toBe(
'http://127.0.0.1:3000/v1/post/0000000000000000000000000000000000000000000000000000000000000000'
);
expect(store.getState().posts.meta[messageId]).toStrictEqual({ likeCounts: 11 });
fetchStub.reset();
});
it('should fetch post', async () => {
const messageId = 'e6789d8ea65b57efd365ada389924246e4bd7be9c109a7fe294646831f67db8b';
gunStub.get
.withArgs('message/e6789d8ea65b57efd365ada389924246e4bd7be9c109a7fe294646831f67db8b')
// @ts-ignore
.returns(
Promise.resolve({
type: 'POST',
subtype: 'REPLY',
createdAt: 1,
payload: {
'#': 'payload',
},
})
)
// @ts-ignore
.withArgs('payload')
.returns(Promise.resolve({ content: 'fetch post' }));
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: {
name: '',
},
}),
})
);
// @ts-ignore
await store.dispatch(fetchPost(messageId));
expect(store.getState().posts.map[messageId].hash()).toBe(messageId);
fetchStub.reset();
});
it('should fetch posts', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: [
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: { content: 'test 1' },
createdAt: 1,
meta: { likeCount: 12 },
},
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: { content: 'test 2' },
createdAt: 1,
meta: { likeCount: 13 },
},
],
}),
})
);
// @ts-ignore
await store.dispatch(fetchPosts());
const map = store.getState().posts.map;
expect(fetchStub.args[0][0]).toBe('http://127.0.0.1:3000/v1/posts?limit=10&offset=0');
expect(
map['0657166868848f6b37debc5833112be36266dbfc8ec4f45c235372306fdfe965'].payload.content
).toBe('test 1');
expect(
map['5775c1181556da4a0f1a1f378006ec71a131f3cd5f759bc852df97748b1d9935'].payload.content
).toBe('test 2');
fetchStub.reset();
});
it('should fetch liked by', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: [
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Reply,
payload: { content: 'like 1' },
createdAt: 1,
meta: { likeCount: 17 },
},
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.MirrorPost,
payload: { content: 'like 2' },
createdAt: 1,
meta: { likeCount: 17 },
},
],
}),
})
);
// @ts-ignore
await store.dispatch(fetchLikedBy('0xuser'));
const map = store.getState().posts.map;
expect(fetchStub.args[0][0]).toBe('http://127.0.0.1:3000/v1/0xuser/likes?limit=10&offset=0');
expect(
map['67b5f4334815476783316977c9608633219705a3eceb00c39a88ce173705f67f'].payload.content
).toBe('like 1');
expect(
map['20d0bcb1b075de4e49d0adb7efd10becbf547ce2c46a2ddf01d48c83964fdfc5'].payload.content
).toBe('like 2');
fetchStub.reset();
});
it('should fetch replied by', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: [
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Reply,
payload: { content: 'reply 1' },
createdAt: 1,
meta: { likeCount: 17 },
},
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.MirrorReply,
payload: { content: 'reply 2' },
createdAt: 1,
meta: { likeCount: 17 },
},
],
}),
})
);
// @ts-ignore
await store.dispatch(fetchRepliedBy('0xuser'));
const map = store.getState().posts.map;
expect(fetchStub.args[0][0]).toBe('http://127.0.0.1:3000/v1/0xuser/replies?limit=10&offset=0');
expect(
map['86e976c49fcdb572e95bdb58ecb4c38b6a4e6e6d132fda8cb910c8a627a8de87'].payload.content
).toBe('reply 1');
expect(
map['6c190cc667449d4867391c2bf6c4c17839143b036e92ed04dd07379d01b9bf7e'].payload.content
).toBe('reply 2');
fetchStub.reset();
});
it('should fetch homefed', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: [
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: { content: 'homefeed 1' },
createdAt: 1,
meta: { likeCount: 17 },
},
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: { content: 'homefeed 2' },
createdAt: 1,
meta: { likeCount: 17 },
},
],
}),
})
);
// @ts-ignore
await store.dispatch(fetchHomeFeed());
const map = store.getState().posts.map;
expect(fetchStub.args[0][0]).toBe('http://127.0.0.1:3000/v1/homefeed?limit=10&offset=0');
expect(
map['04475753c11b8a9a1e9b8fd63d822e416c963b611ef7e09acf74583d4ca32f79'].payload.content
).toBe('homefeed 1');
expect(
map['b5ccff8a5d242605599e182a29c08932d99bda45b8d0b149c8ddee19bb2ba6bf'].payload.content
).toBe('homefeed 2');
fetchStub.reset();
});
it('should fetch tagfeed', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: [
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: { content: '#tagfeed 1' },
createdAt: 1,
meta: { likeCount: 17 },
},
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: { content: '#tagfeed 2' },
createdAt: 1,
meta: { likeCount: 17 },
},
],
}),
})
);
// @ts-ignore
await store.dispatch(fetchTagFeed('#tagfeed'));
const map = store.getState().posts.map;
expect(fetchStub.args[0][0]).toBe('http://127.0.0.1:3000/v1/tags/%23tagfeed?limit=10&offset=0');
expect(
map['0a8a52534ffaf357dd35061f127fa3c2628e29f4e0a7939e0c503a467130128a'].payload.content
).toBe('#tagfeed 1');
expect(
map['b30fa8424d3db0889ddb39d968f5a8b7df4b4b847b0ed1e5df1f74f834cba463'].payload.content
).toBe('#tagfeed 2');
fetchStub.reset();
});
it('should fetch replies', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: [
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: { content: 'replies 3' },
createdAt: 1,
meta: { likeCount: 17 },
},
{
messageId: '0000000000000000000000000000000000000000000000000000000000000000',
type: MessageType.Post,
subtype: PostMessageSubType.Default,
payload: { content: 'replies 4' },
createdAt: 1,
meta: { likeCount: 17 },
},
],
}),
})
);
// @ts-ignore
await store.dispatch(fetchReplies('0xparent'));
const map = store.getState().posts.map;
expect(fetchStub.args[0][0]).toBe(
'http://127.0.0.1:3000/v1/replies?limit=10&offset=0&parent=0xparent'
);
expect(
map['4a524cdf0239a6fc465ada54fa1d0b568c186ca03054607b7463f16c14c56f2d'].payload.content
).toBe('replies 3');
expect(
map['997c84ed80660c89403b530b2e3fc8a8af061de6609415375ccbc4adb34f12e3'].payload.content
).toBe('replies 4');
fetchStub.reset();
});
it('should handle meta', async () => {
await store.dispatch(incrementLike('0xtestmeta'));
await store.dispatch(incrementReply('0xtestmeta'));
await store.dispatch(incrementRepost('0xtestmeta'));
await store.dispatch(setLiked('0xtestmeta', '0xlikedtestmeta'));
await store.dispatch(setReposted('0xtestmeta', '0xrepostedtestmeta'));
await store.dispatch(setBlockedPost('0xtestmeta', '0xblockedtestmeta'));
expect(store.getState().posts.meta['0xtestmeta']).toStrictEqual({
likeCount: 1,
replyCount: 1,
repostCount: 1,
liked: '0xlikedtestmeta',
reposted: '0xrepostedtestmeta',
blocked: '0xblockedtestmeta',
});
it('should fetch meta', async () => {
const messageId = '0xmeta/0000000000000000000000000000000000000000000000000000000000000000';
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: {
messageId: messageId,
meta: {
likeCounts: 11,
},
},
}),
}))
// @ts-ignore
await store.dispatch(fetchMeta(messageId));
expect(fetchStub.args[0][0])
.toBe('http://127.0.0.1:3000/v1/post/0000000000000000000000000000000000000000000000000000000000000000');
expect(store.getState().posts.meta[messageId])
.toStrictEqual({likeCounts: 11});
fetchStub.reset();
await store.dispatch(decrementLike('0xtestmeta'));
await store.dispatch(decrementRepost('0xtestmeta'));
await store.dispatch(setLiked('0xtestmeta', null));
await store.dispatch(setReposted('0xtestmeta', null));
await store.dispatch(setBlockedPost('0xtestmeta', null));
expect(store.getState().posts.meta['0xtestmeta']).toStrictEqual({
likeCount: 0,
replyCount: 1,
repostCount: 0,
liked: null,
reposted: null,
blocked: null,
});
it('should fetch post', async () => {
const messageId = 'e6789d8ea65b57efd365ada389924246e4bd7be9c109a7fe294646831f67db8b';
gunStub.get.withArgs('message/e6789d8ea65b57efd365ada389924246e4bd7be9c109a7fe294646831f67db8b')
// @ts-ignore
.returns(Promise.resolve({
type: 'POST',
'subtype': 'REPLY',
createdAt: 1,
payload: {
'#': 'payload',
},
}))
// @ts-ignore
.withArgs('payload').returns(Promise.resolve({ content: 'fetch post' }));
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: {
name: '',
},
})
}));
// @ts-ignore
await store.dispatch(fetchPost(messageId));
expect(store.getState().posts.map[messageId].hash())
.toBe(messageId);
fetchStub.reset();
});
it('should fetch posts', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: [
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Default, payload: { content: 'test 1' }, createdAt: 1, meta: { likeCount: 12 } },
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Default, payload: { content: 'test 2' }, createdAt: 1, meta: { likeCount: 13 } },
],
})
}));
// @ts-ignore
await store.dispatch(fetchPosts());
const map = store.getState().posts.map;
expect(fetchStub.args[0][0])
.toBe('http://127.0.0.1:3000/v1/posts?limit=10&offset=0');
expect(map['0657166868848f6b37debc5833112be36266dbfc8ec4f45c235372306fdfe965'].payload.content)
.toBe('test 1');
expect(map['5775c1181556da4a0f1a1f378006ec71a131f3cd5f759bc852df97748b1d9935'].payload.content)
.toBe('test 2');
fetchStub.reset();
});
it('should fetch liked by', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: [
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Reply, payload: { content: 'like 1' }, createdAt: 1, meta: { likeCount: 17 } },
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.MirrorPost, payload: { content: 'like 2' }, createdAt: 1, meta: { likeCount: 17 } },
],
})
}));
// @ts-ignore
await store.dispatch(fetchLikedBy('0xuser'));
const map = store.getState().posts.map;
expect(fetchStub.args[0][0])
.toBe('http://127.0.0.1:3000/v1/0xuser/likes?limit=10&offset=0');
expect(map['67b5f4334815476783316977c9608633219705a3eceb00c39a88ce173705f67f'].payload.content)
.toBe('like 1');
expect(map['20d0bcb1b075de4e49d0adb7efd10becbf547ce2c46a2ddf01d48c83964fdfc5'].payload.content)
.toBe('like 2');
fetchStub.reset();
});
it('should fetch replied by', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: [
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Reply, payload: { content: 'reply 1' }, createdAt: 1, meta: { likeCount: 17 } },
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.MirrorReply, payload: { content: 'reply 2' }, createdAt: 1, meta: { likeCount: 17 } },
],
})
}));
// @ts-ignore
await store.dispatch(fetchRepliedBy('0xuser'));
const map = store.getState().posts.map;
expect(fetchStub.args[0][0])
.toBe('http://127.0.0.1:3000/v1/0xuser/replies?limit=10&offset=0');
expect(map['86e976c49fcdb572e95bdb58ecb4c38b6a4e6e6d132fda8cb910c8a627a8de87'].payload.content)
.toBe('reply 1');
expect(map['6c190cc667449d4867391c2bf6c4c17839143b036e92ed04dd07379d01b9bf7e'].payload.content)
.toBe('reply 2');
fetchStub.reset();
});
it('should fetch homefed', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: [
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Default, payload: { content: 'homefeed 1' }, createdAt: 1, meta: { likeCount: 17 } },
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Default, payload: { content: 'homefeed 2' }, createdAt: 1, meta: { likeCount: 17 } },
],
})
}));
// @ts-ignore
await store.dispatch(fetchHomeFeed());
const map = store.getState().posts.map;
expect(fetchStub.args[0][0])
.toBe('http://127.0.0.1:3000/v1/homefeed?limit=10&offset=0');
expect(map['04475753c11b8a9a1e9b8fd63d822e416c963b611ef7e09acf74583d4ca32f79'].payload.content)
.toBe('homefeed 1');
expect(map['b5ccff8a5d242605599e182a29c08932d99bda45b8d0b149c8ddee19bb2ba6bf'].payload.content)
.toBe('homefeed 2');
fetchStub.reset();
});
it('should fetch tagfeed', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: [
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Default, payload: { content: '#tagfeed 1' }, createdAt: 1, meta: { likeCount: 17 } },
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Default, payload: { content: '#tagfeed 2' }, createdAt: 1, meta: { likeCount: 17 } },
],
})
}));
// @ts-ignore
await store.dispatch(fetchTagFeed('#tagfeed'));
const map = store.getState().posts.map;
expect(fetchStub.args[0][0])
.toBe('http://127.0.0.1:3000/v1/tags/%23tagfeed?limit=10&offset=0');
expect(map['0a8a52534ffaf357dd35061f127fa3c2628e29f4e0a7939e0c503a467130128a'].payload.content)
.toBe('#tagfeed 1');
expect(map['b30fa8424d3db0889ddb39d968f5a8b7df4b4b847b0ed1e5df1f74f834cba463'].payload.content)
.toBe('#tagfeed 2');
fetchStub.reset();
});
it('should fetch replies', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: [
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Default, payload: { content: 'replies 3' }, createdAt: 1, meta: { likeCount: 17 } },
{ messageId: '0000000000000000000000000000000000000000000000000000000000000000', type: MessageType.Post, subtype: PostMessageSubType.Default, payload: { content: 'replies 4' }, createdAt: 1, meta: { likeCount: 17 } },
],
})
}));
// @ts-ignore
await store.dispatch(fetchReplies('0xparent'));
const map = store.getState().posts.map;
expect(fetchStub.args[0][0])
.toBe('http://127.0.0.1:3000/v1/replies?limit=10&offset=0&parent=0xparent');
expect(map['4a524cdf0239a6fc465ada54fa1d0b568c186ca03054607b7463f16c14c56f2d'].payload.content)
.toBe('replies 3');
expect(map['997c84ed80660c89403b530b2e3fc8a8af061de6609415375ccbc4adb34f12e3'].payload.content)
.toBe('replies 4');
fetchStub.reset();
});
it('should handle meta', async () => {
await store.dispatch(incrementLike('0xtestmeta'));
await store.dispatch(incrementReply('0xtestmeta'));
await store.dispatch(incrementRepost('0xtestmeta'));
await store.dispatch(setLiked('0xtestmeta', '0xlikedtestmeta'));
await store.dispatch(setReposted('0xtestmeta', '0xrepostedtestmeta'));
await store.dispatch(setBlockedPost('0xtestmeta', '0xblockedtestmeta'));
expect(store.getState().posts.meta['0xtestmeta'])
.toStrictEqual({
likeCount: 1,
replyCount: 1,
repostCount: 1,
liked: '0xlikedtestmeta',
reposted: '0xrepostedtestmeta',
blocked: '0xblockedtestmeta'
});
await store.dispatch(decrementLike('0xtestmeta'));
await store.dispatch(decrementRepost('0xtestmeta'));
await store.dispatch(setLiked('0xtestmeta', null));
await store.dispatch(setReposted('0xtestmeta', null));
await store.dispatch(setBlockedPost('0xtestmeta', null));
expect(store.getState().posts.meta['0xtestmeta'])
.toStrictEqual({
likeCount: 0,
replyCount: 1,
repostCount: 0,
liked: null,
reposted: null,
blocked: null,
});
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,126 +1,125 @@
import {store, ducks, fetchStub} from "../util/testUtils";
import { store, ducks, fetchStub } from '../util/testUtils';
const {
users: {
fetchAddressByName,
fetchUsers,
getUser,
resetUser,
searchUsers,
setFollowed,
},
users: { fetchAddressByName, fetchUsers, getUser, resetUser, searchUsers, setFollowed },
} = ducks;
describe('Users Duck', () => {
it('should initialize', async () => {
expect(store.getState().users).toStrictEqual({
map: {},
});
});
it('should initialize', async () => {
expect(store.getState().users).toStrictEqual({
map: {},
});
});
it('should fetch address by name', async () => {
// @ts-ignore
await store.dispatch(fetchAddressByName('yagamilight.eth'));
expect(store.getState().users.map['0xd44a82dD160217d46D754a03C8f841edF06EBE3c'])
.toStrictEqual({
ens: 'yagamilight.eth',
username: '0xd44a82dD160217d46D754a03C8f841edF06EBE3c',
address: '0xd44a82dD160217d46D754a03C8f841edF06EBE3c',
});
});
it('should fetch address by name', async () => {
// @ts-ignore
await store.dispatch(fetchAddressByName('yagamilight.eth'));
expect(store.getState().users.map['0xd44a82dD160217d46D754a03C8f841edF06EBE3c']).toStrictEqual({
ens: 'yagamilight.eth',
username: '0xd44a82dD160217d46D754a03C8f841edF06EBE3c',
address: '0xd44a82dD160217d46D754a03C8f841edF06EBE3c',
});
});
it('should get user', async () => {
const user = {
address: '0xd44a82dD160217d46D754a03C8f841edF06EBE3c',
ens: 'yagamilight.eth',
username: '0xd44a82dD160217d46D754a03C8f841edF06EBE3c',
name: 'yagami',
pubkey: 'pubkey',
bio: 'my bio',
profileImage: 'http://profile.image',
coverImage: 'http://cover.image',
twitterVerification: 'http://twitter/123',
website: '',
joinedAt: 1,
joinedTx: '0xjoinedtxhash',
type: 'arbitrum',
meta: {
followerCount: 2,
followingCount: 2,
blockedCount: 2,
blockingCount: 2,
postingCount: 2,
followed: 2,
blocked: 2,
},
};
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({ payload: user }),
}));
// @ts-ignore
await store.dispatch(getUser('yagamilight.eth'));
expect(store.getState().users.map['0xd44a82dD160217d46D754a03C8f841edF06EBE3c'])
.toStrictEqual(user);
fetchStub.reset();
});
it('should get user', async () => {
const user = {
address: '0xd44a82dD160217d46D754a03C8f841edF06EBE3c',
ens: 'yagamilight.eth',
username: '0xd44a82dD160217d46D754a03C8f841edF06EBE3c',
name: 'yagami',
pubkey: 'pubkey',
bio: 'my bio',
profileImage: 'http://profile.image',
coverImage: 'http://cover.image',
twitterVerification: 'http://twitter/123',
website: '',
joinedAt: 1,
joinedTx: '0xjoinedtxhash',
type: 'arbitrum',
meta: {
followerCount: 2,
followingCount: 2,
blockedCount: 2,
blockingCount: 2,
postingCount: 2,
followed: 2,
blocked: 2,
},
};
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({ payload: user }),
})
);
// @ts-ignore
await store.dispatch(getUser('yagamilight.eth'));
expect(store.getState().users.map['0xd44a82dD160217d46D754a03C8f841edF06EBE3c']).toStrictEqual(
user
);
fetchStub.reset();
});
it('should fetch users', async () => {
const user1 = {
address: '0x001',
ens: '0x001.eth',
username: '0x001',
};
const user2 = {
address: '0x002',
ens: '0x002.eth',
username: '0x002',
};
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({ payload: [user1, user2] }),
}));
// @ts-ignore
await store.dispatch(fetchUsers());
expect(store.getState().users.map['0x001'].ens).toBe('0x001.eth');
expect(store.getState().users.map['0x002'].ens).toBe('0x002.eth');
fetchStub.reset();
});
it('should fetch users', async () => {
const user1 = {
address: '0x001',
ens: '0x001.eth',
username: '0x001',
};
const user2 = {
address: '0x002',
ens: '0x002.eth',
username: '0x002',
};
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({ payload: [user1, user2] }),
})
);
// @ts-ignore
await store.dispatch(fetchUsers());
expect(store.getState().users.map['0x001'].ens).toBe('0x001.eth');
expect(store.getState().users.map['0x002'].ens).toBe('0x002.eth');
fetchStub.reset();
});
it('should search users', async () => {
const user3 = {
address: '0x003',
ens: '0x003.eth',
username: '0x003',
};
const user4 = {
address: '0x004',
ens: '0x004.eth',
username: '0x004',
};
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({ payload: [user3, user4] }),
}));
// @ts-ignore
const users: any = await store.dispatch(searchUsers('heyo'));
it('should search users', async () => {
const user3 = {
address: '0x003',
ens: '0x003.eth',
username: '0x003',
};
const user4 = {
address: '0x004',
ens: '0x004.eth',
username: '0x004',
};
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({ payload: [user3, user4] }),
})
);
// @ts-ignore
const users: any = await store.dispatch(searchUsers('heyo'));
expect(fetchStub.args[0][0]).toBe('http://127.0.0.1:3000/v1/users/search/heyo?limit=5');
expect(users[0].ens).toBe('0x003.eth');
expect(users[1].ens).toBe('0x004.eth');
expect(fetchStub.args[0][0]).toBe('http://127.0.0.1:3000/v1/users/search/heyo?limit=5');
expect(users[0].ens).toBe('0x003.eth');
expect(users[1].ens).toBe('0x004.eth');
fetchStub.reset();
});
fetchStub.reset();
});
it('should set followed', async () => {
// @ts-ignore
store.dispatch(setFollowed('0x002', '0xfollowhash'));
expect(store.getState().users.map['0x002'].meta.followed).toBe('0xfollowhash');
});
it('should set followed', async () => {
// @ts-ignore
store.dispatch(setFollowed('0x002', '0xfollowhash'));
expect(store.getState().users.map['0x002'].meta.followed).toBe('0xfollowhash');
});
it('should reset', async () => {
// @ts-ignore
store.dispatch(resetUser());
expect(store.getState().users.map).toStrictEqual({});
});
});
it('should reset', async () => {
// @ts-ignore
store.dispatch(resetUser());
expect(store.getState().users.map).toStrictEqual({});
});
});

View File

@@ -1,475 +1,506 @@
import {Dispatch} from "redux";
import {AppRootState} from "../store/configureAppStore";
import {useSelector} from "react-redux";
import deepEqual from "fast-deep-equal";
import config from "../util/config";
import {defaultENS, fetchNameByAddress, fetchAddressByName as _fetchAddressByName} from "../util/web3";
import {ThunkDispatch} from "redux-thunk";
import {setJoinedTx} from "./web3";
import {getContextNameFromState} from "./posts";
import { Dispatch } from 'redux';
import { AppRootState } from '../store/configureAppStore';
import { useSelector } from 'react-redux';
import deepEqual from 'fast-deep-equal';
import config from '../util/config';
import {
defaultENS,
fetchNameByAddress,
fetchAddressByName as _fetchAddressByName,
} from '../util/web3';
import { ThunkDispatch } from 'redux-thunk';
import { setJoinedTx } from './web3';
import { getContextNameFromState } from './posts';
enum ActionTypes {
SET_USER = 'users/setUser',
RESET_USERS = 'users/resetUsers',
SET_FOLLOWED = 'users/setFollowed',
SET_BLOCKED = 'users/setBlocked',
SET_USER_ADDRESS = 'users/setUserAddress',
SET_ECDH = 'users/setECDH',
SET_ID_COMMITMENT = 'users/setIdCommitment',
SET_ACCEPTANCE_SENT = 'users/setAcceptanceSent',
SET_USER = 'users/setUser',
RESET_USERS = 'users/resetUsers',
SET_FOLLOWED = 'users/setFollowed',
SET_BLOCKED = 'users/setBlocked',
SET_USER_ADDRESS = 'users/setUserAddress',
SET_ECDH = 'users/setECDH',
SET_ID_COMMITMENT = 'users/setIdCommitment',
SET_ACCEPTANCE_SENT = 'users/setAcceptanceSent',
}
type Action<payload> = {
type: ActionTypes;
payload?: payload;
meta?: any;
error?: boolean;
}
type: ActionTypes;
payload?: payload;
meta?: any;
error?: boolean;
};
export type User = {
username: string;
ens?: string;
name: string;
pubkey: string;
address: string;
coverImage: string;
profileImage: string;
twitterVerification: string;
bio: string;
website: string;
group: boolean;
ecdh: string;
idcommitment: string;
joinedAt: number;
joinedTx: string;
type: 'ens' | 'arbitrum' | '';
meta: {
blockedCount: number;
blockingCount: number;
followerCount: number;
followingCount: number;
postingCount: number;
followed: string | null;
blocked: string | null;
inviteSent: string | null;
acceptanceReceived: string | null;
inviteReceived: string | null;
acceptanceSent: string | null;
};
}
username: string;
ens?: string;
name: string;
pubkey: string;
address: string;
coverImage: string;
profileImage: string;
twitterVerification: string;
bio: string;
website: string;
group: boolean;
ecdh: string;
idcommitment: string;
joinedAt: number;
joinedTx: string;
type: 'ens' | 'arbitrum' | '';
meta: {
blockedCount: number;
blockingCount: number;
followerCount: number;
followingCount: number;
postingCount: number;
followed: string | null;
blocked: string | null;
inviteSent: string | null;
acceptanceReceived: string | null;
inviteReceived: string | null;
acceptanceSent: string | null;
};
};
type State = {
map: {
[name: string]: User;
};
}
map: {
[name: string]: User;
};
};
const initialState: State = {
map: {},
map: {},
};
let fetchPromises: any = {};
let cachedUser: any = {};
export const fetchAddressByName = (ens: string) => async (dispatch: Dispatch) => {
const address = await _fetchAddressByName(ens);
dispatch({
type: ActionTypes.SET_USER_ADDRESS,
payload: {
ens: ens,
address: address === '0x0000000000000000000000000000000000000000' ? '' : address,
},
});
return address;
}
const address = await _fetchAddressByName(ens);
dispatch({
type: ActionTypes.SET_USER_ADDRESS,
payload: {
ens: ens,
address: address === '0x0000000000000000000000000000000000000000' ? '' : address,
},
});
return address;
};
export const watchUser = (username: string) => async (dispatch: ThunkDispatch<any, any, any>) => {
return new Promise(async (resolve, reject) => {
_getUser();
return new Promise(async (resolve, reject) => {
_getUser();
async function _getUser() {
const user: any = await dispatch(getUser(username));
async function _getUser() {
const user: any = await dispatch(getUser(username));
if (!user?.joinedTx) {
setTimeout(_getUser, 5000);
return;
}
if (!user?.joinedTx) {
setTimeout(_getUser, 5000);
return;
}
resolve(user);
}
});
}
resolve(user);
}
});
};
export const getUser = (address: string) => async (dispatch: Dispatch, getState: () => AppRootState): Promise<User> => {
export const getUser =
(address: string) =>
async (dispatch: Dispatch, getState: () => AppRootState): Promise<User> => {
const contextualName = getContextNameFromState(getState());
const key = contextualName + address;
if (fetchPromises[key]) {
return fetchPromises[key];
return fetchPromises[key];
}
const fetchPromise = new Promise<User>(async (resolve, reject) => {
let payload;
let payload;
if (cachedUser[key]) {
payload = cachedUser[key];
} else {
const resp = await fetch(`${config.indexerAPI}/v1/users/${address}`, {
method: 'GET',
// @ts-ignore
headers: {
'x-contextual-name': contextualName,
},
});
const json = await resp.json();
// @ts-ignore
payload = dispatch(processUserPayload({...json.payload}));
if (payload?.joinedTx) {
cachedUser[key] = payload;
}
delete fetchPromises[key];
if (cachedUser[key]) {
payload = cachedUser[key];
} else {
const resp = await fetch(`${config.indexerAPI}/v1/users/${address}`, {
method: 'GET',
// @ts-ignore
headers: {
'x-contextual-name': contextualName,
},
});
const json = await resp.json();
// @ts-ignore
payload = dispatch(processUserPayload({ ...json.payload }));
if (payload?.joinedTx) {
cachedUser[key] = payload;
}
dispatch({
type: ActionTypes.SET_USER,
payload: payload,
});
delete fetchPromises[key];
}
resolve(payload);
dispatch({
type: ActionTypes.SET_USER,
payload: payload,
});
resolve(payload);
});
fetchPromises[key] = fetchPromise;
return fetchPromise;
}
};
export const fetchUsers = () => async (dispatch: Dispatch, getState: () => AppRootState): Promise<string[]> => {
export const fetchUsers =
() =>
async (dispatch: Dispatch, getState: () => AppRootState): Promise<string[]> => {
const contextualName = getContextNameFromState(getState());
const resp = await fetch(`${config.indexerAPI}/v1/users?limit=5`, {
method: 'GET',
// @ts-ignore
headers: {
'x-contextual-name': contextualName,
},
method: 'GET',
// @ts-ignore
headers: {
'x-contextual-name': contextualName,
},
});
const json = await resp.json();
const list: string[] = [];
for (const user of json.payload) {
// @ts-ignore
const payload = dispatch(processUserPayload(user));
const key = contextualName + user.address;
if (payload?.joinedTx) {
cachedUser[key] = payload;
}
list.push(user.address);
// @ts-ignore
const payload = dispatch(processUserPayload(user));
const key = contextualName + user.address;
if (payload?.joinedTx) {
cachedUser[key] = payload;
}
list.push(user.address);
}
return list;
}
};
export const searchUsers = (query: string) => async (dispatch: Dispatch, getState: () => AppRootState): Promise<string[]> => {
export const searchUsers =
(query: string) =>
async (dispatch: Dispatch, getState: () => AppRootState): Promise<string[]> => {
const contextualName = getContextNameFromState(getState());
const resp = await fetch(`${config.indexerAPI}/v1/users/search/${encodeURIComponent(query)}?limit=5`, {
const resp = await fetch(
`${config.indexerAPI}/v1/users/search/${encodeURIComponent(query)}?limit=5`,
{
method: 'GET',
// @ts-ignore
headers: {
'x-contextual-name': contextualName,
'x-contextual-name': contextualName,
},
});
}
);
const json = await resp.json();
const list: string[] = [];
for (const user of json.payload) {
// @ts-ignore
const payload = dispatch(processUserPayload({...user}));
const key = contextualName + user.address;
if (payload?.joinedTx) {
cachedUser[key] = payload;
}
list.push(user.address);
// @ts-ignore
const payload = dispatch(processUserPayload({ ...user }));
const key = contextualName + user.address;
if (payload?.joinedTx) {
cachedUser[key] = payload;
}
list.push(user.address);
}
return json.payload;
}
};
export const setAcceptanceSent = (address: string, acceptanceSent: string | null): Action<{ address: string; acceptanceSent: string | null }> => ({
type: ActionTypes.SET_ACCEPTANCE_SENT,
payload: {address, acceptanceSent},
export const setAcceptanceSent = (
address: string,
acceptanceSent: string | null
): Action<{ address: string; acceptanceSent: string | null }> => ({
type: ActionTypes.SET_ACCEPTANCE_SENT,
payload: { address, acceptanceSent },
});
export const setFollowed = (address: string, followed: string | null): Action<{ address: string; followed: string | null }> => ({
type: ActionTypes.SET_FOLLOWED,
payload: {address, followed},
export const setFollowed = (
address: string,
followed: string | null
): Action<{ address: string; followed: string | null }> => ({
type: ActionTypes.SET_FOLLOWED,
payload: { address, followed },
});
export const setBlocked = (address: string, blocked: string | null): Action<{ address: string; blocked: string | null }> => ({
type: ActionTypes.SET_BLOCKED,
payload: {address, blocked},
export const setBlocked = (
address: string,
blocked: string | null
): Action<{ address: string; blocked: string | null }> => ({
type: ActionTypes.SET_BLOCKED,
payload: { address, blocked },
});
export const setEcdh = (address: string, ecdh: string): Action<{ address: string; ecdh: string }> => ({
type: ActionTypes.SET_ECDH,
payload: {address, ecdh},
export const setEcdh = (
address: string,
ecdh: string
): Action<{ address: string; ecdh: string }> => ({
type: ActionTypes.SET_ECDH,
payload: { address, ecdh },
});
export const setIdCommitment = (address: string, idcommitment: string): Action<{ address: string; idcommitment: string }> => ({
type: ActionTypes.SET_ID_COMMITMENT,
payload: {address, idcommitment},
export const setIdCommitment = (
address: string,
idcommitment: string
): Action<{ address: string; idcommitment: string }> => ({
type: ActionTypes.SET_ID_COMMITMENT,
payload: { address, idcommitment },
});
export const resetUser = () => {
fetchPromises = {};
cachedUser = {};
return {
type: ActionTypes.RESET_USERS,
}
fetchPromises = {};
cachedUser = {};
return {
type: ActionTypes.RESET_USERS,
};
};
const processUserPayload = (user: any) => (dispatch: Dispatch) => {
const payload: User = {
address: user.username,
ens: user.ens,
username: user.username,
name: user.name || '',
pubkey: user.pubkey || '',
bio: user.bio || '',
profileImage: user.profileImage || '',
coverImage: user.coverImage || '',
group: !!user.group,
twitterVerification: user.twitterVerification || '',
website: user.website || '',
ecdh: user.ecdh || '',
idcommitment: user.idcommitment || '',
joinedAt: user.joinedAt || '',
joinedTx: user.joinedTx || '',
type: user.type || '',
meta: {
followerCount: user.meta?.followerCount || 0,
followingCount: user.meta?.followingCount || 0,
blockedCount: user.meta?.blockedCount || 0,
blockingCount: user.meta?.blockingCount || 0,
postingCount: user.meta?.postingCount || 0,
followed: user.meta?.followed || null,
blocked: user.meta?.blocked || null,
inviteSent: user.meta?.inviteSent || null,
acceptanceReceived: user.meta?.acceptanceReceived || null,
inviteReceived: user.meta?.inviteReceived || null,
acceptanceSent: user.meta?.acceptanceSent || null,
},
};
const payload: User = {
address: user.username,
ens: user.ens,
username: user.username,
name: user.name || '',
pubkey: user.pubkey || '',
bio: user.bio || '',
profileImage: user.profileImage || '',
coverImage: user.coverImage || '',
group: !!user.group,
twitterVerification: user.twitterVerification || '',
website: user.website || '',
ecdh: user.ecdh || '',
idcommitment: user.idcommitment || '',
joinedAt: user.joinedAt || '',
joinedTx: user.joinedTx || '',
type: user.type || '',
meta: {
followerCount: user.meta?.followerCount || 0,
followingCount: user.meta?.followingCount || 0,
blockedCount: user.meta?.blockedCount || 0,
blockingCount: user.meta?.blockingCount || 0,
postingCount: user.meta?.postingCount || 0,
followed: user.meta?.followed || null,
blocked: user.meta?.blocked || null,
inviteSent: user.meta?.inviteSent || null,
acceptanceReceived: user.meta?.acceptanceReceived || null,
inviteReceived: user.meta?.inviteReceived || null,
acceptanceSent: user.meta?.acceptanceSent || null,
},
};
dispatch({
type: ActionTypes.SET_USER,
payload: payload,
});
dispatch({
type: ActionTypes.SET_USER,
payload: payload,
});
return payload;
}
return payload;
};
export const setUser = (user: User) => ({
type: ActionTypes.SET_USER,
payload: user,
type: ActionTypes.SET_USER,
payload: user,
});
export const useConnectedTwitter = (address = '') => {
return useSelector((state: AppRootState) => {
const user = state.users.map[address];
return useSelector((state: AppRootState) => {
const user = state.users.map[address];
if (!user?.twitterVerification) return null;
if (!user?.twitterVerification) return null;
const [twitterHandle] = user.twitterVerification.replace('https://twitter.com/', '').split('/');
return twitterHandle;
}, deepEqual);
}
const [twitterHandle] = user.twitterVerification.replace('https://twitter.com/', '').split('/');
return twitterHandle;
}, deepEqual);
};
export const useUser = (address = ''): User | null => {
return useSelector((state: AppRootState) => {
if (!address) return null;
return useSelector((state: AppRootState) => {
if (!address) return null;
const user = state.users.map[address];
const user = state.users.map[address];
const val: User = user;
const val: User = user;
if (!user) {
return {
username: address,
name: '',
pubkey: '',
address: address,
coverImage: '',
profileImage: '',
twitterVerification: '',
bio: '',
website: '',
ecdh: '',
idcommitment: '',
joinedAt: 0,
joinedTx: '',
type: '',
group: false,
meta: {
followerCount: 0,
followingCount: 0,
blockedCount: 0,
blockingCount: 0,
postingCount: 0,
followed: null,
blocked: null,
inviteSent: null,
acceptanceReceived: null,
inviteReceived: null,
acceptanceSent: null,
},
}
}
if (!user) {
return {
username: address,
name: '',
pubkey: '',
address: address,
coverImage: '',
profileImage: '',
twitterVerification: '',
bio: '',
website: '',
ecdh: '',
idcommitment: '',
joinedAt: 0,
joinedTx: '',
type: '',
group: false,
meta: {
followerCount: 0,
followingCount: 0,
blockedCount: 0,
blockingCount: 0,
postingCount: 0,
followed: null,
blocked: null,
inviteSent: null,
acceptanceReceived: null,
inviteReceived: null,
acceptanceSent: null,
},
};
}
return user;
}, deepEqual);
}
return user;
}, deepEqual);
};
export default function users(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionTypes.SET_USER:
return reduceSetUser(state, action);
case ActionTypes.RESET_USERS:
return {
map: {},
};
case ActionTypes.SET_ACCEPTANCE_SENT:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
meta: {
...state.map[action.payload.address]?.meta,
acceptanceSent: action.payload.acceptanceSent,
},
},
},
}
case ActionTypes.SET_FOLLOWED:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
meta: {
...state.map[action.payload.address]?.meta,
followed: action.payload.followed,
},
},
},
};
case ActionTypes.SET_BLOCKED:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
meta: {
...state.map[action.payload.address]?.meta,
blocked: action.payload.blocked,
},
},
},
};
case ActionTypes.SET_ECDH:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
ecdh: action.payload.ecdh,
},
},
};
case ActionTypes.SET_ID_COMMITMENT:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
idcommitment: action.payload.idcommitment,
},
},
};
case ActionTypes.SET_USER_ADDRESS:
return reduceSetUserAddress(state, action);
default:
return state;
}
}
function reduceSetUserAddress(state: State, action: Action<{ ens: string; address: string }>): State {
if (!action.payload) return state;
const user = state.map[action.payload.address];
return {
switch (action.type) {
case ActionTypes.SET_USER:
return reduceSetUser(state, action);
case ActionTypes.RESET_USERS:
return {
map: {},
};
case ActionTypes.SET_ACCEPTANCE_SENT:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...user,
ens: action.payload.ens,
username: action.payload.address,
address: action.payload.address,
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
meta: {
...state.map[action.payload.address]?.meta,
acceptanceSent: action.payload.acceptanceSent,
},
},
},
};
};
case ActionTypes.SET_FOLLOWED:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
meta: {
...state.map[action.payload.address]?.meta,
followed: action.payload.followed,
},
},
},
};
case ActionTypes.SET_BLOCKED:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
meta: {
...state.map[action.payload.address]?.meta,
blocked: action.payload.blocked,
},
},
},
};
case ActionTypes.SET_ECDH:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
ecdh: action.payload.ecdh,
},
},
};
case ActionTypes.SET_ID_COMMITMENT:
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...state.map[action.payload.address],
idcommitment: action.payload.idcommitment,
},
},
};
case ActionTypes.SET_USER_ADDRESS:
return reduceSetUserAddress(state, action);
default:
return state;
}
}
function reduceSetUserAddress(
state: State,
action: Action<{ ens: string; address: string }>
): State {
if (!action.payload) return state;
const user = state.map[action.payload.address];
return {
...state,
map: {
...state.map,
[action.payload.address]: {
...user,
ens: action.payload.ens,
username: action.payload.address,
address: action.payload.address,
},
},
};
}
function reduceSetUser(state: State, action: Action<User>): State {
if (!action.payload) return state;
if (!action.payload) return state;
const user = state.map[action.payload.username];
const user = state.map[action.payload.username];
return {
...state,
map: {
...state.map,
[action.payload.username]: {
...user,
username: action.payload.username,
address: action.payload.address,
name: action.payload.name,
ens: action.payload.ens,
pubkey: action.payload.pubkey,
bio: action.payload.bio,
profileImage: action.payload.profileImage,
twitterVerification: action.payload.twitterVerification,
coverImage: action.payload.coverImage,
website: action.payload.website,
ecdh: action.payload.ecdh,
idcommitment: action.payload.idcommitment,
joinedAt: action.payload.joinedAt,
joinedTx: action.payload.joinedTx,
type: action.payload.type,
group: action.payload.group,
meta: {
followerCount: action.payload.meta?.followerCount || 0,
followingCount: action.payload.meta?.followingCount || 0,
blockedCount: action.payload.meta?.blockedCount || 0,
blockingCount: action.payload.meta?.blockingCount || 0,
postingCount: action.payload.meta?.postingCount || 0,
followed: action.payload.meta?.followed || null,
blocked: action.payload.meta?.blocked || null,
inviteSent: action.payload.meta?.inviteSent || null,
acceptanceReceived: action.payload.meta?.acceptanceReceived || null,
inviteReceived: action.payload.meta?.inviteReceived || null,
acceptanceSent: action.payload.meta?.acceptanceSent || null,
},
},
return {
...state,
map: {
...state.map,
[action.payload.username]: {
...user,
username: action.payload.username,
address: action.payload.address,
name: action.payload.name,
ens: action.payload.ens,
pubkey: action.payload.pubkey,
bio: action.payload.bio,
profileImage: action.payload.profileImage,
twitterVerification: action.payload.twitterVerification,
coverImage: action.payload.coverImage,
website: action.payload.website,
ecdh: action.payload.ecdh,
idcommitment: action.payload.idcommitment,
joinedAt: action.payload.joinedAt,
joinedTx: action.payload.joinedTx,
type: action.payload.type,
group: action.payload.group,
meta: {
followerCount: action.payload.meta?.followerCount || 0,
followingCount: action.payload.meta?.followingCount || 0,
blockedCount: action.payload.meta?.blockedCount || 0,
blockingCount: action.payload.meta?.blockingCount || 0,
postingCount: action.payload.meta?.postingCount || 0,
followed: action.payload.meta?.followed || null,
blocked: action.payload.meta?.blocked || null,
inviteSent: action.payload.meta?.inviteSent || null,
acceptanceReceived: action.payload.meta?.acceptanceReceived || null,
inviteReceived: action.payload.meta?.inviteReceived || null,
acceptanceSent: action.payload.meta?.acceptanceSent || null,
},
};
}
},
},
};
}

View File

@@ -1,132 +1,132 @@
import sinon from 'sinon';
import {store, ducks, web3Stub, fetchStub, postWorkMessageStub} from "../util/testUtils";
import { store, ducks, web3Stub, fetchStub, postWorkMessageStub } from '../util/testUtils';
const {
web3: {
connectWeb3,
setWeb3,
loginGun,
genSemaphore,
updateIdentity,
web3Modal,
},
web3: { connectWeb3, setWeb3, loginGun, genSemaphore, updateIdentity, web3Modal },
} = ducks;
describe('Web3 Duck', () => {
it('should set web3', async () => {
sinon.stub(web3Modal, 'clearCachedProvider');
sinon.stub(web3Modal, 'connect').returns(Promise.resolve(null));
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: {
joinedTx: 'hello',
},
}),
})
);
it('should set web3', async () => {
sinon.stub(web3Modal, 'clearCachedProvider');
sinon.stub(web3Modal, 'connect').returns(Promise.resolve(null));
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({ payload: {
joinedTx: 'hello',
}}),
}));
try {
// @ts-ignore
await store.dispatch(connectWeb3());
} catch (e) {
expect(e.message).toBe('Provider not set or invalid');
}
try {
// @ts-ignore
await store.dispatch(connectWeb3());
} catch (e) {
expect(e.message).toBe('Provider not set or invalid');
}
// @ts-ignore
await store.dispatch(setWeb3(web3Stub, '0x0000000000000000000000000000000000000000'));
// @ts-ignore
await store.dispatch(setWeb3(web3Stub, '0x0000000000000000000000000000000000000000'));
expect(store.getState().web3.account).toBe('0x0000000000000000000000000000000000000000');
expect(store.getState().web3.networkType).toBe('main');
expect(store.getState().web3.account).toBe('0x0000000000000000000000000000000000000000');
expect(store.getState().web3.networkType).toBe('main');
const onAccountChange = web3Stub.currentProvider.on.args[0][1];
const onNetworkChange = web3Stub.currentProvider.on.args[1][1];
const onAccountChange = web3Stub.currentProvider.on.args[0][1];
const onNetworkChange = web3Stub.currentProvider.on.args[1][1];
web3Stub.eth.getAccounts.returns(['0x0000000000000000000000000000000000000001']);
web3Stub.eth.net.getNetworkType.returns('test');
web3Stub.eth.getAccounts.returns(['0x0000000000000000000000000000000000000001']);
web3Stub.eth.net.getNetworkType.returns('test');
await onAccountChange(['0x0000000000000000000000000000000000000001']);
await onNetworkChange();
await onAccountChange(['0x0000000000000000000000000000000000000001']);
await onNetworkChange();
expect(store.getState().web3.account).toBe('0x0000000000000000000000000000000000000001');
expect(store.getState().web3.networkType).toBe('test');
expect(store.getState().web3.account).toBe('0x0000000000000000000000000000000000000001');
expect(store.getState().web3.networkType).toBe('test');
fetchStub.reset();
});
fetchStub.reset();
it('should login gun', async () => {
// @ts-ignore
const result = await store.dispatch(loginGun());
const pub =
'Ex4tafFuBZQXO610uL0v76bNnYokD7m-WBKFSYdOw6k.m0KmwZKFnqe-5iFOj_VIR50_BHtQDFceK7cF-Fi_-As';
const priv = '47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU';
expect(postWorkMessageStub.args[0][0]).toStrictEqual({
type: 'serviceWorker/identity/setIdentity',
payload: {
type: 'gun',
address: '0x0000000000000000000000000000000000000001',
publicKey: pub,
privateKey: priv,
nonce: 0,
},
});
expect(result).toStrictEqual({ priv, pub });
postWorkMessageStub.reset();
});
it('should login gun', async () => {
// @ts-ignore
const result = await store.dispatch(loginGun());
const pub = 'Ex4tafFuBZQXO610uL0v76bNnYokD7m-WBKFSYdOw6k.m0KmwZKFnqe-5iFOj_VIR50_BHtQDFceK7cF-Fi_-As';
const priv = '47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU';
expect(postWorkMessageStub.args[0][0])
.toStrictEqual({
type: 'serviceWorker/identity/setIdentity',
payload: {
type: 'gun',
address: '0x0000000000000000000000000000000000000001',
publicKey: pub,
privateKey: priv,
nonce: 0,
},
});
expect(result).toStrictEqual({ priv, pub });
postWorkMessageStub.reset();
it('should gen semaphore', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: {
data: {
siblings: [],
pathIndices: [],
root: '3',
},
name: '',
provider: '',
},
}),
})
);
// @ts-ignore
const result = await store.dispatch(genSemaphore('Twitter'));
expect(postWorkMessageStub.args[0][0]).toStrictEqual({
type: 'serviceWorker/identity/setIdentity',
payload: {
type: 'interrep',
address: '0x0000000000000000000000000000000000000001',
nonce: 0,
provider: 'Twitter',
name: '',
identityPath: {
path_elements: [],
path_index: [],
root: BigInt(3),
},
identityCommitment:
'13918226946525796188065125653551272759560533101256756969470448137974823773959',
serializedIdentity:
'{"identityNullifier":"aa3fda7fc5f0d2d2b8322bcc0367a1de25929526568dd90dc3b1891138425b00","identityTrapdoor":"bc63a4fcddf77544f4cedc29c394a72c1a1d134e3693f74d99ec4dffb6bf802a","secret":["aa3fda7fc5f0d2d2b8322bcc0367a1de25929526568dd90dc3b1891138425b00","bc63a4fcddf77544f4cedc29c394a72c1a1d134e3693f74d99ec4dffb6bf802a"]}',
},
});
expect(result).toBeTruthy();
fetchStub.reset();
postWorkMessageStub.reset();
});
it('should gen semaphore', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: {
data: {
siblings: [],
pathIndices: [],
root: '3',
},
name: '',
provider: '',
},
})
}))
// @ts-ignore
const result = await store.dispatch(genSemaphore('Twitter'));
expect(postWorkMessageStub.args[0][0])
.toStrictEqual({
type: 'serviceWorker/identity/setIdentity',
payload: {
type: 'interrep',
address: '0x0000000000000000000000000000000000000001',
nonce: 0,
provider: 'Twitter',
name: '',
identityPath: {
path_elements: [],
path_index: [],
root: BigInt(3),
},
identityCommitment: '13918226946525796188065125653551272759560533101256756969470448137974823773959',
serializedIdentity: '{"identityNullifier":"aa3fda7fc5f0d2d2b8322bcc0367a1de25929526568dd90dc3b1891138425b00","identityTrapdoor":"bc63a4fcddf77544f4cedc29c394a72c1a1d134e3693f74d99ec4dffb6bf802a","secret":["aa3fda7fc5f0d2d2b8322bcc0367a1de25929526568dd90dc3b1891138425b00","bc63a4fcddf77544f4cedc29c394a72c1a1d134e3693f74d99ec4dffb6bf802a"]}'
}
});
expect(result).toBeTruthy();
fetchStub.reset();
postWorkMessageStub.reset();
});
it('should update identity', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
status: 200,
json: async () => ({
payload: {
transactionHash: '0xhash',
},
}),
}));
// @ts-ignore
await store.dispatch(updateIdentity('0xpubkey'));
expect(fetchStub.args[0][0])
.toBe('http://127.0.0.1:3000/v1/users');
fetchStub.reset();
});
});
it('should update identity', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
status: 200,
json: async () => ({
payload: {
transactionHash: '0xhash',
},
}),
})
);
// @ts-ignore
await store.dispatch(updateIdentity('0xpubkey'));
expect(fetchStub.args[0][0]).toBe('http://127.0.0.1:3000/v1/users');
fetchStub.reset();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,45 @@
import {store, ducks, postWorkMessageStub} from "../util/testUtils";
import {Identity} from "../serviceWorkers/identity";
import {ServiceWorkerActionType} from "../serviceWorkers/util";
import { store, ducks, postWorkMessageStub } from '../util/testUtils';
import { Identity } from '../serviceWorkers/identity';
import { ServiceWorkerActionType } from '../serviceWorkers/util';
const {
worker: {
syncWorker,
}
worker: { syncWorker },
} = ducks;
describe('Worker Duck', () => {
it('should sync worker', async () => {
const identities: Identity[] = [
{
type: 'gun',
address: '0xgunaddress',
nonce: 0,
publicKey: '0xpub',
privateKey: '0xpriv',
},
{
type: 'zkpr_interrep',
provider: 'Twitter',
name: 'diamond',
identityPath: {
path_elements: ['0x1', '0x2'],
path_index: [0, 1],
root: '0x123',
},
identityCommitment: '0x12345',
}
];
it('should sync worker', async () => {
const identities: Identity[] = [
{
type: 'gun',
address: '0xgunaddress',
nonce: 0,
publicKey: '0xpub',
privateKey: '0xpriv',
},
{
type: 'zkpr_interrep',
provider: 'Twitter',
name: 'diamond',
identityPath: {
path_elements: ['0x1', '0x2'],
path_index: [0, 1],
root: '0x123',
},
identityCommitment: '0x12345',
},
];
postWorkMessageStub
.withArgs({ type: ServiceWorkerActionType.GET_IDENTITIES })
.returns(Promise.resolve(identities));
postWorkMessageStub
.withArgs({ type: ServiceWorkerActionType.GET_IDENTITIES })
.returns(Promise.resolve(identities));
postWorkMessageStub
.withArgs({ type: ServiceWorkerActionType.GET_IDENTITY_STATUS })
.returns(Promise.resolve({ unlocked: true, currentIdentity: identities[0]}));
postWorkMessageStub
.withArgs({ type: ServiceWorkerActionType.GET_IDENTITY_STATUS })
.returns(Promise.resolve({ unlocked: true, currentIdentity: identities[0] }));
// @ts-ignore
const id = await store.dispatch(syncWorker());
expect(id).toStrictEqual(identities[0]);
postWorkMessageStub.reset();
});
});
// @ts-ignore
const id = await store.dispatch(syncWorker());
expect(id).toStrictEqual(identities[0]);
postWorkMessageStub.reset();
});
});

View File

@@ -1,176 +1,180 @@
import {Identity} from "../serviceWorkers/identity";
import {Dispatch} from "redux";
import {postWorkerMessage} from "../util/sw";
import {getIdentities, getIdentityStatus} from "../serviceWorkers/util";
import {useSelector} from "react-redux";
import {AppRootState} from "../store/configureAppStore";
import deepEqual from "fast-deep-equal";
import { Identity } from '../serviceWorkers/identity';
import { Dispatch } from 'redux';
import { postWorkerMessage } from '../util/sw';
import { getIdentities, getIdentityStatus } from '../serviceWorkers/util';
import { useSelector } from 'react-redux';
import { AppRootState } from '../store/configureAppStore';
import deepEqual from 'fast-deep-equal';
export enum ActionType {
SET_SELECTED_ID = 'worker/setSelectedId',
SET_IDENTITIES = 'worker/setIdentities',
SET_UNLOCKED = 'worker/setUnlocked',
SET_POSTING_GROUP = 'worker/setPostingGroup',
SET_SELECTED_ID = 'worker/setSelectedId',
SET_IDENTITIES = 'worker/setIdentities',
SET_UNLOCKED = 'worker/setUnlocked',
SET_POSTING_GROUP = 'worker/setPostingGroup',
}
export type Action<payload> = {
type: ActionType;
payload?: payload;
meta?: any;
error?: boolean;
}
type: ActionType;
payload?: payload;
meta?: any;
error?: boolean;
};
export type State = {
unlocked: boolean;
selected: Identity | null;
identities: Identity[];
postingGroup: string;
}
unlocked: boolean;
selected: Identity | null;
identities: Identity[];
postingGroup: string;
};
const initialState: State = {
unlocked: false,
selected: null,
identities: [],
postingGroup: '',
unlocked: false,
selected: null,
identities: [],
postingGroup: '',
};
export const syncWorker = () => async (dispatch: Dispatch) => {
const identities = await postWorkerMessage<Identity[]>(getIdentities());
const {unlocked, currentIdentity} = await postWorkerMessage<{
unlocked: boolean;
currentIdentity: Identity | null;
}>(getIdentityStatus());
const identities = await postWorkerMessage<Identity[]>(getIdentities());
const { unlocked, currentIdentity } = await postWorkerMessage<{
unlocked: boolean;
currentIdentity: Identity | null;
}>(getIdentityStatus());
dispatch(setIdentities(identities));
dispatch(setUnlocked(unlocked));
dispatch(setIdentities(identities));
dispatch(setUnlocked(unlocked));
if (currentIdentity) {
if (currentIdentity.type === 'gun' && !currentIdentity.privateKey) return;
if (currentIdentity.type === 'interrep' && !currentIdentity.serializedIdentity) return;
dispatch(setSelectedId(currentIdentity));
return currentIdentity;
}
}
if (currentIdentity) {
if (currentIdentity.type === 'gun' && !currentIdentity.privateKey) return;
if (currentIdentity.type === 'interrep' && !currentIdentity.serializedIdentity) return;
dispatch(setSelectedId(currentIdentity));
return currentIdentity;
}
};
export const setIdentities = (identities: Identity[]): Action<Identity[]> => ({
type: ActionType.SET_IDENTITIES,
payload: identities,
type: ActionType.SET_IDENTITIES,
payload: identities,
});
export const setUnlocked = (unlocked: boolean): Action<boolean> => ({
type: ActionType.SET_UNLOCKED,
payload: unlocked,
type: ActionType.SET_UNLOCKED,
payload: unlocked,
});
export const setSelectedId = (id: Identity | null): Action<Identity | null> => ({
type: ActionType.SET_SELECTED_ID,
payload: id,
type: ActionType.SET_SELECTED_ID,
payload: id,
});
export const setPostingGroup = (id: string): Action<string> => ({
type: ActionType.SET_POSTING_GROUP,
payload: id,
type: ActionType.SET_POSTING_GROUP,
payload: id,
});
export default function worker(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionType.SET_IDENTITIES:
return {
...state,
identities: action.payload,
};
case ActionType.SET_SELECTED_ID:
return {
...state,
selected: action.payload,
postingGroup: '',
};
case ActionType.SET_UNLOCKED:
return {
...state,
unlocked: action.payload,
};
case ActionType.SET_POSTING_GROUP:
return {
...state,
postingGroup: action.payload,
};
default:
return state;
}
switch (action.type) {
case ActionType.SET_IDENTITIES:
return {
...state,
identities: action.payload,
};
case ActionType.SET_SELECTED_ID:
return {
...state,
selected: action.payload,
postingGroup: '',
};
case ActionType.SET_UNLOCKED:
return {
...state,
unlocked: action.payload,
};
case ActionType.SET_POSTING_GROUP:
return {
...state,
postingGroup: action.payload,
};
default:
return state;
}
}
export const useIdentities = () => {
return useSelector((state: AppRootState) => {
return state.worker.identities;
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return state.worker.identities;
}, deepEqual);
};
export const useWorkerUnlocked = () => {
return useSelector((state: AppRootState) => {
return state.worker.unlocked;
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return state.worker.unlocked;
}, deepEqual);
};
export const usePostingGroup = () => {
return useSelector((state: AppRootState) => {
return state.worker.postingGroup;
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return state.worker.postingGroup;
}, deepEqual);
};
export const useSelectedLocalId = () => {
return useSelector((state: AppRootState) => {
const {
worker: { selected },
} = state;
return useSelector((state: AppRootState) => {
const {
worker: { selected },
} = state;
if (selected) {
return selected;
}
if (selected) {
return selected;
}
return null;
}, deepEqual);
}
return null;
}, deepEqual);
};
export const getZKGroupFromIdentity = (id: Identity) => {
if (id?.type !== 'interrep' && id?.type !== 'zkpr_interrep' && id?.type !== 'taz') {
return null;
}
if (id?.type !== 'interrep' && id?.type !== 'zkpr_interrep' && id?.type !== 'taz') {
return null;
}
if (id?.type === 'taz') {
return 'semaphore_taz_members';
}
if (id?.type === 'taz') {
return 'semaphore_taz_members';
}
return `interrep_${id.provider.toLowerCase()}_${id.name.toLowerCase()}`;
}
return `interrep_${id.provider.toLowerCase()}_${id.name.toLowerCase()}`;
};
export const useSelectedZKGroup = () => {
return useSelector((state: AppRootState) => {
const {
worker: { selected },
} = state;
return useSelector((state: AppRootState) => {
const {
worker: { selected },
} = state;
if (selected?.type === 'taz') return 'semaphore_taz_members';
if (selected?.type === 'taz') return 'semaphore_taz_members';
if (selected?.type !== 'interrep' && selected?.type !== 'zkpr_interrep') {
return null;
}
if (selected?.type !== 'interrep' && selected?.type !== 'zkpr_interrep') {
return null;
}
if (!selected.provider || !selected.name) return '';
if (!selected.provider || !selected.name) return '';
return `interrep_${selected.provider.toLowerCase()}_${selected.name.toLowerCase()}`;
}, deepEqual);
}
return `interrep_${selected.provider.toLowerCase()}_${selected.name.toLowerCase()}`;
}, deepEqual);
};
export const useHasIdConnected = () => {
return useSelector((state: AppRootState) => {
const { worker: { selected } } = state;
const { web3: { account } } = state;
return useSelector((state: AppRootState) => {
const {
worker: { selected },
} = state;
const {
web3: { account },
} = state;
if (selected?.address === account) {
return true;
}
if (selected?.address === account) {
return true;
}
return false;
}, deepEqual);
}
return false;
}, deepEqual);
};

View File

@@ -1,51 +1,49 @@
import sinon from "sinon";
import {store, ducks, fetchStub, zkprStub, postWorkMessageStub} from "../util/testUtils";
import sinon from 'sinon';
import { store, ducks, fetchStub, zkprStub, postWorkMessageStub } from '../util/testUtils';
const {
zkpr: {
connectZKPR,
createZKPRIdentity,
maybeSetZKPRIdentity,
},
zkpr: { connectZKPR, createZKPRIdentity, maybeSetZKPRIdentity },
} = ducks;
describe('ZKPR duck', () => {
it('should connect to zkpr', async () => {
// @ts-ignore
fetchStub.returns(Promise.resolve({
json: async () => ({
payload: {
name: 'Twitter',
provider: 'diamond',
data: {
siblings: ['0x1', '0x2'],
pathIndices: [0, 2],
root: ['0x3'],
},
},
}),
}))
// @ts-ignore
const id = await store.dispatch(connectZKPR());
expect(id).toStrictEqual({
type: 'zkpr_interrep',
provider: 'diamond',
it('should connect to zkpr', async () => {
// @ts-ignore
fetchStub.returns(
Promise.resolve({
json: async () => ({
payload: {
name: 'Twitter',
identityPath: { path_elements: [ '1', '2' ], path_index: [ 0, 2 ], root: '3' },
identityCommitment: '291'
});
const onLogout = zkprStub.on.args[0][1];
const onIdentityChanged = zkprStub.on.args[1][1];
expect(store.getState().zkpr.zkpr).toBeTruthy();
onIdentityChanged('45678');
expect(store.getState().zkpr.idCommitment).toBe('284280');
onLogout();
expect(store.getState().zkpr.zkpr).toBeFalsy();
fetchStub.reset();
postWorkMessageStub.reset();
provider: 'diamond',
data: {
siblings: ['0x1', '0x2'],
pathIndices: [0, 2],
root: ['0x3'],
},
},
}),
})
);
// @ts-ignore
const id = await store.dispatch(connectZKPR());
expect(id).toStrictEqual({
type: 'zkpr_interrep',
provider: 'diamond',
name: 'Twitter',
identityPath: { path_elements: ['1', '2'], path_index: [0, 2], root: '3' },
identityCommitment: '291',
});
const onLogout = zkprStub.on.args[0][1];
const onIdentityChanged = zkprStub.on.args[1][1];
expect(store.getState().zkpr.zkpr).toBeTruthy();
onIdentityChanged('45678');
expect(store.getState().zkpr.idCommitment).toBe('284280');
onLogout();
expect(store.getState().zkpr.zkpr).toBeFalsy();
fetchStub.reset();
postWorkMessageStub.reset();
});
});

View File

@@ -1,284 +1,301 @@
import {ThunkDispatch} from "redux-thunk";
import {useSelector} from "react-redux";
import deepEqual from "fast-deep-equal";
import {AppRootState} from "../store/configureAppStore";
import {checkPath} from "../util/interrep";
import {Identity} from "../serviceWorkers/identity";
import {postWorkerMessage} from "../util/sw";
import {selectIdentity, setIdentity} from "../serviceWorkers/util";
import {Dispatch} from "redux";
import {SemaphoreFullProof, SemaphoreSolidityProof, MerkleProof, RLNFullProof} from "@zk-kit/protocols";
import { ThunkDispatch } from 'redux-thunk';
import { useSelector } from 'react-redux';
import deepEqual from 'fast-deep-equal';
import { AppRootState } from '../store/configureAppStore';
import { checkPath } from '../util/interrep';
import { Identity } from '../serviceWorkers/identity';
import { postWorkerMessage } from '../util/sw';
import { selectIdentity, setIdentity } from '../serviceWorkers/util';
import { Dispatch } from 'redux';
import {
SemaphoreFullProof,
SemaphoreSolidityProof,
MerkleProof,
RLNFullProof,
} from '@zk-kit/protocols';
enum ActionTypes {
SET_LOADING = 'zkpr/setLoading',
SET_UNLOCKING = 'zkpr/setUnlocking',
SET_ID_COMMIMENT = 'zkpr/setIdCommitment',
SET_ZKPR = 'zkpr/setZKPR',
SET_LOADING = 'zkpr/setLoading',
SET_UNLOCKING = 'zkpr/setUnlocking',
SET_ID_COMMIMENT = 'zkpr/setIdCommitment',
SET_ZKPR = 'zkpr/setZKPR',
}
type Action<payload> = {
type: ActionTypes;
payload?: payload;
meta?: any;
error?: boolean;
}
type State = {
loading: boolean;
unlocking: boolean;
zkpr: ZKPR | null;
idCommitment: string;
}
const initialState: State = {
zkpr: null,
idCommitment: '',
loading: false,
unlocking: false,
type: ActionTypes;
payload?: payload;
meta?: any;
error?: boolean;
};
export const connectZKPR = () => async (
dispatch: ThunkDispatch<any, any, any>,
getState: () => AppRootState,
) => {
type State = {
loading: boolean;
unlocking: boolean;
zkpr: ZKPR | null;
idCommitment: string;
};
const initialState: State = {
zkpr: null,
idCommitment: '',
loading: false,
unlocking: false,
};
export const connectZKPR =
() => async (dispatch: ThunkDispatch<any, any, any>, getState: () => AppRootState) => {
dispatch(setLoading(true));
try {
let id: Identity | null = null;
let id: Identity | null = null;
// @ts-ignore
if (typeof window.zkpr !== 'undefined') {
// @ts-ignore
if (typeof window.zkpr !== 'undefined') {
// @ts-ignore
const zkpr: any = window.zkpr;
const client = await zkpr.connect();
const zkprClient = new ZKPR(client);
const zkpr: any = window.zkpr;
const client = await zkpr.connect();
const zkprClient = new ZKPR(client);
zkprClient.on('logout', async data => {
const {
worker: { selected, identities },
} = getState();
zkprClient.on('logout', async data => {
const {
worker: { selected, identities },
} = getState();
dispatch(disconnectZKPR());
dispatch(disconnectZKPR());
const [defaultId] = identities;
if (defaultId) {
postWorkerMessage(selectIdentity(defaultId.type === 'gun' ? defaultId.publicKey : defaultId.identityCommitment));
} else {
postWorkerMessage(setIdentity(null));
}
});
const [defaultId] = identities;
if (defaultId) {
postWorkerMessage(
selectIdentity(
defaultId.type === 'gun' ? defaultId.publicKey : defaultId.identityCommitment
)
);
} else {
postWorkerMessage(setIdentity(null));
}
});
zkprClient.on('identityChanged', async data => {
const idCommitment = data && BigInt('0x' + data).toString();
const { worker: { identities } } = getState();
zkprClient.on('identityChanged', async data => {
const idCommitment = data && BigInt('0x' + data).toString();
const {
worker: { identities },
} = getState();
dispatch(setIdCommitment(''));
dispatch(setIdCommitment(''));
if (idCommitment) {
dispatch(setIdCommitment(idCommitment));
const id: any = await maybeSetZKPRIdentity(idCommitment);
if (!id) {
const [defaultId] = identities;
if (defaultId) {
postWorkerMessage(selectIdentity(defaultId.type === 'gun' ? defaultId.publicKey : defaultId.identityCommitment));
} else {
postWorkerMessage(setIdentity(null));
}
}
}
});
localStorage.setItem('ZKPR_CACHED', '1');
const idCommitmentHex = await zkprClient.getActiveIdentity();
const idCommitment = idCommitmentHex && BigInt('0x' + idCommitmentHex).toString();
if (idCommitment) {
dispatch(setIdCommitment(idCommitment));
id = await maybeSetZKPRIdentity(idCommitment);
if (idCommitment) {
dispatch(setIdCommitment(idCommitment));
const id: any = await maybeSetZKPRIdentity(idCommitment);
if (!id) {
const [defaultId] = identities;
if (defaultId) {
postWorkerMessage(
selectIdentity(
defaultId.type === 'gun' ? defaultId.publicKey : defaultId.identityCommitment
)
);
} else {
postWorkerMessage(setIdentity(null));
}
}
}
});
dispatch(setZKPR(zkprClient));
localStorage.setItem('ZKPR_CACHED', '1');
const idCommitmentHex = await zkprClient.getActiveIdentity();
const idCommitment = idCommitmentHex && BigInt('0x' + idCommitmentHex).toString();
if (idCommitment) {
dispatch(setIdCommitment(idCommitment));
id = await maybeSetZKPRIdentity(idCommitment);
}
dispatch(setLoading(false));
dispatch(setZKPR(zkprClient));
}
return id;
dispatch(setLoading(false));
return id;
} catch (e) {
dispatch(setLoading(false));
throw e;
dispatch(setLoading(false));
throw e;
}
}
};
export const createZKPRIdentity = () => async (
dispatch: ThunkDispatch<any, any, any>,
getState: () => AppRootState,
) => {
const { zkpr: { zkpr } } = getState();
export const createZKPRIdentity =
() => async (dispatch: ThunkDispatch<any, any, any>, getState: () => AppRootState) => {
const {
zkpr: { zkpr },
} = getState();
if (zkpr) {
return zkpr.createIdentity();
return zkpr.createIdentity();
}
}
};
export async function maybeSetZKPRIdentity(idCommitment: string) {
let id: Identity | null = null;
const data = await checkPath(idCommitment);
let id: Identity | null = null;
const data = await checkPath(idCommitment);
if (data) {
id = {
type: 'zkpr_interrep',
provider: data.provider,
name: data.name,
identityPath: {
path_elements: data.path.path_elements.map(d => d.toString()),
path_index: data.path.path_index,
root: data.path.root.toString(),
},
identityCommitment: idCommitment,
}
if (data) {
id = {
type: 'zkpr_interrep',
provider: data.provider,
name: data.name,
identityPath: {
path_elements: data.path.path_elements.map(d => d.toString()),
path_index: data.path.path_index,
root: data.path.root.toString(),
},
identityCommitment: idCommitment,
};
await postWorkerMessage(setIdentity(id));
}
await postWorkerMessage(setIdentity(id));
}
return id;
return id;
}
export const disconnectZKPR = () => (dispatch: Dispatch) => {
localStorage.setItem('ZKPR_CACHED', '');
dispatch(setZKPR(null));
dispatch(setIdCommitment(''));
}
localStorage.setItem('ZKPR_CACHED', '');
dispatch(setZKPR(null));
dispatch(setIdCommitment(''));
};
export const setUnlocking = (unlocking: boolean) => ({
type: ActionTypes.SET_UNLOCKING,
payload: unlocking,
type: ActionTypes.SET_UNLOCKING,
payload: unlocking,
});
export const setIdCommitment = (idCommitment: string) => ({
type: ActionTypes.SET_ID_COMMIMENT,
payload: idCommitment,
type: ActionTypes.SET_ID_COMMIMENT,
payload: idCommitment,
});
export const setLoading = (loading: boolean) => ({
type: ActionTypes.SET_LOADING,
payload: loading,
type: ActionTypes.SET_LOADING,
payload: loading,
});
export const setZKPR = (zkpr: ZKPR | null) => ({
type: ActionTypes.SET_ZKPR,
payload: zkpr,
type: ActionTypes.SET_ZKPR,
payload: zkpr,
});
export default function zkpr(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionTypes.SET_UNLOCKING:
return {
...state,
unlocking: action.payload,
};
case ActionTypes.SET_LOADING:
return {
...state,
loading: action.payload,
};
case ActionTypes.SET_ZKPR:
return {
...state,
zkpr: action.payload,
};
case ActionTypes.SET_ID_COMMIMENT:
return {
...state,
idCommitment: action.payload,
};
default:
return state;
}
switch (action.type) {
case ActionTypes.SET_UNLOCKING:
return {
...state,
unlocking: action.payload,
};
case ActionTypes.SET_LOADING:
return {
...state,
loading: action.payload,
};
case ActionTypes.SET_ZKPR:
return {
...state,
zkpr: action.payload,
};
case ActionTypes.SET_ID_COMMIMENT:
return {
...state,
idCommitment: action.payload,
};
default:
return state;
}
}
export const useZKPRLoading = () => {
return useSelector((state: AppRootState) => {
return state.zkpr.loading;
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return state.zkpr.loading;
}, deepEqual);
};
export const useZKPR = () => {
return useSelector((state: AppRootState) => {
return state.zkpr.zkpr;
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return state.zkpr.zkpr;
}, deepEqual);
};
export const useIdCommitment = () => {
return useSelector((state: AppRootState) => {
return BigInt(state.zkpr.idCommitment).toString(16);
}, deepEqual);
}
return useSelector((state: AppRootState) => {
return BigInt(state.zkpr.idCommitment).toString(16);
}, deepEqual);
};
export class ZKPR {
private client: any;
private client: any;
constructor(client: any) {
this.client = client;
}
constructor(client: any) {
this.client = client;
}
on(eventName: string, cb: (data: unknown) => void) {
return this.client.on(eventName, cb);
}
on(eventName: string, cb: (data: unknown) => void) {
return this.client.on(eventName, cb);
}
async getActiveIdentity(): Promise<string | null> {
return this.client.getActiveIdentity();
}
async getActiveIdentity(): Promise<string | null> {
return this.client.getActiveIdentity();
}
async createIdentity(): Promise<void> {
return this.client.createIdentity();
}
async createIdentity(): Promise<void> {
return this.client.createIdentity();
}
async semaphoreProof(
externalNullifier: string,
signal: string,
circuitFilePath: string,
zkeyFilePath: string,
merkleProofArtifactsOrStorageAddress: string | {
leaves: string[];
depth: number;
leavesPerNode: number;
async semaphoreProof(
externalNullifier: string,
signal: string,
circuitFilePath: string,
zkeyFilePath: string,
merkleProofArtifactsOrStorageAddress:
| string
| {
leaves: string[];
depth: number;
leavesPerNode: number;
},
merkleProof?: MerkleProof,
): Promise<{
fullProof: SemaphoreFullProof
solidityProof: SemaphoreSolidityProof
}> {
return this.client.semaphoreProof(
externalNullifier,
signal,
circuitFilePath,
zkeyFilePath,
merkleProofArtifactsOrStorageAddress,
merkleProof,
);
}
merkleProof?: MerkleProof
): Promise<{
fullProof: SemaphoreFullProof;
solidityProof: SemaphoreSolidityProof;
}> {
return this.client.semaphoreProof(
externalNullifier,
signal,
circuitFilePath,
zkeyFilePath,
merkleProofArtifactsOrStorageAddress,
merkleProof
);
}
async rlnProof(
externalNullifier: string,
signal: string,
circuitFilePath: string,
zkeyFilePath: string,
merkleProofArtifactsOrStorageAddress: string | {
leaves: string[];
depth: number;
leavesPerNode: number;
async rlnProof(
externalNullifier: string,
signal: string,
circuitFilePath: string,
zkeyFilePath: string,
merkleProofArtifactsOrStorageAddress:
| string
| {
leaves: string[];
depth: number;
leavesPerNode: number;
},
rlnIdentifier: string,
merkleProof?: MerkleProof,
): Promise<RLNFullProof> {
return this.client.rlnProof(
externalNullifier,
signal,
circuitFilePath,
zkeyFilePath,
merkleProofArtifactsOrStorageAddress,
rlnIdentifier,
merkleProof,
);
}
}
rlnIdentifier: string,
merkleProof?: MerkleProof
): Promise<RLNFullProof> {
return this.client.rlnProof(
externalNullifier,
signal,
circuitFilePath,
zkeyFilePath,
merkleProofArtifactsOrStorageAddress,
rlnIdentifier,
merkleProof
);
}
}

View File

@@ -1,12 +1,13 @@
@import "../../util/variable";
@import '../../util/variable';
html {
font-size: 16px;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
}
#root, body {
font-family: "Open Sans", sans-serif;
#root,
body {
font-family: 'Open Sans', sans-serif;
cursor: default;
width: 100vw;
height: 100vh;
@@ -62,7 +63,7 @@ ul {
li {
margin-left: 2rem;
padding-left: .5rem;
padding-left: 0.5rem;
}
a {
@@ -79,4 +80,4 @@ a {
width: 100%;
}
}
}
}

View File

@@ -1,27 +1,27 @@
import React from "react";
import App from "./index";
import ReactDOM from "react-dom";
import {Provider} from "react-redux";
import {BrowserRouter} from "react-router-dom";
import sinon from "sinon";
import {store} from "../../util/testUtils";
import React from 'react';
import App from './index';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import sinon from 'sinon';
import { store } from '../../util/testUtils';
// @ts-ignore
navigator.serviceWorker = {
addEventListener: sinon.stub(),
addEventListener: sinon.stub(),
};
test('<App> - should mount', async () => {
const root = document.getElementById('root');
const root = document.getElementById('root');
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
root,
);
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
root
);
expect(root!.innerHTML).toBeTruthy();
expect(root!.innerHTML).toBeTruthy();
});

View File

@@ -1,221 +1,226 @@
import React, {ReactElement, useCallback, useContext, useEffect, useState} from "react";
import {Redirect, Route, RouteProps, Switch, useHistory, useLocation} from "react-router";
import TopNav from "../../components/TopNav";
import GlobalFeed from "../GlobalFeed";
import "./app.scss";
import {connectWeb3, useGunLoggedIn, web3Modal} from "../../ducks/web3";
import {useDispatch} from "react-redux";
import PostView from "../PostView";
import ProfileView from "../ProfileView";
import HomeFeed from "../HomeFeed";
import TagFeed from "../../components/TagFeed";
import SignupView, {ViewType} from "../SignupView";
import {syncWorker, useSelectedLocalId} from "../../ducks/worker";
import BottomNav from "../../components/BottomNav";
import InterrepOnboarding from "../InterrepOnboarding";
import ConnectTwitterView from "../ConnectTwitterView";
import {loginUser} from "../../util/user";
import {connectZKPR} from "../../ducks/zkpr";
import SettingView from "../SettingView";
import MetaPanel from "../../components/MetaPanel";
import ChatView from "../ChatView";
import {zkchat} from "../../ducks/chats";
import {generateECDHKeyPairFromhex, generateZkIdentityFromHex, sha256, signWithP256} from "../../util/crypto";
import {Strategy, ZkIdentity} from "@zk-kit/identity";
import sse from "../../util/sse";
import ThemeContext from "../../components/ThemeContext";
import classNames from "classnames";
import {Identity} from "@semaphore-protocol/identity";
import TazModal from "../../components/TazModal";
import React, { ReactElement, useCallback, useContext, useEffect, useState } from 'react';
import { Redirect, Route, RouteProps, Switch, useHistory, useLocation } from 'react-router';
import TopNav from '../../components/TopNav';
import GlobalFeed from '../GlobalFeed';
import './app.scss';
import { connectWeb3, useGunLoggedIn, web3Modal } from '../../ducks/web3';
import { useDispatch } from 'react-redux';
import PostView from '../PostView';
import ProfileView from '../ProfileView';
import HomeFeed from '../HomeFeed';
import TagFeed from '../../components/TagFeed';
import SignupView, { ViewType } from '../SignupView';
import { syncWorker, useSelectedLocalId } from '../../ducks/worker';
import BottomNav from '../../components/BottomNav';
import InterrepOnboarding from '../InterrepOnboarding';
import ConnectTwitterView from '../ConnectTwitterView';
import { loginUser } from '../../util/user';
import { connectZKPR } from '../../ducks/zkpr';
import SettingView from '../SettingView';
import MetaPanel from '../../components/MetaPanel';
import ChatView from '../ChatView';
import { zkchat } from '../../ducks/chats';
import {
generateECDHKeyPairFromhex,
generateZkIdentityFromHex,
sha256,
signWithP256,
} from '../../util/crypto';
import { Strategy, ZkIdentity } from '@zk-kit/identity';
import sse from '../../util/sse';
import ThemeContext from '../../components/ThemeContext';
import classNames from 'classnames';
import { Identity } from '@semaphore-protocol/identity';
import TazModal from '../../components/TazModal';
export default function App(): ReactElement {
const dispatch = useDispatch();
const selected = useSelectedLocalId();
const theme = useContext(ThemeContext);
const loc = useLocation();
const history = useHistory();
const [tazIdentity, setTazIdentity] = useState<string[]|null>(null);
const [showingTazModal, showTazModal] = useState(false);
const dispatch = useDispatch();
const selected = useSelectedLocalId();
const theme = useContext(ThemeContext);
const loc = useLocation();
const history = useHistory();
const [tazIdentity, setTazIdentity] = useState<string[] | null>(null);
const [showingTazModal, showTazModal] = useState(false);
useEffect(() => {
if (loc.pathname === '/taz/') {
const [nullifer, trapdoor] = loc.hash.slice(1).split('_');
history.push('/');
setTazIdentity([nullifer, trapdoor]);
showTazModal(true);
}
}, [loc]);
useEffect(() => {
if (loc.pathname === '/taz/') {
const [nullifer, trapdoor] = loc.hash.slice(1).split('_');
history.push('/');
setTazIdentity([nullifer, trapdoor]);
showTazModal(true);
}
}, [loc]);
useEffect(() => {
const style = document.createElement('style');
style.innerText = `
useEffect(() => {
const style = document.createElement('style');
style.innerText = `
:root {
color-scheme: ${theme};
}
`
document.head.appendChild(style);
}, [theme]);
`;
document.head.appendChild(style);
}, [theme]);
useEffect(() => {
(async function onAppMount() {
useEffect(() => {
(async function onAppMount() {
const id: any = await dispatch(syncWorker());
const id: any = await dispatch(syncWorker());
if (id) {
await loginUser(id);
}
if (id) {
await loginUser(id);
}
const cachedZKPR = localStorage.getItem('ZKPR_CACHED');
const cachedZKPR = localStorage.getItem('ZKPR_CACHED');
if (cachedZKPR) {
await dispatch(connectZKPR());
} else if (web3Modal.cachedProvider) {
await dispatch(connectWeb3());
}
})();
}, []);
if (cachedZKPR) {
await dispatch(connectZKPR());
} else if (web3Modal.cachedProvider) {
await dispatch(connectWeb3());
}
})();
}, []);
useEffect(() => {
if (selected?.type === 'gun') {
(async () => {
const ecdhseed = await signWithP256(selected.privateKey, 'signing for ecdh - 0');
const zkseed = await signWithP256(selected.privateKey, 'signing for zk identity - 0');
const ecdhHex = await sha256(ecdhseed);
const zkHex = await sha256(zkseed);
const keyPair = await generateECDHKeyPairFromhex(ecdhHex);
const zkIdentity = await generateZkIdentityFromHex(zkHex);
await sse.updateTopics([`ecdh:${keyPair.pub}`]);
await zkchat.importIdentity({
address: selected.address,
zk: zkIdentity,
ecdh: keyPair,
});
})();
} else if (selected?.type === 'interrep') {
(async () => {
const zkIdentity = new ZkIdentity(Strategy.SERIALIZED, selected.serializedIdentity);
const ecdhseed = await sha256(zkIdentity.getSecret().map(d => d.toString()).join());
const ecdhHex = await sha256(ecdhseed);
const keyPair = await generateECDHKeyPairFromhex(ecdhHex);
await zkchat.importIdentity({
address: selected?.identityCommitment,
zk: zkIdentity,
ecdh: keyPair,
});
})();
} else if (selected?.type === 'taz') {
(async () => {
const zkIdentity = new Identity(selected.serializedIdentity);
const ecdhseed = await sha256(selected.serializedIdentity);
const ecdhHex = await sha256(ecdhseed);
const keyPair = await generateECDHKeyPairFromhex(ecdhHex);
await zkchat.importIdentity({
address: selected?.identityCommitment,
zk: zkIdentity,
ecdh: keyPair,
});
})();
}
}, [selected])
useEffect(() => {
navigator.serviceWorker.addEventListener('message', event => {
const data = event.data;
if (!data) return;
if (data.target === 'redux') {
const action = data.action;
dispatch(action);
}
useEffect(() => {
if (selected?.type === 'gun') {
(async () => {
const ecdhseed = await signWithP256(selected.privateKey, 'signing for ecdh - 0');
const zkseed = await signWithP256(selected.privateKey, 'signing for zk identity - 0');
const ecdhHex = await sha256(ecdhseed);
const zkHex = await sha256(zkseed);
const keyPair = await generateECDHKeyPairFromhex(ecdhHex);
const zkIdentity = await generateZkIdentityFromHex(zkHex);
await sse.updateTopics([`ecdh:${keyPair.pub}`]);
await zkchat.importIdentity({
address: selected.address,
zk: zkIdentity,
ecdh: keyPair,
});
}, []);
return (
<div
className={classNames("flex flex-col flex-nowrap w-screen h-screen overflow-hidden app", {
'dark': theme === 'dark',
'light': theme !== 'light',
})}
>
{ showingTazModal && (
<TazModal
tazIdentity={tazIdentity}
onClose={() => showTazModal(false)}
/>
)}
<TopNav />
<div className="flex flex-row flex-nowrap app__content">
<Switch>
<Route path="/explore">
<GlobalFeed />
</Route>
<Route path="/tag/:tagName">
<TagFeed />
</Route>
<Route path="/:name/status/:hash">
<PostView />
</Route>
<Route path="/post/:hash">
<PostView />
</Route>
<AuthRoute path="/home">
<HomeFeed />
</AuthRoute>
<Route path="/notifications" />
<Route path="/create-local-backup">
<SignupView viewType={ViewType.localBackup} />
</Route>
<Route path="/onboarding/interrep">
<InterrepOnboarding />
</Route>
<Route path="/signup/interep">
<InterrepOnboarding />
</Route>
<Route path="/connect/twitter">
<ConnectTwitterView />
</Route>
<Route path="/signup">
<SignupView />
</Route>
<Route path="/settings">
<SettingView />
</Route>
<Route path="/chat">
<ChatView />
</Route>
<Route path="/taz">
<GlobalFeed />
</Route>
<Route path="/:name">
<ProfileView />
</Route>
<Route path="/">
<Redirect to="/explore" />
</Route>
</Switch>
<MetaPanel className="mobile-hidden" />
</div>
<BottomNav />
</div>
)
}
function AuthRoute(props: {
redirect?: string;
} & RouteProps): ReactElement {
const { redirect = '/', ...routeProps} = props;
const loggedIn = useGunLoggedIn();
if (loggedIn) {
return (
<Route {...routeProps} />
)
})();
} else if (selected?.type === 'interrep') {
(async () => {
const zkIdentity = new ZkIdentity(Strategy.SERIALIZED, selected.serializedIdentity);
const ecdhseed = await sha256(
zkIdentity
.getSecret()
.map(d => d.toString())
.join()
);
const ecdhHex = await sha256(ecdhseed);
const keyPair = await generateECDHKeyPairFromhex(ecdhHex);
await zkchat.importIdentity({
address: selected?.identityCommitment,
zk: zkIdentity,
ecdh: keyPair,
});
})();
} else if (selected?.type === 'taz') {
(async () => {
const zkIdentity = new Identity(selected.serializedIdentity);
const ecdhseed = await sha256(selected.serializedIdentity);
const ecdhHex = await sha256(ecdhseed);
const keyPair = await generateECDHKeyPairFromhex(ecdhHex);
await zkchat.importIdentity({
address: selected?.identityCommitment,
zk: zkIdentity,
ecdh: keyPair,
});
})();
}
}, [selected]);
return (
<Route {...routeProps}>
<Redirect to={redirect} />
</Route>
)
useEffect(() => {
navigator.serviceWorker.addEventListener('message', event => {
const data = event.data;
if (!data) return;
if (data.target === 'redux') {
const action = data.action;
dispatch(action);
}
});
}, []);
return (
<div
className={classNames('flex flex-col flex-nowrap w-screen h-screen overflow-hidden app', {
dark: theme === 'dark',
light: theme !== 'light',
})}>
{showingTazModal && (
<TazModal tazIdentity={tazIdentity} onClose={() => showTazModal(false)} />
)}
<TopNav />
<div className="flex flex-row flex-nowrap app__content">
<Switch>
<Route path="/explore">
<GlobalFeed />
</Route>
<Route path="/tag/:tagName">
<TagFeed />
</Route>
<Route path="/:name/status/:hash">
<PostView />
</Route>
<Route path="/post/:hash">
<PostView />
</Route>
<AuthRoute path="/home">
<HomeFeed />
</AuthRoute>
<Route path="/notifications" />
<Route path="/create-local-backup">
<SignupView viewType={ViewType.localBackup} />
</Route>
<Route path="/onboarding/interrep">
<InterrepOnboarding />
</Route>
<Route path="/signup/interep">
<InterrepOnboarding />
</Route>
<Route path="/connect/twitter">
<ConnectTwitterView />
</Route>
<Route path="/signup">
<SignupView />
</Route>
<Route path="/settings">
<SettingView />
</Route>
<Route path="/chat">
<ChatView />
</Route>
<Route path="/taz">
<GlobalFeed />
</Route>
<Route path="/:name">
<ProfileView />
</Route>
<Route path="/">
<Redirect to="/explore" />
</Route>
</Switch>
<MetaPanel className="mobile-hidden" />
</div>
<BottomNav />
</div>
);
}
function AuthRoute(
props: {
redirect?: string;
} & RouteProps
): ReactElement {
const { redirect = '/', ...routeProps } = props;
const loggedIn = useGunLoggedIn();
if (loggedIn) {
return <Route {...routeProps} />;
}
return (
<Route {...routeProps}>
<Redirect to={redirect} />
</Route>
);
}

View File

@@ -1,4 +1,4 @@
@import "../../util/variable.scss";
@import '../../util/variable.scss';
.dark {
.chat-view {
@@ -13,4 +13,4 @@
border-left: 1px solid $gray-50;
border-right: 1px solid $gray-50;
overflow: hidden;
}
}

Some files were not shown because too many files have changed in this diff Show More