Implement notifications
This commit is contained in:
parent
0375e02d93
commit
ac047034fa
@ -10,7 +10,7 @@
|
||||
"poll": {
|
||||
"refresh": "Refresh",
|
||||
"vote": "Vote",
|
||||
"peolpe": "{num} people",
|
||||
"people": "{num} people",
|
||||
"closed": "Closed"
|
||||
}
|
||||
}
|
||||
@ -21,5 +21,38 @@
|
||||
"sign_in": "Sign in",
|
||||
"authorize": "Authorize"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"favourite": {
|
||||
"body": "{user} favourited your post"
|
||||
},
|
||||
"reblog": {
|
||||
"body": "{user} boosted your post"
|
||||
},
|
||||
"quote": {
|
||||
"body": "{user} quoted your post"
|
||||
},
|
||||
"poll_expired": {
|
||||
"body": "{user} poll has ended"
|
||||
},
|
||||
"poll_vote": {
|
||||
"body": "{user} voted your poll"
|
||||
},
|
||||
"status": {
|
||||
"body": "{user} just posted"
|
||||
},
|
||||
"update": {
|
||||
"body": "{user} updated the post"
|
||||
},
|
||||
"emoji_reaction": {
|
||||
"body": "{user} reacted your post"
|
||||
},
|
||||
"follow": {
|
||||
"body": "{user} followed you",
|
||||
"followers": "{num} followers"
|
||||
},
|
||||
"follow_request": {
|
||||
"body": "{user} requested to follow you"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,7 @@
|
||||
"flowbite": "^2.0.0",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"megalodon": "^9.1.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-intl": "^6.5.1",
|
||||
"react-virtuoso": "^4.6.2"
|
||||
},
|
||||
|
74
renderer/components/timelines/Notifications.tsx
Normal file
74
renderer/components/timelines/Notifications.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { Account } from '@/db'
|
||||
import { TextInput } from 'flowbite-react'
|
||||
import { Entity, MegalodonInterface } from 'megalodon'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FormattedMessage, useIntl } from 'react-intl'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import Notification from './notification/Notification'
|
||||
|
||||
type Props = {
|
||||
account: Account
|
||||
client: MegalodonInterface
|
||||
}
|
||||
|
||||
export default function Notifications(props: Props) {
|
||||
const { formatMessage } = useIntl()
|
||||
const [notifications, setNotifications] = useState<Array<Entity.Notification>>([])
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
const res = await loadNotifications(props.client)
|
||||
setNotifications(res)
|
||||
}
|
||||
f()
|
||||
}, [props.client])
|
||||
|
||||
const loadNotifications = async (client: MegalodonInterface, maxId?: string): Promise<Array<Entity.Notification>> => {
|
||||
let options = { limit: 30 }
|
||||
if (maxId) {
|
||||
options = Object.assign({}, options, { max_id: maxId })
|
||||
}
|
||||
const res = await client.getNotifications(options)
|
||||
return res.data
|
||||
}
|
||||
|
||||
const updateStatus = (status: Entity.Status) => {
|
||||
const renew = notifications.map(n => {
|
||||
if (n.status === undefined || n.status === null) {
|
||||
return n
|
||||
}
|
||||
if (n.status.id === status.id) {
|
||||
return Object.assign({}, n, { status })
|
||||
} else if (n.status.reblog && n.status.reblog.id === status.id) {
|
||||
const s = Object.assign({}, n.status, { reblog: status })
|
||||
return Object.assign({}, n, { status: s })
|
||||
}
|
||||
return n
|
||||
})
|
||||
setNotifications(renew)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="h-full w-full">
|
||||
<div className="w-full bg-blue-950 text-blue-100 p-2 flex justify-between">
|
||||
<div className="text-lg font-bold">
|
||||
<FormattedMessage id="timeline.notifications" />
|
||||
</div>
|
||||
<div className="w-64 text-xs">
|
||||
<form>
|
||||
<TextInput type="text" placeholder={formatMessage({ id: 'timeline.search' })} disabled sizing="sm" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="timeline overflow-y-auto w-full overflow-x-hidden" style={{ height: 'calc(100% - 50px)' }}>
|
||||
<Virtuoso
|
||||
style={{ height: '100%' }}
|
||||
data={notifications}
|
||||
itemContent={(_, notification) => (
|
||||
<Notification client={props.client} notification={notification} onRefresh={updateStatus} key={notification.id} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
63
renderer/components/timelines/notification/Follow.tsx
Normal file
63
renderer/components/timelines/notification/Follow.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Avatar } from 'flowbite-react'
|
||||
import { Entity } from 'megalodon'
|
||||
import { FaUserPlus } from 'react-icons/fa6'
|
||||
import { FormattedMessage, useIntl } from 'react-intl'
|
||||
import emojify from '@/utils/emojify'
|
||||
|
||||
type Props = {
|
||||
notification: Entity.Notification
|
||||
}
|
||||
|
||||
export default function Follow(props: Props) {
|
||||
const { formatMessage } = useIntl()
|
||||
|
||||
return (
|
||||
<div className="border-b mr-2 py-1">
|
||||
<div className="flex items-center">
|
||||
<div style={{ width: '56px' }}>
|
||||
<FaUserPlus className="text-blue-600 w-4 mr-2 ml-auto" />
|
||||
</div>
|
||||
<div style={{ width: 'calc(100% - 56px)' }}>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojify(
|
||||
formatMessage(
|
||||
{
|
||||
id: actionId(props.notification)
|
||||
},
|
||||
{ user: props.notification.account.username }
|
||||
),
|
||||
props.notification.account.emojis
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="p-2" style={{ width: '56px' }}>
|
||||
<Avatar img={props.notification.account.avatar} />
|
||||
</div>
|
||||
<div style={{ width: 'calc(100% - 56px)' }}>
|
||||
<div className="flex">
|
||||
<span
|
||||
className="text-gray-950 text-ellipsis break-all overflow-hidden"
|
||||
dangerouslySetInnerHTML={{ __html: emojify(props.notification.account.display_name, props.notification.account.emojis) }}
|
||||
></span>
|
||||
<span className="text-gray-600 text-ellipsis break-all overflow-hidden">@{props.notification.account.acct}</span>
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
<FormattedMessage id="notification.follow.followers" values={{ num: props.notification.account.followers_count }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const actionId = (notification: Entity.Notification) => {
|
||||
if (notification.type === 'follow_request') {
|
||||
return 'notification.follow_request.body'
|
||||
} else {
|
||||
return 'notification.follow.body'
|
||||
}
|
||||
}
|
44
renderer/components/timelines/notification/Notification.tsx
Normal file
44
renderer/components/timelines/notification/Notification.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Entity, MegalodonInterface } from 'megalodon'
|
||||
import Status from '../status/Status'
|
||||
import Reaction from './Reaction'
|
||||
import Follow from './Follow'
|
||||
|
||||
type Props = {
|
||||
notification: Entity.Notification
|
||||
client: MegalodonInterface
|
||||
onRefresh: (status: Entity.Status) => void
|
||||
}
|
||||
|
||||
export default function Notification(props: Props) {
|
||||
switch (props.notification.type) {
|
||||
case 'mention': {
|
||||
if (props.notification.status) {
|
||||
return <Status client={props.client} status={props.notification.status} onRefresh={props.onRefresh} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
case 'favourite':
|
||||
case 'reblog':
|
||||
case 'poll_expired':
|
||||
case 'poll_vote':
|
||||
case 'quote':
|
||||
case 'status':
|
||||
case 'update':
|
||||
case 'emoji_reaction':
|
||||
case 'reaction': {
|
||||
if (props.notification.status) {
|
||||
return <Reaction client={props.client} notification={props.notification} onRefresh={props.onRefresh} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
case 'follow':
|
||||
case 'follow_request':
|
||||
return <Follow notification={props.notification} />
|
||||
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
123
renderer/components/timelines/notification/Reaction.tsx
Normal file
123
renderer/components/timelines/notification/Reaction.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { Avatar } from 'flowbite-react'
|
||||
import { Entity, MegalodonInterface } from 'megalodon'
|
||||
import dayjs from 'dayjs'
|
||||
import emojify from '@/utils/emojify'
|
||||
import Body from '../status/Body'
|
||||
import Poll from '../status/Poll'
|
||||
import Card from '../status/Card'
|
||||
import Media from '../status/Media'
|
||||
import { FaBarsProgress, FaHouse, FaPenToSquare, FaRetweet, FaStar } from 'react-icons/fa6'
|
||||
import { useIntl } from 'react-intl'
|
||||
|
||||
type Props = {
|
||||
notification: Entity.Notification
|
||||
client: MegalodonInterface
|
||||
onRefresh: (status: Entity.Status) => void
|
||||
}
|
||||
|
||||
export default function Reaction(props: Props) {
|
||||
const status = props.notification.status
|
||||
const { formatMessage } = useIntl()
|
||||
|
||||
const refresh = async () => {
|
||||
const res = await props.client.getStatus(status.id)
|
||||
props.onRefresh(res.data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b mr-2 py-1">
|
||||
<div className="flex items-center">
|
||||
<div style={{ width: '56px' }}>{actionIcon(props.notification)}</div>
|
||||
<div style={{ width: 'calc(100% - 56px)' }}>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojify(
|
||||
formatMessage(
|
||||
{
|
||||
id: actionId(props.notification)
|
||||
},
|
||||
{ user: props.notification.account.username }
|
||||
),
|
||||
props.notification.account.emojis
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="p-2" style={{ width: '56px' }}>
|
||||
<Avatar img={status.account.avatar} />
|
||||
</div>
|
||||
<div className="text-gray-600 break-all overflow-hidden" style={{ width: 'calc(100% - 56px)' }}>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex">
|
||||
<span
|
||||
className="text-gray-600 text-ellipsis break-all overflow-hidden"
|
||||
dangerouslySetInnerHTML={{ __html: emojify(status.account.display_name, status.account.emojis) }}
|
||||
></span>
|
||||
<span className="text-gray-600 text-ellipsis break-all overflow-hidden">@{status.account.acct}</span>
|
||||
</div>
|
||||
<div className="text-gray-600 text-right">
|
||||
<time dateTime={status.created_at}>{dayjs(status.created_at).format('YYYY-MM-DD HH:mm:ss')}</time>
|
||||
</div>
|
||||
</div>
|
||||
<Body status={status} className="text-gray-600" />
|
||||
{status.poll && <Poll poll={status.poll} onRefresh={refresh} client={props.client} className="text-gray-600" />}
|
||||
{status.card && <Card card={status.card} />}
|
||||
<Media media={status.media_attachments} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const actionIcon = (notification: Entity.Notification) => {
|
||||
switch (notification.type) {
|
||||
case 'favourite': {
|
||||
return <FaStar className="text-orange-500 w-4 mr-2 ml-auto" />
|
||||
}
|
||||
case 'reblog':
|
||||
case 'quote': {
|
||||
return <FaRetweet className="text-blue-600 w-4 mr-2 ml-auto" />
|
||||
}
|
||||
case 'poll_expired':
|
||||
case 'poll_vote': {
|
||||
return <FaBarsProgress className="text-blue-600 w-4 mr-2 ml-auto" />
|
||||
}
|
||||
case 'status': {
|
||||
return <FaHouse className="text-blue-600 w-4 mr-2 ml-auto" />
|
||||
}
|
||||
case 'update': {
|
||||
return <FaPenToSquare className="text-blue-600 w-4 mr-2 ml-auto" />
|
||||
}
|
||||
case 'emoji_reaction': {
|
||||
return <span dangerouslySetInnerHTML={{ __html: notification.emoji }} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const actionId = (notification: Entity.Notification) => {
|
||||
switch (notification.type) {
|
||||
case 'favourite':
|
||||
return 'notification.favourite.body'
|
||||
case 'reblog':
|
||||
return 'notification.reblog.body'
|
||||
case 'quote':
|
||||
return 'notification.quote.body'
|
||||
case 'poll_expired':
|
||||
return 'notification.poll_expired.body'
|
||||
case 'poll_vote':
|
||||
return 'notification.poll.vote'
|
||||
case 'status':
|
||||
return 'notification.status.body'
|
||||
case 'update':
|
||||
return 'notification.update.body'
|
||||
case 'emoji_reqction':
|
||||
return 'notification.emoji_reaction.body'
|
||||
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ export default function Body(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={props.className}
|
||||
style={Object.assign({ wordWrap: 'break-word' }, props.style)}
|
||||
dangerouslySetInnerHTML={{ __html: emojify(props.status.content, props.status.emojis) }}
|
||||
/>
|
||||
|
@ -8,6 +8,7 @@ export default function Media(props: Props) {
|
||||
<div className="mt-2 flex flex-wrap">
|
||||
{props.media.map((media, key) => (
|
||||
<img
|
||||
key={key}
|
||||
src={media.preview_url}
|
||||
className="h-36 mr-2 mb-2 rounded-md max-w-full cursor-pointer"
|
||||
alt={media.description}
|
||||
|
@ -1,13 +1,15 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Progress, Button, Radio, Label, Checkbox } from 'flowbite-react'
|
||||
import { Entity, MegalodonInterface } from 'megalodon'
|
||||
import { HTMLAttributes } from 'react'
|
||||
import { FormattedMessage } from 'react-intl'
|
||||
|
||||
type Props = {
|
||||
poll: Entity.Poll
|
||||
client: MegalodonInterface
|
||||
onRefresh: () => void
|
||||
}
|
||||
} & HTMLAttributes<HTMLElement>
|
||||
|
||||
export default function Poll(props: Props) {
|
||||
if (props.poll.voted || props.poll.expired) {
|
||||
return <PollResult {...props} />
|
||||
@ -33,7 +35,7 @@ function SimplePoll(props: Props) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className={props.className + ' my-2'}>
|
||||
{props.poll.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 my-2 pl-1">
|
||||
<Radio id={option.title} name={props.poll.id} value={option.title} />
|
||||
@ -71,7 +73,7 @@ function MultiplePoll(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className={props.className + ' my-2'}>
|
||||
{props.poll.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 my-2 pl-1">
|
||||
<Checkbox id={option.title} />
|
||||
@ -95,7 +97,7 @@ function MultiplePoll(props: Props) {
|
||||
|
||||
function PollResult(props: Props) {
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className={props.className + ' my-2'}>
|
||||
{props.poll.options.map((option, index) => (
|
||||
<div key={index}>
|
||||
<span className="pr-2">{percent(option.votes_count ?? 0, props.poll.votes_count)}%</span>
|
||||
|
@ -3,6 +3,7 @@ import Timeline from '@/components/timelines/Timeline'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Account, db } from '@/db'
|
||||
import generator, { MegalodonInterface } from 'megalodon'
|
||||
import Notifications from '@/components/timelines/Notifications'
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter()
|
||||
@ -23,5 +24,13 @@ export default function Page() {
|
||||
}
|
||||
}, [router.query.id])
|
||||
|
||||
return <>{account && client && <Timeline timeline={router.query.timeline as string} account={account} client={client} />}</>
|
||||
if (!account || !client) return null
|
||||
switch (router.query.timeline as string) {
|
||||
case 'notifications': {
|
||||
return <Notifications account={account} client={client} />
|
||||
}
|
||||
default: {
|
||||
return <Timeline timeline={router.query.timeline as string} account={account} client={client} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import Image from 'next/image'
|
||||
import Icon from '@/assets/256x256.png'
|
||||
|
||||
type AccountProps = {}
|
||||
|
||||
export default function Account(props: AccountProps) {
|
||||
export default function Account() {
|
||||
const router = useRouter()
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const lastTimeline = localStorage.getItem(`${router.query.id}_lastTimeline`)
|
||||
@ -13,5 +13,9 @@ export default function Account(props: AccountProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return <>{router.query.id}</>
|
||||
return (
|
||||
<div className="h-screen w-full flex justify-center items-center">
|
||||
<Image src={Icon} alt="icon" width={128} height={128} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -3754,7 +3754,7 @@ react-dom@^18.2.0:
|
||||
loose-envify "^1.1.0"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-icons@^4.10.1:
|
||||
react-icons@^4.10.1, react-icons@^4.11.0:
|
||||
version "4.11.0"
|
||||
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.11.0.tgz#4b0e31c9bfc919608095cc429c4f1846f4d66c65"
|
||||
integrity sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA==
|
||||
|
Loading…
x
Reference in New Issue
Block a user