diff --git a/app/reports/page-content.tsx b/app/reports/page-content.tsx index 299afc0a..ff77036e 100644 --- a/app/reports/page-content.tsx +++ b/app/reports/page-content.tsx @@ -22,7 +22,6 @@ import { ModActionPanelQuick } from '../actions/ModActionPanel/QuickAction' import { ButtonGroup } from '@/common/buttons' import { SubjectTable } from 'components/subject/table' import { useTitle } from 'react-use' -import { LanguagePicker } from '@/common/LanguagePicker' import { QueueSelector } from '@/reports/QueueSelector' import { simpleHash, unique } from '@/lib/util' import { useEmitEvent } from '@/mod-event/helpers/emitEvent' @@ -30,8 +29,8 @@ import { useFluentReportSearchParams } from '@/reports/useFluentReportSearch' import { useLabelerAgent } from '@/shell/ConfigurationContext' import { WorkspacePanel } from 'components/workspace/Panel' import { useWorkspaceOpener } from '@/common/useWorkspaceOpener' -import { EmbedTypePickerForModerationQueue } from '@/common/EmbedTypePicker' import { useQueueSetting } from 'components/setting/useQueueSetting' +import QueueFilterPanel from '@/reports/QueueFilter/Panel' const TABS = [ { @@ -106,13 +105,15 @@ const ResolvedFilters = () => { const appealed = params.get('appealed') const updateParams = useCallback( - (key: string, newState: boolean) => { + (updates: Record) => { const nextParams = new URLSearchParams(params) - if (nextParams.get(key) == `${newState}`) { - nextParams.delete(key) - } else { - nextParams.set(key, `${newState}`) - } + Object.entries(updates).forEach(([key, newState]) => { + if (nextParams.get(key) === `${newState}`) { + nextParams.delete(key) + } else { + nextParams.set(key, `${newState}`) + } + }) router.push((pathname ?? '') + '?' + nextParams.toString()) }, [params, pathname, router], @@ -126,20 +127,29 @@ const ResolvedFilters = () => { { id: 'takendown', text: 'Taken Down', - onClick: () => updateParams('takendown', true), + onClick: () => updateParams({ takendown: true }), isActive: takendown === 'true', }, { - id: 'includeMuted', - text: 'Show Muted', - onClick: () => updateParams('includeMuted', true), - isActive: includeMuted === 'true', - }, - { - id: 'onlyMuted', - text: 'Only Muted', - onClick: () => updateParams('onlyMuted', true), - isActive: onlyMuted === 'true', + id: 'mute', + text: + includeMuted === 'true' + ? 'Include Muted' + : onlyMuted === 'true' + ? 'Only Muted' + : 'Mutes', + onClick: () => { + // setting a param to it's current value toggles it off + // so we toggle off includeMuted and toggle on onlyMuted + if (includeMuted === 'true') { + updateParams({ includeMuted: true, onlyMuted: true }) + } else if (onlyMuted === 'true') { + updateParams({ onlyMuted: true }) + } else { + updateParams({ includeMuted: true }) + } + }, + isActive: includeMuted === 'true' || onlyMuted === 'true', }, { id: 'appealed', @@ -151,12 +161,12 @@ const ResolvedFilters = () => { : 'Appeals', onClick: () => { if (appealed === 'true') { - updateParams('appealed', false) + updateParams({ appealed: false }) } else if (appealed === 'false') { // setting the same value toggles the param off - updateParams('appealed', false) + updateParams({ appealed: false }) } else { - updateParams('appealed', true) + updateParams({ appealed: true }) } }, isActive: appealed === 'true' || appealed === 'false', @@ -237,8 +247,7 @@ export const ReportsPageContent = () => {
- - +
@@ -295,6 +304,8 @@ function useModerationQueueQuery() { const tags = params.get('tags') const excludeTags = params.get('excludeTags') const queueName = params.get('queueName') + const subjectType = params.get('subjectType') + const collections = params.get('collections') const { sortField, sortDirection } = getSortParams(params) const { lastReviewedBy, subject, reporters, includeAllUserRecords } = useFluentReportSearchParams() @@ -317,6 +328,8 @@ function useModerationQueueQuery() { queueName, includeMuted, onlyMuted, + subjectType, + collections, }, ], queryFn: async ({ pageParam }) => { @@ -330,6 +343,17 @@ function useModerationQueueQuery() { if (subject) { queryParams.subject = subject + } else { + if (subjectType) { + queryParams.subjectType = subjectType + } + + if (subjectType === 'record') { + const collectionNames = collections?.split(',') + if (collectionNames?.length) { + queryParams.collections = collectionNames + } + } } if (takendown) { diff --git a/app/repositories/page-content.tsx b/app/repositories/page-content.tsx index cd0dfd59..536459da 100644 --- a/app/repositories/page-content.tsx +++ b/app/repositories/page-content.tsx @@ -221,7 +221,7 @@ export default function RepositoriesListPage() { void -}) => { - return ( - setEmbedType(), - }, - { - id: 'image', - text: EmbedTypeTitles['image'], - onClick: () => setEmbedType('image'), - }, - { - id: 'video', - text: EmbedTypeTitles['video'], - onClick: () => setEmbedType('video'), - }, - { - id: 'external', - text: EmbedTypeTitles['external'], - onClick: () => setEmbedType('external'), - }, - ]} - data-cy="lang-selector" - > - {embedType ? EmbedTypeTitles[embedType] : 'Embed type'} - - - ) -} - -export const EmbedTypePickerForModerationQueue = () => { - const searchParams = useSearchParams() - const router = useRouter() - const pathname = usePathname() - - const tagsParam = searchParams.get('tags') - - const tags = tagsParam?.split(',') || [] - - const includedEmbedTypes = tags - .filter((tag) => tag.startsWith('embed:')) - .map((t) => t.replace('embed:', '')) - - const setEmbedType = (embedType?: string) => { - const nextParams = new URLSearchParams(searchParams) - - if (embedType) { - nextParams.set('tags', `embed:${embedType}`) - } else { - nextParams.delete('tags') - } - - router.push((pathname ?? '') + '?' + nextParams.toString()) - } - - return ( - - ) -} diff --git a/components/common/LanguagePicker.tsx b/components/common/LanguagePicker.tsx index 53dfd7b6..01cfe4a3 100644 --- a/components/common/LanguagePicker.tsx +++ b/components/common/LanguagePicker.tsx @@ -1,9 +1,5 @@ import { getLanguageName } from '@/lib/locale/helpers' -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' +import { ChevronDownIcon } from '@heroicons/react/20/solid' import { Dropdown } from './Dropdown' // Please make sure that any item added here exists in LANGUAGES_MAP_CODE2 or add it there first @@ -21,207 +17,6 @@ export const availableLanguageCodes = [ 'ar', ] -const SelectionTitle = ({ - includedLanguages, - excludedLanguages, -}: { - includedLanguages: string[] - excludedLanguages: string[] -}) => { - if (includedLanguages.length === 0 && excludedLanguages.length === 0) { - return ( - All Languages - ) - } - - const includedNames = includedLanguages.map( - (lang) => LANGUAGES_MAP_CODE2[lang].name, - ) - const excludedNames = excludedLanguages.map( - (lang) => LANGUAGES_MAP_CODE2[lang].name, - ) - - return ( - <> - - {includedNames.join(', ')} - - {includedNames.length > 0 && excludedNames.length > 0 && ( - | - )} - - {excludedNames.map((name, i) => ( - - {name} - {i < excludedNames.length - 1 && ', '} - - ))} - - - ) -} - -// 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 ( - - {({ open, close }) => ( - <> - - - - - - {/* Use the `Transition` component. */} - - -
-
- toggleLanguage('include', lang)} - /> - toggleLanguage('exclude', lang)} - /> -
- -

- Note:{' '} - - When multiple languages are selected, only subjects that are - tagged with all of those languages will be - included/excluded. - -

- {(includedLanguages.length > 0 || - excludedLanguages.length > 0) && ( -
- { - clearLanguages() - close() - }} - > - Clear All - -
- )} -
-
-
- - )} -
- ) -} - -const LanguageList = ({ - header, - onSelect, - selected = [], - disabled = [], -}: { - selected: string[] - disabled: string[] - header: string - onSelect: (lang: string) => void -}) => { - return ( -
-

- {header} -

-
- {availableLanguageCodes.map((code2) => { - const isDisabled = disabled.includes(code2) - return ( - - ) - })} -
-
- ) -} - export const LanguageSelectorDropdown = ({ selectedLang, setSelectedLang, diff --git a/components/common/buttons.tsx b/components/common/buttons.tsx index 536c7075..d1ffdf61 100644 --- a/components/common/buttons.tsx +++ b/components/common/buttons.tsx @@ -99,10 +99,15 @@ export const ButtonGroup = ({ appearance, size = 'sm', items, + leftAligned = false, }: ComponentProps<'span'> & - ActionButtonProps & { items: ButtonGroupItem[] }) => { + ActionButtonProps & { items: ButtonGroupItem[]; leftAligned?: boolean }) => { + const containerClasses = classNames( + `isolate inline-flex rounded-md shadow-sm my-2 sm:my-0`, + leftAligned ? '' : 'sm:ml-4', + ) return ( - + {items.map(({ id, className, Icon, text, isActive, ...rest }, i) => ( + ) + })} + + + ) +} diff --git a/components/reports/QueueFilter/Panel.tsx b/components/reports/QueueFilter/Panel.tsx new file mode 100644 index 00000000..52658ad7 --- /dev/null +++ b/components/reports/QueueFilter/Panel.tsx @@ -0,0 +1,135 @@ +import { Popover, Transition } from '@headlessui/react' +import { ChevronDownIcon } from '@heroicons/react/20/solid' +import { QueueFilterLanguage } from './Language' +import { QueueFilterSubjectType } from './SubjectType' +import { useSearchParams } from 'next/navigation' +import { useQueueFilterBuilder } from '../useQueueFilter' +import { ToolsOzoneModerationQueryStatuses } from '@atproto/api' +import { getLanguageFlag } from 'components/tags/SubjectTag' +import { getCollectionName } from '../helpers/subject' +import { classNames } from '@/lib/util' + +// Takes all the queue filters manageable in the panel and displays a summary of selections made +const FilterSummary = ({ + queueFilters, +}: { + queueFilters: ToolsOzoneModerationQueryStatuses.QueryParams +}) => { + const { tags, excludeTags, collections, subjectType } = queueFilters + if ( + !tags?.length && + !excludeTags?.length && + !collections?.length && + !subjectType + ) { + return <>Filters + } + + const inclusions: string[] = [] + const exclusions: string[] = [] + + if (subjectType === 'account') { + inclusions.push('Only Accounts') + } + + if (subjectType === 'record') { + inclusions.push('Only Records') + } + + tags?.forEach((tag) => { + if (tag.startsWith('lang:')) { + const langCode = tag.split(':')[1] + inclusions.push(getLanguageFlag(langCode) || langCode) + } + + if (tag.startsWith('embed:')) { + inclusions.push(tag.split(':')[1]) + } + }) + + excludeTags?.forEach((tag) => { + if (tag.startsWith('lang:')) { + const langCode = tag.split(':')[1] + exclusions.push(getLanguageFlag(langCode) || langCode) + } + + if (tag.startsWith('embed:')) { + exclusions.push(tag.split(':')[1]) + } + }) + return ( + <> + {!!inclusions.length && inclusions.join(' ')} + {!!exclusions.length && ( + + {exclusions.join(' ')} + + )} + {!!collections?.length && ( + + Collections: {collections.map(getCollectionName).join(', ')} + + )} + + ) +} + +const FilterButton = () => { + const searchParams = useSearchParams() + const queueFilters = useQueueFilterBuilder(searchParams) + + return ( + + + + + + + ) +} + +export const QueueFilterPanel = () => { + return ( + + {({ open }) => ( + <> + + {/* Use the `Transition` component. */} + + +
+
+ + +
+
+
+
+ + )} +
+ ) +} + +export default QueueFilterPanel diff --git a/components/reports/QueueFilter/SubjectType.tsx b/components/reports/QueueFilter/SubjectType.tsx new file mode 100644 index 00000000..f9ae891c --- /dev/null +++ b/components/reports/QueueFilter/SubjectType.tsx @@ -0,0 +1,109 @@ +import { ButtonGroup } from '@/common/buttons' +import { + CollectionId, + EmbedTypes, + getCollectionName, + getEmbedTypeName, +} from '../helpers/subject' +import { Checkbox } from '@/common/forms' +import { useQueueFilter } from '../useQueueFilter' + +export const QueueFilterSubjectType = () => { + const { queueFilters, toggleCollection, toggleSubjectType, toggleEmbedType } = + useQueueFilter() + const allEmbedTypes = Object.values(EmbedTypes) + + const selectedCollections = queueFilters.collections || [] + const selectedIncludeEmbedTypes: string[] = + queueFilters.tags?.filter((tag) => { + return tag.startsWith('embed:') + }) || [] + const selectedExcludeEmbedTypes: string[] = + queueFilters.excludeTags?.filter((tag) => { + return tag.startsWith('embed:') + }) || [] + + return ( +
+

+ Subject Type Filters +

+ + { + toggleSubjectType('account') + }, + isActive: queueFilters.subjectType === 'account', + }, + { + id: 'subjectTypeRecord', + text: 'Record', + onClick: () => { + toggleSubjectType('record') + }, + isActive: + queueFilters.subjectType === 'record' || + !!selectedCollections.length || + !!selectedIncludeEmbedTypes.length, + }, + ]} + /> + + {queueFilters.subjectType === 'record' && ( +
+
+

+ Record Collection +

+ + {Object.values(CollectionId).map((collectionId) => { + const isSelected = selectedCollections.includes(collectionId) + return ( + { + toggleCollection(collectionId) + }} + /> + ) + })} +
+
+

+ Record Embed +

+ {[...allEmbedTypes, 'noEmbed'].map((embedType) => { + const isNoEmbed = embedType === 'noEmbed' + const isSelected = isNoEmbed + ? allEmbedTypes.every((et) => + selectedExcludeEmbedTypes.includes(et), + ) + : selectedIncludeEmbedTypes.includes(embedType) + return ( + { + toggleEmbedType(embedType) + }} + /> + ) + })} +
+
+ )} +
+ ) +} diff --git a/components/reports/helpers/subject.ts b/components/reports/helpers/subject.ts index 36c19f85..0ccf4124 100644 --- a/components/reports/helpers/subject.ts +++ b/components/reports/helpers/subject.ts @@ -103,3 +103,25 @@ export const getCollectionName = (collection: string) => { } return '' } + +export const EmbedTypes = { + Image: 'embed:image', + Video: 'embed:video', + External: 'embed:external', +} + +export const getEmbedTypeName = (embedType: string) => { + if (embedType === EmbedTypes.Image) { + return 'Image' + } + if (embedType === EmbedTypes.Video) { + return 'Video' + } + if (embedType === EmbedTypes.External) { + return 'External' + } + if (embedType === 'noEmbed') { + return 'No Embed' + } + return '' +} diff --git a/components/reports/useQueueFilter.tsx b/components/reports/useQueueFilter.tsx new file mode 100644 index 00000000..04ef625e --- /dev/null +++ b/components/reports/useQueueFilter.tsx @@ -0,0 +1,178 @@ +import { ToolsOzoneModerationQueryStatuses } from '@atproto/api' +import { + ReadonlyURLSearchParams, + usePathname, + useRouter, + useSearchParams, +} from 'next/navigation' +import { useCallback, useMemo } from 'react' +import { EmbedTypes } from './helpers/subject' + +export const useQueueFilterBuilder = ( + searchParams: ReadonlyURLSearchParams, +) => { + return useMemo(() => { + const filters: ToolsOzoneModerationQueryStatuses.QueryParams = {} + + searchParams.forEach((value, key) => { + if (key === 'tags' || key === 'excludeTags' || key === 'collections') { + filters[key] = value.split(',') + } else if (key === 'limit') { + filters.limit = parseInt(value, 10) + } else { + filters[key] = value + } + }) + + return filters + }, [searchParams]) +} + +export const useQueueFilter = () => { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + // Dynamically create `queueFilters` from current `searchParams` + const queueFilters = useQueueFilterBuilder(searchParams) + + const updateFilters = ( + newParams: Partial, + ) => { + const updatedParams = new URLSearchParams(searchParams) + + Object.entries(newParams).forEach(([key, value]) => { + if (value === undefined) { + updatedParams.delete(key) + } else if (Array.isArray(value)) { + updatedParams.set(key, value.join(',')) + } else { + updatedParams.set(key, value.toString()) + } + }) + + router.replace(`${pathname}?${updatedParams.toString()}`) + } + + const resetFilters = useCallback(() => { + router.replace(pathname) + }, [router, pathname]) + + const toggleSubjectType = (targetType: 'account' | 'record') => { + const subjectType = + queueFilters.subjectType === targetType ? undefined : targetType + + const newParams: Partial = { + subjectType, + } + + if ( + (targetType === 'record' && subjectType === undefined) || + subjectType === 'account' + ) { + newParams.collections = undefined + const newTags = queueFilters.tags?.filter( + (tag) => !tag.startsWith('embed:'), + ) + const newExcludeTags = queueFilters.excludeTags?.filter( + (tag) => !tag.startsWith('embed:'), + ) + newParams.tags = newTags?.length ? newTags : undefined + newParams.excludeTags = newExcludeTags?.length + ? newExcludeTags + : undefined + } + + updateFilters(newParams) + } + + const toggleEmbedType = (embedType: string) => { + const allEmbedTypes = Object.values(EmbedTypes) + + const newTags = new Set(queueFilters.tags ?? []) + const newExcludeTags = new Set(queueFilters.excludeTags ?? []) + + if (embedType.startsWith('embed:')) { + if (newTags.has(embedType)) { + newTags.delete(embedType) + } else { + allEmbedTypes.forEach((embed) => newExcludeTags.delete(embed)) + newTags.add(embedType) + } + } else if (embedType === 'noEmbed') { + const hasAllEmbedExcludes = allEmbedTypes.every((embed) => + newExcludeTags.has(embed), + ) + + if (hasAllEmbedExcludes) { + allEmbedTypes.forEach((embed) => newExcludeTags.delete(embed)) + } else { + allEmbedTypes.forEach((embed) => { + newTags.delete(embed) + newExcludeTags.add(embed) + }) + } + } + + updateFilters({ + tags: newTags.size > 0 ? Array.from(newTags) : undefined, + excludeTags: + newExcludeTags.size > 0 ? Array.from(newExcludeTags) : undefined, + }) + } + + const toggleCollection = (collection: string) => { + const newCollections = new Set(queueFilters.collections ?? []) + + if (newCollections.has(collection)) { + newCollections.delete(collection) + } else { + newCollections.add(collection) + } + + updateFilters({ + collections: + newCollections.size > 0 ? Array.from(newCollections) : undefined, + }) + } + + const toggleLanguage = (section: 'include' | 'exclude', newLang: string) => { + const filterKey = section === 'include' ? 'tags' : 'excludeTags' + const currentTags = + section === 'include' ? queueFilters.tags : queueFilters.excludeTags + + const newTags = new Set(currentTags ?? []) + if (newTags.has(`lang:${newLang}`)) { + newTags.delete(`lang:${newLang}`) + } else { + newTags.add(`lang:${newLang}`) + } + + updateFilters({ + [filterKey]: newTags.size > 0 ? Array.from(newTags) : undefined, + }) + } + + const clearLanguages = () => { + const newTags = queueFilters.tags?.filter((tag) => !tag.startsWith('lang:')) + const newExcludeTags = queueFilters.excludeTags?.filter( + (tag) => !tag.startsWith('lang:'), + ) + + updateFilters({ + tags: newTags?.length ? newTags : undefined, + excludeTags: newExcludeTags?.length ? newExcludeTags : undefined, + }) + } + + return { + queueFilters, + updateFilters, + toggleCollection, + resetFilters, + toggleSubjectType, + toggleEmbedType, + clearLanguages, + toggleLanguage, + } +} diff --git a/components/tags/SubjectTag.tsx b/components/tags/SubjectTag.tsx index caadcb54..e3b0cf9b 100644 --- a/components/tags/SubjectTag.tsx +++ b/components/tags/SubjectTag.tsx @@ -1,6 +1,13 @@ import { LabelChip } from '@/common/labels' import { LANGUAGES_MAP_CODE2 } from '@/lib/locale/languages' +export const getLanguageFlag = (langCode: string) => { + if (!langCode) return undefined + + const langDetails = LANGUAGES_MAP_CODE2[langCode] + return langDetails?.flag +} + export const SubjectTag = ({ tag }: { tag: string }) => { if (tag.startsWith('lang:')) { const langCode = tag.split(':')[1]?.toLowerCase()