refs #4798 Add FollowRequests menu

This commit is contained in:
AkiraFukushima 2024-10-12 14:46:04 +09:00
parent 8994dbfd0e
commit fc3cbcc603
No known key found for this signature in database
GPG Key ID: B7EA3A3C9AEC9F0E
6 changed files with 223 additions and 38 deletions

View File

@ -6,6 +6,7 @@
"public": "Public", "public": "Public",
"favourites": "Favourites", "favourites": "Favourites",
"bookmarks": "Bookmarks", "bookmarks": "Bookmarks",
"follow_requests": "Follow Requests",
"search": "Search", "search": "Search",
"status": { "status": {
"avatar": "Aavtar of {user}", "avatar": "Aavtar of {user}",
@ -100,6 +101,10 @@
"body": "{user} requested to follow you" "body": "{user} requested to follow you"
} }
}, },
"follow_requests": {
"authorize": "Authorize",
"reject": "Reject"
},
"compose": { "compose": {
"placeholder": "What's on your mind?", "placeholder": "What's on your mind?",
"spoiler": { "spoiler": {

View File

@ -187,7 +187,7 @@ export default function New(props: NewProps) {
size="sm" size="sm"
variant="text" variant="text"
color="blue" color="blue"
title={formatMessage({ id: 'accounts.copy_authorization_url' })} title={formatMessage({ id: 'accounts.new.copy_authorization_url' })}
onClick={() => copyText(appData.url)} onClick={() => copyText(appData.url)}
> >
<FaClipboard className="text-xl" /> <FaClipboard className="text-xl" />

View File

@ -4,7 +4,7 @@ import generator, { Entity, MegalodonInterface } from 'megalodon'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { FaBell, FaBookmark, FaGlobe, FaHouse, FaList, FaStar, FaUsers, FaHashtag } from 'react-icons/fa6' import { FaBell, FaBookmark, FaGlobe, FaHouse, FaList, FaStar, FaUsers, FaHashtag, FaUserPlus } from 'react-icons/fa6'
import { useIntl } from 'react-intl' import { useIntl } from 'react-intl'
import Jump from '../Jump' import Jump from '../Jump'
import { useUnreads } from '@/provider/unreads' import { useUnreads } from '@/provider/unreads'
@ -37,13 +37,14 @@ export type Timeline = {
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const router = useRouter() const router = useRouter()
const { formatMessage } = useIntl() const { formatMessage } = useIntl()
const { unreads } = useUnreads() const { unreads, setUnreads } = useUnreads()
const [account, setAccount] = useState<Account | null>(null) const [account, setAccount] = useState<Account | null>(null)
const [lists, setLists] = useState<Array<Entity.List>>([]) const [lists, setLists] = useState<Array<Entity.List>>([])
const [followedTags, setFollowedTags] = useState<Array<Entity.Tag>>([]) const [followedTags, setFollowedTags] = useState<Array<Entity.Tag>>([])
const [openJump, setOpenJump] = useState(false) const [openJump, setOpenJump] = useState(false)
const [client, setClient] = useState<MegalodonInterface>() const [client, setClient] = useState<MegalodonInterface>()
const [followRequests, setFollowRequests] = useState<Array<Timeline>>([])
useHotkeys('mod+k', () => setOpenJump(current => !current)) useHotkeys('mod+k', () => setOpenJump(current => !current))
@ -68,10 +69,37 @@ export default function Layout({ children }: LayoutProps) {
} }
f() f()
const g = async () => { const g = async () => {
try {
const res = await c.getFollowedTags() const res = await c.getFollowedTags()
setFollowedTags(res.data) setFollowedTags(res.data)
} catch (err) {
setFollowedTags([])
console.error(err)
}
} }
g() g()
const r = async () => {
const res = await c.getFollowRequests()
if (res.data.length > 0) {
setUnreads(current =>
Object.assign({}, current, {
[`${account.id?.toString()}_follow_requests`]: res.data.length
})
)
console.log(unreads)
setFollowRequests([
{
id: 'follow_requests',
title: formatMessage({ id: 'timeline.follow_requests' }),
icon: <FaUserPlus />,
path: `/accounts/${router.query.id}/follow_requests`
}
])
} else {
setFollowRequests([])
}
}
r()
}, [account]) }, [account])
const pages: Array<Timeline> = [ const pages: Array<Timeline> = [
@ -127,7 +155,7 @@ export default function Layout({ children }: LayoutProps) {
<p>@{account?.domain}</p> <p>@{account?.domain}</p>
</div> </div>
<List className="min-w-[64px]"> <List className="min-w-[64px]">
{pages.map(page => ( {pages.concat(followRequests).map(page => (
<ListItem <ListItem
key={page.id} key={page.id}
selected={router.asPath.includes(page.path)} selected={router.asPath.includes(page.path)}
@ -137,10 +165,11 @@ export default function Layout({ children }: LayoutProps) {
> >
<ListItemPrefix>{page.icon}</ListItemPrefix> <ListItemPrefix>{page.icon}</ListItemPrefix>
<span className="sidebar-menu text-ellipsis whitespace-nowrap overflow-hidden">{page.title}</span> <span className="sidebar-menu text-ellipsis whitespace-nowrap overflow-hidden">{page.title}</span>
{page.id === 'notifications' && unreads[account?.id?.toString()] ? ( {(page.id === 'notifications' && unreads[account?.id?.toString()]) ||
(page.id === 'follow_requests' && unreads[`${account.id.toString()}_follow_requests`]) ? (
<ListItemSuffix className="sidebar-toolchip"> <ListItemSuffix className="sidebar-toolchip">
<Chip <Chip
value={unreads[account.id.toString()]} value={unreads[account.id.toString()] || unreads[`${account.id.toString()}_follow_requests`]}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="rounded-full theme-text-primary theme-badge" className="rounded-full theme-text-primary theme-badge"

View File

@ -0,0 +1,75 @@
import { Entity, MegalodonInterface } from 'megalodon'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { FormattedMessage } from 'react-intl'
import User from './followRequests/User'
import Detail from '../detail/Detail'
import { Account } from '@/db'
import { useUnreads } from '@/provider/unreads'
type Props = {
client: MegalodonInterface
account: Account
openMedia: (media: Array<Entity.Attachment>, index: number) => void
}
export default function FollowRequests(props: Props) {
const router = useRouter()
const { setUnreads } = useUnreads()
const [requests, setRequests] = useState<Array<Entity.FollowRequest | Entity.Account>>([])
useEffect(() => {
refreshRequests()
}, [])
const refreshRequests = async () => {
const res = await props.client.getFollowRequests()
setRequests(res.data)
return res.data
}
const updateUnreads = (length: number) => {
setUnreads(current =>
Object.assign({}, current, {
[`${props.account.id?.toString()}_follow_requests`]: length
})
)
}
const timelineClass = () => {
if (router.query.detail) {
return 'timeline-with-drawer'
}
return 'timeline'
}
return (
<>
<div className="flex timeline-wrapper">
<section className={`h-full ${timelineClass()}`}>
<div className="w-full theme-bg theme-text-primary p-2 flex justify-between">
<div className="text-lg font-bold cursor-pointer">
<FormattedMessage id="timeline.follow_requests" />
</div>
</div>
<div className="timeline overflow-y-auto w-full overflow-x-hidden" style={{ height: 'calc(100% - 44px)' }}>
{requests.map(r => (
<>
<User
key={r.id}
user={r}
client={props.client}
refresh={async () => {
const data = await refreshRequests()
updateUnreads(data.length)
}}
/>
</>
))}
</div>
</section>
<Detail client={props.client} account={props.account} className="detail" openMedia={props.openMedia} />
</div>
</>
)
}

View File

@ -0,0 +1,58 @@
import emojify from '@/utils/emojify'
import { Avatar } from '@material-tailwind/react'
import { Entity, MegalodonInterface } from 'megalodon'
import { useRouter } from 'next/router'
import { FaCheck, FaXmark } from 'react-icons/fa6'
import { useIntl } from 'react-intl'
type Props = {
user: Entity.Account | Entity.FollowRequest
client: MegalodonInterface
refresh: () => Promise<void>
}
export default function User(props: Props) {
const router = useRouter()
const { formatMessage } = useIntl()
const openUser = (id: string) => {
router.push({ query: { id: router.query.id, timeline: router.query.timeline, user_id: id, detail: true } })
}
const authorize = async () => {
await props.client.acceptFollowRequest(`${props.user.id}`)
await props.refresh()
}
const reject = async () => {
await props.client.rejectFollowRequest(`${props.user.id}`)
await props.refresh()
}
return (
<div className="border-b border-gray-200 dark:border-gray-700 mr-2 p-1">
<div className="flex justify-between">
<div className="flex" onClick={() => openUser(`${props.user.id}`)}>
<div className="p2 cursor-pointer" style={{ width: '56px' }}>
<Avatar src={props.user.avatar} />
</div>
<div>
<p
className="text-gray-800 dark:text-gray-200"
dangerouslySetInnerHTML={{ __html: emojify(props.user.display_name, props.user.emojis) }}
/>
<p className="text-gray-500">@{props.user.acct}</p>
</div>
</div>
<div className="flex gap-4">
<button title={formatMessage({ id: 'follow_requests.authorize' })} onClick={authorize}>
<FaCheck />
</button>
<button title={formatMessage({ id: 'follow_requests.reject' })} onClick={reject}>
<FaXmark />
</button>
</div>
</div>
</div>
)
}

View File

@ -7,6 +7,7 @@ import Notifications from '@/components/timelines/Notifications'
import Search from '@/components/timelines/Search' import Search from '@/components/timelines/Search'
import Media from '@/components/Media' import Media from '@/components/Media'
import Report from '@/components/report/Report' import Report from '@/components/report/Report'
import FollowRequests from '@/components/timelines/FollowRequests'
export default function Page() { export default function Page() {
const router = useRouter() const router = useRouter()
@ -58,10 +59,10 @@ export default function Page() {
}) })
} }
if (!account || !client) return null const timeline = (timeline: string) => {
switch (timeline) {
case 'notifications':
return ( return (
<>
{(router.query.timeline as string) === 'notifications' ? (
<Notifications <Notifications
account={account} account={account}
client={client} client={client}
@ -69,9 +70,9 @@ export default function Page() {
dispatch({ target: 'media', value: true, object: media, index: index }) dispatch({ target: 'media', value: true, object: media, index: index })
} }
/> />
) : ( )
<> case 'search':
{(router.query.timeline as string) === 'search' ? ( return (
<Search <Search
client={client} client={client}
account={account} account={account}
@ -79,7 +80,19 @@ export default function Page() {
dispatch({ target: 'media', value: true, object: media, index: index }) dispatch({ target: 'media', value: true, object: media, index: index })
} }
/> />
) : ( )
case 'follow_requests':
return (
<FollowRequests
client={client}
account={account}
openMedia={(media: Array<Entity.Attachment>, index: number) =>
dispatch({ target: 'media', value: true, object: media, index: index })
}
/>
)
default:
return (
<Timeline <Timeline
timeline={router.query.timeline as string} timeline={router.query.timeline as string}
account={account} account={account}
@ -88,9 +101,14 @@ export default function Page() {
dispatch({ target: 'media', value: true, object: media, index: index }) dispatch({ target: 'media', value: true, object: media, index: index })
} }
/> />
)} )
</> }
)} }
if (!account || !client) return null
return (
<>
{timeline(router.query.timeline as string)}
<Media <Media
open={modalState.media.opened} open={modalState.media.opened}
close={() => dispatch({ target: 'media', value: false, object: [], index: -1 })} close={() => dispatch({ target: 'media', value: false, object: [], index: -1 })}