mirror of
https://github.com/social-tw/social-tw-website.git
synced 2026-01-08 23:18:05 -05:00
Merge branch 'main' into update-report-judgement
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -9,5 +9,5 @@
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"cSpell.words": ["partialize", "headlessui", "reportor"]
|
||||
"cSpell.words": ["partialize", "headlessui", "reportor", "uidotdev"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
BIN
packages/frontend/src/assets/img/share.png
Normal file
BIN
packages/frontend/src/assets/img/share.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 705 B |
3
packages/frontend/src/assets/svg/action.svg
Normal file
3
packages/frontend/src/assets/svg/action.svg
Normal 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 |
3
packages/frontend/src/assets/svg/long-arrow-left.svg
Normal file
3
packages/frontend/src/assets/svg/long-arrow-left.svg
Normal 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 |
3
packages/frontend/src/assets/svg/long-arrow-right.svg
Normal file
3
packages/frontend/src/assets/svg/long-arrow-right.svg
Normal 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 |
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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]'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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次內,可確保匿名身份不被交叉比對'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
96
packages/frontend/src/features/core/stores/tour.ts
Normal file
96
packages/frontend/src/features/core/stores/tour.ts
Normal 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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -13,3 +13,4 @@ export { ReportFormSubmitBtn } from './ReportFormSubmitBtn'
|
||||
export { ReportFormSubmitFailure } from './ReportFormSubmitFailure'
|
||||
export { ReportFormSubmitSuccess } from './ReportFormSubmitSuccess'
|
||||
export { ReportFormSubmitting } from './ReportFormSubmitting'
|
||||
export { InfoDialog } from './InfoDialog'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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: '連結' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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: '連結' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '得分' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
24
packages/frontend/src/features/shared/hooks/useCopy.ts
Normal file
24
packages/frontend/src/features/shared/hooks/useCopy.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
96
yarn.lock
96
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user