From 7bfc763dc13c23f02f1eb7aef63c9f510b585155 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Thu, 8 Oct 2026 11:21:51 +0100 Subject: [PATCH] web: introduce moderation labels Signed-off-by: oppiliappan --- web/src/fixtures/labels.ts | 11 +++ web/src/lib/api/comment.ts | 2 +- web/src/lib/api/enrich.ts | 14 ++- .../types/sh/tangled/query/enrichResponse.ts | 14 +++ web/src/lib/api/moderationLabels.ts | 88 +++++++++++++++++++ .../ModerationNotice.stories.svelte | 45 ++++++++++ .../moderation/ModerationNotice.svelte | 19 ++++ .../lib/components/moderation/gate.svelte.ts | 80 +++++++++++++++++ .../ui/HiddenContent.stories.svelte | 27 ++++++ .../lib/components/ui/HiddenContent.svelte | 49 +++++++++++ 10 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 web/src/fixtures/labels.ts create mode 100644 web/src/lib/api/moderationLabels.ts create mode 100644 web/src/lib/components/moderation/ModerationNotice.stories.svelte create mode 100644 web/src/lib/components/moderation/ModerationNotice.svelte create mode 100644 web/src/lib/components/moderation/gate.svelte.ts create mode 100644 web/src/lib/components/ui/HiddenContent.stories.svelte create mode 100644 web/src/lib/components/ui/HiddenContent.svelte diff --git a/web/src/fixtures/labels.ts b/web/src/fixtures/labels.ts new file mode 100644 index 000000000..8aea128a5 --- /dev/null +++ b/web/src/fixtures/labels.ts @@ -0,0 +1,11 @@ +import type { LabelsDescriptor, Sidecar } from "$lib/api/enrich"; + +export const labeled = (ref: string, of: LabelsDescriptor, val: string): Sidecar => ({ + [ref]: { + [of.source]: { + [of.type]: { + labels: [{ src: "did:plc:labeler", uri: ref, val, cts: "2026-10-01T00:00:00.000Z" }] + } + } + } +}); diff --git a/web/src/lib/api/comment.ts b/web/src/lib/api/comment.ts index cfc285336..bbb11a550 100644 --- a/web/src/lib/api/comment.ts +++ b/web/src/lib/api/comment.ts @@ -40,7 +40,7 @@ import type * as ShTangledFeedDefs from "$lib/api/lexicons/types/sh/tangled/feed export type CommentRecord = OrgTangledFeedComment.Main; export type LegacyCommentRecord = ShTangledFeedComment.Main; -const COMMENT_COLLECTION = nsidOf("org.tangled.feed.comment"); +export const COMMENT_COLLECTION = nsidOf("org.tangled.feed.comment"); const LEGACY_COMMENT_COLLECTION = nsidOf("sh.tangled.feed.comment"); // subject and replyTo have to name the current version or the knot refuses diff --git a/web/src/lib/api/enrich.ts b/web/src/lib/api/enrich.ts index 4060cb789..f48879d08 100644 --- a/web/src/lib/api/enrich.ts +++ b/web/src/lib/api/enrich.ts @@ -4,15 +4,21 @@ import { INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; import { jsonPost } from "$lib/api/_request"; import { authorDidOf, rkeyFromUri } from "$lib/api/uri"; import type * as ShTangledRepoDefs from "$lib/api/lexicons/types/sh/tangled/repo/defs"; +import type * as ShTangledQueryEnrichResponse from "$lib/api/lexicons/types/sh/tangled/query/enrichResponse"; // payload types, also the keys payloads land under in the data sidecar export const TYPE_COUNT = "sh.tangled.query.enrichResponse#count"; export const TYPE_VIEWER = "sh.tangled.query.enrichResponse#viewer"; export const TYPE_MINIDOC = "blue.microcosm.identity.resolveMiniDoc"; export const TYPE_REPO_VIEW = "sh.tangled.repo.defs#repoViewBasic"; +export const TYPE_LABELS = "sh.tangled.query.enrichResponse#labels"; export type PayloadType = - typeof TYPE_COUNT | typeof TYPE_VIEWER | typeof TYPE_MINIDOC | typeof TYPE_REPO_VIEW; + | typeof TYPE_COUNT + | typeof TYPE_VIEWER + | typeof TYPE_MINIDOC + | typeof TYPE_REPO_VIEW + | typeof TYPE_LABELS; export type RecordPath = string & {}; export type EnvelopePath = ".repo" | ".collection" | ".rkey" | "."; @@ -30,6 +36,7 @@ export type CountDescriptor = LinkDescriptor; export type ViewerDescriptor = LinkDescriptor; export type DocDescriptor = LinkDescriptor; export type RepoViewDescriptor = LinkDescriptor; +export type LabelsDescriptor = LinkDescriptor; // data[ref][source][type] = payload, payload shape depends on the type export type Sidecar = Record>>; @@ -125,3 +132,8 @@ export const authorOf = ( const did = authorDidOf(record); return { did, handle: handleOf(data, did, of) }; }; + +export type Label = ShTangledQueryEnrichResponse.Labels["labels"][number]; + +export const labelsOf = (data: Sidecar, ref: string | undefined, of: LabelsDescriptor): Label[] => + (payloadOf(data, ref, of) as ShTangledQueryEnrichResponse.Labels | undefined)?.labels ?? []; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts b/web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts index 098ec6f26..daa88e125 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/query/enrichResponse.ts @@ -1,7 +1,16 @@ import type {} from "@atcute/lexicons"; import * as v from "@atcute/lexicons/validations"; import type {} from "@atcute/lexicons/ambient"; +import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; +const _labelsSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.query.enrichResponse#labels"), + ), + get labels() { + return /*#__PURE__*/ v.array(ComAtprotoLabelDefs.labelSchema); + }, +}); const _linkDescriptorSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal("sh.tangled.query.enrichResponse#linkDescriptor"), @@ -23,6 +32,7 @@ const _linkDescriptorSchema = /*#__PURE__*/ v.object({ | "blue.microcosm.identity.resolveMiniDoc" | "sh.tangled.query.enrichResponse#count" | "sh.tangled.query.enrichResponse#distinctAuthors" + | "sh.tangled.query.enrichResponse#labels" | "sh.tangled.query.enrichResponse#viewer" | "sh.tangled.repo.defs#repoViewBasic" | (string & {}) @@ -71,16 +81,20 @@ const _mainSchema = /*#__PURE__*/ v.procedure( }, ); +type labels$schematype = typeof _labelsSchema; type linkDescriptor$schematype = typeof _linkDescriptorSchema; type main$schematype = typeof _mainSchema; +export interface labelsSchema extends labels$schematype {} export interface linkDescriptorSchema extends linkDescriptor$schematype {} export interface mainSchema extends main$schematype {} +export const labelsSchema = _labelsSchema as labelsSchema; export const linkDescriptorSchema = _linkDescriptorSchema as linkDescriptorSchema; export const mainSchema = _mainSchema as mainSchema; +export interface Labels extends v.InferInput {} export interface LinkDescriptor extends v.InferInput< typeof linkDescriptorSchema > {} diff --git a/web/src/lib/api/moderationLabels.ts b/web/src/lib/api/moderationLabels.ts new file mode 100644 index 000000000..fb6cd0925 --- /dev/null +++ b/web/src/lib/api/moderationLabels.ts @@ -0,0 +1,88 @@ +import { + isCanonicalResourceUri, + isDid, + type CanonicalResourceUri, + type Did, + type Nsid +} from "@atcute/lexicons/syntax"; +import { + labelsOf, + target, + TYPE_LABELS, + type Label, + type LabelsDescriptor, + type RecordPath, + type Sidecar +} from "$lib/api/enrich"; +import { PROFILE_COLLECTION } from "$lib/api/profile"; + +const SEVERITY = ["hide", "warn"] as const; + +export type ModerationLevel = (typeof SEVERITY)[number]; + +export type ModerationTarget = Did | CanonicalResourceUri; + +export interface Moderation { + level: ModerationLevel; + target: ModerationTarget; +} + +export const isAccountModeration = ( + moderation: Moderation +): moderation is Moderation & { target: Did } => isDid(moderation.target); + +export const ACCOUNT_MODERATION: LabelsDescriptor = { + source: `${PROFILE_COLLECTION}:.repo`, + type: TYPE_LABELS +}; + +export const recordModeration = (collection: Nsid): LabelsDescriptor => ({ + source: `${collection}:.`, + type: TYPE_LABELS +}); + +export const PROFILE_MODERATION = recordModeration(PROFILE_COLLECTION); + +export const moderationTargets = ( + collection: Nsid, + paths: { records: RecordPath[]; authors: RecordPath[] } +): LabelsDescriptor[] => [ + target(ACCOUNT_MODERATION, paths.authors), + target(recordModeration(collection), paths.records) +]; + +const labeledAs = (labels: Label[], level: ModerationLevel): Moderation | undefined => { + const uri = labels.find(({ val }) => val === `!${level}`)?.uri; + return uri && (isDid(uri) || isCanonicalResourceUri(uri)) ? { level, target: uri } : undefined; +}; + +const severityOf = (moderation: Moderation): number => SEVERITY.indexOf(moderation.level); + +export const strongestModeration = ( + ...candidates: (Moderation | undefined)[] +): Moderation | undefined => + candidates + .filter((candidate): candidate is Moderation => candidate !== undefined) + .sort((a, b) => severityOf(a) - severityOf(b))[0]; + +export const moderationOf = (labels: Label[]): Moderation | undefined => + strongestModeration(...SEVERITY.map((level) => labeledAs(labels, level))); + +export const accountModerationOf = (data: Sidecar, did: string): Moderation | undefined => + moderationOf(labelsOf(data, did, ACCOUNT_MODERATION)); + +export const recordModerationOf = ( + data: Sidecar, + collection: Nsid, + uri: string +): Moderation | undefined => moderationOf(labelsOf(data, uri, recordModeration(collection))); + +export const subjectModeration = ( + data: Sidecar, + collection: Nsid, + subject: { uri: string; author: string } +): Moderation | undefined => + strongestModeration( + accountModerationOf(data, subject.author), + recordModerationOf(data, collection, subject.uri) + ); diff --git a/web/src/lib/components/moderation/ModerationNotice.stories.svelte b/web/src/lib/components/moderation/ModerationNotice.stories.svelte new file mode 100644 index 000000000..43980600b --- /dev/null +++ b/web/src/lib/components/moderation/ModerationNotice.stories.svelte @@ -0,0 +1,45 @@ + + +{#snippet notice(moderation: Moderation, size?: HiddenContentSize)} + {@const gate = createModerationGate(() => moderation)} + +{/snippet} + + + {@render notice({ level: "warn", target: account })} + + + {@render notice({ level: "hide", target: account })} + + + {@render notice({ level: "warn", target: comment })} + + + {@render notice({ level: "hide", target: issue }, "regular")} + + + { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Show anyway" })); + await expect(canvas.queryByText("This issue has been flagged.")).toBeNull(); + }} +> + {@render notice({ level: "warn", target: issue })} + diff --git a/web/src/lib/components/moderation/ModerationNotice.svelte b/web/src/lib/components/moderation/ModerationNotice.svelte new file mode 100644 index 000000000..da10936c2 --- /dev/null +++ b/web/src/lib/components/moderation/ModerationNotice.svelte @@ -0,0 +1,19 @@ + + +{#if gate.moderation} + +{/if} diff --git a/web/src/lib/components/moderation/gate.svelte.ts b/web/src/lib/components/moderation/gate.svelte.ts new file mode 100644 index 000000000..78b3a935a --- /dev/null +++ b/web/src/lib/components/moderation/gate.svelte.ts @@ -0,0 +1,80 @@ +import { parseCanonicalResourceUri, type Nsid } from "@atcute/lexicons/syntax"; +import { COMMENT_COLLECTION } from "$lib/api/comment"; +import { + isAccountModeration, + type Moderation, + type ModerationLevel +} from "$lib/api/moderationLabels"; +import { PROFILE_COLLECTION } from "$lib/api/profile"; +import { TICKET_COLLECTION } from "$lib/api/ticket"; +import { getAuth } from "$lib/auth.svelte"; + +const RECORD_NOUNS: Partial> = { + [PROFILE_COLLECTION]: "profile", + [COMMENT_COLLECTION]: "comment", + [TICKET_COLLECTION]: "issue" +}; + +const OUTCOMES: Record = { + hide: "hidden by moderators", + warn: "flagged" +}; + +const subjectNoun = (moderation: Moderation): string => { + if (isAccountModeration(moderation)) return "account"; + return RECORD_NOUNS[parseCanonicalResourceUri(moderation.target).collection] ?? "post"; +}; + +export const moderationMessage = (moderation: Moderation): string => + `This ${subjectNoun(moderation)} has been ${OUTCOMES[moderation.level]}.`; + +export interface ModerationGate { + readonly moderation?: Moderation; + readonly masked: boolean; + readonly revealable: boolean; + reveal(): void; +} + +export const createModerationGate = ( + moderation: () => Moderation | undefined, + isOwner: () => boolean = () => false, + subject: () => string | undefined = () => undefined +): ModerationGate => { + let revealedFor = $state(); + const active = $derived(isOwner() ? undefined : moderation()); + const key = $derived(subject() ?? active?.target); + const covering = $derived( + active?.level === "warn" && key !== undefined && revealedFor === key ? undefined : active + ); + + return { + get moderation() { + return covering; + }, + get masked() { + return covering !== undefined; + }, + get revealable() { + return covering?.level === "warn"; + }, + reveal: () => { + revealedFor = key; + } + }; +}; + +export const createAuthorGate = ( + moderation: () => Moderation | undefined, + author: () => string | undefined, + subject?: () => string | undefined +): ModerationGate => { + const auth = getAuth(); + return createModerationGate( + moderation, + () => { + const did = author(); + return did !== undefined && auth?.currentDid === did; + }, + subject + ); +}; diff --git a/web/src/lib/components/ui/HiddenContent.stories.svelte b/web/src/lib/components/ui/HiddenContent.stories.svelte new file mode 100644 index 000000000..9d43a8a83 --- /dev/null +++ b/web/src/lib/components/ui/HiddenContent.stories.svelte @@ -0,0 +1,27 @@ + + + + +