Merge branch 'main' into update-report-judgement

This commit is contained in:
xiawpohr
2024-12-09 14:05:35 +08:00
70 changed files with 1491 additions and 927 deletions

View File

@@ -9,5 +9,5 @@
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"cSpell.words": ["partialize", "headlessui", "reportor"]
"cSpell.words": ["partialize", "headlessui", "reportor", "uidotdev"]
}

View File

@@ -47,6 +47,7 @@
"react-hook-form": "^7.51.4",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.2.1",
"react-joyride": "^2.9.2",
"react-lines-ellipsis": "^0.15.4",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.23.1",

View File

@@ -37,3 +37,17 @@
}
}
}
@layer components {
.progress-gradient {
@apply bg-white;
}
.progress-gradient::-webkit-progress-value {
@apply bg-gradient-to-r from-[#52acbc] to-[#ff892a];
}
.progress-gradient::-moz-progress-bar {
@apply bg-gradient-to-r from-[#52acbc] to-[#ff892a];
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -0,0 +1,3 @@
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.9" fill-rule="evenodd" clip-rule="evenodd" d="M2 0C0.895431 0 0 0.89543 0 2V8C0 9.10457 0.89543 10 2 10H12C13.1046 10 14 9.10457 14 8V2C14 0.895431 13.1046 0 12 0H2ZM12.25 1.81818H7.4375V8.18182H12.25V1.81818ZM1.75 1.81818H6.5625V2.72727H1.75V1.81818ZM6.5625 3.63636H1.75V4.54545H6.5625V3.63636ZM1.75 5.45455H6.5625V6.36364H1.75V5.45455ZM6.5625 7.27273H1.75V8.18182H6.5625V7.27273Z" fill="#FF892A"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -0,0 +1,3 @@
<svg width="26" height="12" viewBox="0 0 26 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.469669 5.46967C0.176777 5.76256 0.176777 6.23743 0.469669 6.53033L5.24264 11.3033C5.53553 11.5962 6.01041 11.5962 6.3033 11.3033C6.59619 11.0104 6.59619 10.5355 6.3033 10.2426L2.06066 6L6.3033 1.75736C6.59619 1.46446 6.59619 0.989591 6.3033 0.696697C6.01041 0.403804 5.53553 0.403804 5.24264 0.696697L0.469669 5.46967ZM26 5.25L1 5.25L1 6.75L26 6.75L26 5.25Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,3 @@
<svg width="26" height="12" viewBox="0 0 26 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.5303 6.53033C25.8232 6.23744 25.8232 5.76256 25.5303 5.46967L20.7574 0.696698C20.4645 0.403804 19.9896 0.403804 19.6967 0.696698C19.4038 0.989591 19.4038 1.46446 19.6967 1.75736L23.9393 6L19.6967 10.2426C19.4038 10.5355 19.4038 11.0104 19.6967 11.3033C19.9896 11.5962 20.4645 11.5962 20.7574 11.3033L25.5303 6.53033ZM4.72083e-08 6.75L25 6.75L25 5.25L-4.72083e-08 5.25L4.72083e-08 6.75Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 525 B

View File

@@ -1,6 +1,7 @@
export enum QueryKeys {
RelayConfig = 'relay_config',
UserState = 'user_state',
UserInfo = 'user_info',
HasSignedUp = 'has_signed_up',
CurrentEpoch = 'current_epoch',
EpochRemainingTime = 'epoch_remaining_time',

View File

@@ -34,7 +34,7 @@ export default function CyanButton({
max-w-[44rem]
items-center
rounded-xl
bg-[#2F9CAF]
bg-secondary
p-4
text-white
focus:outline-offset-0

View File

@@ -0,0 +1,35 @@
import { openTour } from '@/features/core'
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
import { useEffect, useState } from 'react'
import { useAuthStatus } from '../../hooks/useAuthStatus/useAuthStatus'
import SignupProgress from '../SignupProgress/SignupProgress'
export default function SignupPending() {
const { isSigningUp } = useAuthStatus()
const [open, setOpen] = useState(false)
useEffect(() => {
if (isSigningUp) {
setOpen(true)
setTimeout(() => {
setOpen(false)
openTour()
}, 2000)
}
}, [isSigningUp])
return (
<Dialog className="relative z-50" open={open} onClose={() => {}}>
<DialogBackdrop className="fixed inset-0 bg-black/70" />
<div className="fixed inset-0 flex items-center justify-center w-screen p-4">
<DialogPanel className="relative p-0 w-85 shadow-base">
<div className="flex flex-col justify-center h-48 overflow-y-auto rounded-xl bg-white/90">
<SignupProgress />
</div>
</DialogPanel>
</div>
</Dialog>
)
}

View File

@@ -1,153 +0,0 @@
import { MutationKeys } from '@/constants/queryKeys'
import { useAuthStatus } from '@/features/auth/hooks/useAuthStatus/useAuthStatus'
import Backdrop from '@/features/shared/components/Backdrop/Backdrop'
import { useMutationState } from '@tanstack/react-query'
import clsx from 'clsx'
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import './signupPendingTransition.css'
const textsAndTimes: { text: string; time: number }[] = [
{ text: 'Unirep Social TW 是個全匿名且去中心化的社群平台', time: 7000 },
{ text: '匿名的友善互動環境還需請您一起共同守護 ', time: 14000 },
]
export default function SignUpLoadingModal() {
const navigate = useNavigate()
const onClick = () => {
navigate('/login', { replace: true, state: {} })
}
const { isLoggedIn, isLoggingIn, isSigningUp } = useAuthStatus()
const [isShowSignupLoadingTransition, setIsShowSignupLoadingTransition] =
useState(false)
const isPending =
isLoggingIn || isSigningUp || isShowSignupLoadingTransition
useEffect(() => {
if (isLoggedIn) {
setTimeout(() => {
setIsShowSignupLoadingTransition(false)
}, 1500)
} else {
setIsShowSignupLoadingTransition(true)
}
}, [isLoggedIn])
const statuses = useMutationState({
filters: {
mutationKey: [MutationKeys.Signup],
},
select: (mutation) => mutation.state.status,
})
const status = statuses[0]
const [pendingText, setPendingText] =
useState('努力註冊中,先來看看文章吧!')
const opacityVarients = {
visible: { opacity: 1 },
hidden: {
opacity: 0,
transition: {
delay: 0,
duration: 1.5,
ease: 'easeInOut',
},
},
}
useEffect(() => {
if (status === 'pending') {
const timers: NodeJS.Timeout[] = []
textsAndTimes.forEach(({ text, time }) => {
const timer = setTimeout(() => {
setPendingText(text)
}, time)
timers.push(timer)
})
return () => {
timers.forEach((timer) => clearTimeout(timer))
}
} else return
}, [status])
let content
switch (status) {
case 'pending':
content = (
<>
<div className="w-8/12 h-[12px] rounded-2xl progress bg-[#222222]" />
<p className="w-11/12 text-lg font-semibold tracking-wider text-center text-white h-14">
{pendingText}
</p>
</>
)
break
case 'success':
content = (
<>
<motion.div
className="w-8/12 h-[12px] rounded-2xl bg-gradient-to-r from-[#52ACBC] to-[#FF892A]"
variants={opacityVarients}
initial="visible"
animate="hidden"
/>
<motion.p
className="w-11/12 text-lg font-semibold tracking-wider text-center text-white h-14"
variants={opacityVarients}
initial="visible"
animate="hidden"
>
Po
</motion.p>
</>
)
break
case 'idle' || 'error':
content = (
<>
<p className="text-lg font-semibold tracking-wider text-white">
/
</p>
<p className="text-lg font-semibold tracking-wider text-white">
Po文
</p>
<button
className="py-4 bg-[#FF892A] rounded-lg text-white font-bold tracking-wider text-lg w-4/5 my-4"
onClick={onClick}
>
/
</button>
</>
)
break
}
return (
<Backdrop
isOpen={isPending}
position="absolute"
background="bg-gradient-to-t from-black/100 to-white/0"
>
<div
className={clsx(
`flex flex-col justify-center items-center gap-2 w-full h-full`,
status !== 'idle' && 'md:pt-12',
)}
>
{content}
</div>
</Backdrop>
)
}

View File

@@ -1,51 +0,0 @@
.progress {
overflow: hidden;
box-shadow: 0px 2px 2px 0px #00000040 inset;
box-shadow: 0px -1px 5px 0px #ffffff4d inset;
}
.progress::after {
content: '';
display: block;
width: 100%;
height: 100%;
border-radius: 1rem;
background: linear-gradient(90deg, #52acbc, #ff892a);
animation: load 20s linear;
}
@keyframes load {
0% {
width: 0;
}
10% {
width: 5%;
}
20% {
width: 15%;
}
30% {
width: 25%;
}
40% {
width: 30%;
}
50% {
width: 44%;
}
60% {
width: 50%;
}
70% {
width: 72%;
}
80% {
width: 84%;
}
90% {
width: 92%;
}
100% {
width: 98%;
}
}

View File

@@ -0,0 +1,25 @@
import { useAuthStatus } from '../../hooks/useAuthStatus/useAuthStatus'
import { useSignupProgressStore } from './signupProgressStore'
export default function SignupProgress() {
const { value, max } = useSignupProgressStore()
const { isSignedUp, isSigningUp } = useAuthStatus()
const message = isSignedUp
? '恭喜註冊成功! 🙌🏻'
: isSigningUp
? '努力註冊中,先來認識平台的功能吧!'
: ''
return (
<div className="flex flex-col items-center justify-center gap-2">
<progress
className="h-3 max-w-72 progress progress-gradient"
value={value}
max={max}
/>
<p className="text-sm tracking-wide text-content">{message}</p>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
export interface SignupProgressState {
value: number
max: number
isPaused: boolean
}
const initialState: SignupProgressState = {
value: 0,
max: 20,
isPaused: false,
}
const slot = 1000 // 1 second
export const useSignupProgressStore = create<SignupProgressState>()(
immer(() => initialState),
)
export function startSignupProgress() {
const { isPaused } = useSignupProgressStore.getState()
if (isPaused) return
useSignupProgressStore.setState((state) => {
state.value++
})
setTimeout(() => {
startSignupProgress()
}, slot)
}
export function pauseSignupProgress() {
useSignupProgressStore.setState({
isPaused: true,
})
}
export function continueSignupProgress() {
useSignupProgressStore.setState({
isPaused: false,
})
startSignupProgress()
}
export function resetSignupProgress() {
pauseSignupProgress()
setTimeout(() => {
useSignupProgressStore.setState(initialState)
}, slot)
}

View File

@@ -2,8 +2,11 @@ import { MutationKeys, QueryKeys } from '@/constants/queryKeys'
import { useAuthStatus } from '@/features/auth'
import { useUserState, useWeb3Provider } from '@/features/core'
import { relaySignUp } from '@/utils/api'
import { SignupFailedError } from '@/utils/errors'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
resetSignupProgress,
startSignupProgress,
} from '../../components/SignupProgress/signupProgressStore'
export function useSignup() {
const queryClient = useQueryClient()
@@ -29,28 +32,25 @@ export function useSignup() {
accessToken: string
fromServer: boolean
}) => {
try {
if (isSignedUp) return
if (isSignedUp) return
startSignupProgress()
const provider = getGuaranteedProvider()
const userState = await getGuaranteedUserState()
const provider = getGuaranteedProvider()
const userState = await getGuaranteedUserState()
const proof = await userState.genUserSignUpProof()
const proof = await userState.genUserSignUpProof()
const data = await relaySignUp(
proof,
hashUserId,
accessToken,
fromServer,
)
const data = await relaySignUp(
proof,
hashUserId,
accessToken,
fromServer,
)
await provider.waitForTransaction(data.txHash)
await userState.waitForSync()
await provider.waitForTransaction(data.txHash)
await userState.waitForSync()
return data
} catch {
throw new SignupFailedError()
}
return data
},
onSuccess: async () => {
await queryClient.invalidateQueries({
@@ -60,6 +60,9 @@ export function useSignup() {
queryKey: [QueryKeys.ReputationScore],
})
},
onError: () => {
resetSignupProgress()
},
})
return {

View File

@@ -7,7 +7,8 @@ export { default as Greeting } from './components/Greeting/Greeting'
export { default as LoginButton } from './components/LoginButton/LoginButton'
export { default as LogoutModal } from './components/LogoutModal/LogoutModal'
export { default as ProtectedRoute } from './components/ProtectedRoute/ProtectedRoute'
export { default as SignupPendingTransition } from './components/SignupPendingTransition/SignupPendingTransition'
export { default as SignupPending } from './components/SignupPending/SignupPending'
export { default as SignupProgress } from './components/SignupProgress/SignupProgress'
export { default as StepInfo } from './components/StepInfo/StepInfo'
export * from './components/TwitterButton/TwitterButton'
export { default as UserDropdown } from './components/UserDropdown/UserDropdown'

View File

@@ -1,65 +0,0 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ActionType, addAction } from '@/features/core'
import ActionNotification from './ActionNotification'
import { TestWrapper } from '@/utils/test-helpers/wrapper'
describe('ActionNotification', () => {
it('should display nothing if no actions', () => {
render(
<TestWrapper>
<ActionNotification />
</TestWrapper>,
)
const countButton = screen.queryByTestId('action-count-button')
expect(countButton).toBeNull()
})
it('should display action label if action is added', () => {
const postData = {
id: 'post-id-1',
content: 'This is a post',
epochKey: 'epochKey',
transactionHash: 'hash',
}
render(
<TestWrapper>
<ActionNotification />
</TestWrapper>,
)
act(() => {
addAction(ActionType.Post, postData)
})
const countButton = screen.queryByTestId('action-count-button')
expect(countButton).toBeInTheDocument()
})
it('should open a dialog to display action list', async () => {
const postData = {
id: 'post-id-1',
content: 'This is a post',
epochKey: 'epochKey',
transactionHash: 'hash',
}
render(
<TestWrapper>
<ActionNotification />
</TestWrapper>,
)
act(() => {
addAction(ActionType.Post, postData)
})
const countButton = screen.getByTestId('action-count-button')
await userEvent.click(countButton)
const actionDialog = screen.queryByTestId('actions-dialog')
expect(actionDialog).toBeInTheDocument()
})
})

View File

@@ -1,42 +0,0 @@
import { ActionType, addAction } from '@/features/core'
import { TestWrapper } from '@/utils/test-helpers/wrapper'
import { act, render, screen } from '@testing-library/react'
import { useState } from 'react'
import * as router from 'react-router'
import ActionTable from './ActionTable'
const ActionTableWrapper = () => {
const [, setIsOpen] = useState(false)
return <ActionTable onClose={() => setIsOpen(false)} />
}
describe('ActionTable', () => {
const mockedUsedNavigate = jest.fn()
jest.spyOn(router, 'useNavigate').mockImplementation(
() => mockedUsedNavigate,
)
it('should display action list', () => {
render(
<TestWrapper>
<ActionTableWrapper />
</TestWrapper>,
)
const postData = {
id: 'post-id-1',
content: 'This is a post',
epochKey: 'epochKey',
transactionHash: 'hash',
}
act(() => {
addAction(ActionType.Post, postData)
})
expect(screen.getByText('時間')).toBeInTheDocument()
expect(screen.getByText('操作')).toBeInTheDocument()
expect(screen.getByText('上鏈交易狀態')).toBeInTheDocument()
expect(screen.getByText('連結')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,31 @@
import { useActionCount } from '@/features/core'
import clsx from 'clsx'
export default function ActionCounter() {
const count = useActionCount()
return (
<div className="flex h-full gap-2">
{new Array(7).fill(0).map((_, index) => (
<div
key={index}
className={clsx('flex-1', getCounterColor(index, count))}
/>
))}
</div>
)
}
function getCounterColor(index: number, count: number) {
if (index < count) {
if (index > 4) {
return 'bg-error'
} else if (index > 2) {
return 'bg-primary'
} else {
return 'bg-secondary'
}
} else {
return 'bg-[#d9d9d9]'
}
}

View File

@@ -0,0 +1,34 @@
import { ReactComponent as CloseIcon } from '@/assets/svg/close.svg'
import {
CloseButton,
Dialog,
DialogBackdrop,
DialogPanel,
} from '@headlessui/react'
import ActionTable from './ActionTable'
export default function ActionDialog({
open = false,
onClose = () => {},
}: {
open?: boolean
onClose?: () => void
}) {
return (
<Dialog className="relative z-40" open={open} onClose={onClose}>
<DialogBackdrop className="fixed inset-0 bg-black/70" />
<div className="fixed inset-0 overflow-y-auto">
<div className="min-h-full px-4 pt-[12rem] md:py-4 md:flex md:justify-center md:items-center">
<DialogPanel className="relative w-full max-w-md mx-auto overflow-hidden rounded-xl bg-black/90 drop-shadow-[0_0_30px_rgba(255,255,255,0.1)]">
<CloseButton className="absolute top-4 right-4 btn btn-sm btn-circle btn-ghost text-white/90">
<CloseIcon />
</CloseButton>
<div className="p-6 overflow-y-auto h-72 rounded-xl">
<ActionTable onClose={onClose} />
</div>
</DialogPanel>
</div>
</div>
</Dialog>
)
}

View File

@@ -0,0 +1,22 @@
import { useActionCount } from '@/features/core'
export default function ActionMessage() {
const count = useActionCount()
const message = getAccountCountMessage(count)
return (
<span className="inline-block text-xs font-medium text-white/60">
{message}
</span>
)
}
function getAccountCountMessage(count: number) {
if (count > 5) {
return '強烈建議等下個Epoch後執行動作以免身份洩漏'
} else if (count > 3) {
return '動作次數超出安全範圍建議等下個Epoch後執行'
} else {
return '目前動作次數3次內可確保匿名身份不被交叉比對'
}
}

View File

@@ -1,8 +1,6 @@
import { ReactComponent as CloseIcon } from '@/assets/svg/close.svg'
import { ReactComponent as PostIcon } from '@/assets/svg/post.svg'
import { ReactComponent as ActionIcon } from '@/assets/svg/action.svg'
import {
ActionStatus,
ActionTable,
ActionType,
getActionMessage,
getActionSubject,
@@ -11,9 +9,100 @@ import {
useActionStore,
type Action,
} from '@/features/core'
import { Dialog } from '@headlessui/react'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import ActionDialog from './ActionDialog'
export default function ActionTracker() {
const lastestAction = useActionStore(latestActionSelector)
const pendingCount = useActionStore(pendingCountSelector)
const actionStatus = getActionStatus(lastestAction)
const [open, setOpen] = useState(false)
return (
<>
<div
className="grid grid-cols-[1fr_auto] items-center gap-2"
data-tour-step="3"
>
<div>{actionStatus}</div>
<button
className="px-1.5 py-px text-xs border text-primary border-primary leading-none"
onClick={() => setOpen(true)}
>
{pendingCount}
</button>
</div>
<ActionDialog open={open} onClose={() => setOpen(false)} />
</>
)
}
function getActionStatus(action: Action | undefined) {
if (!action) {
return null
}
const message = getActionMessage(action.type)
const subject = getActionSubject(action.type)
const actionLink = getActionLink(action)
switch (action.status) {
case ActionStatus.Pending: {
return (
<div className="flex items-center gap-2">
<ActionIcon className="w-4 text-primary" />
<span className="text-xs text-primary">
{message}
</span>
<progress className="flex-1 h-3 rounded-none progress progress-primary" />
</div>
)
}
case ActionStatus.Success: {
return (
<div className="flex items-center gap-2">
<ActionIcon className="w-4 text-primary" />
<span className="text-xs text-primary">
{message}!
</span>
{isActionLinkExistWhenSuccess(action) && (
<Link
className="text-xs text-secondary"
to={actionLink}
>
{subject}
</Link>
)}
</div>
)
}
case ActionStatus.Failure: {
return (
<div className="flex items-center gap-2">
<ActionIcon className="w-4 text-primary" />
<span className="text-xs text-primary">
{message}!
</span>
{isActionLinkExistWhenFailure(action) && (
<Link
className="text-xs text-secondary"
to={actionLink}
>
{subject}
</Link>
)}
</div>
)
}
default: {
return null
}
}
}
function getActionLink(action: Action) {
if (action.type === ActionType.Post) {
@@ -33,12 +122,7 @@ function getActionLink(action: Action) {
}
function isActionLinkExistWhenSuccess(action: Action) {
return (
action.type === ActionType.Post ||
action.type === ActionType.Comment ||
action.type === ActionType.UpVote ||
action.type === ActionType.DownVote
)
return action.type === ActionType.Post || action.type === ActionType.Comment
}
function isActionLinkExistWhenFailure(action: Action) {
@@ -48,116 +132,3 @@ function isActionLinkExistWhenFailure(action: Action) {
action.type === ActionType.DeleteComment
)
}
function getActionStatusLabel(action: Action) {
const message = getActionMessage(action.type)
const subject = getActionSubject(action.type)
const actionLink = getActionLink(action)
switch (action.status) {
case ActionStatus.Pending: {
return (
<div className="flex items-center gap-2">
<PostIcon className="w-4 text-primary" />
<span className="text-xs text-primary">
{message}
</span>
<progress className="flex-1 h-3 rounded-none progress progress-primary" />
</div>
)
}
case ActionStatus.Success: {
return (
<div className="flex items-center gap-2">
<PostIcon className="w-4 text-white" />
<span className="text-xs text-white">
{message}!
</span>
{isActionLinkExistWhenSuccess(action) && (
<Link
className="text-xs text-secondary"
to={actionLink}
>
{subject}
</Link>
)}
</div>
)
}
case ActionStatus.Failure: {
return (
<div className="flex items-center gap-2">
<PostIcon className="w-4 text-primary" />
<span className="text-xs text-primary">
{message}!
</span>
{isActionLinkExistWhenFailure(action) && (
<Link
className="text-xs text-secondary"
to={actionLink}
>
{subject}
</Link>
)}
</div>
)
}
default: {
return null
}
}
}
export default function ActionNotification() {
const latestAction = useActionStore(latestActionSelector)
const pendingCount = useActionStore(pendingCountSelector)
const [isOpen, setIsOpen] = useState(false)
if (!latestAction) return null
const statusLabel = getActionStatusLabel(latestAction)
return (
<>
<div className="grid grid-cols-[1fr_auto] items-center gap-2">
{statusLabel}
<button
className="px-1.5 py-px text-xs border text-primary border-primary leading-none"
data-testid="action-count-button"
onClick={() => setIsOpen(true)}
>
{pendingCount}
</button>
</div>
<Dialog
className="relative z-40"
open={isOpen}
onClose={() => setIsOpen(false)}
>
<div className="fixed inset-0 overflow-y-auto">
<div className="min-h-full px-4 pt-[15.75rem] md:py-4 md:flex md:justify-center md:items-center">
<Dialog.Panel
className="relative w-full max-w-md mx-auto overflow-hidden rounded-xl bg-black/90 drop-shadow-[0_0_30px_rgba(255,255,255,0.1)]"
data-testid="actions-dialog"
>
<div className="px-6 py-4">
<button
className="absolute top-4 right-4 btn btn-sm btn-circle btn-ghost text-white/90"
type="submit"
onClick={() => setIsOpen(false)}
>
<CloseIcon />
</button>
</div>
<div className="px-6 pb-6">
<ActionTable onClose={() => setIsOpen(false)} />
</div>
</Dialog.Panel>
</div>
</div>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,31 @@
import EpochImg from '@/assets/img/epoch.png'
import ActionCounter from './ActionCounter'
import ActionMessage from './ActionMessage'
import ActionTracker from './ActionTracker'
import EpochTimer from './EpochTimer'
export default function ActionWidget() {
return (
<div className="space-y-2" data-tour-step="1">
<div className="flex items-stretch gap-2">
<img
className="w-16 h-16 basis-16 shrink-0"
src={EpochImg}
alt="epoch actions"
/>
<div className="flex-1 space-y-1.5" data-tour-step="2">
<div className="flex gap-2">
<div className="w-28 basis-28">
<EpochTimer />
</div>
<div className="flex-1">
<ActionCounter />
</div>
</div>
<ActionMessage />
</div>
</div>
<ActionTracker />
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { PATHS } from '@/constants/paths'
import { useEpoch } from '@/features/core'
import Countdown from 'react-countdown'
import { AiOutlineQuestionCircle } from 'react-icons/ai'
import { Link } from 'react-router-dom'
export default function EpochTimer() {
const { epochEndTime } = useEpoch()
return (
<div className="space-y-1">
<div className="flex items-start gap-1 text-xs font-semibold text-white">
Epoch
<Link to={`${PATHS.ABOUT_US}?viewId=feature-epoch`}>
<AiOutlineQuestionCircle size={12} />
</Link>
</div>
<div className="text-3xl font-semibold leading-6 text-white">
{epochEndTime && (
<Countdown
key={epochEndTime}
date={epochEndTime}
renderer={(props) =>
`${props.formatted.minutes}:${props.formatted.seconds}`
}
/>
)}
</div>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import { render, screen } from '@testing-library/react'
import { wrapper } from '@/utils/test-helpers/wrapper'
import EpochInfo from './EpochInfo'
describe('EpochInfo', () => {
it('should display remaining time', () => {
render(<EpochInfo />, { wrapper })
expect(screen.getByTestId('epoch-remaining-time')).toBeInTheDocument()
})
it('should display action count indicator', () => {
render(<EpochInfo />, { wrapper })
expect(screen.getByTestId('action-counter')).toBeInTheDocument()
})
it('should display message', () => {
render(<EpochInfo />, { wrapper })
expect(
screen.getByText('目前動作次數(3次內)可確保匿名身份不被洩漏'),
).toBeInTheDocument()
})
})

View File

@@ -1,99 +0,0 @@
import EpochImg from '@/assets/img/epoch.png'
import { useActionCount, useEpoch } from '@/features/core'
import clsx from 'clsx'
import Countdown from 'react-countdown'
function EpochTimer() {
const { epochEndTime } = useEpoch()
return (
<div>
<div className="text-xs font-semibold text-white">
Next Epoch in
</div>
<div
className="text-3xl font-semibold text-white h-9"
data-testid="epoch-remaining-time"
>
{epochEndTime && (
<Countdown
key={epochEndTime}
date={epochEndTime}
renderer={(props) =>
`${props.formatted.minutes}:${props.formatted.seconds}`
}
/>
)}
</div>
</div>
)
}
function getCounterColor(index: number, count: number) {
if (index < count) {
if (index > 4) {
return 'bg-error'
} else if (index > 2) {
return 'bg-primary'
} else {
return 'bg-secondary'
}
} else {
return 'bg-[#d9d9d9]'
}
}
function EpochActionCounter() {
const count = useActionCount()
return (
<div className="flex h-full gap-2" data-testid="action-counter">
{new Array(7).fill(0).map((_, index) => (
<div
key={index}
className={clsx('flex-1', getCounterColor(index, count))}
/>
))}
</div>
)
}
function getAccountCountMessage(count: number) {
if (count > 5) {
return '強烈建議等下個Epoch後執行動作以免身份洩漏'
} else if (count > 3) {
return '動作次數超出安全範圍建議等下個Epoch後執行'
} else {
return '目前動作次數(3次內)可確保匿名身份不被洩漏'
}
}
function EpochMessage() {
const count = useActionCount()
const message = getAccountCountMessage(count)
return <p className="text-xs font-medium text-white/60">{message}</p>
}
export default function EpochInfo() {
return (
<div className="flex items-stretch gap-3">
<img
className="w-14 h-14 basis-14 shrink-0"
src={EpochImg}
alt="epoch actions"
/>
<div className="flex-1 space-y-1">
<div className="flex gap-2">
<div className="w-[90px] basis-[90px]">
<EpochTimer />
</div>
<div className="flex-1">
<EpochActionCounter />
</div>
</div>
<EpochMessage />
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { ReactComponent as LongArrowLeftIcon } from '@/assets/svg/long-arrow-left.svg'
import { ReactComponent as LongArrowRightIcon } from '@/assets/svg/long-arrow-right.svg'
import { jumpStep, nextStep, prevStep, useTourStore } from '../../stores/tour'
export default function TourPagination() {
const { steps, stepIndex } = useTourStore()
const isFirstStep = stepIndex === 0
const isLastStep = stepIndex === steps.length - 1
return (
<div className="flex gap-3">
<button
className="flex-1 h-2 text-content disabled:text-content/30"
disabled={isFirstStep}
onClick={prevStep}
>
<LongArrowLeftIcon />
</button>
{[1, 2, 3, 4, 5, 6].map((_, index) => (
<button
key={index}
className="flex-1 h-2 bg-white data-[active=true]:bg-primary"
data-active={stepIndex === index}
onClick={() => jumpStep(index)}
/>
))}
<button
className="flex-1 h-2 text-content disabled:text-content/30"
disabled={isLastStep}
onClick={nextStep}
>
<LongArrowRightIcon />
</button>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { ReactComponent as CloseIcon } from '@/assets/svg/close.svg'
import { SignupProgress, useAuthStatus } from '@/features/auth'
import { type TooltipRenderProps } from 'react-joyride'
import { closeTour, resetTour } from '../../stores/tour'
import TourPagination from './TourPagination'
function StepTitle({ children }: { children: React.ReactNode }) {
return (
<h1 className="mb-2 text-xl font-bold tracking-wide text-center text-content">
{children}
</h1>
)
}
function StepParagraph({ children }: { children: React.ReactNode }) {
return (
<p className="text-sm tracking-wide text-left text-content">
{children}
</p>
)
}
export default function TourTooltip(props: TooltipRenderProps) {
const { index, step, tooltipProps, isLastStep } = props
const { isSignedUp } = useAuthStatus()
return (
<article
{...tooltipProps}
className="relative border-2 border-white max-w-85 bg-white/90 rounded-xl"
>
<div className="absolute -top-2 -left-2">
<span className="flex items-center justify-center text-lg font-bold text-white rounded-full w-7 h-7 bg-primary">
{index + 1}
</span>
</div>
{isSignedUp && (
<div className="absolute top-2 right-2">
<button onClick={closeTour}>
<CloseIcon />
</button>
</div>
)}
<div className="px-4 py-8 space-y-6">
<section>
{step.title && <StepTitle>{step.title}</StepTitle>}
<StepParagraph>{step.content}</StepParagraph>
</section>
<section>
<TourPagination />
</section>
<section>
{isSignedUp && isLastStep ? (
<button
className="flex items-center justify-center px-4 py-2 mx-auto text-base font-bold text-white rounded-full h-9 bg-secondary"
onClick={resetTour}
>
</button>
) : (
<SignupProgress />
)}
</section>
</div>
</article>
)
}

View File

@@ -0,0 +1,34 @@
import Joyride, { STATUS, type CallBackProps, type Status } from 'react-joyride'
import { closeTour, useTourStore } from '../../stores/tour'
import TourTooltip from './TourTooltip'
export default function UITour() {
const { steps, stepIndex, run } = useTourStore()
const handleCallback = (data: CallBackProps) => {
const { status } = data
const finishedStatuses: Status[] = [STATUS.FINISHED, STATUS.SKIPPED]
if (finishedStatuses.includes(status)) {
closeTour()
}
}
return (
<Joyride
tooltipComponent={TourTooltip}
steps={steps}
stepIndex={stepIndex}
run={run}
callback={handleCallback}
continuous
disableCloseOnEsc
disableOverlayClose
scrollToFirstStep
showProgress
floaterProps={{
hideArrow: true,
}}
/>
)
}

View File

@@ -0,0 +1,35 @@
import { QueryKeys } from '@/constants/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useUserState } from '../useUserState/useUserState'
export function useUserInfo() {
const { userState } = useUserState()
return useQuery({
queryKey: [QueryKeys.UserInfo, userState?.id.toString()],
queryFn: async () => {
if (!userState) {
return null
}
const identity = userState.id
const db = userState.db
const provider = userState.sync.provider
const userId = identity.secret.toString()
const signup = await db.findOne('UserSignUp', {
where: {
commitment: identity.commitment.toString(),
},
})
const blockNumber = signup.blockNumber
const block = await provider.getBlock(blockNumber)
return {
userId,
signedUpDate: new Date(block.timestamp * 1000),
}
},
})
}

View File

@@ -1,12 +1,12 @@
export { default as ActionNotification } from './components/ActionNotification/ActionNotification'
export { default as ActionTable } from './components/ActionTable/ActionTable'
export { default as EpochInfo } from './components/EpochInfo/EpochInfo'
export { default as ActionWidget } from './components/ActionWidget/ActionWidget'
export { default as QueryProvider } from './components/QueryProvider/QueryProvider'
export { default as UITour } from './components/UITour/UITour'
export { useActionCount } from './hooks/useActionCount/useActionCount'
export { useEpoch } from './hooks/useEpoch/useEpoch'
export { useProveData } from './hooks/useProveData/useProveData'
export { useRelayConfig } from './hooks/useRelayConfig/useRelayConfig'
export { useRequestData } from './hooks/useRequestData/useRequestData'
export { useUserInfo } from './hooks/useUserInfo/useUserInfo'
export { useUserState } from './hooks/useUserState/useUserState'
export { useUserStateTransition } from './hooks/useUserStateTransition/useUserStateTransition'
export { useWeb3Provider } from './hooks/useWeb3Provider/useWeb3Provider'
@@ -18,6 +18,7 @@ export { ReputationService } from './services/ReputationService/ReputationServic
export { UserService } from './services/UserService/UserService'
export { VoteService } from './services/VoteService/VoteService'
export * from './stores/actions'
export * from './stores/tour'
export {
genReportIdentityProof,
genReportNullifier,

View File

@@ -0,0 +1,96 @@
import { type Step } from 'react-joyride'
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
export interface TourState {
run: boolean
stepIndex: number
steps: Step[]
}
const steps: Step[] = [
{
title: '動作操作計數區塊',
content:
'此區塊為 Unirep Social Taiwan 特有的動作操作計數區塊,方便你快速辨識平台上的操作動作的相關資訊,以防止身份被辨識。在平台中,每 5 分鐘為一個 Epoch每個 Epoch 內你可使用 3 把 Epoch Keys (一種隨機生成的身份標示符,用來識別用戶在特定 Epoch 期間的行為)來執行各種操作',
target: '[data-tour-step="1"]',
placement: 'auto',
disableBeacon: true,
},
{
title: '動作操作計數條',
content:
'發文、按讚、留言、檢舉和評判等每個操作都會使用到 1 把 Epoch Key。若在各 Epoch 內進行超過 3 個操作,將重複使用 Epoch Key此時可能增加交叉比對的可行性從而提高身份被識別的風險。此計數條幫助你辨識當前 Epoch 內的操作次數',
target: '[data-tour-step="2"]',
placement: 'left',
disableBeacon: true,
},
{
title: '上鏈交易進度與狀態',
content:
'計數區底部會顯示最新的操作行為以及該操作的上鏈交易Transaction是指將某一筆交易或動作記錄永久寫入區塊鏈的過程進度與狀態。最右方橘色框中的數字代表當前進行中的上鏈交易數量',
target: '[data-tour-step="3"]',
placement: 'left',
disableBeacon: true,
},
{
title: '貼文卡片 & 隨機身份',
content:
'在貼文首頁中可以瀏覽所有貼文的貼文卡片。貼文卡片上會顯示該篇貼文作者的「隨機身份」頭貼、發表時間、完整內容或局部內容、按讚數、倒讚數、留言數等資訊。為保護你的隱私與身份,會在每個動作被執行時賦予你新的隨機身份, 因此你的用戶頭像圖片會是隨機生成的,並且每次都會不一樣',
target: '[data-tour-step="4"]',
placement: 'bottom',
disableBeacon: true,
},
{
title: '檢舉不當貼文',
content:
'當你有看到不當的貼文內容,你可以透過該貼文卡片的右上角的三個小圓點按鈕呼叫出檢舉按鈕進行檢舉。當檢舉送出後,將交由 5 位隨機用戶進行檢舉內容的評判審核。若檢舉通過,你的聲譽分數將提高 3 分,反之,則您的聲譽分數會降低 1 分',
target: '[data-tour-step="5"]',
placement: 'bottom-end',
disableBeacon: true,
},
{
title: '恭喜瀏覽完所有介紹!🙌🏻',
content:
'恭喜你看完所有功能與特色介紹,希望對你使用 Unirep Social Taiwan 是有幫助的!若後續在使用時還有不太清楚明白的地方,可以參閱「平台說明」的頁面去查看相關資訊',
target: '[data-tour-step="6"]',
placement: 'left-start',
disableBeacon: true,
},
]
const initialState: TourState = {
run: false,
stepIndex: 0,
steps,
}
export const useTourStore = create<TourState>()(immer(() => initialState))
export function openTour() {
useTourStore.setState({ run: true })
}
export function closeTour() {
useTourStore.setState({ run: false })
}
export function resetTour() {
useTourStore.setState({ run: false, stepIndex: 0 })
}
export function prevStep() {
useTourStore.setState((state) => {
state.stepIndex -= 1
})
}
export function nextStep() {
useTourStore.setState((state) => {
state.stepIndex += 1
})
}
export function jumpStep(stepIndex: number) {
useTourStore.setState({ stepIndex })
}

View File

@@ -11,22 +11,20 @@ export default function CommentDeleteDialog({
}) {
return (
<Dialog isOpen={open} onClose={onClose}>
<section className="p-6 md:px-12">
<p className="text-base font-medium text-black/90">
<div className="py-8 px-12 flex flex-col items-center justify-between gap-16 w-full">
<p className="mt-6 tracking-wide leading-7">
<br />
<br />
</p>
</section>
<section className="flex justify-center p-6 md:p-12 md:pt-0">
<button
className="max-w-[285px] w-full h-14 rounded-lg bg-primary/90 text-white/90 flex justify-center items-center text-xl font-bold tracking-[30%]"
className="max-w-[280px] w-full h-14 rounded-lg text-white/90 flex justify-center items-center text-xl font-bold tracking-[30%] bg-primary/90"
type="button"
onClick={onConfirm}
>
</button>
</section>
</div>
</Dialog>
)
}

View File

@@ -0,0 +1,96 @@
import CommentImg from '@/assets/img/comment.png'
import DownVoteImg from '@/assets/img/downvote.png'
import UpVoteImg from '@/assets/img/upvote.png'
import { ReactComponent as EllipsisIcon } from '@/assets/svg/ellipsis.svg'
import { useTourStore } from '@/features/core'
import { Avatar } from '@/features/shared'
import dayjs from 'dayjs'
import { nanoid } from 'nanoid'
const examplePost = {
epochKey: nanoid(),
publishedAt: new Date(),
content:
'這是一篇貼文:D 歡迎你來到 Unirep Social Taiwan 🙌🏻 !這是一個透過區塊鏈技術所打造的全匿名的社群平台,你可以在這個擁有社群自主機制的空間裡自由發表言論,享受真正的言論自由!',
upCount: 520,
downCount: 66,
commentCount: 1314,
}
export default function ExamplePost() {
const { run } = useTourStore()
if (!run) {
return null
}
const dateLabel = dayjs(examplePost.publishedAt).fromNow()
return (
<article
className="p-4 space-y-3 bg-white/90 rounded-xl shadow-base"
data-tour-step="4"
>
<header className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Avatar name={examplePost.epochKey} />
<span className="text-xs font-medium tracking-wide text-black/80">
{dateLabel}
</span>
</div>
<button
className="flex items-center justify-center w-6 h-6"
data-tour-step="5"
>
<EllipsisIcon className="cursor-pointer" />
</button>
</header>
<section>
<p className="text-sm font-medium tracking-wider break-words text-black/90 whitespace-break-spaces">
{examplePost.content}
</p>
</section>
<footer className="flex items-center gap-4">
<ActionButton
iconImage={UpVoteImg}
iconAlt={`upvote`}
label={`${examplePost.upCount}`}
/>
<ActionButton
iconImage={DownVoteImg}
iconAlt={`downvote`}
label={`${examplePost.downCount}`}
/>
<ActionButton
iconImage={CommentImg}
iconAlt={`comment`}
label={`${examplePost.commentCount}`}
/>
</footer>
</article>
)
}
function ActionButton({
iconImage,
iconAlt,
label,
onClick,
}: {
iconImage: string
iconAlt: string
label: string
onClick?: () => void
}) {
return (
<button
className="flex items-center gap-1 cursor-pointer"
onClick={onClick}
>
<img className="w-5 h-5" src={iconImage} alt={iconAlt} />
<span className="text-xs font-medium tracking-wide text-black/80">
{label}
</span>
</button>
)
}

View File

@@ -16,6 +16,8 @@ import PostFooter from './PostFooter'
import { PostReportedMask } from './PostReportedMask'
import { useUserState } from '@/features/core'
import { shouldShowMask } from '@/utils/helpers/postMaskHelper'
import ShareLinkTransition from '../ShareLinkTransition/ShareLinkTransition'
import { useCopy } from '@/features/shared/hooks/useCopy'
export default function Post({
id = '',
@@ -92,6 +94,8 @@ export default function Post({
votedEpoch,
])
const { hasCopied, copyToClipboard } = useCopy()
const [localUpCount, setLocalUpCount] = useState(upCount)
const [localDownCount, setLocalDownCount] = useState(downCount)
@@ -103,6 +107,13 @@ export default function Post({
const [isMineState, setIsMineState] = useState(isMine)
const [isError, setIsError] = useState(false)
const handleShareClick = () => {
if (id) {
const postLink = `${window.location.origin}/posts/${id}`
copyToClipboard(postLink)
}
}
// set isAction when finalAction is changed
useEffect(() => {
setIsMineState(isMine)
@@ -183,6 +194,7 @@ export default function Post({
{isShowReportedMasks && <PostReportedMask />}
{isShowBlockedMasks && <PostBlockedMask />}
{<LikeAnimation isLiked={show} imgType={imgType} />}
{<ShareLinkTransition isOpen={hasCopied} />}
<div className="flex-1 px-6 py-4 space-y-3">
{compact && status === PostStatus.Success ? (
<Link to={`/posts/${id}`}>{postInfo}</Link>
@@ -203,6 +215,7 @@ export default function Post({
voteAction={isAction}
handleVote={handleVote}
handleComment={onComment}
handleShare={handleShareClick}
/>
</div>
{compact && imageUrl && (

View File

@@ -1,5 +1,6 @@
import CommentImg from '@/assets/img/comment.png'
import DownVoteImg from '@/assets/img/downvote.png'
import ShareImg from '@/assets/img/share.png'
import UpVoteImg from '@/assets/img/upvote.png'
import { VoteAction } from '@/types/Vote'
@@ -12,6 +13,7 @@ interface PostFooterProps {
voteAction: VoteAction | null
handleVote: (voteType: VoteAction) => void
handleComment: () => void
handleShare: () => void
}
function PostFooter({
@@ -23,6 +25,7 @@ function PostFooter({
voteAction,
handleVote,
handleComment,
handleShare,
}: PostFooterProps) {
return (
<footer className="flex items-center gap-4">
@@ -45,6 +48,7 @@ function PostFooter({
count={countComment}
onClick={handleComment}
/>
<ShareBtn onClick={handleShare} isLoggedIn={isLoggedIn} />
</footer>
)
}
@@ -173,4 +177,28 @@ function UpVoteBtn({
)
}
interface ShareBtnProps {
onClick: () => void
isLoggedIn: boolean
}
function ShareBtn({ onClick, isLoggedIn }: ShareBtnProps) {
const cursor = isLoggedIn ? 'pointer' : 'not-allowed'
return (
<>
<button
className="flex items-center gap-1 cursor-pointer disabled:cursor-not-allowed"
onClick={onClick}
>
<img
className="w-5 h-5"
src={ShareImg}
alt="share"
style={{ cursor }}
/>
</button>
</>
)
}
export default PostFooter

View File

@@ -1,8 +1,9 @@
import { Dialog } from '@/features/shared'
import ReportContent from '@/features/reporting/components/Adjudicate/ReportContent'
import { useEffect, useState } from 'react'
import { FieldValues, useForm } from 'react-hook-form'
import { useReportPost } from '../../hooks/useReportPost/useReportPost'
import {
InfoDialog,
REGISTER_ID_DESC,
REGISTER_ID_REASON,
ReportFormCategories,
@@ -14,6 +15,8 @@ import {
ReportFormSubmitBtn,
ReportFormSubmitting,
} from '../ReportForm'
import Dialog from '@/features/shared/components/Dialog/Dialog'
import { usePostById } from '@/features/post/hooks/usePostById/usePostById'
interface PostReportDialogProps {
postId: string
@@ -33,6 +36,8 @@ export function PostReportDialog({
}: PostReportDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [step, setStep] = useState<number>(1)
const {
register,
handleSubmit,
@@ -61,6 +66,10 @@ export function PostReportDialog({
} catch (error) {}
})
const resetStep = () => {
setStep(1)
}
const onCloseTransition = () => {
setIsSubmitting(false)
}
@@ -70,6 +79,7 @@ export function PostReportDialog({
resetState,
onClose,
onCloseTransition,
resetStep,
})
useEffect(() => {
@@ -92,28 +102,49 @@ export function PostReportDialog({
}
}, [isSuccess, isError, onCloseDialog])
const { data: post } = usePostById(postId)
return (
<>
<Dialog isOpen={isOpen} onClose={onCloseDialog}>
<ReportFormCtn onSubmit={onSubmit}>
<ReportFormIntro />
<ReportFormStepGroup>
<ReportFormStepLabel title="1. 檢舉原因" isRequired />
<ReportFormCategories
register={register}
errors={errors}
setValue={setValue}
getValues={getValues}
trigger={trigger}
/>
</ReportFormStepGroup>
<ReportFormStepGroup>
<ReportFormStepLabel title="2. 檢舉描述" isRequired />
<ReportFormReason register={register} errors={errors} />
</ReportFormStepGroup>
<ReportFormSubmitBtn />
</ReportFormCtn>
</Dialog>
{step === 1 && (
<InfoDialog
isOpen={isOpen}
onClose={onCloseDialog}
onButtonClick={() => setStep(2)}
/>
)}
{step === 2 && (
<Dialog isOpen={isOpen} onClose={onCloseDialog}>
<ReportFormCtn onSubmit={onSubmit}>
<ReportFormIntro />
<ReportContent content={post?.content} />
<ReportFormStepGroup>
<ReportFormStepLabel
title="1. 檢舉原因"
isRequired
/>
<ReportFormCategories
register={register}
errors={errors}
setValue={setValue}
getValues={getValues}
trigger={trigger}
/>
</ReportFormStepGroup>
<ReportFormStepGroup>
<ReportFormStepLabel
title="2. 檢舉描述"
isRequired
/>
<ReportFormReason
register={register}
errors={errors}
/>
</ReportFormStepGroup>
<ReportFormSubmitBtn />
</ReportFormCtn>
</Dialog>
)}
<ReportFormSubmitting
isOpen={isSubmitting}
onClose={onCloseDialog}
@@ -126,17 +157,20 @@ function useCloseDialogFlow({
resetForm,
resetState,
onClose,
resetStep,
onCloseTransition,
}: {
resetForm: () => void
resetState: () => void
onClose: () => void
resetStep: () => void
onCloseTransition: () => void
}) {
return () => {
resetForm()
resetState()
onClose()
resetStep()
onCloseTransition()
}
}

View File

@@ -28,22 +28,6 @@ describe('PostReportDialog', () => {
<PostReportDialog postId={''} isOpen={true} onClose={() => {}} />,
{ wrapper },
)
expect(
screen.getByText(
'若您確定要檢舉此內容,請選擇以下欄位中的原因,並填寫詳盡的檢舉描述。填寫完成後,您即可提交檢舉。',
),
).toBeInTheDocument()
})
it('should render isPending content', () => {
const mockUseReportPost = useReportPost as jest.MockedFunction<
typeof useReportPost
>
mockUseReportPost.mockReturnValue({ ...mockValues, isPending: true })
render(
<PostReportDialog postId={''} isOpen={true} onClose={() => {}} />,
{ wrapper },
)
expect(screen.getByText('您的檢舉報告正在送出中')).toBeInTheDocument()
expect(screen.getByText('確認檢舉')).toBeInTheDocument()
})
})

View File

@@ -25,6 +25,7 @@ import { useIntersectionObserver } from '@uidotdev/usehooks'
import { nanoid } from 'nanoid'
import { Fragment, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import ExamplePost from '../ExamplePost/ExamplePost'
export default function PostList() {
const { userState } = useUserState()
@@ -168,8 +169,11 @@ export default function PostList() {
const { isValidReputationScore } = useReputationScore()
return (
<div className="px-4">
<div className="px-4 lg:px-0">
<ul className="space-y-3 md:space-y-6">
<li>
<ExamplePost />
</li>
{localPosts.map((post) => (
<li
key={post.id}

View File

@@ -0,0 +1,48 @@
import { Dialog } from '@/features/shared'
interface InfoDialogProps {
isOpen: boolean
onClose: () => void
onButtonClick: () => void
}
export function InfoDialog({
isOpen,
onClose,
onButtonClick,
}: InfoDialogProps) {
return (
<Dialog isOpen={isOpen} onClose={onClose}>
<div className="py-8 px-12 flex flex-col items-center justify-between gap-16 w-full">
<p className="mt-6 tracking-wide leading-7">
<br />
<br />
Unirep Social
Taiwan為一去中心化的用戶自治管理社群平台
<span className="font-bold text-secondary">
</span>
<span className="font-bold text-secondary">
5
</span>
3
1
<span className="font-bold text-secondary">
</span>
</p>
<button
className="max-w-[280px] w-full h-14 rounded-lg text-white/90 flex justify-center items-center text-xl font-bold tracking-[30%] bg-primary/90"
type="button"
onClick={onButtonClick}
>
</button>
</div>
</Dialog>
)
}

View File

@@ -13,3 +13,4 @@ export { ReportFormSubmitBtn } from './ReportFormSubmitBtn'
export { ReportFormSubmitFailure } from './ReportFormSubmitFailure'
export { ReportFormSubmitSuccess } from './ReportFormSubmitSuccess'
export { ReportFormSubmitting } from './ReportFormSubmitting'
export { InfoDialog } from './InfoDialog'

View File

@@ -0,0 +1,50 @@
import ShareImg from '@/assets/img/share.png'
import { motion, useAnimation } from 'framer-motion'
import { useEffect } from 'react'
interface ShareLinkTransitionProps {
isOpen: boolean
}
export default function ShareLinkTransition({
isOpen,
}: ShareLinkTransitionProps) {
const controls = useAnimation()
useEffect(() => {
if (!isOpen) return
controls.start({
y: [100, 0],
opacity: [0, 1, 0],
transition: {
y: {
type: 'spring',
stiffness: 50,
damping: 10,
duration: 1,
},
opacity: {
duration: 2, // Complete fade-in-out cycle
ease: 'easeInOut',
},
},
})
}, [controls, isOpen])
if (!isOpen) return null
return (
<motion.div
className="
z-10 w-full h-full absolute top-0 left-0 text-white
flex flex-row gap-2 items-center justify-center
"
animate={controls}
>
<dialog className="bg-white flex gap-2 block p-2 rounded-lg border-gray shadow-sm">
<img className="w-6 h-6" src={ShareImg} alt="share" />
<span className="block"></span>
</dialog>
</motion.div>
)
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '@/constants/queryKeys'
import { PostService } from '@/features/core'
export function usePostById(id: string) {
return useQuery({
queryKey: [QueryKeys.SinglePost, id],
queryFn: async () => {
if (!id) return undefined
const postService = new PostService()
return postService.fetchPostById(id)
},
enabled: !!id,
})
}

View File

@@ -29,3 +29,4 @@ export { useRemoveComment } from './hooks/useRemoveComment/useRemoveComment'
export { useVoteEvents } from './hooks/useVoteEvents/useVoteEvents'
export { useVotes } from './hooks/useVotes/useVotes'
export { useVoteStore } from './stores/useVoteStore'
export { usePostById } from './hooks/usePostById/usePostById'

View File

@@ -1,3 +1,4 @@
import { PATHS } from '@/constants/paths'
import {
BodyCellData,
BodyCellType,
@@ -9,6 +10,8 @@ import {
} from '@/features/shared'
import { FetchCommentHistoryResponse } from '@/types/api'
import dayjs from 'dayjs'
import { AiOutlineQuestionCircle } from 'react-icons/ai'
import { Link } from 'react-router-dom'
import { useMyCommentHistory } from '../../hooks/useMyCommentHistory/useMyCommentHistory'
interface CommentTableProps {
@@ -39,10 +42,19 @@ export function CommentTable({ fromToEpoch }: CommentTableProps) {
function getHeaderData(): HeaderCellData[] {
return [
{ label: 'Date' },
{ label: 'Content' },
{ label: 'Epoch Key' },
{ label: 'Link' },
{ label: '日期' },
{ label: '內容' },
{
label: (
<div className="flex items-start gap-1">
<span>EpochKey</span>
<Link to={`${PATHS.ABOUT_US}?viewId=feature-epoch`}>
<AiOutlineQuestionCircle />
</Link>
</div>
),
},
{ label: '連結' },
]
}

View File

@@ -1,3 +1,4 @@
import { PATHS } from '@/constants/paths'
import {
BodyCellData,
BodyCellType,
@@ -8,7 +9,10 @@ import {
TableHeader,
} from '@/features/shared'
import { FetchPostHistoryResponse } from '@/types/api'
import { type RelayRawPost, RelayRawPostStatus } from '@/types/Post'
import dayjs from 'dayjs'
import { AiOutlineQuestionCircle } from 'react-icons/ai'
import { Link } from 'react-router-dom'
import { useMyPostHistory } from '../../hooks/useMyPostHistory/useMyPostHistory'
interface PostTableProps {
@@ -39,10 +43,19 @@ export function PostTable({ fromToEpoch }: PostTableProps) {
function getHeaderData(): HeaderCellData[] {
return [
{ label: 'Date' },
{ label: 'Content' },
{ label: 'Epoch Key' },
{ label: 'Link' },
{ label: '日期' },
{ label: '內容' },
{
label: (
<div className="flex items-start gap-1">
<span>EpochKey</span>
<Link to={`${PATHS.ABOUT_US}?viewId=feature-epoch`}>
<AiOutlineQuestionCircle />
</Link>
</div>
),
},
{ label: '連結' },
]
}
@@ -55,7 +68,7 @@ function parsePostHistoryToBodyData(
type: BodyCellType.Text,
content: formatPostDate(Number(item.publishedAt)),
},
{ type: BodyCellType.Text, content: item.content },
{ type: BodyCellType.Text, content: getPostContent(item) },
{ type: BodyCellType.Text, content: item.epochKey },
{
type: BodyCellType.Link,
@@ -66,6 +79,36 @@ function parsePostHistoryToBodyData(
})
}
function BeReportingContent() {
return (
<div className="p-0.5 rounded border-2 border-black text-white text-xs text-center font-bold bg-[linear-gradient(108.3deg,#0C3037_8.76%,#131313_51.1%,#502A0C_93.43%)]">
</div>
)
}
function BeBlockedContent() {
return (
<div className="p-0.5 rounded border-2 border-black text-white text-xs text-center font-bold bg-[linear-gradient(108.3deg,#0C3037_8.76%,#131313_51.1%,#502A0C_93.43%)]">
</div>
)
}
function getPostContent(post: RelayRawPost) {
switch (post.status) {
case RelayRawPostStatus.REPORTED: {
return <BeReportingContent />
}
case RelayRawPostStatus.DISAGREED: {
return <BeBlockedContent />
}
default: {
return post.content
}
}
}
function formatPostDate(date: number) {
return dayjs(date).format('YYYY/MM/DD')
}

View File

@@ -1,5 +1,6 @@
import Downvote from '@/assets/img/downvote.png'
import Upvote from '@/assets/img/upvote.png'
import { PATHS } from '@/constants/paths'
import { VoteService } from '@/features/core'
import {
BodyCellData,
@@ -12,6 +13,8 @@ import {
} from '@/features/shared'
import { FetchVoteHistoryResponse } from '@/types/api'
import dayjs from 'dayjs'
import { AiOutlineQuestionCircle } from 'react-icons/ai'
import { Link } from 'react-router-dom'
import { useMyVoteHistory } from '../../hooks/useMyVoteHistory/useMyVoteHistory'
interface VoteTableProps {
@@ -42,10 +45,19 @@ export function VoteTable({ fromToEpoch }: VoteTableProps) {
function getHeaderData(): HeaderCellData[] {
return [
{ label: 'Date' },
{ label: 'Type' },
{ label: 'Epoch Key' },
{ label: 'Link' },
{ label: '日期' },
{ label: '類別' },
{
label: (
<div className="flex items-start gap-1">
<span>EpochKey</span>
<Link to={`${PATHS.ABOUT_US}?viewId=feature-epoch`}>
<AiOutlineQuestionCircle />
</Link>
</div>
),
},
{ label: '連結' },
]
}

View File

@@ -1,3 +1,4 @@
import { PATHS } from '@/constants/paths'
import { useUserState } from '@/features/core'
import {
BodyCellType,
@@ -15,6 +16,8 @@ import { formatDateByEpoch } from '@/utils/helpers/formatDateByEpoch'
import { UserState } from '@unirep/core'
import dayjs from 'dayjs'
import { ReactNode } from 'react'
import { AiOutlineQuestionCircle } from 'react-icons/ai'
import { Link } from 'react-router-dom'
import { useMyReputationHistory } from '../../hooks/useMyReputationHistory/useMyReputationHistory'
import SearchByDate from '../SearchByDate/SearchByDate'
import { SearchDayLimitDialog } from './SearchDayLimitDialog'
@@ -90,10 +93,19 @@ function ReputationTable({ fromToEpoch }: ReputationTableProps) {
function getHeaderData(): HeaderCellData[] {
return [
{ label: 'Date' },
{ label: 'Reason' },
{ label: 'Epoch Key' },
{ label: 'Point' },
{ label: '日期' },
{ label: '原因' },
{
label: (
<div className="flex items-start gap-1">
<span>EpochKey</span>
<Link to={`${PATHS.ABOUT_US}?viewId=feature-epoch`}>
<AiOutlineQuestionCircle />
</Link>
</div>
),
},
{ label: '得分' },
]
}

View File

@@ -1,4 +1,4 @@
export default function ReportContent({ content }: { content: string }) {
export default function ReportContent({ content }: { content?: string }) {
return (
<div className="pt-3">
<div className="relative rounded-lg bg-dark-gradient h-36">
@@ -7,7 +7,7 @@ export default function ReportContent({ content }: { content: string }) {
</h3>
<div className="h-full p-4 pt-5 overflow-auto">
<p className="text-sm font-medium leading-relaxed tracking-wider text-white md:leading-slightly-loose">
{content}
{content ? content : '無法加載內容'}
</p>
</div>
</div>

View File

@@ -8,14 +8,10 @@ import { ReactComponent as BookUserIcon } from '@/assets/svg/book-user.svg'
import { ReactComponent as HomeParagraphActiveIcon } from '@/assets/svg/home-paragraph-active.svg'
import { ReactComponent as HomeParagraphIcon } from '@/assets/svg/home-paragraph.svg'
import { PATHS } from '@/constants/paths'
import { useAuthStatus } from '@/features/auth'
import SignupPendingTransition from '@/features/auth/components/SignupPendingTransition/SignupPendingTransition'
import { motion } from 'framer-motion'
import { NavLink } from 'react-router-dom'
export default function MobileBottomNav() {
const { isLoggingIn, isSigningUp } = useAuthStatus()
const navVariants = {
start: { y: 100 },
end: {
@@ -29,87 +25,81 @@ export default function MobileBottomNav() {
}
return (
<>
{isLoggingIn || isSigningUp ? (
<div className="fixed bottom-0 w-screen h-24">
<SignupPendingTransition />
</div>
) : (
<motion.nav
className="
fixed
z-40
bottom-0
w-screen
h-20
flex
items-stretch
rounded-t-3xl
bg-secondary/90
shadow-[0_0_20px_0_rgba(0,0,0,0.6)_inset"
variants={navVariants}
initial="start"
animate="end"
<motion.nav
className="
fixed
z-40
bottom-0
w-screen
h-20
flex
gap-2
items-stretch
rounded-t-3xl
bg-secondary/90
shadow-[0_0_20px_0_rgba(0,0,0,0.6)_inset"
variants={navVariants}
initial="start"
animate="end"
>
<NavLink
className="flex items-center justify-center flex-1"
to={PATHS.HOME}
>
{({ isActive }) =>
isActive ? (
<HomeParagraphActiveIcon className="w-14 h-14" />
) : (
<HomeParagraphIcon className="w-14 h-14" />
)
}
</NavLink>
<NavLink
className="flex items-center justify-center flex-1"
to={PATHS.ABOUT_US}
data-tour-step="6"
>
{({ isActive }) =>
isActive ? (
<BookSearchActiveIcon className="w-14 h-14" />
) : (
<BookSearchIcon className="w-14 h-14" />
)
}
</NavLink>
<div className="relative flex justify-center flex-1">
<NavLink
className="absolute flex items-center justify-center w-16 h-16 bg-white rounded-full bottom-8 drop-shadow-[0_4px_20px_rgba(0,0,0,0.6)]"
title="create a post"
to="/write-post"
>
<NavLink
className="flex items-center justify-center flex-1"
to={PATHS.HOME}
>
{({ isActive }) =>
isActive ? (
<HomeParagraphActiveIcon className="w-14 h-14" />
) : (
<HomeParagraphIcon className="w-14 h-14" />
)
}
</NavLink>
<NavLink
className="flex items-center justify-center flex-1"
to={PATHS.ABOUT_US}
>
{({ isActive }) =>
isActive ? (
<BookSearchActiveIcon className="w-14 h-14" />
) : (
<BookSearchIcon className="w-14 h-14" />
)
}
</NavLink>
<div className="relative flex justify-center flex-1">
<NavLink
className="absolute flex items-center justify-center w-16 h-16 bg-white rounded-full bottom-8 drop-shadow-[0_4px_20px_rgba(0,0,0,0.6)]"
title="create a post"
to="/write-post"
>
<AddIcon className="w-8 h-8 text-secondary" />
</NavLink>
</div>
<NavLink
className="flex items-center justify-center flex-1"
to={PATHS.NOTIFICATION}
>
{({ isActive }) =>
isActive ? (
<BellActiveIcon className="w-14 h-14" />
) : (
<BellIcon className="w-14 h-14" />
)
}
</NavLink>
<NavLink
className="flex items-center justify-center flex-1"
to={PATHS.PROFILE}
>
{({ isActive }) =>
isActive ? (
<BookUserActiveIcon className="w-14 h-14" />
) : (
<BookUserIcon className="w-14 h-14" />
)
}
</NavLink>
</motion.nav>
)}
</>
<AddIcon className="w-8 h-8 text-secondary" />
</NavLink>
</div>
<NavLink
className="flex items-center justify-center flex-1"
to={PATHS.NOTIFICATION}
>
{({ isActive }) =>
isActive ? (
<BellActiveIcon className="w-14 h-14" />
) : (
<BellIcon className="w-14 h-14" />
)
}
</NavLink>
<NavLink
className="flex items-center justify-center flex-1"
to={PATHS.PROFILE}
>
{({ isActive }) =>
isActive ? (
<BookUserActiveIcon className="w-14 h-14" />
) : (
<BookUserIcon className="w-14 h-14" />
)
}
</NavLink>
</motion.nav>
)
}

View File

@@ -10,7 +10,7 @@ import { PATHS } from '@/constants/paths'
import clsx from 'clsx'
import { NavLink } from 'react-router-dom'
export default function MainNav() {
export default function MainSideNav() {
return (
<nav className="space-y-9">
<NavLink className="flex items-center gap-5" to={PATHS.HOME}>
@@ -32,7 +32,11 @@ export default function MainNav() {
</>
)}
</NavLink>
<NavLink className="flex items-center gap-5" to={PATHS.ABOUT_US}>
<NavLink
className="flex items-center gap-5"
to={PATHS.ABOUT_US}
data-tour-step="6"
>
{({ isActive }) => (
<>
{isActive ? (

View File

@@ -23,7 +23,7 @@ export enum BodyCellType {
interface BodyCellTextData {
type: BodyCellType.Text
content: string
content: React.ReactNode
}
interface BodyCellLinkData {
@@ -46,7 +46,7 @@ interface BodyCellProps {
}
interface CellTextProps {
content: string
content: React.ReactNode
}
interface CellLinkProps {
@@ -123,7 +123,7 @@ function TableBodyNoData({ hint }: TableBodyNoDataProps) {
function BodyCell({ data, rowIndex, columnIndex, style }: BodyCellProps) {
const type = data.type
return (
<div style={{ ...style, overflow: 'hidden', paddingRight: '20px' }}>
<div style={{ ...style, overflow: 'hidden', padding: '4px' }}>
{type === BodyCellType.Text && (
<BodyCellText content={data.content} />
)}
@@ -140,7 +140,7 @@ function BodyCell({ data, rowIndex, columnIndex, style }: BodyCellProps) {
function BodyCellText({ content }: CellTextProps) {
return (
<div
className={`text-ellipsis overflow-hidden whitespace-nowrap text-white`}
className={`text-ellipsis overflow-hidden whitespace-nowrap text-white text-sm`}
>
{content}
</div>
@@ -149,7 +149,7 @@ function BodyCellText({ content }: CellTextProps) {
function BodyCellLink({ content, url }: CellLinkProps) {
return (
<Link to={url} className={`text-[#2F9CAF] underline`}>
<Link to={url} className={`text-[#2F9CAF] underline text-sm`}>
{content}
</Link>
)

View File

@@ -11,7 +11,7 @@ interface HeaderCellProps {
}
export interface HeaderCellData {
label: string
label: React.ReactNode
}
export function TableHeader({ data }: TabContentHeaderProps) {
@@ -48,7 +48,7 @@ export function TableHeader({ data }: TabContentHeaderProps) {
function HeaderCell({ data, style }: HeaderCellProps) {
return (
<div
className="text-gray-400"
className="px-1 text-sm text-gray-400 lg:text-base"
style={{
...style,
display: 'flex',

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
import { useCopyToClipboard } from 'react-use'
export function useCopy(autoClearTimer: number = 2000) {
const [copiedText, copyToClipboard] = useCopyToClipboard()
const [hasCopied, setHasCopied] = useState(false)
const handleCopy = (text: string) => {
copyToClipboard(text)
setHasCopied(true)
}
useEffect(() => {
if (!hasCopied || autoClearTimer === 0) return
const timer = setTimeout(() => {
setHasCopied(false)
}, autoClearTimer)
return () => clearTimeout(timer)
}, [hasCopied, autoClearTimer])
return { copiedText, hasCopied, copyToClipboard: handleCopy }
}

View File

@@ -1,11 +1,13 @@
import '@/assets/css/main.css'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-tw'
import relativeTime from 'dayjs/plugin/relativeTime'
import React from 'react'
import ReactDOM from 'react-dom/client'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import '@/assets/css/main.css'
import App from './App'
import reportWebVitals from './reportWebVitals'
dayjs.locale('zh-tw')
dayjs.extend(relativeTime)
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)

View File

@@ -9,10 +9,23 @@ import {
DisclosureButton,
DisclosurePanel,
} from '@headlessui/react'
import { useEffect } from 'react'
import { LuChevronDown } from 'react-icons/lu'
import Markdown from 'react-markdown'
import { useSearchParams } from 'react-router-dom'
export default function AboutPage() {
const [searchParams] = useSearchParams()
const viewId = searchParams.get('viewId')
useEffect(() => {
const viewId = searchParams.get('viewId')
if (!viewId) return
const element = document.getElementById(viewId)
if (!element) return
element.scrollIntoView()
}, [searchParams])
return (
<div className="px-4 pb-10 lg:pt-4 lg:px-0">
<section className="py-4">
@@ -27,6 +40,7 @@ export default function AboutPage() {
id={feature.id}
title={feature.title}
content={feature.content}
defaultOpen={feature.id === viewId}
/>
))}
</div>
@@ -43,6 +57,7 @@ export default function AboutPage() {
id={policy.id}
title={policy.title}
content={policy.content}
defaultOpen={policy.id === viewId}
/>
))}
</div>
@@ -79,11 +94,12 @@ export default function AboutPage() {
</h2>
<div className="space-y-4">
{faqs.map((faq, i) => (
{faqs.map((faq) => (
<Collapse
key={i}
key={faq.id}
title={faq.title}
content={faq.content}
defaultOpen={faq.id === viewId}
/>
))}
</div>
@@ -231,13 +247,20 @@ function Collapse({
id,
title,
content,
defaultOpen = false,
}: {
id?: string
title?: string
content?: string
defaultOpen?: boolean
}) {
return (
<Disclosure as="div" id={id}>
<Disclosure
as="div"
id={id}
className="scroll-mt-20 lg:scroll-mt-36"
defaultOpen={defaultOpen}
>
<DisclosureButton className="justify-between group btn btn-block btn-secondary no-animation">
{title}
<LuChevronDown className="w-6 h-6 group-data-[open]:rotate-180" />

View File

@@ -2,8 +2,8 @@ import LogoImage from '@/assets/img/logo.png'
import { ReactComponent as ArrowLeftIcon } from '@/assets/svg/arrow-left.svg'
import { ReactComponent as SearchIcon } from '@/assets/svg/search.svg'
import { PATHS } from '@/constants/paths'
import { ErrorDialog } from '@/features/auth'
import { ActionNotification, EpochInfo } from '@/features/core'
import { ErrorDialog, SignupPending } from '@/features/auth'
import { ActionWidget, UITour } from '@/features/core'
import {
AdjudicationNotification,
CheckInNotification,
@@ -29,7 +29,7 @@ export default function DesktopAppLayout() {
return (
<div className="max-w-7xl min-h-screen mx-auto before:content-[' '] before:fixed before:top-0 before:left-0 before:-z-10 before:w-screen before:h-screen before:bg-[linear-gradient(200deg,#FF892A_-10%,#000000_15%,#2B2B2B_50%,#000000_85%,#52ACBC_110%)]">
<div className="grid grid-cols-[1fr_26rem] xl:grid-cols-[20rem_1fr_20rem] min-h-screen divide-x divide-neutral-600">
<div className="grid grid-cols-[1fr_27rem] xl:grid-cols-[20rem_1fr_27rem] min-h-screen divide-x divide-neutral-600">
<section className="hidden xl:block">
<div className="fixed top-0 h-full px-10 pt-20">
<div className="h-10 px-4 flex items-center gap-2 bg-[#3E3E3E] rounded-full text-white">
@@ -48,12 +48,11 @@ export default function DesktopAppLayout() {
</main>
</section>
<section>
<div className="fixed top-0 h-full px-10 pt-20">
<div className="fixed top-0 h-full px-10 py-20 w-[27rem]">
<Logo />
<MainSideNav />
<div className="mt-16 space-y-3">
<EpochInfo />
<ActionNotification />
<ActionWidget />
</div>
</div>
</section>
@@ -67,6 +66,8 @@ export default function DesktopAppLayout() {
onClose={closeForbidActionDialog}
/>
<ErrorDialog />
<SignupPending />
<UITour />
</div>
)
}

View File

@@ -1,8 +1,8 @@
import Logo from '@/assets/img/logo.png'
import { ReactComponent as ArrowLeftIcon } from '@/assets/svg/arrow-left.svg'
import { PATHS } from '@/constants/paths'
import { ErrorDialog } from '@/features/auth'
import { ActionNotification, EpochInfo } from '@/features/core'
import { ErrorDialog, SignupPending } from '@/features/auth'
import { ActionWidget, UITour } from '@/features/core'
import {
AdjudicationNotification,
CheckInNotification,
@@ -33,6 +33,8 @@ export default function MobileAppLayout() {
<AdjudicationNotification />
<CheckInNotification />
</NotificationContainer>
<SignupPending />
<UITour />
<ForbidActionDialog
isOpen={isForbidActionDialogOpen}
onClose={closeForbidActionDialog}
@@ -83,10 +85,9 @@ function MobileLayoutHeader() {
</div>
{isContainingPosts && (
<section className="px-4 py-2 space-y-3">
<div className="max-w-xs mx-auto">
<EpochInfo />
<div className="max-w-[22rem] mx-auto">
<ActionWidget />
</div>
<ActionNotification />
</section>
)}
</header>

View File

@@ -1,4 +1,3 @@
import { SignupPendingTransition } from '@/features/auth'
import { CreatePost, PostList } from '@/features/post'
export default function PostPage() {
@@ -6,7 +5,6 @@ export default function PostPage() {
<div>
<section className="relative hidden py-6 border-b border-neutral-600 lg:block">
<CreatePost />
<SignupPendingTransition />
</section>
<section className="py-6">
<PostList />

View File

@@ -1,5 +1,9 @@
import { AccountHistory } from '@/features/profile'
export default function History() {
return <AccountHistory />
return (
<div className="px-4 py-8 lg:px-0">
<AccountHistory />
</div>
)
}

View File

@@ -1,28 +0,0 @@
import AvatarIcon from '@/assets/img/avatar.png'
import { Outlet } from 'react-router-dom'
function Avatar() {
return (
<div className="flex items-center gap-4">
<div
className={`
w-[80px] h-[80px] rounded-full bg-gray-400 border-white border-4 flex items-center justify-center
md:w-[100px] md:h-[100px]
`}
>
<img src={AvatarIcon} alt="Avatar" />
</div>
</div>
)
}
export default function ProfileLayout() {
return (
<div className="px-4 py-8 md:pt-24">
<div className="flex justify-center pb-8">
<Avatar />
</div>
<Outlet />
</div>
)
}

View File

@@ -1,11 +1,85 @@
import EpochImg from '@/assets/img/epoch.png'
import { CyanButton, LogoutModal } from '@/features/auth'
import { useUserInfo } from '@/features/core'
import { useReputationScore } from '@/features/reporting'
import dayjs from 'dayjs'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
RiHourglassFill,
RiLogoutBoxRLine,
RiShieldStarLine,
} from 'react-icons/ri'
import { CyanButton, LoginButton, LogoutModal } from '@/features/auth'
import { useNavigate } from 'react-router-dom'
export default function ProfilePage() {
return (
<div className="px-4 py-8 space-y-8 lg:px-0">
<div className="px-4 space-y-6">
<ReputationInfo />
<AccountInfo />
</div>
<div className="flex flex-col w-full gap-4 md:flex-row md:gap-5">
<HistoryButton />
<ReputationButton />
<LogoutButton />
</div>
</div>
)
}
function ReputationInfo() {
const { reputationScore, isValidReputationScore } = useReputationScore()
const score = reputationScore ?? 0
const message = isValidReputationScore
? '您的聲譽分數良好,\n可以行使所有平台操作行為權利'
: '您的聲譽分數為負值\n行為權力已被限制'
return (
<div className="flex flex-wrap items-center justify-center gap-5">
<img
className="w-14 h-14 basis-14 shrink-0"
src={EpochImg}
alt="epoch actions"
/>
<div className="flex items-center gap-2">
<div className="text-sm font-bold tracking-widest text-white/90">
<div></div>
<div></div>
</div>
<div className="text-6xl font-black text-white/95">{score}</div>
</div>
<div className="space-y-1">
<p className="text-sm font-bold tracking-wider text-center text-white/90 whitespace-break-spaces">
{message}
</p>
<p className="text-xs font-medium tracking-wider text-center text-white/60">
Epoch
</p>
</div>
</div>
)
}
function formatUserId(id: string) {
return `${id.slice(0, 4)}...${id.slice(-4, id.length)}`
}
function formatDate(date: Date) {
return dayjs(date).format('YYYY.MM.DD')
}
function AccountInfo() {
const { data } = useUserInfo()
const userId = data?.userId ? formatUserId(data.userId) : undefined
const date = data?.signedUpDate ? formatDate(data.signedUpDate) : undefined
return (
<div className="space-y-1 text-sm font-bold tracking-wider text-center text-white/90">
<p>User ID{userId}</p>
<p>{date}</p>
</div>
)
}
function HistoryButton() {
const navigate = useNavigate()
@@ -47,29 +121,16 @@ function LogoutButton() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<LoginButton
<CyanButton
isLoading={false}
onClick={() => setIsOpen(true)}
title="登出"
color="#2F9CAF"
icon={RiLogoutBoxRLine}
start={true}
text="lg"
size="lg"
iconSize={24}
/>
<LogoutModal isOpen={isOpen} closeModal={() => setIsOpen(false)} />
</>
)
}
export default function ProfilePage() {
return (
<div>
<div className="flex flex-col w-full gap-4 md:flex-row md:gap-8">
<HistoryButton />
<ReputationButton />
<LogoutButton />
</div>
</div>
)
}

View File

@@ -1,13 +1,16 @@
import ReputationHistory from '@/features/profile/components/ReputationHistory/ReputationHistory'
import { useReputationScore } from '@/features/reporting'
const CONTENT =
'為維護匿名平台的抗審查及自治特性Reputation 代表著您在此平台上的信用分數,每位用戶在註冊時的分數都為0,當分數為負數時,平台將限制您的行為使您無法發文、留言、投票,若希望提高分數,請參閱平台政策。此分數受您的在平台上的行為所影響,若您受到他人檢舉,並且檢舉被判斷為有效時,您將會被扣分;若您檢舉他人成功、或是幫助平台裁定檢舉,您將會被加分。平台方保有最終解釋權'
import { Link } from 'react-router-dom'
function Hint() {
return (
<div className={`bg-white text-black p-8 rounded-xl leading-8`}>
{CONTENT}
<div className="p-4 text-sm leading-6 text-black bg-white lg:p-6 rounded-xl">
Reputation Score Unirep Social Taiwan
0
<Link className="underline text-secondary" to="/about">
</Link>
</div>
)
}
@@ -20,27 +23,25 @@ function Score() {
const subHint = '*聲譽分數會在每個 Epoch 開始時更新'
return (
<div>
<hr className="mb-8 border-gray-600" />
<div className={myScoreStyle}>{myScore}</div>
<div className="mt-4 text-sm text-center text-white">{hint}</div>
<div className="mt-4 text-[12px] text-center text-gray-400">
<div className="mt-2 text-sm text-center text-white">{hint}</div>
<div className="mt-1 text-xs text-center text-gray-400">
{subHint}
</div>
<hr className="mt-8 mb-8 border-gray-600" />
</div>
)
}
function getHintByScore(score: number) {
return checkIsMyScoreNegative(score)
? '您的 Reputation 分數為負值,行為權力已被限制'
: '您的 Reputation 分數良好,不會被限制行為權利'
? '您的聲譽分數為負值,行為權力已被限制'
: '您的聲譽分數良好,不會被限制行為權利'
}
function getScoreStyle(score: number) {
const isMyScoreNegative = checkIsMyScoreNegative(score)
const textColor = isMyScoreNegative ? 'text-red-600' : 'text-white'
return `text-center ${textColor} text-9xl`
return `text-center ${textColor} text-7xl`
}
function checkIsMyScoreNegative(score: number) {
@@ -49,10 +50,10 @@ function checkIsMyScoreNegative(score: number) {
export default function Reputation() {
return (
<div>
<div className="px-4 py-8 space-y-8 lg:px-0">
<Score />
<ReputationHistory />
<Hint />
<ReputationHistory />
</div>
)
}

View File

@@ -7,7 +7,6 @@ import AppLayout from './app/layout'
import PostPage from './app/posts/[id]/page'
import PostListPage from './app/posts/page'
import HistoryPage from './app/profile/history/page'
import ProfileLayout from './app/profile/layout'
import ProfilePage from './app/profile/page'
import ReputationPage from './app/profile/reputation/page'
import FullScreenLayout from './full-screen/layout'
@@ -42,7 +41,6 @@ const router = createBrowserRouter([
},
],
},
{
path: PATHS.TWITTER_CALLBACK,
element: <TwitterCallbackPage />,
@@ -86,21 +84,16 @@ const router = createBrowserRouter([
element: <PostPage />,
},
{
element: <ProfileLayout />,
children: [
{
path: PATHS.PROFILE,
element: <ProfilePage />,
},
{
path: PATHS.REPUTATION,
element: <ReputationPage />,
},
{
path: PATHS.HISTORY,
element: <HistoryPage />,
},
],
path: PATHS.PROFILE,
element: <ProfilePage />,
},
{
path: PATHS.REPUTATION,
element: <ReputationPage />,
},
{
path: PATHS.HISTORY,
element: <HistoryPage />,
},
{
path: PATHS.NOTIFICATION,

View File

@@ -7,9 +7,11 @@ module.exports = {
'btn-signup': '#74C5F8',
'btn-login': '#DC832B',
link: '#5F8297',
content: '#080717',
},
spacing: {
30: '7.5rem',
85: '21.25rem',
},
boxShadow: {
base: '0px 0px 20px 0px rgba(0,0,0,0.03)',

View File

@@ -126,6 +126,11 @@ export class ReportService {
[ReportStatus.VOTING]: {
where: {
AND: [
{
adjudicateCount: {
lt: parseInt(REPORT_SETTLE_VOTE_THRESHOLD),
},
},
{ reportEpoch: { lt: epoch } },
{ status: ReportStatus.VOTING },
],
@@ -205,15 +210,6 @@ export class ReportService {
adjudicateCount,
},
})
// check REPORT_SETTLE_VOTE_THRESHOLD and update status
if (adjudicateCount >= REPORT_SETTLE_VOTE_THRESHOLD) {
const status = ReportStatus.WAITING_FOR_TRANSACTION
await db.update('ReportHistory', {
where: { reportId },
update: { status },
})
}
}
upsertAdjudicatorsNullifier(

View File

@@ -470,7 +470,7 @@ describe('POST /api/report', function () {
})
})
it('should fetch report whose adjudication result is tie', async function () {
it('should not fetch report whose adjudication count has reached threshold', async function () {
// update mock value into report
const adjudicatorsNullifier = [
{ adjudicateValue: AdjudicateValue.AGREE },
@@ -478,7 +478,6 @@ describe('POST /api/report', function () {
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
]
await db.update('ReportHistory', {
where: {
@@ -490,11 +489,7 @@ describe('POST /api/report', function () {
},
})
// epoch transition
await provider.send('evm_increaseTime', [EPOCH_LENGTH])
await provider.send('evm_mine', [])
const reports = await express
const votingReports = await express
.get(
`/api/report?status=${ReportStatus.VOTING}&publicSignals=${epochKeyLitePublicSignals}&proof=${epochKeyLiteProof}`
)
@@ -503,23 +498,31 @@ describe('POST /api/report', function () {
return res.body
})
// flatMap to [adjudicateValue1, adjucateValue2 ...]
// add all adjudicateValues (0: disagree, 1: agree)
const adjudicateResult = reports[0].adjudicatorsNullifier
.flatMap((nullifier) => nullifier.adjudicateValue)
.reduce((acc, value) => {
// disagree
if (Number(value) == 0) {
return acc - 1
}
// agree
return acc + 1
})
expect(adjudicateResult).equal(0)
expect(reports[0].adjudicateCount).gt(5)
// filter out the report whose objectId is '0' and type is POST
const filteredReports = votingReports.filter(
(report: any) =>
report.objectId !== '0' && report.type === ReportType.POST
)
expect(filteredReports.length).equal(0)
const waitingForTxReports = await express
.get(
`/api/report?status=${ReportStatus.WAITING_FOR_TRANSACTION}&publicSignals=${epochKeyLitePublicSignals}&proof=${epochKeyLiteProof}`
)
.then((res) => res.body)
// no report is waiting for transaction
// because the report is still in voting status
// and since the adjudication count has reached threshold
// so when querying voting reports, it will be filtered out
expect(waitingForTxReports.length).equal(0)
})
it('should fetch report whose adjudication count is less than 5', async function () {
// epoch transition
await provider.send('evm_increaseTime', [EPOCH_LENGTH])
await provider.send('evm_mine', [])
// nobody vote on reports[1], it can be fetched on next epoch
const reports = await express
.get(
@@ -530,10 +533,10 @@ describe('POST /api/report', function () {
return res.body
})
expect(reports[1].adjudicateCount).lt(5)
expect(reports[1].status).equal(0)
expect(reports[0].adjudicateCount).lt(5)
expect(reports[0].status).equal(0)
const currentEpoch = await sync.loadCurrentEpoch()
const epochDiff = currentEpoch - reports[1].reportEpoch
const epochDiff = currentEpoch - reports[0].reportEpoch
expect(epochDiff).gt(1)
})
@@ -543,8 +546,6 @@ describe('POST /api/report', function () {
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
]
@@ -568,7 +569,7 @@ describe('POST /api/report', function () {
return res.body
})
expect(reports[0].adjudicateCount).gt(5)
expect(reports[0].adjudicateCount).gte(5)
expect(reports[0].status).equal(1)
const currentEpoch = await sync.loadCurrentEpoch()
const epochDiff = currentEpoch - reports[0].reportEpoch
@@ -935,10 +936,6 @@ describe('POST /api/report', function () {
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
]
@@ -1036,54 +1033,6 @@ describe('POST /api/report', function () {
})
})
it('should not settle report if the vote is tie', async function () {
// insert mock value into report
const adjudicatorsNullifier = [
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.AGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
{ adjudicateValue: AdjudicateValue.DISAGREE },
]
const prevEpoch = await sync.loadCurrentEpoch()
await db.update('ReportHistory', {
where: {
AND: [{ objectId: '0' }, { type: ReportType.POST }],
},
update: {
adjudicatorsNullifier,
adjudicateCount: adjudicatorsNullifier.length,
status: ReportStatus.VOTING,
reportEpoch: prevEpoch,
},
})
// epoch transition
await provider.send('evm_increaseTime', [EPOCH_LENGTH])
await provider.send('evm_mine', [])
const curEpoch = await sync.loadCurrentEpoch()
expect(curEpoch).equal(prevEpoch + 1)
await unirep.updateEpochIfNeeded(sync.attesterId).then((t) => t.wait())
await sync.waitForSync()
const report = await express
.get(
`/api/report?status=${ReportStatus.VOTING}&publicSignals=${epochKeyLitePublicSignals}&proof=${epochKeyLiteProof}`
)
.then((res) => {
expect(res).to.have.status(200)
const reports = res.body
expect(reports.length).to.be.equal(2)
return reports
})
await express.get(`/api/post/${report[0].objectId}`).then((res) => {
expect(res).to.have.status(200)
const curPost = res.body as Post
expect(curPost.status).to.equal(PostStatus.REPORTED)
})
})
it('should fetch report category', async function () {
const reportCategories = await express
.get('/api/report/category')

View File

@@ -35,7 +35,7 @@ describe('Reputation', () => {
await insertReputationHistory(db)
const res = await express.get(
`/api/reputation/history?fromEpoch=2&toEpoch=5`
`/api/reputation/history?from_epoch=2&to_epoch=5`
)
const reputations = res.body

View File

@@ -1879,6 +1879,16 @@
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==
"@gilbarbara/deep-equal@^0.1.1":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz#1a106721368dba5e7e9fb7e9a3a6f9efbd8df36d"
integrity sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA==
"@gilbarbara/deep-equal@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz#9c72ed0b2e6f8edb1580217e28d78b5b03ad4aee"
integrity sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==
"@headlessui/react@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-2.1.0.tgz#a699e60a6b72b6bc2eb3a23eacf5bda28e773926"
@@ -7863,6 +7873,11 @@ dedent@^1.0.0:
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a"
integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==
deep-diff@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26"
integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==
deep-eql@^4.0.1, deep-eql@^4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d"
@@ -7880,7 +7895,7 @@ deep-is@^0.1.3, deep-is@~0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.2.2:
deepmerge@^4.2.2, deepmerge@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
@@ -11041,6 +11056,16 @@ is-lambda@^1.0.1:
resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5"
integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==
is-lite@^0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/is-lite/-/is-lite-0.8.2.tgz#26ab98b32aae8cc8b226593b9a641d2bf4bd3b6a"
integrity sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw==
is-lite@^1.2.0, is-lite@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-lite/-/is-lite-1.2.1.tgz#401f30bfccd34cb8cc1283f958907c97859d8f25"
integrity sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw==
is-loopback-addr@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/is-loopback-addr/-/is-loopback-addr-2.0.2.tgz#70a6668fa3555d47caebdcee045745ab80adf5e4"
@@ -15282,6 +15307,11 @@ platform@^1.3.3:
resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7"
integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==
popper.js@^1.16.0:
version "1.16.1"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b"
integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==
poseidon-lite@0.2.0, poseidon-lite@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/poseidon-lite/-/poseidon-lite-0.2.0.tgz#dbc242ebd9c10c32d507a533fa497231d168fd72"
@@ -16337,6 +16367,17 @@ react-error-overlay@^6.0.11:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-floater@^0.7.9:
version "0.7.9"
resolved "https://registry.yarnpkg.com/react-floater/-/react-floater-0.7.9.tgz#b15a652e817f200bfa42a2023ee8d3105803b968"
integrity sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==
dependencies:
deepmerge "^4.3.1"
is-lite "^0.8.2"
popper.js "^1.16.0"
prop-types "^15.8.1"
tree-changes "^0.9.1"
react-hook-form@^7.51.4:
version "7.51.4"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.51.4.tgz#c3a47aeb22b699c45de9fc12b58763606cb52f0c"
@@ -16354,6 +16395,11 @@ react-icons@^5.2.1:
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.2.1.tgz#28c2040917b2a2eda639b0f797bff1888e018e4a"
integrity sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==
react-innertext@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/react-innertext/-/react-innertext-1.1.5.tgz#8147ac54db3f7067d95f49e2d2c05a720d27d8d0"
integrity sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -16369,6 +16415,23 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react-joyride@^2.9.2:
version "2.9.2"
resolved "https://registry.yarnpkg.com/react-joyride/-/react-joyride-2.9.2.tgz#45b211c0061d0c0346cb47699cf87dca16bb6e2b"
integrity sha512-DQ3m3W/GeoASv4UE9ZaadFp3ACJusV0kjjBe7zTpPwWuHpvEoofc+2TCJkru0lbA+G9l39+vPVttcJA/p1XeSA==
dependencies:
"@gilbarbara/deep-equal" "^0.3.1"
deep-diff "^1.0.2"
deepmerge "^4.3.1"
is-lite "^1.2.1"
react-floater "^0.7.9"
react-innertext "^1.1.5"
react-is "^16.13.1"
scroll "^3.0.1"
scrollparent "^2.1.0"
tree-changes "^0.11.2"
type-fest "^4.26.1"
react-lines-ellipsis@^0.15.4:
version "0.15.4"
resolved "https://registry.yarnpkg.com/react-lines-ellipsis/-/react-lines-ellipsis-0.15.4.tgz#2bff3089d62a354fa4ccf630d9f755562e260386"
@@ -17112,6 +17175,16 @@ screenfull@^5.1.0:
resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.2.0.tgz#6533d524d30621fc1283b9692146f3f13a93d1ba"
integrity sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==
scroll@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/scroll/-/scroll-3.0.1.tgz#d5afb59fb3592ee3df31c89743e78b39e4cd8a26"
integrity sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==
scrollparent@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/scrollparent/-/scrollparent-2.1.0.tgz#6cae915c953835886a6ba0d77fdc2bb1ed09076d"
integrity sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==
scrypt-js@3.0.1, scrypt-js@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312"
@@ -18420,6 +18493,22 @@ tr46@~0.0.3:
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
tree-changes@^0.11.2:
version "0.11.2"
resolved "https://registry.yarnpkg.com/tree-changes/-/tree-changes-0.11.2.tgz#e02e65c4faae6230dfe357aa97a26e8eb7c7d321"
integrity sha512-4gXlUthrl+RabZw6lLvcCDl6KfJOCmrC16BC5CRdut1EAH509Omgg0BfKLY+ViRlzrvYOTWR0FMS2SQTwzumrw==
dependencies:
"@gilbarbara/deep-equal" "^0.3.1"
is-lite "^1.2.0"
tree-changes@^0.9.1:
version "0.9.3"
resolved "https://registry.yarnpkg.com/tree-changes/-/tree-changes-0.9.3.tgz#89433ab3b4250c2910d386be1f83912b7144efcc"
integrity sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==
dependencies:
"@gilbarbara/deep-equal" "^0.1.1"
is-lite "^0.8.2"
treeverse@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-3.0.0.tgz#dd82de9eb602115c6ebd77a574aae67003cb48c8"
@@ -18640,6 +18729,11 @@ type-fest@^0.8.1:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
type-fest@^4.26.1:
version "4.26.1"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.26.1.tgz#a4a17fa314f976dd3e6d6675ef6c775c16d7955e"
integrity sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==
type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"