diff --git a/content-filters-example.json b/content-filters-example.json new file mode 100644 index 00000000..bf756948 --- /dev/null +++ b/content-filters-example.json @@ -0,0 +1,9 @@ +{ + "content_warnings": { + "enabled": true, + "blocked_warnings": ["cwarn:violence", "cwarn:nudity", "cwarn:death"] + }, + "distribution_policy": { + "enabled": true + } +} diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index a19565f2..35bc5ec1 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -1,4 +1,7 @@ import { + Button, + ContentMetadataForm, + Textarea, useCreateStreamRecord, useLivestream, useToast, @@ -15,8 +18,6 @@ import { TouchableOpacity, View, } from "react-native"; -import { Button } from "../../../components/src/components/ui/button"; -import { Textarea } from "../../../components/src/components/ui/textarea"; import { selectUserProfile } from "../../features/bluesky/blueskySlice"; import { useCaptureVideoFrame } from "../../hooks/useCaptureVideoFrame"; import { useLiveUser } from "../../hooks/useLiveUser"; @@ -170,13 +171,16 @@ function LivestreamPanel() { const [selectedImage, setSelectedImage] = useState< string | File | Blob | undefined >(); - const [mode, setMode] = useState<"create" | "edit">( + const [mode, setMode] = useState<"create" | "edit" | "metadata">( livestream ? "edit" : "create", ); - const handleModeChange = useCallback((newMode: "create" | "edit") => { - setMode(newMode); - }, []); + const handleModeChange = useCallback( + (newMode: "create" | "edit" | "metadata") => { + setMode(newMode); + }, + [], + ); const handleSubmit = useCallback(async () => { if (!title.trim()) return; @@ -327,6 +331,7 @@ function LivestreamPanel() { values={[ { label: "Create", value: "create" }, { label: "Edit", value: "edit" }, + { label: "Metadata", value: "metadata" }, ]} style={[{ marginVertical: -2 }]} selectedValue={mode} @@ -349,7 +354,16 @@ function LivestreamPanel() { No active livestream to edit. Start a livestream first! + ) : mode === "metadata" ? ( + // Metadata view + + + ) : ( + // Create/Edit view x.livestream); + const segment = useLivestreamStore((x) => x.segment); const did = useDID(); + // Get content warnings and rights directly from the latest segment + const contentWarnings = + (segment?.contentWarnings?.warnings as string[]) || []; + const contentRights = segment?.contentRights; + return ( + + {/* Content Metadata - Below the main profile/controls bar */} + {(contentWarnings.length > 0 || + (contentRights && Object.keys(contentRights).length > 0)) && ( + + + {contentRights && ( + + )} + + )} ); } diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx index f318e567..ce5f7685 100644 --- a/js/app/components/mobile/ui.tsx +++ b/js/app/components/mobile/ui.tsx @@ -1,5 +1,7 @@ import { useNavigation } from "@react-navigation/native"; import { + ContentRights, + ContentWarnings, PlayerUI, Slider, Text, @@ -7,6 +9,7 @@ import { useAvatars, useCameraToggle, useLivestreamInfo, + useLivestreamStore, useMuted, usePlayerDimensions, usePlayerStore, @@ -67,6 +70,12 @@ export function MobileUi({ const { isPlayerRatioGreater } = useSegmentDimensions(); const { doSetIngestCamera } = useCameraToggle(); const avatars = useAvatars(profile?.did ? [profile?.did] : []); + const segment = useLivestreamStore((x) => x.segment); + + // Get content warnings and rights directly from the latest segment + const contentWarnings = + (segment?.contentWarnings?.warnings as string[]) || []; + const contentRights = segment?.contentRights; const muteWasForced = usePlayerStore((state) => state.muteWasForced); const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); @@ -166,51 +175,87 @@ export function MobileUi({ {/* Top Left - Back Button and Profile */} - - { - navigation.canGoBack() - ? navigation.goBack() - : navigation.navigate("Home", { screen: "StreamList" }); - }} + + - - - - {profile?.handle} + { + navigation.canGoBack() + ? navigation.goBack() + : navigation.navigate("Home", { + screen: "StreamList", + }); + }} + > + + + + {profile?.handle} + + {/* Content Metadata - Below mute button */} + {(contentWarnings.length > 0 || + (contentRights && Object.keys(contentRights).length > 0)) && ( + + + {contentRights && ( + + )} + + )} + {/* Right Controls Column */} ({ + createContentMetadata: create.asyncThunk( + async ( + { + contentWarnings = [], + distributionPolicy = { + deleteAfter: undefined, + }, + contentRights = {}, + }: { + contentWarnings?: string[]; + distributionPolicy?: { + deleteAfter?: number; + }; + contentRights?: { + creator?: string; + copyrightNotice?: string; + copyrightYear?: number; + license?: string; + creditLine?: string; + }; + }, + thunkAPI, + ) => { + const { bluesky } = thunkAPI.getState() as { + bluesky: BlueskyState; + }; + + if (!bluesky.pdsAgent) { + throw new Error("No agent"); + } + + const did = bluesky.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + + const metadataRecord = { + $type: "place.stream.metadata.configuration", + createdAt: new Date().toISOString(), + ...(contentWarnings.length > 0 && { + contentWarnings: { warnings: contentWarnings }, + }), + ...(distributionPolicy.deleteAfter && { distributionPolicy }), + ...(contentRights && + Object.keys(contentRights).length > 0 && { + contentRights, + }), + }; + + const result = await bluesky.pdsAgent.com.atproto.repo.createRecord({ + repo: did, + collection: "place.stream.metadata.configuration", + rkey: "self", + record: metadataRecord, + }); + + // Extract rkey from the URI + const rkey = result.data.uri.split("/").pop(); + + return { + record: metadataRecord, + uri: result.data.uri, + cid: result.data.cid, + rkey, + }; + }, + { + pending: (state) => { + return { + ...state, + creating: true, + error: null, + }; + }, + fulfilled: (state, action) => { + return { + ...state, + creating: false, + error: null, + lastCreatedRecord: action.payload, + }; + }, + rejected: (state, action) => { + return { + ...state, + creating: false, + error: action.error?.message ?? "Failed to create content metadata", + }; + }, + }, + ), + + updateContentMetadata: create.asyncThunk( + async ( + { + rkey, + livestreamRef, + contentWarnings = [], + distributionPolicy = { + deleteAfter: undefined, // No expiration means forever + }, + contentRights = {}, + }: { + rkey?: string; + livestreamRef?: { + uri: string; + cid: string; + }; + contentWarnings?: string[]; + distributionPolicy?: { + deleteAfter?: number; + }; + contentRights?: { + creator?: string; + copyrightNotice?: string; + copyrightYear?: number; + license?: string; + creditLine?: string; + }; + }, + thunkAPI, + ) => { + const { bluesky } = thunkAPI.getState() as { + bluesky: BlueskyState; + }; + + if (!bluesky.pdsAgent) { + throw new Error("No agent"); + } + + const did = bluesky.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + + const metadataRecord = { + $type: "place.stream.metadata.configuration", + ...(livestreamRef && { livestreamRef }), + createdAt: new Date().toISOString(), + ...(contentWarnings.length > 0 && { + contentWarnings: { warnings: contentWarnings }, + }), + ...(distributionPolicy.deleteAfter && { distributionPolicy }), + ...(contentRights && + Object.keys(contentRights).length > 0 && { + contentRights, + }), + }; + + const result = await bluesky.pdsAgent.com.atproto.repo.putRecord({ + repo: did, + collection: "place.stream.metadata.configuration", + rkey: "self", + record: metadataRecord, + }); + + return { + record: metadataRecord, + uri: `at://${did}/place.stream.metadata.configuration/self`, + cid: result.data.cid, + }; + }, + { + pending: (state) => { + return { + ...state, + updating: true, + error: null, + }; + }, + fulfilled: (state, action) => { + return { + ...state, + updating: false, + error: null, + lastCreatedRecord: action.payload, + }; + }, + rejected: (state, action) => { + return { + ...state, + updating: false, + error: action.error?.message ?? "Failed to update content metadata", + }; + }, + }, + ), + + getContentMetadata: create.asyncThunk( + async ( + { userDid, rkey = "self" }: { userDid?: string; rkey?: string } = {}, + thunkAPI, + ) => { + const { bluesky } = thunkAPI.getState() as { + bluesky: BlueskyState; + }; + + if (!bluesky.pdsAgent) { + throw new Error("No agent"); + } + + // Use provided userDid or fall back to current user's DID + const targetDid = userDid || bluesky.oauthSession?.did; + if (!targetDid) { + throw new Error("No DID provided or user not authenticated"); + } + + // Add debugging information + console.log(`[getContentMetadata] Debug info:`, { + targetDid, + rkey, + pdsAgentType: bluesky.pdsAgent.constructor.name, + hasOAuthSession: !!bluesky.oauthSession, + currentUserDid: bluesky.oauthSession?.did, + pdsAgentHost: + (bluesky.pdsAgent as any)?.host || + (bluesky.pdsAgent as any)?.service?.host || + "unknown", + pdsAgentUrl: + (bluesky.pdsAgent as any)?.url || + (bluesky.pdsAgent as any)?.service?.url || + "unknown", + }); + + try { + // First, try to resolve the correct PDS for the target user + let targetPDS = null; + try { + const didResponse = await fetch( + `https://plc.directory/${targetDid}`, + ); + if (didResponse.ok) { + const didDoc = await didResponse.json(); + const pdsService = didDoc.service?.find( + (s: any) => s.id === "#atproto_pds", + ); + if (pdsService) { + targetPDS = pdsService.serviceEndpoint; + console.log( + `[getContentMetadata] Resolved PDS for ${targetDid}:`, + targetPDS, + ); + } + } + } catch (pdsResolveError) { + console.log( + `[getContentMetadata] Failed to resolve PDS for ${targetDid}:`, + pdsResolveError, + ); + } + + // Use the target PDS if available, otherwise fall back to the current agent + let agent = bluesky.pdsAgent; + if (targetPDS && targetPDS !== (bluesky.pdsAgent as any)?.host) { + // Create a new agent pointing to the target PDS + const { StreamplaceAgent } = await import("streamplace"); + agent = new StreamplaceAgent(targetPDS) as any; + console.log( + `[getContentMetadata] Created new agent for PDS:`, + targetPDS, + ); + } + + console.log(`[getContentMetadata] Attempting to fetch record from:`, { + repo: targetDid, + collection: "place.stream.metadata.configuration", + rkey, + usingPDS: targetPDS || "default", + }); + + const result = await agent.com.atproto.repo.getRecord({ + repo: targetDid, + collection: "place.stream.metadata.configuration", + rkey, + }); + + console.log(`[getContentMetadata] API response:`, result); + + if (!result.success) { + throw new Error("Failed to get content metadata record"); + } + + return { + userDid: targetDid, + record: result.data.value, + uri: result.data.uri, + cid: result.data.cid, + }; + } catch (error) { + console.log(`[getContentMetadata] Error details:`, { + error: error.message, + errorType: error.constructor.name, + errorStack: error.stack, + }); + + // If user doesn't have metadata record, return null instead of throwing + if ( + error.message?.includes("not found") || + error.message?.includes("RecordNotFound") + ) { + return { + userDid: targetDid, + record: null, + uri: null, + cid: null, + }; + } + throw error; + } + }, + { + pending: (state) => { + return { + ...state, + error: null, + }; + }, + fulfilled: (state, action) => { + return { + ...state, + error: null, + lastCreatedRecord: action.payload, + }; + }, + rejected: (state, action) => { + return { + ...state, + error: action.error?.message ?? "Failed to get content metadata", + }; + }, + }, + ), + + clearError: create.reducer((state) => { + return { + ...state, + error: null, + }; + }), + }), + + selectors: { + selectContentMetadata: (state) => state, + selectIsCreating: (state) => state.creating, + selectIsUpdating: (state) => state.updating, + selectError: (state) => state.error, + selectLastCreatedRecord: (state) => state.lastCreatedRecord, + }, +}); + +export const { + createContentMetadata, + updateContentMetadata, + getContentMetadata, + clearError, +} = contentMetadataSlice.actions; + +export const { + selectContentMetadata, + selectIsCreating, + selectIsUpdating, + selectError, + selectLastCreatedRecord, +} = contentMetadataSlice.selectors; diff --git a/js/app/store/store.tsx b/js/app/store/store.tsx index 1d06cfc4..293c156b 100644 --- a/js/app/store/store.tsx +++ b/js/app/store/store.tsx @@ -4,6 +4,7 @@ import { setupListeners } from "@reduxjs/toolkit/query"; import { baseSlice } from "features/base/baseSlice"; import { sidebarSlice } from "features/base/sidebarSlice"; import { blueskySlice } from "features/bluesky/blueskySlice"; +import { contentMetadataSlice } from "features/bluesky/contentMetadataSlice"; import { platformSlice } from "features/platform/platformSlice"; import { streamplaceSlice } from "features/streamplace/streamplaceSlice"; @@ -11,6 +12,7 @@ import { listenerMiddleware } from "./listener"; const rootReducer = combineSlices( blueskySlice, + contentMetadataSlice, streamplaceSlice, platformSlice, sidebarSlice, diff --git a/js/components/src/components/content-metadata/content-metadata-form.tsx b/js/components/src/components/content-metadata/content-metadata-form.tsx new file mode 100644 index 00000000..41061e48 --- /dev/null +++ b/js/components/src/components/content-metadata/content-metadata-form.tsx @@ -0,0 +1,753 @@ +import { forwardRef, useCallback, useEffect, useState } from "react"; +import { ScrollView, View } from "react-native"; +import { + CONTENT_WARNINGS, + LICENSE_OPTIONS, +} from "../../lib/metadata-constants"; + +import { + useGetContentMetadata, + useSaveContentMetadata, +} from "../../streamplace-store/content-metadata-actions"; +import { useDID } from "../../streamplace-store/streamplace-store"; +import { usePDSAgent } from "../../streamplace-store/xrpc"; +import * as zero from "../../ui"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Input } from "../ui/input"; +import { Select } from "../ui/select"; +import { Text } from "../ui/text"; +import { Textarea } from "../ui/textarea"; +import { toast, useToast } from "../ui/toast"; +import { Tooltip } from "../ui/tooltip"; + +const { p, r, bg, borders, w, text, layout, gap, flex } = zero; + +// Types +export interface DistributionPolicy { + deleteAfter?: number; +} + +export interface Rights { + creator?: string; + copyrightNotice?: string; + copyrightYear?: string | number; + license?: string; + creditLine?: string; +} + +export interface ContentMetadata { + contentWarnings: { warnings: string[] }; + distributionPolicy: DistributionPolicy; + contentRights: Rights; +} + +export interface ContentMetadataFormProps { + showUpdateButton?: boolean; + onMetadataChange?: (metadata: ContentMetadata) => void; + initialMetadata?: ContentMetadata; + style?: any; +} + +// ButtonSelector component (same as in livestream-panel) +const ButtonSelector = ({ + values, + selectedValue, + setSelectedValue, + disabledValues = [], + style = [], +}: { + values: { label: string; value: string }[]; + selectedValue: string; + setSelectedValue: (value: string) => void; + disabledValues?: string[]; + style?: any[]; +}) => ( + + {values.map(({ label, value }) => ( + + ))} + +); + +export const ContentMetadataForm = forwardRef( + ( + { showUpdateButton = false, onMetadataChange, initialMetadata, style }, + ref, + ) => { + const pdsAgent = usePDSAgent(); + const did = useDID(); + const getContentMetadata = useGetContentMetadata(); + const saveContentMetadata = useSaveContentMetadata(); + const { toast: toastState } = useToast(); + + // Local state for metadata + const [contentWarnings, setContentWarnings] = useState([]); + const [distributionPolicy, setDistributionPolicy] = + useState({}); + const [contentRights, setContentRights] = useState({}); + const [selectedLicense, setSelectedLicense] = useState(""); + const [customLicenseText, setCustomLicenseText] = useState(""); + const [customDateTime, setCustomDateTime] = useState(""); + const [loading, setLoading] = useState(false); + const [hasMetadata, setHasMetadata] = useState(false); + + // State for section toggles + const [activeSection, setActiveSection] = + useState("contentWarnings"); + + const currentYear = new Date().getFullYear(); + + // Load existing metadata on mount or from initialMetadata prop + useEffect(() => { + if (initialMetadata) { + // Use provided initial metadata + if (initialMetadata.contentWarnings?.warnings) { + setContentWarnings(initialMetadata.contentWarnings.warnings); + } + if (initialMetadata.distributionPolicy) { + setDistributionPolicy(initialMetadata.distributionPolicy); + setCustomDateTime( + initialMetadata.distributionPolicy.deleteAfter + ? String(initialMetadata.distributionPolicy.deleteAfter) + : "", + ); + } + if (initialMetadata.contentRights) { + setContentRights(initialMetadata.contentRights); + setSelectedLicense(initialMetadata.contentRights.license || ""); + } + return; + } + + const loadMetadata = async () => { + if (!pdsAgent || !did) return; + + try { + const metadata = await getContentMetadata(); + if (metadata?.record) { + setHasMetadata(true); + if (metadata.record.contentWarnings?.warnings) { + setContentWarnings( + metadata.record.contentWarnings.warnings as string[], + ); + } + if (metadata.record.distributionPolicy) { + setDistributionPolicy(metadata.record.distributionPolicy); + setCustomDateTime( + metadata.record.distributionPolicy.deleteAfter + ? String(metadata.record.distributionPolicy.deleteAfter) + : "", + ); + } + if (metadata.record.contentRights) { + setContentRights(metadata.record.contentRights); + setSelectedLicense(metadata.record.contentRights.license || ""); + } + } + } catch (error) { + // No existing metadata is fine + console.log("No existing metadata found"); + } + }; + + loadMetadata(); + }, [pdsAgent, did, initialMetadata]); + + const handleContentWarningChange = useCallback( + (warning: string, checked: boolean) => { + const newWarnings = checked + ? [...contentWarnings, warning] + : contentWarnings.filter((w) => w !== warning); + + setContentWarnings(newWarnings); + + if (onMetadataChange) { + onMetadataChange({ + contentWarnings: { warnings: newWarnings }, + distributionPolicy, + contentRights, + }); + } + }, + [contentWarnings, distributionPolicy, contentRights, onMetadataChange], + ); + + // Notify parent component when metadata changes + useEffect(() => { + if (onMetadataChange) { + onMetadataChange({ + contentWarnings: { warnings: contentWarnings }, + distributionPolicy, + contentRights, + }); + } + }, [contentWarnings, distributionPolicy, contentRights, onMetadataChange]); + + // Handle distribution policy changes + const handleDistributionPolicyChange = useCallback( + (deleteAfter: string) => { + const duration = parseInt(deleteAfter, 10); + const newPolicy = + deleteAfter.trim() !== "" && !isNaN(duration) + ? { deleteAfter: duration } + : {}; + setDistributionPolicy(newPolicy); + + if (onMetadataChange) { + onMetadataChange({ + contentWarnings: { warnings: contentWarnings }, + distributionPolicy: newPolicy, + contentRights, + }); + } + }, + [contentWarnings, contentRights, onMetadataChange], + ); + + // Handle content rights changes + const handleContentRightsChange = useCallback( + (field: string, value: any) => { + const newRights = { ...contentRights, [field]: value }; + setContentRights(newRights); + + if (onMetadataChange) { + onMetadataChange({ + contentWarnings: { warnings: contentWarnings }, + distributionPolicy, + contentRights: newRights, + }); + } + }, + [contentWarnings, distributionPolicy, contentRights, onMetadataChange], + ); + + const handleSave = useCallback(async () => { + setLoading(true); + try { + // Build the metadata object, only including non-empty fields + const metadata: any = {}; + + // Only include contentWarnings if it has values + if (contentWarnings && contentWarnings.length > 0) { + metadata.contentWarnings = contentWarnings; + } + + // Only include distributionPolicy if it has a deleteAfter value + if (customDateTime && customDateTime.trim() !== "") { + const duration = parseInt(customDateTime, 10); + if (!isNaN(duration)) { + metadata.distributionPolicy = { deleteAfter: duration }; + } + } + + // Only include contentRights if it has actual values + const rightsWithLicense = { + ...contentRights, + license: + selectedLicense === "custom" + ? customLicenseText + : selectedLicense || undefined, + }; + + // Filter out empty values from contentRights and convert copyrightYear to number + const filteredRights = Object.fromEntries( + Object.entries(rightsWithLicense) + .filter( + ([_, value]) => + value !== undefined && value !== null && value !== "", + ) + .map(([key, value]) => { + // Convert copyrightYear to integer as per lexicon + if (key === "copyrightYear" && typeof value === "string") { + const year = parseInt(value, 10); + return [key, isNaN(year) ? undefined : year]; + } + return [key, value]; + }) + .filter(([_, value]) => value !== undefined), + ); + + if (Object.keys(filteredRights).length > 0) { + metadata.contentRights = filteredRights; + } + + await saveContentMetadata(metadata); + setHasMetadata(true); + // Show success toast + toast.show( + hasMetadata ? "Content metadata updated" : "Content metadata created", + "Your settings have been saved successfully", + ); + } catch (error) { + console.error("Failed to save metadata:", error); + // Show error toast + toast.show("Failed to save metadata", "Please try again later"); + } finally { + setLoading(false); + } + }, [ + contentWarnings, + contentRights, + selectedLicense, + customLicenseText, + customDateTime, + hasMetadata, + saveContentMetadata, + ]); + + return ( + <> + + + {/* Section Selector */} + + + + + {/* Content Warnings Section */} + {activeSection === "contentWarnings" && ( + + + + Content Warnings + + + optional + + + + {CONTENT_WARNINGS.map((warning) => ( + + + + handleContentWarningChange(warning.value, checked) + } + label={warning.label} + style={[{ fontSize: 12 }]} + /> + + + ))} + + + )} + + {/* Content Rights Section */} + {activeSection === "contentRights" && ( + + + + Content Rights + + + optional + + + + + + + Copyright Year + + + + handleContentRightsChange("copyrightYear", value) + } + placeholder={currentYear.toString()} + variant="filled" + inputStyle={[ + p[3], + r.md, + bg.neutral[800], + text.white, + borders.width.thin, + borders.color.neutral[600], + w.percent[100], + ]} + /> + + + + + + License + + +