diff --git a/app/reports/page-content.tsx b/app/reports/page-content.tsx index ed6e7b59..ff77036e 100644 --- a/app/reports/page-content.tsx +++ b/app/reports/page-content.tsx @@ -22,14 +22,14 @@ import { ModActionPanelQuick } from '../actions/ModActionPanel/QuickAction' import { ButtonGroup } from '@/common/buttons' import { SubjectTable } from 'components/subject/table' import { useTitle } from 'react-use' -import { QueueSelector, QUEUE_NAMES } from '@/reports/QueueSelector' +import { QueueSelector } from '@/reports/QueueSelector' import { simpleHash, unique } from '@/lib/util' import { useEmitEvent } from '@/mod-event/helpers/emitEvent' import { useFluentReportSearchParams } from '@/reports/useFluentReportSearch' import { useLabelerAgent } from '@/shell/ConfigurationContext' import { WorkspacePanel } from 'components/workspace/Panel' import { useWorkspaceOpener } from '@/common/useWorkspaceOpener' -import { QUEUE_SEED } from '@/lib/constants' +import { useQueueSetting } from 'components/setting/useQueueSetting' import QueueFilterPanel from '@/reports/QueueFilter/Panel' const TABS = [ @@ -294,6 +294,7 @@ function getTabFromParams({ reviewState }: { reviewState?: string | null }) { function useModerationQueueQuery() { const labelerAgent = useLabelerAgent() const params = useSearchParams() + const { setting: queueSetting } = useQueueSetting() const takendown = !!params.get('takendown') const includeMuted = !!params.get('includeMuted') @@ -393,7 +394,18 @@ function useModerationQueueQuery() { } }) - return getQueueItems(labelerAgent, queryParams, queueName) + return getQueueItems( + labelerAgent, + queryParams, + queueName, + 0, + queueSetting.data + ? { + queueNames: queueSetting.data.queueNames, + queueSeed: queueSetting.data.queueSeed.setting, + } + : undefined, + ) }, getNextPageParam: (lastPage) => lastPage.cursor, }) @@ -404,6 +416,7 @@ const getQueueItems = async ( queryParams: ToolsOzoneModerationQueryStatuses.QueryParams, queueName: string | null, attempt = 0, + queueSetting?: { queueNames: string[]; queueSeed: string }, ) => { const pageSize = 100 const { data } = await labelerAgent.tools.ozone.moderation.queryStatuses({ @@ -412,13 +425,19 @@ const getQueueItems = async ( ...queryParams, }) - const queueIndex = QUEUE_NAMES.indexOf(queueName ?? '') + const queueIndex = queueSetting?.queueNames.indexOf(queueName ?? '') const statusesInQueue = queueName ? data.subjectStatuses.filter((status) => { const subjectDid = ComAtprotoAdminDefs.isRepoRef(status.subject) ? status.subject.did : new AtUri(`${status.subject.uri}`).host - return getQueueIndex(subjectDid) === queueIndex + return ( + getQueueIndex( + subjectDid, + queueSetting?.queueNames || [], + queueSetting?.queueSeed || '', + ) === queueIndex + ) }) : data.subjectStatuses @@ -434,12 +453,13 @@ const getQueueItems = async ( }, queueName, ++attempt, + queueSetting, ) } return { cursor: data.cursor, subjectStatuses: statusesInQueue } } -function getQueueIndex(did: string) { - return simpleHash(did + QUEUE_SEED) % QUEUE_NAMES.length +function getQueueIndex(did: string, queueNames: string[], queueSeed: string) { + return simpleHash(`${queueSeed}:${did}`) % queueNames.length } diff --git a/components/config/Labeler.tsx b/components/config/Labeler.tsx index c24bffb6..b5ead45f 100644 --- a/components/config/Labeler.tsx +++ b/components/config/Labeler.tsx @@ -14,6 +14,7 @@ import { ServerConfig } from './server-config' import { useConfigurationContext } from '@/shell/ConfigurationContext' import { usePdsAgent } from '@/shell/AuthContext' import { LocalPreferences } from './LocalPreferences' +import { QueueSetting } from 'components/setting/Queue' const BrowserReactJsonView = dynamic(() => import('react-json-view'), { ssr: false, @@ -41,6 +42,7 @@ export function LabelerConfig() { + ) diff --git a/components/reports/QueueSelector.tsx b/components/reports/QueueSelector.tsx index 288a8812..1502f15f 100644 --- a/components/reports/QueueSelector.tsx +++ b/components/reports/QueueSelector.tsx @@ -1,27 +1,14 @@ import { Dropdown } from '@/common/Dropdown' -import { QUEUE_CONFIG } from '@/lib/constants' import { ChevronDownIcon } from '@heroicons/react/20/solid' +import { useQueueSetting } from 'components/setting/useQueueSetting' import { usePathname, useRouter, useSearchParams } from 'next/navigation' -type QueueConfig = Record - -const getQueueConfig = () => { - const config = QUEUE_CONFIG - try { - return JSON.parse(config) as QueueConfig - } catch (err) { - return {} - } -} - -export const QUEUES = getQueueConfig() -export const QUEUE_NAMES = Object.keys(QUEUES) - export const QueueSelector = () => { const searchParams = useSearchParams() const router = useRouter() const pathname = usePathname() const queueName = searchParams.get('queueName') + const { setting: queueSetting } = useQueueSetting() const selectQueue = (queue: string) => () => { const nextParams = new URLSearchParams(searchParams) @@ -34,7 +21,11 @@ export const QueueSelector = () => { } // If no queues are configured, just use a static title - if (!QUEUE_NAMES.length) { + if ( + queueSetting.isLoading || + !queueSetting.data || + !queueSetting.data?.queueNames.length + ) { return ( Queue @@ -42,6 +33,8 @@ export const QueueSelector = () => { ) } + const { queueNames, queueList } = queueSetting.data + return ( { text: 'All', onClick: selectQueue(''), }, - ...QUEUE_NAMES.map((q) => ({ - text: QUEUES[q].name, + ...queueNames.map((q) => ({ + text: queueList[q].name, onClick: selectQueue(q), })), ]} > - {queueName ? `${QUEUES[queueName].name} Queue` : 'Queue'} + {queueName ? `${queueList[queueName].name} Queue` : 'Queue'} import('react-json-view'), { + ssr: false, +}) + +export const QueueSetting = () => { + const { setting: queueSetting, upsert: upsertQueueSetting } = + useQueueSetting() + const darkMode = isDarkModeEnabled() + const queueListRef = useRef(null) + const { role } = useServerConfig() + + const canManageQueueSeed = + (queueSetting.data?.queueSeed && + !queueSetting.data.queueSeed.managerRole) || + (!!role && + !!queueSetting.data?.queueSeed.managerRole && + isRoleSuperiorOrSame(role, queueSetting.data?.queueSeed.managerRole)) + + const canManageQueueList = + (queueSetting.data?.queueList && + !queueSetting.data.queueList.managerRole) || + (!!role && + !!queueSetting.data?.queueList.managerRole && + isRoleSuperiorOrSame(role, queueSetting.data?.queueList.managerRole)) + + return ( + <> + + + Queue Setting + + + + + & { target: HTMLFormElement }, + ) => { + e.preventDefault() + const queueSeedManagerRole = e.target.queueSeedManagerRole.value + const queueSeedSetting = e.target.queueSeed.value + const queueListSetting = JSON.parse( + e.target.queueList.value || {}, + ) + const queueListManagerRole = e.target.queueListManagerRole.value + + await upsertQueueSetting.mutateAsync({ + queueSeed: { + setting: queueSeedSetting, + managerRole: queueSeedManagerRole, + }, + queueList: { + setting: queueListSetting, + managerRole: queueListManagerRole, + }, + }) + e.target.reset() + }} + > + + + + + {Object.entries(MemberRoleNames).map(([role, name]) => ( + + {name} + + ))} + + + + + + + + + + + + {Object.entries(MemberRoleNames).map(([role, name]) => ( + + {name} + + ))} + + + + + + { + if (queueListRef.current) + queueListRef.current.value = JSON.stringify(edit.updated_src) + }} + onAdd={(add) => { + if (queueListRef.current) + queueListRef.current.value = JSON.stringify(add.updated_src) + }} + onDelete={(del) => { + if (queueListRef.current) + queueListRef.current.value = JSON.stringify(del.updated_src) + }} + /> + + + Save Setting + + + + + + > + ) +} diff --git a/components/setting/useQueueSetting.ts b/components/setting/useQueueSetting.ts new file mode 100644 index 00000000..fbb06d0f --- /dev/null +++ b/components/setting/useQueueSetting.ts @@ -0,0 +1,103 @@ +import { useLabelerAgent, useServerConfig } from '@/shell/ConfigurationContext' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { QUEUE_CONFIG } from '@/lib/constants' +import { toast } from 'react-toastify' + +type QueueConfig = Record + +const getQueueConfig = () => { + const config = QUEUE_CONFIG + try { + return JSON.parse(config) as QueueConfig + } catch (err) { + return {} + } +} + +export const useQueueSetting = () => { + const queryClient = useQueryClient() + const serverConfig = useServerConfig() + const labelerAgent = useLabelerAgent() + const setting = useQuery({ + queryKey: ['queue-setting'], + queryFn: async () => { + const { data } = await labelerAgent.tools.ozone.setting.listOptions({ + scope: 'instance', + keys: [ + 'tools.ozone.setting.client.queue.list', + 'tools.ozone.setting.client.queue.seed', + ], + }) + + let queueList: { + managerRole: string | null + setting: QueueConfig + } = { managerRole: null, setting: getQueueConfig() } + let queueSeed: { + managerRole: string | null + setting: string + } = { managerRole: null, setting: '' } + + data.options.forEach((option) => { + if (option.key === 'tools.ozone.setting.client.queue.list') { + queueList = { + managerRole: option.managerRole || null, + setting: option.value as QueueConfig, + } + } + if (option.key === 'tools.ozone.setting.client.queue.seed') { + queueSeed = { + managerRole: option.managerRole || null, + setting: option.value?.['val'], + } + } + }) + + return { + queueList, + queueSeed, + queueNames: Object.keys(queueList.setting), + } + }, + }) + + const upsert = useMutation({ + mutationKey: ['queue-setting', 'upsert'], + mutationFn: async (payload: { + queueList?: { managerRole: string; setting: QueueConfig } + queueSeed: { managerRole: string; setting: string } + }) => { + const actions = [ + payload.queueList + ? labelerAgent.tools.ozone.setting.upsertOption({ + value: payload.queueList.setting, + scope: 'instance', + managerRole: payload.queueList.managerRole, + key: 'tools.ozone.setting.client.queue.list', + }) + : Promise.resolve(), + payload.queueSeed + ? labelerAgent.tools.ozone.setting.upsertOption({ + value: { val: payload.queueSeed.setting }, + scope: 'instance', + managerRole: payload.queueSeed.managerRole, + key: 'tools.ozone.setting.client.queue.seed', + }) + : Promise.resolve(), + ] + + await Promise.all(actions) + }, + + onSuccess: () => { + queryClient.invalidateQueries(['queue-setting']) + toast.success('Queue setting saved') + }, + + onError: (error) => { + toast.error(`Failed to save queue setting: ${error?.['message']}`) + }, + }) + + return { setting, upsert } +} diff --git a/components/team/MemberEditor.tsx b/components/team/MemberEditor.tsx index a7d3497a..e25ed6a7 100644 --- a/components/team/MemberEditor.tsx +++ b/components/team/MemberEditor.tsx @@ -7,7 +7,7 @@ import { ActionButton } from '@/common/buttons' import { Card } from '@/common/Card' import { Checkbox, FormLabel, Input, Select } from '@/common/forms' import { getDidFromHandle } from '@/lib/identity' -import { MemberRoleNames } from './Role' +import { MemberRoleNames } from './helpers' import { useLabelerAgent } from '@/shell/ConfigurationContext' import { useQueryClient } from '@tanstack/react-query' diff --git a/components/team/Role.tsx b/components/team/Role.tsx index 5c6c0be4..5ed94cd6 100644 --- a/components/team/Role.tsx +++ b/components/team/Role.tsx @@ -1,24 +1,6 @@ import { LabelChip } from '@/common/labels' import { ToolsOzoneTeamDefs } from '@atproto/api' - -export const MemberRoleNames = { - [ToolsOzoneTeamDefs.ROLETRIAGE]: 'Triage', - [ToolsOzoneTeamDefs.ROLEMODERATOR]: 'Moderator', - [ToolsOzoneTeamDefs.ROLEADMIN]: 'Admin', -} - -const getRoleText = (role: ToolsOzoneTeamDefs.Member['role']) => { - if (role === ToolsOzoneTeamDefs.ROLEADMIN) { - return MemberRoleNames[ToolsOzoneTeamDefs.ROLEADMIN] - } - if (role === ToolsOzoneTeamDefs.ROLEMODERATOR) { - return MemberRoleNames[ToolsOzoneTeamDefs.ROLEMODERATOR] - } - if (role === ToolsOzoneTeamDefs.ROLETRIAGE) { - return MemberRoleNames[ToolsOzoneTeamDefs.ROLETRIAGE] - } - return 'Unknown' -} +import { getRoleText } from './helpers' export function RoleTag({ role }: { role: ToolsOzoneTeamDefs.Member['role'] }) { return {getRoleText(role)} diff --git a/components/team/helpers.ts b/components/team/helpers.ts new file mode 100644 index 00000000..ec8ca752 --- /dev/null +++ b/components/team/helpers.ts @@ -0,0 +1,39 @@ +import { ToolsOzoneTeamDefs } from '@atproto/api' + +export const MemberRoleNames = { + [ToolsOzoneTeamDefs.ROLETRIAGE]: 'Triage', + [ToolsOzoneTeamDefs.ROLEMODERATOR]: 'Moderator', + [ToolsOzoneTeamDefs.ROLEADMIN]: 'Admin', +} + +export const getRoleText = (role: ToolsOzoneTeamDefs.Member['role']) => { + if (role === ToolsOzoneTeamDefs.ROLEADMIN) { + return MemberRoleNames[ToolsOzoneTeamDefs.ROLEADMIN] + } + if (role === ToolsOzoneTeamDefs.ROLEMODERATOR) { + return MemberRoleNames[ToolsOzoneTeamDefs.ROLEMODERATOR] + } + if (role === ToolsOzoneTeamDefs.ROLETRIAGE) { + return MemberRoleNames[ToolsOzoneTeamDefs.ROLETRIAGE] + } + return 'Unknown' +} + +export const isRoleSuperiorOrSame = (manager: string, target: string) => { + if (manager === ToolsOzoneTeamDefs.ROLEADMIN) { + return true + } + + if (manager === ToolsOzoneTeamDefs.ROLEMODERATOR) { + return [ + ToolsOzoneTeamDefs.ROLEMODERATOR, + ToolsOzoneTeamDefs.ROLETRIAGE, + ].includes(target) + } + + if (manager === ToolsOzoneTeamDefs.ROLETRIAGE) { + return target === ToolsOzoneTeamDefs.ROLETRIAGE + } + + return false +} diff --git a/package.json b/package.json index 4fef85b7..b3070d06 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "e2e:run": "$(yarn bin)/cypress run --browser chrome" }, "dependencies": { - "@atproto/api": "^0.13.14", + "@atproto/api": "^0.13.15", "@atproto/oauth-client-browser": "^0.2.0", "@atproto/oauth-types": "^0.1.4", "@atproto/xrpc": "^0.6.1", diff --git a/yarn.lock b/yarn.lock index f214ba4a..61e06a44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -60,10 +60,10 @@ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== -"@atproto/api@^0.13.14": - version "0.13.14" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.14.tgz#3a74f0dbb19a8813d45bcf645dcc25b7e2623aca" - integrity sha512-CG5UpjI1WwSasSJTGadmr07EwWvl5JV658YZHcwIIg+Psk5sDloQOUJckuo1MP6wke1z6p/BUoPL4lxAATzMzA== +"@atproto/api@^0.13.15": + version "0.13.15" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.15.tgz#7d6f49e05cf437ef67a98f754956df42d9f3b3f8" + integrity sha512-zC8KH+Spcr2HE6vD4hddP5rZpWrGUTWvL8hQmUxa/sAnlsjoFyv/Oja8ZHGXoDsAl6ie5Gd77cPNxaxWH/yIBQ== dependencies: "@atproto/common-web" "^0.3.1" "@atproto/lexicon" "^0.4.2"