From 4d66e7bebd706b92c6e44b3e6f2aa3a138600b4f Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Wed, 17 Apr 2024 19:37:05 +0200 Subject: [PATCH] Show labeler info in labels and adjust color coding (#61) * :lipstick: Handle reviewNone, labeler account display and sticky note more prominent * :bug: Change icon for reviewNone * :lipstick: Handle user badge in dark mode * :lipstick: Show labels with mod service details * :lipstick: Color code labels based on blur and hide config * :lipstick: Add ModerationLabel in record and repo view * :sparkles: Cleanup * :broom: Cleanup --- app/actions/ModActionPanel/QuickAction.tsx | 15 +- components/common/labels/List.tsx | 269 +++++++++++++++++- .../common/labels/useLabelerDefinition.ts | 39 +++ components/common/labels/util.ts | 15 +- components/common/posts/PostsFeed.tsx | 33 +-- components/mod-event/EventItem.tsx | 33 ++- components/reports/QueueSelector.tsx | 3 +- components/repositories/AccountView.tsx | 11 +- components/repositories/DidHistory.tsx | 2 +- components/repositories/RecordView.tsx | 11 +- docs/userguide.md | 2 +- lib/client.ts | 8 +- lib/constants.ts | 10 + lib/identity.ts | 4 +- lib/util.ts | 3 +- 15 files changed, 395 insertions(+), 63 deletions(-) create mode 100644 components/common/labels/useLabelerDefinition.ts create mode 100644 lib/constants.ts diff --git a/app/actions/ModActionPanel/QuickAction.tsx b/app/actions/ModActionPanel/QuickAction.tsx index b13f1b26..26f8ed17 100644 --- a/app/actions/ModActionPanel/QuickAction.tsx +++ b/app/actions/ModActionPanel/QuickAction.tsx @@ -24,6 +24,7 @@ import { getLabelGroupInfo, unFlagSelfLabel, isSelfLabel, + ModerationLabel, } from '@/common/labels' import { FullScreenActionPanel } from '@/common/FullScreenActionPanel' import { PreviewCard } from '@/common/PreviewCard' @@ -543,15 +544,13 @@ function Form( {!currentLabels.length && } - {currentLabels.map((label) => { - const labelGroup = getLabelGroupInfo(unFlagSelfLabel(label)) + {allLabels.map((label) => { return ( - - {displayLabel(label)} - + ) })} diff --git a/components/common/labels/List.tsx b/components/common/labels/List.tsx index 72b81616..d934e828 100644 --- a/components/common/labels/List.tsx +++ b/components/common/labels/List.tsx @@ -1,10 +1,25 @@ -import { ComponentProps } from 'react' +import { OZONE_SERVICE_DID } from '@/lib/constants' +import { buildBlueSkyAppUrl, classNames } from '@/lib/util' +import { AppBskyActorDefs, ComAtprotoLabelDefs } from '@atproto/api' +import { Popover, Transition } from '@headlessui/react' +import { ExclamationCircleIcon } from '@heroicons/react/20/solid' +import { + ArrowTopRightOnSquareIcon, + ClockIcon, + CogIcon, + EyeSlashIcon, + HomeIcon, + TagIcon, +} from '@heroicons/react/24/outline' +import { ComponentProps, Fragment } from 'react' +import { useLabelerServiceDef } from './useLabelerDefinition' +import { isSelfLabel, toLabelVal } from './util' export function LabelList(props: ComponentProps<'div'>) { const { className = '', ...others } = props return (
) @@ -28,3 +43,253 @@ export function LabelChip(props: ComponentProps<'span'>) { /> ) } + +const getLabelChipClassNames = ({ + label, + isSelfLabeled = false, + labelDefFromService, +}: { + label: ComAtprotoLabelDefs.Label + isSelfLabeled: boolean + labelDefFromService?: ComAtprotoLabelDefs.LabelValueDefinition +}) => { + const wrapper: string[] = [] + const text: string[] = [] + + if (isSelfLabeled) { + wrapper.push('bg-green-200 text-green-700') + text.push('text-green-700') + } else if (labelDefFromService) { + if (labelDefFromService.severity === 'alert') { + wrapper.push('bg-red-200 text-red-700') + text.push('text-red-700') + } else if (labelDefFromService.blurs === 'content') { + wrapper.push('bg-indigo-200 text-indigo-700') + text.push('text-indigo-700') + } else if (labelDefFromService.blurs === 'media') { + wrapper.push('bg-yellow-200 text-yellow-700') + text.push('text-yellow-700') + } + } + + return { wrapper: classNames(...wrapper), text: classNames(...text) } +} + +/* +- Make sure the color coding is right based on labeler service def +- Make sure self labels are flagged +- Make sure expiry is flagged +- Make sure labeler definition is displayed in popover +*/ +export const ModerationLabel = ({ + label, + recordAuthorDid, + className, + ...props +}: { + label: ComAtprotoLabelDefs.Label + recordAuthorDid?: string +} & ComponentProps<'span'>) => { + const labelerServiceDef = useLabelerServiceDef(label.src) + const isFromCurrentService = label.src === OZONE_SERVICE_DID + + const labelVal = toLabelVal(label, recordAuthorDid) + const isSelfLabeled = isSelfLabel(labelVal) + const labelDefFromService = + labelerServiceDef?.policies.definitionById[label.val] + const labelerProfile = labelerServiceDef?.creator + const labelClassNames = getLabelChipClassNames({ + label, + isSelfLabeled, + labelDefFromService, + }) + + return ( + + {({ open }) => ( + <> + + + {isFromCurrentService && ( + + )} + {isSelfLabeled && ( + + )} + {label.exp && ( + + )} + {labelVal} + + + + +
+
+ +
+
+
+
+ + )} +
+ ) +} + +export const LabelDefinition = ({ + labelDefFromService, + isFromCurrentService, + isSelfLabeled, + labelerProfile, + label, +}: { + labelerProfile?: AppBskyActorDefs.ProfileView + isSelfLabeled: boolean + isFromCurrentService: boolean + label: ComAtprotoLabelDefs.Label + labelDefFromService?: ComAtprotoLabelDefs.LabelValueDefinition +}) => { + if (isSelfLabeled) { + return ( +
+

+ + Self label +

+

+ This label was added by the the author of the content. Moderators are + not allowed to change this. +

+
+ ) + } + + if (!labelDefFromService && !isFromCurrentService) { + return ( +
+

+ Sorry, no details found about the labeler +

+
+ ) + } + + // Get the english language definition + const labelDefInLocale = labelDefFromService?.locales.find( + ({ lang }) => lang === 'en', + ) + const hasPreferences = + labelDefFromService?.blurs || + labelDefFromService?.severity || + labelDefFromService?.defaultSetting + + const temporaryWarning = label.exp && ( +
+ +

+ This is a temporary label and will expire at {label.exp} +

+
+ ) + + const currentServiceReminder = isFromCurrentService && ( +
+ +

This label is from your own labeling service

+
+ ) + + return ( + <> +
+ {labelerProfile && ( + <> +

+ + {labelerProfile.displayName} + + +

+

{labelerProfile.description}

+ + )} + {currentServiceReminder} + {temporaryWarning} +
+ +
+ {labelDefFromService ? ( + <> +

+ {labelDefInLocale?.name || label.val} +

+ {labelDefInLocale?.description && ( +

{labelDefInLocale.description}

+ )} + {hasPreferences && ( +
    + {labelDefFromService.blurs ? ( +
  • + Blurs{' '} + {labelDefFromService.blurs} +
  • + ) : null} + {labelDefFromService.severity ? ( +
  • + Severity{' '} + {labelDefFromService.severity} +
  • + ) : null} + {labelDefFromService.defaultSetting ? ( +
  • + + {`Default setting ${labelDefFromService.defaultSetting}`} +
  • + ) : null} +
+ )} + + ) : ( +

+ {label.val} label does not have a custom definition. Users + might be able to configure the behavior of the label in app. +

+ )} +
+ + ) +} diff --git a/components/common/labels/useLabelerDefinition.ts b/components/common/labels/useLabelerDefinition.ts new file mode 100644 index 00000000..1898d716 --- /dev/null +++ b/components/common/labels/useLabelerDefinition.ts @@ -0,0 +1,39 @@ +import clientManager from '@/lib/client' +import { AppBskyLabelerDefs, ComAtprotoLabelDefs } from '@atproto/api' +import { useQuery } from '@tanstack/react-query' +import { ExtendedLabelerServiceDef } from './util' + +export const useLabelerServiceDef = (did: string) => { + const { data: labelerDef } = useQuery({ + queryKey: ['labelerDef', { did }], + queryFn: async () => { + const { data } = await clientManager.api.app.bsky.labeler.getServices({ + dids: [did], + detailed: true, + }) + if (!data.views?.[0]) { + return null + } + + const labelerDef = data.views[0] as ExtendedLabelerServiceDef + if (labelerDef?.policies.labelValueDefinitions) { + const definitionsById: Record< + string, + ComAtprotoLabelDefs.LabelValueDefinition + > = {} + labelerDef.policies.labelValueDefinitions.forEach((def) => { + definitionsById[def.identifier] = def + }) + labelerDef.policies.definitionById = definitionsById + } + + return labelerDef + }, + // These are not super likely to change frequently but labels will be rendered quite a lot + // so caching them for longer period is ideal + staleTime: 60 * 60 * 1000, + cacheTime: 60 * 60 * 1000, + }) + + return labelerDef || null +} diff --git a/components/common/labels/util.ts b/components/common/labels/util.ts index 2fdf5369..d6ec063e 100644 --- a/components/common/labels/util.ts +++ b/components/common/labels/util.ts @@ -2,6 +2,7 @@ import client from '@/lib/client' import { unique } from '@/lib/util' import { AppBskyActorDefs, + AppBskyLabelerDefs, ComAtprotoLabelDefs, ToolsOzoneModerationDefs, } from '@atproto/api' @@ -12,7 +13,7 @@ import { LABEL_GROUPS, } from './data' -type LabelGroupInfoRecord = { +export type LabelGroupInfoRecord = { color: string labels: Array } @@ -22,6 +23,14 @@ type GroupedLabelList = Record< LabelGroupInfoRecord & Omit > +export type ExtendedLabelerServiceDef = + | (AppBskyLabelerDefs.LabelerViewDetailed & { + policies: AppBskyLabelerDefs.LabelerViewDetailed['policies'] & { + definitionById: Record + } + }) + | null + export function diffLabels(current: string[], next: string[]) { return { createLabelVals: next @@ -194,9 +203,7 @@ export const getLabelsForSubject = ({ repo?: ToolsOzoneModerationDefs.RepoViewDetail record?: ToolsOzoneModerationDefs.RecordViewDetail }) => { - return (record?.labels ?? - repo?.labels ?? - []) as Partial[] + return record?.labels ?? repo?.labels ?? [] } export const buildAllLabelOptions = ( diff --git a/components/common/posts/PostsFeed.tsx b/components/common/posts/PostsFeed.tsx index c9baf2a5..61943384 100644 --- a/components/common/posts/PostsFeed.tsx +++ b/components/common/posts/PostsFeed.tsx @@ -20,17 +20,12 @@ import { isRepost } from '@/lib/types' import { buildBlueSkyAppUrl, classNames, parseAtUri } from '@/lib/util' import { getActionClassNames } from '@/reports/ModerationView/ActionHelpers' import { RichText } from '../RichText' -import { - LabelChip, - LabelList, - doesLabelNeedBlur, - toLabelVal, - getLabelGroupInfo, -} from '../labels' +import { LabelList, doesLabelNeedBlur, ModerationLabel } from '../labels' import { CollectionId } from '@/reports/helpers/subject' import { ProfileAvatar } from '@/repositories/ProfileAvatar' import { getTranslatorLink, isPostInLanguage } from '@/lib/locale/helpers' import { MOD_EVENTS } from '@/mod-event/constants' +import { SOCIAL_APP_URL } from '@/lib/constants' import { ReplyParent } from './ReplyParent' export function PostsFeed({ @@ -132,7 +127,7 @@ function PostHeader({  ·  +
{showActionLine && (

@@ -267,7 +266,7 @@ function PostEmbeds({ item }: { item: AppBskyFeedDefs.FeedViewPost }) { src={embed.external.thumb} /> ) : undefined} -

+
{embed.external.title}
{embed.external.description}
@@ -408,19 +407,15 @@ function PostLabels({ {labels?.map((label, i) => { const { val, src } = label - const labelGroup = getLabelGroupInfo(val) return ( - - {toLabelVal(label, item.post.author.did)} - + /> ) })} diff --git a/components/mod-event/EventItem.tsx b/components/mod-event/EventItem.tsx index 0e5ce571..0df9f542 100644 --- a/components/mod-event/EventItem.tsx +++ b/components/mod-event/EventItem.tsx @@ -1,11 +1,6 @@ +import client from '@/lib/client' import { Card } from '@/common/Card' -import { - LabelChip, - LabelList, - displayLabel, - getLabelGroupInfo, - unFlagSelfLabel, -} from '@/common/labels' +import { LabelChip, LabelList, ModerationLabel } from '@/common/labels' import { ReasonBadge } from '@/reports/ReasonBadge' import { ToolsOzoneModerationDefs, @@ -152,20 +147,32 @@ const TakedownOrMute = ({ const EventLabels = ({ header, labels, + isTag = false, }: { header: string labels?: string[] + isTag?: boolean }) => { if (!labels?.length) return null return ( {header} {labels.map((label) => { - const labelGroup = getLabelGroupInfo(unFlagSelfLabel(label)) + if (isTag) { + return {label} + } + // Moderation events being displayed means that these events were added by the current service + // so we can assume that the src is the same as the configured ozone service DID return ( - - {displayLabel(label)} - + ) })} @@ -218,8 +225,8 @@ const Tag = ({ {modEvent.event.comment ? (

{`${modEvent.event.comment}`}

) : null} - - + + ) } diff --git a/components/reports/QueueSelector.tsx b/components/reports/QueueSelector.tsx index 241d31ac..288a8812 100644 --- a/components/reports/QueueSelector.tsx +++ b/components/reports/QueueSelector.tsx @@ -1,11 +1,12 @@ import { Dropdown } from '@/common/Dropdown' +import { QUEUE_CONFIG } from '@/lib/constants' import { ChevronDownIcon } from '@heroicons/react/20/solid' import { usePathname, useRouter, useSearchParams } from 'next/navigation' type QueueConfig = Record const getQueueConfig = () => { - const config = process.env.NEXT_PUBLIC_QUEUE_CONFIG || '{}' + const config = QUEUE_CONFIG try { return JSON.parse(config) as QueueConfig } catch (err) { diff --git a/components/repositories/AccountView.tsx b/components/repositories/AccountView.tsx index 073d36e2..1669130a 100644 --- a/components/repositories/AccountView.tsx +++ b/components/repositories/AccountView.tsx @@ -28,6 +28,7 @@ import { displayLabel, toLabelVal, getLabelsForSubject, + ModerationLabel, } from '../common/labels' import { Loading, LoadingFailed } from '../common/Loader' import { InviteCodeGenerationStatus } from './InviteCodeGenerationStatus' @@ -411,9 +412,7 @@ function Details({ repo: GetRepo.OutputSchema id: string }) { - const labels = getLabelsForSubject({ repo }).map((label) => - toLabelVal(label, repo.did), - ) + const labels = getLabelsForSubject({ repo }) const canShowDidHistory = repo.did.startsWith('did:plc') return (
@@ -449,7 +448,11 @@ function Details({ {!labels.length && } {labels.map((label) => ( - {displayLabel(label)} + ))} diff --git a/components/repositories/DidHistory.tsx b/components/repositories/DidHistory.tsx index 44123811..12332ac6 100644 --- a/components/repositories/DidHistory.tsx +++ b/components/repositories/DidHistory.tsx @@ -1,5 +1,5 @@ import { Loading } from '@/common/Loader' -import { PLC_DIRECTORY_URL } from '@/lib/identity' +import { PLC_DIRECTORY_URL } from '@/lib/constants' import { ArrowTopRightOnSquareIcon } from '@heroicons/react/20/solid' import { useQuery } from '@tanstack/react-query' diff --git a/components/repositories/RecordView.tsx b/components/repositories/RecordView.tsx index f78ed85b..5006fc29 100644 --- a/components/repositories/RecordView.tsx +++ b/components/repositories/RecordView.tsx @@ -23,6 +23,7 @@ import { displayLabel, toLabelVal, getLabelsForSubject, + ModerationLabel, } from '../common/labels' import { DataField } from '@/common/DataField' import { AccountsGrid } from './AccountView' @@ -241,9 +242,7 @@ function Tabs({ function Details({ record }: { record: GetRecord.OutputSchema }) { const { collection, rkey } = parseAtUri(record.uri) ?? {} - const labels = getLabelsForSubject({ record }).map((label) => - toLabelVal(label, record.repo.did), - ) + const labels = getLabelsForSubject({ record }) return (
@@ -275,7 +274,11 @@ function Details({ record }: { record: GetRecord.OutputSchema }) { {!labels.length && } {labels.map((label) => ( - {displayLabel(label)} + ))} diff --git a/docs/userguide.md b/docs/userguide.md index 2bee2a78..89c43fb1 100644 --- a/docs/userguide.md +++ b/docs/userguide.md @@ -88,6 +88,6 @@ Another consideration is being logged in to an application like Bluesky at the s The control panel (Ctrl-K) is very helpful for quickly pasting links or identifiers to jump to the relevant page in Ozone. Learn to use it! "Peek" links allow going in the other direction (Ozone to app) for viewing content in-context. -Images which have been labeled as (or reported as) graphic or sexual will display with a blur in the Ozone interface. Mousing over will remove the blur, and clicking will show the original image full-sized in a new tab. If disturbing "not safe for life" images are viewed, even temporarily, some research has suggested that viewing unrelated patterns in a game can prevent imprinting memories. Ozone has a hidden feature: if you open the Ctrl-K menu, type "tetris", and press enter, there is a simple Tetris game embedded in the UI. While Ozone provides a couple small affordences for the reality of encountering disturbing images, it is not designed to be a replacement or substitute for domain-specific tools and workflows for intensive review of disturbing or illegal content. +Images which have been labeled as (or reported as) graphic or sexual will display with a blur in the Ozone interface. Mousing over will remove the blur, and clicking will show the original image full-sized in a new tab. If disturbing "not safe for life" images are viewed, even temporarily, some research has suggested that viewing unrelated patterns in a game can prevent imprinting memories. Ozone has a hidden feature: if you open the Ctrl-K menu, type "tetris", and press enter, there is a simple Tetris game embedded in the UI. While Ozone provides a couple small affordances for the reality of encountering disturbing images, it is not designed to be a replacement or substitute for domain-specific tools and workflows for intensive review of disturbing or illegal content. A common source of confusion is the distinction between account-level events, and content-level events. This is particularly true for "profile records", which are a form of content that is account-wide, but distinct from the account itself. In a few interfaces, there are toggle buttons to rapidly switch between "action account" and "action account's profile"; or "all events for account" and "all events for account and account's content". diff --git a/lib/client.ts b/lib/client.ts index b8c32ef6..aa799bcb 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -1,6 +1,7 @@ import { AtpAgent, AtpServiceClient, AtpSessionData } from '@atproto/api' import { AuthState } from './types' import { OzoneConfig, getConfig } from './client-config' +import { OZONE_SERVICE_DID } from './constants' export interface ClientSession extends AtpSessionData { service: string @@ -131,9 +132,12 @@ class ClientManager extends EventTarget { } } + getServiceDid() { + return this._session?.config.did + } + private async _getConfig(ozoneDid?: string) { - const builtIn = - ozoneDid || process.env.NEXT_PUBLIC_OZONE_SERVICE_DID || undefined + const builtIn = ozoneDid || OZONE_SERVICE_DID return await getConfig(builtIn) } diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 00000000..23ce7c6b --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,10 @@ +export const OZONE_SERVICE_DID = + process.env.NEXT_PUBLIC_OZONE_SERVICE_DID || undefined + +export const PLC_DIRECTORY_URL = + process.env.NEXT_PUBLIC_PLC_DIRECTORY_URL || `https://plc.directory` + +export const QUEUE_CONFIG = process.env.NEXT_PUBLIC_QUEUE_CONFIG || '{}' + +export const SOCIAL_APP_DOMAIN = 'bsky.app' +export const SOCIAL_APP_URL = `https://${SOCIAL_APP_DOMAIN}` diff --git a/lib/identity.ts b/lib/identity.ts index 24c64cd2..3fa4f5fb 100644 --- a/lib/identity.ts +++ b/lib/identity.ts @@ -1,7 +1,5 @@ import clientManager from './client' - -export const PLC_DIRECTORY_URL = - process.env.NEXT_PUBLIC_PLC_DIRECTORY_URL || `https://plc.directory` +import { PLC_DIRECTORY_URL } from './constants' export const getDidFromHandle = async ( handle: string, diff --git a/lib/util.ts b/lib/util.ts index 34e425bb..802eabdd 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -1,4 +1,5 @@ import { CollectionId } from '@/reports/helpers/subject' +import { SOCIAL_APP_URL } from './constants' import { AtUri } from '@atproto/api' export function classNames(...classes: (string | undefined)[]) { @@ -60,7 +61,7 @@ export const isBlueSkyAppUrl = (url: string) => blueSkyUrlMatcher.test(url) export const buildBlueSkyAppUrl = ( params: { did: string } & ({ collection: string; rkey: string } | {}), ) => { - let url = `https://bsky.app/profile` + let url = `${SOCIAL_APP_URL}/profile` if ('did' in params) { url += `/${params.did}`