Skip to content

Commit

Permalink
❇️ Language filter on moderation queue (#25)
Browse files Browse the repository at this point in the history
* ✨ Use dids in all links instead of handles

* 🐛 Maintain search params when redirecting

* 🐛 Wait for auth before attempting to resolve handle

* ✨ Add language filter to moderation queue

* ✨ Move lang filter to use tags

* 🐛 Unique key for option

* ✨ Add tag display and emit form

* ✨ Allow multiple language inclusion and exclusion from the language picker

* 💄 Fix style of the lang picker button

* ⬆️ Upgrade atproto api version

* 💄 Add inline comments and refactor

* 💄 Improve the language picker styling and add help text

* 🐛 Use tag filters in the query key to refresh after lang selection
  • Loading branch information
foysalit authored Feb 21, 2024
1 parent 71b8c58 commit 3089191
Show file tree
Hide file tree
Showing 12 changed files with 371 additions and 15 deletions.
35 changes: 35 additions & 0 deletions app/actions/ModActionPanel/QuickAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { SubjectReviewStateBadge } from '@/subject/ReviewStateMarker'
import { getProfileUriForDid } from '@/reports/helpers/subject'
import { Dialog } from '@headlessui/react'
import { SubjectSwitchButton } from '@/common/SubjectSwitchButton'
import { diffTags } from 'components/tags/utils'

const FORM_ID = 'mod-action-panel'
const useBreakpoint = createBreakpoint({ xs: 340, sm: 640 })
Expand Down Expand Up @@ -176,6 +177,7 @@ function Form(
const [modEventType, setModEventType] = useState<string>(
MOD_EVENTS.ACKNOWLEDGE,
)
const isTagEvent = modEventType === MOD_EVENTS.TAG
const isLabelEvent = modEventType === MOD_EVENTS.LABEL
const isMuteEvent = modEventType === MOD_EVENTS.MUTE
const isCommentEvent = modEventType === MOD_EVENTS.COMMENT
Expand Down Expand Up @@ -252,6 +254,15 @@ function Form(
coreEvent.sticky = true
}

if (formData.get('tags')) {
const tags = String(formData.get('tags'))
.split(',')
.map((tag) => tag.trim())
const { add, remove } = diffTags(subjectStatus?.tags || [], tags)
coreEvent.add = add
coreEvent.remove = remove
}

const { subject: subjectInfo, record: recordInfo } =
await createSubjectFromId(subject)

Expand Down Expand Up @@ -528,6 +539,17 @@ function Form(
</LabelList>
</FormLabel>
</div>
{!!subjectStatus?.tags?.length && (
<div className={`mb-3`}>
<FormLabel label="Tags">
<LabelList className="-ml-1">
{subjectStatus.tags.map((tag) => {
return <LabelChip key={tag}>{tag}</LabelChip>
})}
</LabelList>
</FormLabel>
</div>
)}

{/* This is only meant to be switched on in mobile/small screen view */}
{/* The parent component ensures to toggle this based on the screen size */}
Expand Down Expand Up @@ -568,6 +590,19 @@ function Form(
</FormLabel>
)}

{isTagEvent && (
<FormLabel label="Tags" className="mt-2">
<Input
type="text"
id="tags"
name="tags"
className="block w-full"
placeholder="Comma separated tags"
defaultValue={subjectStatus?.tags?.join(',') || ''}
/>
</FormLabel>
)}

<div className="mt-2">
<Textarea
name="comment"
Expand Down
22 changes: 17 additions & 5 deletions app/reports/page-content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client'
import { useContext, useCallback, Suspense, useEffect } from 'react'
import { useContext, useCallback } from 'react'
import {
ReadonlyURLSearchParams,
usePathname,
Expand All @@ -23,6 +23,7 @@ import { ButtonGroup } from '@/common/buttons'
import { useFluentReportSearch } from '@/reports/useFluentReportSearch'
import { SubjectTable } from 'components/subject/table'
import { useTitle } from 'react-use'
import { LanguagePicker } from '@/common/LanguagePicker'

const TABS = [
{
Expand Down Expand Up @@ -156,6 +157,8 @@ export const ReportsPageContent = () => {
const includeMuted = !!params.get('includeMuted')
const appealed = !!params.get('appealed')
const reviewState = params.get('reviewState')
const tags = params.get('tags')
const excludeTags = params.get('excludeTags')
const { sortField, sortDirection } = getSortParams(params)
const { getReportSearchParams } = useFluentReportSearch()
const { lastReviewedBy, subject, reporters } = getReportSearchParams()
Expand Down Expand Up @@ -186,6 +189,8 @@ export const ReportsPageContent = () => {
reporters,
takendown,
appealed,
tags,
excludeTags,
},
],
queryFn: async ({ pageParam }) => {
Expand All @@ -209,6 +214,14 @@ export const ReportsPageContent = () => {
queryParams.appealed = appealed
}

if (tags) {
queryParams.tags = tags.split(',')
}

if (excludeTags) {
queryParams.excludeTags = excludeTags.split(',')
}

// For these fields, we only want to add them to the filter if the values are set, otherwise, defaults will kick in
Object.entries({
sortField,
Expand Down Expand Up @@ -255,10 +268,9 @@ export const ReportsPageContent = () => {
</button>
</div>
</SectionHeader>
<div className="flex mt-2 mb-2 flex-row justify-end px-4 sm:px-6 lg:px-8">
<Suspense fallback={<div></div>}>
<ResolvedFilters />
</Suspense>
<div className="md:flex mt-2 mb-2 flex-row justify-between px-4 sm:px-6 lg:px-8">
<LanguagePicker />
<ResolvedFilters />
</div>
<SubjectTable
subjectStatuses={subjectStatuses}
Expand Down
216 changes: 216 additions & 0 deletions components/common/LanguagePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { LANGUAGES_MAP_CODE2 } from '@/lib/locale/languages'
import { Popover, Transition } from '@headlessui/react'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { ActionButton } from './buttons'

// Please make sure that any item added here exists in LANGUAGES_MAP_CODE2 or add it there first
const availableLanguageCodes = [
'en',
'es',
'fr',
'de',
'it',
'ja',
'ko',
'pt',
'ru',
]

const SelectionTitle = ({
includedLanguages,
excludedLanguages,
}: {
includedLanguages: string[]
excludedLanguages: string[]
}) => {
if (includedLanguages.length === 0 && excludedLanguages.length === 0) {
return (
<span className="text-gray-700 dark:text-gray-100">All Languages</span>
)
}

const includedNames = includedLanguages.map(
(lang) => LANGUAGES_MAP_CODE2[lang].name,
)
const excludedNames = excludedLanguages.map(
(lang) => LANGUAGES_MAP_CODE2[lang].name,
)

return (
<>
<span className="text-gray-700 dark:text-gray-100">
{includedNames.join(', ')}
</span>
{includedNames.length > 0 && excludedNames.length > 0 && (
<span className="text-gray-700 dark:text-gray-100 mx-1">|</span>
)}
<span className="text-gray-700 dark:text-gray-100">
{excludedNames.map((name, i) => (
<s key={name}>
{name}
{i < excludedNames.length - 1 && ', '}
</s>
))}
</span>
</>
)
}

// Tags can be any arbitrary string, and lang tags are prefixed with lang:[code2] so we use this to get the lang code from tag string
const getLangFromTag = (tag: string) => tag.split(':')[1]

export const LanguagePicker: React.FC = () => {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()

const tagsParam = searchParams.get('tags')
const excludeTagsParam = searchParams.get('excludeTags')
const tags = tagsParam?.split(',') || []
const excludedTags = excludeTagsParam?.split(',') || []
const includedLanguages = tags
.filter((tag) => tag.startsWith('lang:'))
.map(getLangFromTag)
const excludedLanguages = excludedTags
.filter((tag) => tag.startsWith('lang:'))
.map(getLangFromTag)

const toggleLanguage = (section: 'include' | 'exclude', newLang: string) => {
const nextParams = new URLSearchParams(searchParams)
const urlQueryKey = section === 'include' ? 'tags' : 'excludeTags'
const selectedLanguages =
section === 'include' ? includedLanguages : excludedLanguages
const selectedLanguageTags = section === 'include' ? tags : excludedTags

if (selectedLanguages.includes(newLang)) {
const newTags = selectedLanguageTags.filter(
(tag) => `lang:${newLang}` !== tag,
)
if (newTags.length) {
nextParams.set(urlQueryKey, newTags.join(','))
} else {
nextParams.delete(urlQueryKey)
}
} else {
nextParams.set(
urlQueryKey,
[...selectedLanguageTags, `lang:${newLang}`].join(','),
)
}

router.push((pathname ?? '') + '?' + nextParams.toString())
}
const clearLanguages = () => {
const nextParams = new URLSearchParams(searchParams)

nextParams.delete('tags')
nextParams.delete('excludeTags')
router.push((pathname ?? '') + '?' + nextParams.toString())
}

return (
<Popover>
{({ open, close }) => (
<>
<Popover.Button className="text-sm flex flex-row items-center">
<SelectionTitle {...{ includedLanguages, excludedLanguages }} />
<ChevronDownIcon className="dark:text-gray-50 w-4 h-4" />
</Popover.Button>

{/* Use the `Transition` component. */}
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute left-0 z-10 mt-1 flex w-screen max-w-max -translate-x-1/5 px-4">
<div className="w-fit-content flex-auto rounded bg-white dark:bg-slate-800 p-4 text-sm leading-6 shadow-lg dark:shadow-slate-900 ring-1 ring-gray-900/5">
<div className="flex flex-row gap-4 text-gray-700 dark:text-gray-100">
<LanguageList
disabled={excludedLanguages}
selected={includedLanguages}
header="Include Languages"
onSelect={(lang) => toggleLanguage('include', lang)}
/>
<LanguageList
disabled={includedLanguages}
selected={excludedLanguages}
header="Exclude Languages"
onSelect={(lang) => toggleLanguage('exclude', lang)}
/>
</div>

<p className="py-2 block max-w-xs text-gray-500 dark:text-gray-300 text-xs">
Note: <i>When multiple languages are selected, only subjects that are
tagged with <b>all</b> of those languages will be
included/excluded.</i>
</p>
{(includedLanguages.length > 0 ||
excludedLanguages.length > 0) && (
<div className="flex flex-row mt-2">
<ActionButton
size="xs"
appearance="outlined"
onClick={() => {
clearLanguages()
close()
}}
>
<span className="text-xs">Clear All</span>
</ActionButton>
</div>
)}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
)
}

const LanguageList = ({
header,
onSelect,
selected = [],
disabled = [],
}: {
selected: string[]
disabled: string[]
header: string
onSelect: (lang: string) => void
}) => {
return (
<div>
<h4 className="text-gray-900 dark:text-gray-200 border-b border-gray-300 mb-2 pb-1">
{header}
</h4>
<div className="flex flex-col items-start">
{availableLanguageCodes.map((code2) => {
const isDisabled = disabled.includes(code2)
return (
<button
className={`w-full flex flex-row items-center justify-between ${
isDisabled
? 'text-gray-400'
: 'text-gray-700 dark:text-gray-100'
}`}
onClick={() => !isDisabled && onSelect(code2)}
key={code2}
>
{LANGUAGES_MAP_CODE2[code2].name}
{selected.includes(code2) && (
<CheckIcon className="h-4 w-4 text-green-700" />
)}
</button>
)
})}
</div>
</div>
)
}
30 changes: 30 additions & 0 deletions components/mod-event/EventItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,32 @@ const Label = ({
)
}

const Tag = ({
modEvent,
}: {
modEvent: {
event: ComAtprotoAdminDefs.ModEventTag
} & ComAtprotoAdminDefs.ModEventView
}) => {
return (
<div className="shadow dark:shadow-slate-700 bg-white dark:bg-slate-800 rounded-sm p-2">
<p>
<span>
By{' '}
{modEvent.creatorHandle
? `@${modEvent.creatorHandle}`
: modEvent.createdBy}
</span>
</p>{' '}
{modEvent.event.comment ? (
<p className="pb-1">{`${modEvent.event.comment}`}</p>
) : null}
<EventLabels header="Added: " labels={modEvent.event.add} />
<EventLabels header="Removed: " labels={modEvent.event.remove} />
</div>
)
}

const dateFormatter = new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
Expand Down Expand Up @@ -242,6 +268,10 @@ export const ModEventItem = ({
//@ts-ignore
eventItem = <Label modEvent={modEvent} />
}
if (ComAtprotoAdminDefs.isModEventTag(modEvent.event)) {
//@ts-ignore
eventItem = <Tag modEvent={modEvent} />
}
if (ComAtprotoAdminDefs.isModEventEmail(modEvent.event)) {
//@ts-ignore
eventItem = <Email modEvent={modEvent} />
Expand Down
Loading

0 comments on commit 3089191

Please sign in to comment.