feat: profile page, tabs, mods

This commit is contained in:
enes 2024-10-23 17:49:45 +02:00
parent a95cd8b6ec
commit 2e367ecde8
5 changed files with 739 additions and 5 deletions

View File

@ -61,7 +61,12 @@ export const ModFilter = React.memo(
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
const isOwnProfile =
filterOptions.author &&
userState.auth &&
userState.user?.pubkey === filterOptions.author
if (!(isAdmin || isOwnProfile)) return null
}
return (

View File

@ -29,6 +29,7 @@ type FetchModsOptions = {
until?: number
since?: number
limit?: number
author?: string
}
interface NDKContextType {
@ -146,7 +147,8 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
source,
until,
since,
limit
limit,
author
}: FetchModsOptions): Promise<ModDetails[]> => {
// Define the filter criteria for fetching mods
const filter: NDKFilter = {
@ -154,7 +156,8 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
limit: limit || MOD_FILTER_LIMIT, // Limit the number of events fetched to 20
'#t': [T_TAG_VALUE],
until, // Optional filter to fetch events until this timestamp
since // Optional filter to fetch events from this timestamp
since, // Optional filter to fetch events from this timestamp
authors: author ? [author] : undefined // Optional filter to fetch events from only this author
}
// If the source matches the current window location, add a filter condition

View File

@ -8,6 +8,7 @@ import {
NSFWFilter,
SortBy
} from 'types'
import { npubToHex } from 'utils'
export const useFilteredMods = (
mods: ModDetails[],
@ -38,11 +39,15 @@ export const useFilteredMods = (
let filtered = nsfwFilter(mods)
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.npub &&
npubToHex(userState.user.npub as string) === filterOptions.author
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
if (!(isAdmin && isUnmoderatedFully)) {
// Allow "Unmoderated Fully" when author visits own profile
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
@ -70,6 +75,7 @@ export const useFilteredMods = (
filterOptions.sort,
filterOptions.moderated,
filterOptions.nsfw,
filterOptions.author,
mods,
muteLists,
nsfwList

View File

@ -1,3 +1,722 @@
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter'
import { Pagination } from 'components/Pagination'
import { ProfileSection } from 'components/ProfileSection'
import { Tabs } from 'components/Tabs'
import { MOD_FILTER_LIMIT } from '../constants'
import {
useAppSelector,
useDidMount,
useFilteredMods,
useMuteLists,
useNDKContext,
useNSFWList
} from 'hooks'
import { nip19, UnsignedEvent } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useParams, Navigate, Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import { appRoutes, getProfilePageRoute } from 'routes'
import {
FilterOptions,
ModDetails,
ModeratedFilter,
NSFWFilter,
SortBy,
UserProfile,
UserRelaysType
} from 'types'
import {
copyTextToClipboard,
now,
npubToHex,
scrollIntoView,
sendDMUsingRandomKey,
signAndPublish
} from 'utils'
import { CheckboxField } from 'components/Inputs'
export const ProfilePage = () => {
return <h1>WIP</h1>
// Try to decode nprofile parameter
const { nprofile } = useParams()
let profilePubkey: string | undefined
try {
const value = nprofile
? nip19.decode(nprofile as `nprofile1${string}`)
: undefined
profilePubkey = value?.data.pubkey
} catch (error) {
// Failed to decode the nprofile
// Silently ignore and redirect to home or logged in user
}
const scrollTargetRef = useRef<HTMLDivElement>(null)
const { ndk, publish, findMetadata, fetchEventFromUserRelays, fetchMods } =
useNDKContext()
const [profile, setProfile] = useState<UserProfile>()
const userState = useAppSelector((state) => state.user)
const isOwnProfile =
userState.auth && userState.user?.pubkey === profilePubkey
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
useDidMount(() => {
if (profilePubkey) {
findMetadata(profilePubkey).then((res) => {
setProfile(res)
})
}
})
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const [showReportPopUp, setShowReportPopUp] = useState(false)
const [isBlocked, setIsBlocked] = useState(false)
useEffect(() => {
if (userState.auth && userState.user?.pubkey) {
const userHexKey = userState.user.pubkey as string
const muteListFilter: NDKFilter = {
kinds: [NDKKind.MuteList],
authors: [userHexKey]
}
fetchEventFromUserRelays(
muteListFilter,
userHexKey,
UserRelaysType.Write
).then((event) => {
if (event) {
// get a list of tags
const tags = event.tags
const blocked =
tags.findIndex(
(item) => item[0] === 'p' && item[1] === profilePubkey
) !== -1
setIsBlocked(blocked)
}
})
}
}, [userState, profilePubkey, fetchEventFromUserRelays])
const handleBlock = async () => {
if (!profilePubkey) {
toast.error(`Something went wrong. Unable to find reported user's pubkey`)
return
}
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
toast.error('Could not get pubkey for updating mute list')
setIsLoading(false)
return
}
setLoadingSpinnerDesc(`Finding user's mute list`)
// Define the event filter to search for the user's mute list events.
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = {
kinds: [NDKKind.MuteList],
authors: [userHexKey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
// get a list of tags
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex(
(item) => item[0] === 'p' && item[1] === profilePubkey
) !== -1
if (alreadyExists) {
setIsLoading(false)
setIsBlocked(true)
return toast.warn(`User is already in the mute list`)
}
tags.push(['p', profilePubkey])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: userHexKey,
kind: NDKKind.MuteList,
content: '',
created_at: now(),
tags: [['p', profilePubkey]]
}
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) {
setIsBlocked(true)
}
setIsLoading(false)
}
const handleUnblock = async () => {
if (!profilePubkey) {
toast.error(`Something went wrong. Unable to find reported user's pubkey`)
return
}
const userHexKey = userState.user?.pubkey as string
const filter: NDKFilter = {
kinds: [NDKKind.MuteList],
authors: [userHexKey]
}
setIsLoading(true)
setLoadingSpinnerDesc(`Finding user's mute list`)
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
if (!muteListEvent) {
toast.error(`Couldn't get user's mute list event from relays`)
return
}
const tags = muteListEvent.tags
const unsignedEvent: UnsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== profilePubkey)
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) {
setIsBlocked(false)
}
setIsLoading(false)
}
// Tabs
const [tab, setTab] = useState(0)
const [page, setPage] = useState(1)
// Mods
const [mods, setMods] = useState<ModDetails[]>([])
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated,
author: profilePubkey
})
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const handleNext = useCallback(() => {
setIsLoading(true)
const until =
mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined
fetchMods({
source: filterOptions.source,
until,
author: profilePubkey
})
.then((res) => {
setMods(res)
setPage((prev) => prev + 1)
scrollIntoView(scrollTargetRef.current)
})
.finally(() => {
setIsLoading(false)
})
}, [mods, fetchMods, filterOptions.source, profilePubkey])
const handlePrev = useCallback(() => {
setIsLoading(true)
const since = mods.length > 0 ? mods[0].published_at + 1 : undefined
fetchMods({
source: filterOptions.source,
since,
author: profilePubkey
})
.then((res) => {
setMods(res)
setPage((prev) => prev - 1)
scrollIntoView(scrollTargetRef.current)
})
.finally(() => {
setIsLoading(false)
})
}, [mods, fetchMods, filterOptions.source, profilePubkey])
useEffect(() => {
setIsLoading(true)
switch (tab) {
case 0:
fetchMods({ source: filterOptions.source, author: profilePubkey })
.then((res) => {
setMods(res)
})
.finally(() => {
setIsLoading(false)
})
break
default:
setIsLoading(false)
break
}
}, [filterOptions.source, tab, fetchMods, profilePubkey])
const filteredModList = useFilteredMods(
mods,
userState,
filterOptions,
nsfwList,
muteLists
)
// Redirect route
let profileRoute = appRoutes.home
if (!nprofile && userState.auth && userState.user) {
// Redirect to user's profile is no profile is linked
const userHexKey = npubToHex(userState.user.npub as string)
if (userHexKey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userHexKey
})
)
}
}
if (!profilePubkey) return <Navigate to={profileRoute} replace={true} />
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Profile'>
<div className='IBMSMSMBSSModFor'>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<p className='IBMSMSMBSSModForPara'>{displayName}'s Page</p>
<div
className='dropdown dropdownMain'
style={{ flexGrow: 'unset' }}
>
<button
className='btn btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
style={{
borderRadius: '5px',
background: 'unset',
padding: '5px'
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-192 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
</svg>
</button>
<div className='dropdown-menu dropdown-menu-end dropdownMainMenu'>
{isOwnProfile && (
<Link
className='dropdown-item dropdownMainMenuItem'
to={appRoutes.settingsProfile}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
</svg>
Edit
</Link>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => {
copyTextToClipboard(window.location.href).then(
(isCopied) => {
if (isCopied)
toast.success('Url copied to clipboard!')
}
)
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
Copy URL
</a>
<a
className='dropdown-item dropdownMainMenuItem'
href='#'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
</svg>
Share
</a>
{!isOwnProfile && (
<>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportUser'
onClick={() => setShowReportPopUp(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<a
className='dropdown-item dropdownMainMenuItem'
onClick={isBlocked ? handleUnblock : handleBlock}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 72.74-130.8c7 8 98.88 125.4 98.88 125.4l58.63-66.88c4.125 6.75 7.867 13.52 11.24 19.9C364.9 290.6 353.4 357.4 304.1 391.9z'></path>
</svg>
{isBlocked ? 'Unblock' : 'Block User'}
</a>
</>
)}
</div>
</div>
</div>
<Tabs
tabs={['Mods', 'Blogs', 'Posts']}
tab={tab}
setTab={setTab}
/>
{/* Tabs Content */}
{tab === 0 && (
<>
<ModFilter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<div className='IBMSMList IBMSMListAlt'>
{filteredModList.map((mod) => (
<ModCard key={mod.id} {...mod} />
))}
</div>
<Pagination
page={page}
disabledNext={mods.length < MOD_FILTER_LIMIT}
handlePrev={handlePrev}
handleNext={handleNext}
/>
</>
)}
{tab === 1 && <>USER's BLOGS WIP</>}
{tab === 2 && <>USER's POSTS WIP</>}
</div>
</div>
</div>
<ProfileSection pubkey={profilePubkey} />
</div>
</div>
{showReportPopUp && (
<ReportUserPopup
reportedPubkey={profilePubkey}
handleClose={() => setShowReportPopUp(false)}
/>
)}
</div>
</div>
)
}
type ReportUserPopupProps = {
reportedPubkey: string
handleClose: () => void
}
const USER_REPORT_REASONS = [
{ label: `User posts actual CP`, key: 'user_actuallyCP' },
{ label: `User is a spammer`, key: 'user_spam' },
{ label: `User is a scammer`, key: 'user_scam' },
{ label: `User posts malware`, key: 'user_malware' },
{ label: `User posts non-mods`, key: 'user_notAGameMod' },
{ label: `User doesn't tag NSFW`, key: 'user_wasntTaggedNSFW' },
{ label: `Other (user)`, key: 'user_otherReason' }
]
const ReportUserPopup = ({
reportedPubkey,
handleClose
}: ReportUserPopupProps) => {
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [selectedOptions, setSelectedOptions] = useState(
USER_REPORT_REASONS.reduce((acc: { [key: string]: boolean }, cur) => {
acc[cur.key] = false
return acc
}, {})
)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const handleCheckboxChange = (option: keyof typeof selectedOptions) => {
setSelectedOptions((prevState) => ({
...prevState,
[option]: !prevState[option]
}))
}
const handleSubmit = async () => {
const selectedOptionsCount = Object.values(selectedOptions).filter(
(isSelected) => isSelected
).length
if (selectedOptionsCount === 0) {
toast.error('At least one option should be checked!')
return
}
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
let userHexKey: string
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
toast.error('Could not get pubkey for reporting user!')
setIsLoading(false)
return
}
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
const reportingPubkey = npubToHex(reportingNpub)
if (reportingPubkey === userHexKey) {
setLoadingSpinnerDesc(`Finding user's mute list`)
// Define the event filter to search for the user's mute list events.
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = {
kinds: [NDKKind.MuteList],
authors: [userHexKey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
// get a list of tags
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex(
(item) => item[0] === 'p' && item[1] === reportedPubkey
) !== -1
if (alreadyExists) {
setIsLoading(false)
return toast.warn(
`Reporter user's pubkey is already in the mute list`
)
}
tags.push(['p', reportedPubkey])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: userHexKey,
kind: NDKKind.MuteList,
content: '',
created_at: now(),
tags: [['p', reportedPubkey]]
}
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) handleClose()
} else {
const href = window.location.href
let message = `I'd like to report ${href} due to following reasons:\n`
Object.entries(selectedOptions).forEach(([key, value]) => {
if (value) {
message += `* ${key}\n`
}
})
setLoadingSpinnerDesc('Sending report')
const isSent = await sendDMUsingRandomKey(
message,
reportingPubkey!,
ndk,
publish
)
if (isSent) handleClose()
}
setIsLoading(false)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Report Post</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Why are you reporting the user?
</label>
{USER_REPORT_REASONS.map((r) => (
<CheckboxField
key={r.key}
label={r.label}
name={r.key}
isChecked={selectedOptions[r.key]}
handleChange={() => handleCheckboxChange(r.key)}
/>
))}
<button
className='btn btnMain pUMCB_Report'
type='button'
style={{ width: '100%' }}
onClick={handleSubmit}
>
Submit Report
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -22,4 +22,5 @@ export interface FilterOptions {
nsfw: NSFWFilter
source: string
moderated: ModeratedFilter
author?: string
}