diff --git a/js/app/components/settings/badge-issuer-panel.tsx b/js/app/components/settings/badge-issuer-panel.tsx new file mode 100644 index 000000000..c900e0ed8 --- /dev/null +++ b/js/app/components/settings/badge-issuer-panel.tsx @@ -0,0 +1,820 @@ +import { BlobRef } from "@atproto/lexicon"; +import { + Button, + Input, + MenuContainer, + MenuGroup, + MenuSeparator, + Text, + useToast, + View, + zero, +} from "@streamplace/components"; +import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import { Image } from "expo-image"; +import { + Check, + ChevronLeft, + ImagePlus, + Plus, + Send, + X, +} from "lucide-react-native"; +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + Platform, + ScrollView, + TouchableOpacity, +} from "react-native"; +import type { PlaceStreamBadgeDef } from "streamplace"; + +const { gap, p, px, py, r, layout } = zero; + +type BadgeType = + | "place.stream.badge.defs#vip" + | "place.stream.badge.defs#event"; + +const BADGE_TYPE_OPTIONS: { label: string; value: BadgeType }[] = [ + { label: "VIP", value: "place.stream.badge.defs#vip" }, + { label: "Event", value: "place.stream.badge.defs#event" }, +]; + +type PanelView = "main" | "create" | "issue"; + +interface BadgeDefItem { + uri: string; + value: PlaceStreamBadgeDef.Record; +} + +function getDidFromAtUri(uri: string) { + const parts = uri.split("/"); + if (parts.length >= 3) { + return parts[2]; + } + return null; +} + +function BadgeDefRow({ + def, + selected, + onPress, +}: { + def: BadgeDefItem; + selected: boolean; + onPress: () => void; +}) { + const { theme } = zero.useTheme(); + + return ( + + {def.value.image ? ( + + ) : ( + + 🎭 + + )} + + + {def.value.name} + + + {def.value.badgeType.split("#")[1]} + + + + ); +} + +export function BadgeIssuerPanel() { + const agent = usePDSAgent(); + const { theme } = zero.useTheme(); + const toast = useToast(); + + const [view, setView] = useState("main"); + const [working, setWorking] = useState(false); + const [lastResult, setLastResult] = useState<{ + label: string; + uri: string; + } | null>(null); + + const [defs, setDefs] = useState([]); + const [loadingDefs, setLoadingDefs] = useState(false); + const [selectedDefUri, setSelectedDefUri] = useState(null); + + const [createName, setCreateName] = useState(""); + const [createDescription, setCreateDescription] = useState(""); + const [createBadgeType, setCreateBadgeType] = useState( + "place.stream.badge.defs#vip", + ); + const [createImageUri, setCreateImageUri] = useState(null); + const [createImageBlob, setCreateImageBlob] = useState(null); + + const [issueRecipientDid, setIssueRecipientDid] = useState(""); + + const loadDefs = useCallback(async () => { + if (!agent?.did) return; + setLoadingDefs(true); + try { + const res = await agent.place.stream.badge.def.list({ + repo: agent.did, + limit: 100, + }); + setDefs(res.records); + } catch (e: any) { + toast.show("Failed to load badge definitions", e?.message, { + variant: "error", + }); + } finally { + setLoadingDefs(false); + } + }, [agent]); + + useEffect(() => { + if (agent?.did) { + loadDefs(); + } + }, [agent, loadDefs]); + + const pickImage = useCallback(() => { + if (Platform.OS !== "web") { + toast.show("Image upload is only available on web", undefined, { + variant: "error", + }); + return; + } + // @ts-ignore document exists on web + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/png,image/jpeg,image/gif,image/webp"; + input.onchange = (e: any) => { + const file = e.target?.files?.[0]; + if (file) { + if (file.size > 262144) { + toast.show("Image must be under 256KB", undefined, { + variant: "error", + }); + return; + } + const blob = new Blob([file], { type: file.type }); + const url = URL.createObjectURL(blob); + setCreateImageUri(url); + setCreateImageBlob(blob); + } + }; + input.click(); + }, []); + + const handleCreateDef = async () => { + if (!agent?.did || !createName.trim() || working) return; + setWorking(true); + try { + let imageBlob: BlobRef | undefined; + if (createImageBlob) { + const uploaded = await agent.uploadBlob(createImageBlob, { + encoding: createImageBlob.type, + }); + imageBlob = uploaded.data.blob; + } + + await agent.place.stream.badge.def.create( + { repo: agent.did }, + { + name: createName.trim(), + description: createDescription.trim() || undefined, + badgeType: createBadgeType, + image: imageBlob, + createdAt: new Date().toISOString(), + }, + ); + + setLastResult({ + label: "Badge definition created", + uri: createName.trim(), + }); + setCreateName(""); + setCreateDescription(""); + setCreateImageUri(null); + setCreateImageBlob(null); + toast.show("Badge definition created", undefined, { + variant: "success", + }); + loadDefs(); + setView("main"); + } catch (e: any) { + toast.show("Failed to create badge definition", e?.message, { + variant: "error", + }); + } finally { + setWorking(false); + } + }; + + const handleIssueFromDef = async () => { + if (!agent?.did || !selectedDefUri || !issueRecipientDid.trim() || working) + return; + setWorking(true); + try { + const def = defs.find((d) => d.uri === selectedDefUri); + if (!def) return; + + const getRes = await agent.com.atproto.repo.getRecord({ + repo: agent.did, + collection: "place.stream.badge.def", + rkey: def.uri.split("/").pop()!, + }); + + await agent.place.stream.badge.issuance.create( + { repo: agent.did }, + { + did: issueRecipientDid.trim(), + badge: { uri: def.uri, cid: getRes.data.cid ?? "" }, + createdAt: new Date().toISOString(), + }, + ); + + setLastResult({ + label: "Badge issued", + uri: def.value.name + " → " + issueRecipientDid.trim().slice(0, 30), + }); + setIssueRecipientDid(""); + setSelectedDefUri(null); + toast.show("Badge issued", undefined, { variant: "success" }); + setView("main"); + } catch (e: any) { + toast.show("Failed to issue badge", e?.message, { variant: "error" }); + } finally { + setWorking(false); + } + }; + + const renderBackButton = (label: string) => ( + setView("main")} + style={[ + layout.flex.row, + layout.flex.align.center, + gap.all[2], + py[2], + { marginBottom: 4 }, + ]} + > + + + {label} + + + ); + + if (view === "create") { + return ( + + + + {renderBackButton("Badge definitions")} + + Create Badge Definition + + + Create a reusable badge definition. You can then issue it to + multiple users. + + + + + + Badge type + + + {BADGE_TYPE_OPTIONS.map(({ label, value }) => ( + + ))} + + + + + + Badge name + + + + + + + Description (optional) + + + + + + + Badge image (optional, max 256KB) + + + {createImageUri ? ( + + + { + setCreateImageUri(null); + setCreateImageBlob(null); + }} + > + + + + ) : ( + + )} + + + + + + + + + ); + } + + if (view === "issue") { + return ( + + + + {renderBackButton("Badge definitions")} + + Issue Badge to User + + + {loadingDefs ? ( + + ) : defs.length === 0 ? ( + + No badge definitions yet. Create one first. + + ) : ( + + + + + SELECT A BADGE + + + {defs.map((def, i) => ( + + {i > 0 && } + setSelectedDefUri(def.uri)} + /> + + ))} + + + )} + + {selectedDefUri && ( + + + + Recipient DID + + + + + + + )} + + + + ); + } + + return ( + + + + + Manage badge definitions and issue badges to users. + + + + + setView("create")} + style={[ + layout.flex.row, + layout.flex.align.center, + gap.all[3], + py[3], + px[4], + ]} + > + + + + + + Create Badge Definition + + + Define a reusable badge with name, type, and optional image + + + + + + + setView("issue")} + style={[ + layout.flex.row, + layout.flex.align.center, + gap.all[3], + py[3], + px[4], + ]} + > + + + + + + Issue Existing Badge + + + Grant one of your badge definitions to a user + + + + + + + {lastResult && ( + + + + + {lastResult.label} + + + + {lastResult.uri} + + + )} + + {defs.length > 0 && ( + <> + + Your Badge Definitions + + + + {defs.map((def, i) => ( + + {i > 0 && } + + {def.value.image ? ( + + ) : ( + + 🎭 + + )} + + + {def.value.name} + + + {def.value.badgeType.split("#")[1]} + + + + + ))} + + + + )} + + + + ); +} diff --git a/js/app/components/settings/badge-selection-manager.tsx b/js/app/components/settings/badge-selection-manager.tsx new file mode 100644 index 000000000..d0ddbb2de --- /dev/null +++ b/js/app/components/settings/badge-selection-manager.tsx @@ -0,0 +1,312 @@ +import { + MenuContainer, + MenuGroup, + MenuSeparator, + Text, + useToast, + zero, +} from "@streamplace/components"; +import { + Badge, + BADGE_IMAGES, +} from "@streamplace/components/src/components/chat/badge"; +import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import { Image } from "expo-image"; +import { Check } from "lucide-react-native"; +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + ScrollView, + TouchableOpacity, + View, +} from "react-native"; +import type { PlaceStreamBadgeDefs } from "streamplace"; + +const { gap, p, px, py, r, layout, w } = zero; + +function BadgeIssuanceRow({ + badge, + onToggle, + toggling, +}: { + badge: PlaceStreamBadgeDefs.BadgeIssuanceView; + onToggle: (badge: PlaceStreamBadgeDefs.BadgeIssuanceView) => void; + toggling: boolean; +}) { + const { theme } = zero.useTheme(); + const isSelected = badge.selected ?? false; + + return ( + onToggle(badge)} + disabled={toggling} + style={[ + layout.flex.row, + layout.flex.align.center, + gap.all[3], + py[3], + px[4], + { opacity: toggling ? 0.5 : 1 }, + ]} + > + {badge.imageUrl ? ( + + ) : BADGE_IMAGES[badge.badgeType] ? ( + + ) : ( + + )} + + + + {badge.name ?? badge.badgeType.split("#")[1]} + + {badge.description && ( + + {badge.description} + + )} + + issued by {badge.issuer} + + + + {toggling ? ( + + ) : isSelected ? ( + + + + ) : ( + + )} + + ); +} + +export function BadgeSelectionManager() { + const agent = usePDSAgent(); + const { theme } = zero.useTheme(); + const toast = useToast(); + + const [loading, setLoading] = useState(true); + const [streamerSlot, setStreamerSlot] = + useState(null); + const [userSlot, setUserSlot] = + useState(null); + const [toggling, setToggling] = useState(null); + + const load = useCallback(async () => { + if (!agent) return; + try { + setLoading(true); + const res = await agent.place.stream.badge.getIssuedBadges({}); + setStreamerSlot(res.data.streamer); + setUserSlot(res.data.user); + } catch (e: any) { + toast.show("Failed to load badges", e?.message, { variant: "error" }); + } finally { + setLoading(false); + } + }, [agent]); + + useEffect(() => { + load(); + }, [load]); + + const handleToggle = useCallback( + async (badge: PlaceStreamBadgeDefs.BadgeIssuanceView) => { + if (!agent?.did || toggling) return; + setToggling(badge.issuanceUri); + + try { + // Fetch current profile record + let currentRecord: Record = { + $type: "place.stream.chat.profile", + selection: [], + createdAt: new Date().toISOString(), + }; + let swapCid: string | undefined; + + try { + const getRes = await agent.com.atproto.repo.getRecord({ + repo: agent.did, + collection: "place.stream.chat.profile", + rkey: "self", + }); + currentRecord = getRes.data.value as Record; + swapCid = getRes.data.cid; + } catch { + // no profile yet, will create + } + + const currentSelection: Array<{ uri: string; cid: string }> = + (currentRecord.selection as any[]) ?? []; + + let newSelection: Array<{ uri: string; cid: string }>; + const isCurrentlySelected = badge.selected ?? false; + + if (isCurrentlySelected) { + // Remove from selection + newSelection = currentSelection.filter( + (s) => s.uri !== badge.issuanceUri, + ); + } else { + // Add to selection (max 2 cosmetic slots) + const ref = { uri: badge.issuanceUri, cid: "" }; + newSelection = [ + ...currentSelection.filter((s) => s.uri !== badge.issuanceUri), + ref, + ]; + } + + await agent.com.atproto.repo.putRecord({ + repo: agent.did, + collection: "place.stream.chat.profile", + rkey: "self", + record: { ...currentRecord, selection: newSelection }, + swapRecord: swapCid, + }); + + await load(); + } catch (e: any) { + toast.show("Failed to update badge selection", e?.message, { + variant: "error", + }); + } finally { + setToggling(null); + } + }, + [agent, toggling, load], + ); + + if (loading) { + return ( + + + + ); + } + + const hasStreamerBadges = (streamerSlot?.available?.length ?? 0) > 0; + const hasUserBadges = (userSlot?.available?.length ?? 0) > 0; + + if (!hasStreamerBadges && !hasUserBadges) { + return ( + + + No badges yet. Badges appear here when streamers or the server issues + them to you. + + + ); + } + + return ( + + + + {hasStreamerBadges && ( + + + + STREAMER BADGES + + + {streamerSlot!.available.map((badge, i) => ( + + {i > 0 && } + + + ))} + + )} + + {hasUserBadges && ( + + + + COSMETIC BADGES + + + {userSlot!.available.map((badge, i) => ( + + {i > 0 && } + + + ))} + + )} + + + + ); +} diff --git a/js/app/components/settings/settings.tsx b/js/app/components/settings/settings.tsx index c941a26a3..399ebfaa6 100644 --- a/js/app/components/settings/settings.tsx +++ b/js/app/components/settings/settings.tsx @@ -17,6 +17,7 @@ import { } from "components/settings/components/settings-navigation-item"; import { ImageBackground } from "expo-image"; import { + Award, Brush, Globe, Info, @@ -149,6 +150,21 @@ export function Settings() { screen="DanmuCategory" icon={Mu as any} /> + + + + )} + {loggedIn && ( + + )} {isAdmin && ( diff --git a/js/app/src/linking-config.ts b/js/app/src/linking-config.ts index 2c56eeab5..0227e7d88 100644 --- a/js/app/src/linking-config.ts +++ b/js/app/src/linking-config.ts @@ -30,6 +30,8 @@ export const SCREEN_PATHS = { KeyManagement: "settings/streaming/key-management", LanguagesCategory: "settings/languages", BrandingAdmin: "settings/branding", + BadgeSelection: "settings/account/badges", + BadgeIssuer: "settings/issue-badges", // Tabs GoLiveTab: "go-live", // Root stack screens @@ -117,6 +119,8 @@ export const streamplaceLinkingOptions: LinkingOptions + + ); } diff --git a/js/components/src/components/chat/badge.tsx b/js/components/src/components/chat/badge.tsx index 34ddb57d4..3af584a9f 100644 --- a/js/components/src/components/chat/badge.tsx +++ b/js/components/src/components/chat/badge.tsx @@ -13,29 +13,54 @@ export const Badge = ({ badgeType, size = 18, isInChat = false, + imageUrl, }: { badgeType: string; size?: number; isInChat?: boolean; + imageUrl?: string; }) => { const source = BADGE_IMAGES[badgeType]; - if (!source) return null; - return ( - - ); + if (source) { + return ( + + ); + } + if (imageUrl) { + return ( + + ); + } + return null; }; export const BadgeDisplayRow = ({ @@ -47,7 +72,12 @@ export const BadgeDisplayRow = ({ return ( <> {badges.map((badge, index) => ( - + ))} ); diff --git a/js/components/src/components/chat/user-profile-card.tsx b/js/components/src/components/chat/user-profile-card.tsx index 748b9d4b2..af74cf8e0 100644 --- a/js/components/src/components/chat/user-profile-card.tsx +++ b/js/components/src/components/chat/user-profile-card.tsx @@ -88,7 +88,7 @@ interface ProfileCardData { profile: ReturnType[string] | undefined; profiles: ReturnType; serviceDid: string | null; - serviceBadges: NonNullable; + allBadges: NonNullable; streamer: ProfileViewBasic | undefined; } @@ -114,17 +114,16 @@ function useProfileCardData( ); const profiles = useAvatars(allDids); - const serviceBadges = useMemo( - () => badges?.filter((b) => serviceDid && b.issuer === serviceDid) ?? [], - [badges, serviceDid], - ) as NonNullable; + const allBadges = (badges ?? []) as NonNullable< + ChatMessageViewHydrated["badges"] + >; return { author, profile: profiles[author.did], profiles, serviceDid, - serviceBadges, + allBadges, streamer, }; } @@ -142,22 +141,21 @@ const BadgeRow = ({ }) => { const isServiceIssued = badge.issuer === serviceDid; const meta = BADGE_META[badge.badgeType]; - - if (!meta) return null; + const label = meta?.label ?? badge.name ?? badge.badgeType.split("#")[1]; + const description = meta?.description ?? badge.description; let issuerLabel = isServiceIssued ? "Streamplace" : issuerProfiles[badge.issuer]?.handle ? `@${issuerProfiles[badge.issuer].handle}` : badge.issuer; - if (meta.issuedBy) { - issuerLabel = meta.issuedBy - .replace("{issuer}", issuerLabel) - .replace( - "{streamer}", - streamer?.handle ? formatHandleWithAt(streamer) : "the streamer", - ); - } + const issuedByTemplate = meta?.issuedBy ?? "Issued by {issuer}"; + issuerLabel = issuedByTemplate + .replace("{issuer}", issuerLabel) + .replace( + "{streamer}", + streamer?.handle ? formatHandleWithAt(streamer) : "the streamer", + ); return ( - + - {meta.label} + {label} - Issued by {issuerLabel} + {issuerLabel} - {meta.description && ( + {description && ( - {meta.description} + {description} )} @@ -190,8 +188,7 @@ const ProfileCardContent = ({ data: ProfileCardData; theme: ReturnType["theme"]; }) => { - const { author, profile, profiles, serviceDid, serviceBadges, streamer } = - data; + const { author, profile, profiles, serviceDid, allBadges, streamer } = data; return ( @@ -269,14 +266,14 @@ const ProfileCardContent = ({ )} - {serviceBadges.length > 0 && serviceDid ? ( + {allBadges.length > 0 ? ( - {serviceBadges.map((badge, i) => ( + {allBadges.map((badge, i) => ( diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-def.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-def.md new file mode 100644 index 000000000..8533a2559 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-def.md @@ -0,0 +1,80 @@ +--- +title: place.stream.badge.def +description: Reference for the place.stream.badge.def lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `record` + +Defines a badge's visual appearance and type. Created by the issuer and referenced by issuance records. + +**Record Key:** `tid` + +**Record Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ------------- | -------- | ----- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `name` | `string` | ✅ | Display name for this badge. | Max Length: 64 | +| `description` | `string` | ❌ | Optional description of the badge. | Max Length: 256 | +| `image` | `blob` | ❌ | Badge icon image. | Accept: `image/png`, `image/jpeg`, `image/gif`, `image/webp`
Max Size: 262144 bytes | +| `badgeType` | `string` | ✅ | The category of this badge, used for scope and display rules. | Known Values: `place.stream.badge.defs#vip`, `place.stream.badge.defs#event` | +| `createdAt` | `string` | ✅ | | Format: `datetime` | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.def", + "defs": { + "main": { + "type": "record", + "description": "Defines a badge's visual appearance and type. Created by the issuer and referenced by issuance records.", + "key": "tid", + "record": { + "type": "object", + "required": ["name", "badgeType", "createdAt"], + "properties": { + "name": { + "type": "string", + "maxLength": 64, + "description": "Display name for this badge." + }, + "description": { + "type": "string", + "maxLength": 256, + "description": "Optional description of the badge." + }, + "image": { + "type": "blob", + "accept": ["image/png", "image/jpeg", "image/gif", "image/webp"], + "maxSize": 262144, + "description": "Badge icon image." + }, + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#vip", + "place.stream.badge.defs#event" + ], + "description": "The category of this badge, used for scope and display rules." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-defs.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-defs.md index 7fa8e8de3..9259355f2 100644 --- a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-defs.md +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-defs.md @@ -17,12 +17,55 @@ View of a badge record, with fields resolved for display. If the DID in issuer i **Properties:** -| Name | Type | Req'd | Description | Constraints | -| ----------- | -------- | ----- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#streamer`, `place.stream.badge.defs#bot` | -| `issuer` | `string` | ✅ | DID of the badge issuer. | Format: `did` | -| `recipient` | `string` | ✅ | DID of the badge recipient. | Format: `did` | -| `signature` | `string` | ❌ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | +| Name | Type | Req'd | Description | Constraints | +| ------------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#streamer`, `place.stream.badge.defs#vip`, `place.stream.badge.defs#event`, `place.stream.badge.defs#bot` | +| `issuer` | `string` | ✅ | DID of the badge issuer. | Format: `did` | +| `recipient` | `string` | ✅ | DID of the badge recipient. | Format: `did` | +| `name` | `string` | ❌ | Display name from the badge definition. | | +| `description` | `string` | ❌ | Description from the badge definition. | | +| `imageUrl` | `string` | ❌ | Resolved image URL for the badge icon. | Format: `uri` | +| `signature` | `string` | ❌ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | + +--- + + + +### `badgeIssuanceView` + +**Type:** `object` + +A resolved view of a badge issuance, including def fields for display. + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ------------- | --------- | ----- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `issuanceUri` | `string` | ✅ | AT URI of the place.stream.badge.issuance record. | Format: `at-uri` | +| `issuanceCid` | `string` | ❌ | CID of the place.stream.badge.issuance record. | | +| `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#vip`, `place.stream.badge.defs#event` | +| `name` | `string` | ❌ | Display name from the badge definition. | | +| `description` | `string` | ❌ | Description from the badge definition. | | +| `imageUrl` | `string` | ❌ | Resolved image URL for the badge icon. | Format: `uri` | +| `issuer` | `string` | ✅ | DID of the badge issuer. | Format: `did` | +| `selected` | `boolean` | ❌ | Whether this badge is currently in the user's chat profile selection. | | + +--- + + + +### `badgeSlot` + +**Type:** `object` + +A display slot containing available issuance-based badges and which one (if any) is currently selected. + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ----------- | --------------------------------------------------- | ----- | -------------------------------------------------- | ----------- | +| `selected` | [`#badgeIssuanceView`](#badgeissuanceview) | ❌ | The currently selected badge in this slot, if any. | | +| `available` | Array of [`#badgeIssuanceView`](#badgeissuanceview) | ✅ | All badges available for this slot. | | --- @@ -66,6 +109,16 @@ This user is a bot. Self-applied via place.stream.chat.profile selfLabels. --- + + +### `event` + +**Type:** `token` + +This user has won or earned a special event or contest badge. + +--- + ## Lexicon Source ```json @@ -83,6 +136,8 @@ This user is a bot. Self-applied via place.stream.chat.profile selfLabels. "knownValues": [ "place.stream.badge.defs#mod", "place.stream.badge.defs#streamer", + "place.stream.badge.defs#vip", + "place.stream.badge.defs#event", "place.stream.badge.defs#bot" ] }, @@ -96,12 +151,90 @@ This user is a bot. Self-applied via place.stream.chat.profile selfLabels. "format": "did", "description": "DID of the badge recipient." }, + "name": { + "type": "string", + "description": "Display name from the badge definition." + }, + "description": { + "type": "string", + "description": "Description from the badge definition." + }, + "imageUrl": { + "type": "string", + "format": "uri", + "description": "Resolved image URL for the badge icon." + }, "signature": { "type": "string", "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." } } }, + "badgeIssuanceView": { + "type": "object", + "required": ["issuanceUri", "badgeType", "issuer"], + "description": "A resolved view of a badge issuance, including def fields for display.", + "properties": { + "issuanceUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the place.stream.badge.issuance record." + }, + "issuanceCid": { + "type": "string", + "description": "CID of the place.stream.badge.issuance record." + }, + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#vip", + "place.stream.badge.defs#event" + ] + }, + "name": { + "type": "string", + "description": "Display name from the badge definition." + }, + "description": { + "type": "string", + "description": "Description from the badge definition." + }, + "imageUrl": { + "type": "string", + "format": "uri", + "description": "Resolved image URL for the badge icon." + }, + "issuer": { + "type": "string", + "format": "did", + "description": "DID of the badge issuer." + }, + "selected": { + "type": "boolean", + "description": "Whether this badge is currently in the user's chat profile selection." + } + } + }, + "badgeSlot": { + "type": "object", + "required": ["available"], + "description": "A display slot containing available issuance-based badges and which one (if any) is currently selected.", + "properties": { + "selected": { + "type": "ref", + "ref": "#badgeIssuanceView", + "description": "The currently selected badge in this slot, if any." + }, + "available": { + "type": "array", + "description": "All badges available for this slot.", + "items": { + "type": "ref", + "ref": "#badgeIssuanceView" + } + } + } + }, "mod": { "type": "token", "description": "This user is a moderator. Displayed with a sword icon." @@ -117,6 +250,10 @@ This user is a bot. Self-applied via place.stream.chat.profile selfLabels. "bot": { "type": "token", "description": "This user is a bot. Self-applied via place.stream.chat.profile selfLabels." + }, + "event": { + "type": "token", + "description": "This user has won or earned a special event or contest badge." } } } diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getissuedbadges.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getissuedbadges.md new file mode 100644 index 000000000..51f146af1 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getissuedbadges.md @@ -0,0 +1,86 @@ +--- +title: place.stream.badge.getIssuedBadges +description: Reference for the place.stream.badge.getIssuedBadges lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `query` + +Returns badges issued to the authenticated user, organized by display slot. Also indicates which badges are currently selected in the user's chat profile. + +**Parameters:** + +| Name | Type | Req'd | Description | Constraints | +| ---------- | -------- | ----- | ---------------------------------------------------------------- | ------------- | +| `streamer` | `string` | ❌ | DID of the streamer channel to compute the server badge against. | Format: `did` | + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| ---------- | --------------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ----------- | +| `server` | [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | ❌ | Computed server badge (streamer, mod, or bot) for the given streamer context. Absent if the user has no server role. | | +| `streamer` | [`place.stream.badge.defs#badgeSlot`](/lex-reference/place-stream-badge-defs#badgeslot) | ✅ | Streamer-issued badges (e.g. VIP) available to the user. | | +| `user` | [`place.stream.badge.defs#badgeSlot`](/lex-reference/place-stream-badge-defs#badgeslot) | ✅ | Globally-issued cosmetic badges (e.g. event) available to the user. | | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.getIssuedBadges", + "defs": { + "main": { + "type": "query", + "description": "Returns badges issued to the authenticated user, organized by display slot. Also indicates which badges are currently selected in the user's chat profile.", + "parameters": { + "type": "params", + "properties": { + "streamer": { + "type": "string", + "format": "did", + "description": "DID of the streamer channel to compute the server badge against." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["streamer", "user"], + "properties": { + "server": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeView", + "description": "Computed server badge (streamer, mod, or bot) for the given streamer context. Absent if the user has no server role." + }, + "streamer": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeSlot", + "description": "Streamer-issued badges (e.g. VIP) available to the user." + }, + "user": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeSlot", + "description": "Globally-issued cosmetic badges (e.g. event) available to the user." + } + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md new file mode 100644 index 000000000..ea8e54408 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md @@ -0,0 +1,65 @@ +--- +title: place.stream.badge.issuance +description: Reference for the place.stream.badge.issuance lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `record` + +Grants a specific badge to a recipient. The badge only appears in chat after the recipient adds this record to their place.stream.chat.profile selection array. + +**Record Key:** `tid` + +**Record Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------- | ------------------ | +| `did` | `string` | ✅ | The DID of the user being granted the badge. | Format: `did` | +| `badge` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ✅ | Strong reference to the place.stream.badge.def record being granted. | | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this issuance was created. | Format: `datetime` | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.issuance", + "defs": { + "main": { + "type": "record", + "description": "Grants a specific badge to a recipient. The badge only appears in chat after the recipient adds this record to their place.stream.chat.profile selection array.", + "key": "tid", + "record": { + "type": "object", + "required": ["did", "badge", "createdAt"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "The DID of the user being granted the badge." + }, + "badge": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the place.stream.badge.def record being granted." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this issuance was created." + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuebadge.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuebadge.md new file mode 100644 index 000000000..10d6c2b18 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuebadge.md @@ -0,0 +1,122 @@ +--- +title: place.stream.badge.issueBadge +description: Reference for the place.stream.badge.issueBadge lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `procedure` + +Create a badge definition and grant it to a recipient. Both records are written to the authenticated user's repo. + +**Parameters:** _(None defined)_ + +**Input:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| -------------- | -------- | ----- | ---------------------------------------- | ---------------------------------------------------------------------------- | +| `recipientDid` | `string` | ✅ | The DID of the user receiving the badge. | Format: `did` | +| `name` | `string` | ✅ | Display name for the badge. | Max Length: 64 | +| `description` | `string` | ❌ | Optional description of the badge. | Max Length: 256 | +| `badgeType` | `string` | ✅ | The category of badge being issued. | Known Values: `place.stream.badge.defs#vip`, `place.stream.badge.defs#event` | + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| ------------- | -------- | ----- | ---------------------------------------------- | ---------------- | +| `defUri` | `string` | ✅ | AT URI of the created badge definition record. | Format: `at-uri` | +| `defCid` | `string` | ✅ | CID of the created badge definition record. | | +| `issuanceUri` | `string` | ✅ | AT URI of the created badge issuance record. | Format: `at-uri` | +| `issuanceCid` | `string` | ✅ | CID of the created badge issuance record. | | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.issueBadge", + "defs": { + "main": { + "type": "procedure", + "description": "Create a badge definition and grant it to a recipient. Both records are written to the authenticated user's repo.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["recipientDid", "name", "badgeType"], + "properties": { + "recipientDid": { + "type": "string", + "format": "did", + "description": "The DID of the user receiving the badge." + }, + "name": { + "type": "string", + "maxLength": 64, + "description": "Display name for the badge." + }, + "description": { + "type": "string", + "maxLength": 256, + "description": "Optional description of the badge." + }, + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#vip", + "place.stream.badge.defs#event" + ], + "description": "The category of badge being issued." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["defUri", "defCid", "issuanceUri", "issuanceCid"], + "properties": { + "defUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the created badge definition record." + }, + "defCid": { + "type": "string", + "description": "CID of the created badge definition record." + }, + "issuanceUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the created badge issuance record." + }, + "issuanceCid": { + "type": "string", + "description": "CID of the created badge issuance record." + } + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-profile.md b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-profile.md index d16b795d7..1c6ef8e05 100644 --- a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-profile.md +++ b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-profile.md @@ -19,10 +19,11 @@ Record containing customizations for a user's chat profile. **Record Properties:** -| Name | Type | Req'd | Description | Constraints | -| ------------ | ----------------------------------- | ----- | ------------------------------------------------- | ------------- | -| `color` | [`#color`](#color) | ❌ | | | -| `selfLabels` | Array of [`#selfLabel`](#selflabel) | ❌ | Self-applied labels for this profile, e.g. 'bot'. | Max Items: 10 | +| Name | Type | Req'd | Description | Constraints | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------- | +| `color` | [`#color`](#color) | ❌ | | | +| `selfLabels` | Array of [`#selfLabel`](#selflabel) | ❌ | Self-applied labels for this profile, e.g. 'bot'. | Max Items: 10 | +| `selection` | Array of [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ❌ | Badge issuances this user has selected to display in chat. Each entry is a strong reference to a place.stream.badge.issuance record. | Max Items: 10 | --- @@ -83,6 +84,15 @@ Customizations for the color of a user's name in chat "type": "ref", "ref": "#selfLabel" } + }, + "selection": { + "type": "array", + "description": "Badge issuances this user has selected to display in chat. Each entry is a strong reference to a place.stream.badge.issuance record.", + "maxLength": 10, + "items": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + } } } } diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 4f3bb86b6..e4fd58506 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -2302,6 +2302,53 @@ } } }, + "/xrpc/place.stream.badge.getIssuedBadges": { + "get": { + "summary": "Returns badges issued to the authenticated user, organized by display slot. Also indicates which badges are currently selected in the user's chat profile.", + "operationId": "place.stream.badge.getIssuedBadges", + "tags": ["place.stream.badge"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "server": { + "$ref": "#/components/schemas/place.stream.badge.defs_badgeView", + "description": "Computed server badge (streamer, mod, or bot) for the given streamer context. Absent if the user has no server role." + }, + "streamer": { + "$ref": "#/components/schemas/place.stream.badge.defs_badgeSlot", + "description": "Streamer-issued badges (e.g. VIP) available to the user." + }, + "user": { + "$ref": "#/components/schemas/place.stream.badge.defs_badgeSlot", + "description": "Globally-issued cosmetic badges (e.g. event) available to the user." + } + }, + "required": ["streamer", "user"] + } + } + } + } + }, + "parameters": [ + { + "name": "streamer", + "in": "query", + "required": false, + "description": "DID of the streamer channel to compute the server badge against.", + "schema": { + "type": "string", + "description": "DID of the streamer channel to compute the server badge against.", + "format": "did" + } + } + ] + } + }, "/xrpc/place.stream.badge.getValidBadges": { "get": { "summary": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", @@ -2343,6 +2390,78 @@ ] } }, + "/xrpc/place.stream.badge.issueBadge": { + "post": { + "summary": "Create a badge definition and grant it to a recipient. Both records are written to the authenticated user's repo.", + "operationId": "place.stream.badge.issueBadge", + "tags": ["place.stream.badge"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "defUri": { + "type": "string", + "description": "AT URI of the created badge definition record.", + "format": "uri" + }, + "defCid": { + "type": "string", + "description": "CID of the created badge definition record." + }, + "issuanceUri": { + "type": "string", + "description": "AT URI of the created badge issuance record.", + "format": "uri" + }, + "issuanceCid": { + "type": "string", + "description": "CID of the created badge issuance record." + } + }, + "required": ["defUri", "defCid", "issuanceUri", "issuanceCid"] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recipientDid": { + "type": "string", + "description": "The DID of the user receiving the badge.", + "format": "did" + }, + "name": { + "type": "string", + "description": "Display name for the badge.", + "maxLength": 64 + }, + "description": { + "type": "string", + "description": "Optional description of the badge.", + "maxLength": 256 + }, + "badgeType": { + "type": "string", + "description": "The category of badge being issued." + } + }, + "required": ["recipientDid", "name", "badgeType"] + } + } + } + } + } + }, "/xrpc/com.atproto.sync.getRecord": { "get": { "summary": "Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.", @@ -3938,6 +4057,19 @@ "description": "DID of the badge recipient.", "format": "did" }, + "name": { + "type": "string", + "description": "Display name from the badge definition." + }, + "description": { + "type": "string", + "description": "Description from the badge definition." + }, + "imageUrl": { + "type": "string", + "description": "Resolved image URL for the badge icon.", + "format": "uri" + }, "signature": { "type": "string", "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." @@ -3945,6 +4077,65 @@ }, "required": ["badgeType", "issuer", "recipient"] }, + "place.stream.badge.defs_badgeSlot": { + "type": "object", + "description": "A display slot containing available issuance-based badges and which one (if any) is currently selected.", + "properties": { + "selected": { + "$ref": "#/components/schemas/place.stream.badge.defs_badgeIssuanceView", + "description": "The currently selected badge in this slot, if any." + }, + "available": { + "type": "array", + "description": "All badges available for this slot.", + "items": { + "$ref": "#/components/schemas/place.stream.badge.defs_badgeIssuanceView" + } + } + }, + "required": ["available"] + }, + "place.stream.badge.defs_badgeIssuanceView": { + "type": "object", + "description": "A resolved view of a badge issuance, including def fields for display.", + "properties": { + "issuanceUri": { + "type": "string", + "description": "AT URI of the place.stream.badge.issuance record.", + "format": "uri" + }, + "issuanceCid": { + "type": "string", + "description": "CID of the place.stream.badge.issuance record." + }, + "badgeType": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Display name from the badge definition." + }, + "description": { + "type": "string", + "description": "Description from the badge definition." + }, + "imageUrl": { + "type": "string", + "description": "Resolved image URL for the badge icon.", + "format": "uri" + }, + "issuer": { + "type": "string", + "description": "DID of the badge issuer.", + "format": "did" + }, + "selected": { + "type": "boolean", + "description": "Whether this badge is currently in the user's chat profile selection." + } + }, + "required": ["issuanceUri", "badgeType", "issuer"] + }, "com.atproto.sync.listRepos_repo": { "type": "object", "properties": { diff --git a/lexicons/place/stream/badge/defs.json b/lexicons/place/stream/badge/defs.json index 7fb959257..a616626f4 100644 --- a/lexicons/place/stream/badge/defs.json +++ b/lexicons/place/stream/badge/defs.json @@ -46,6 +46,71 @@ } } }, + "badgeIssuanceView": { + "type": "object", + "required": ["issuanceUri", "badgeType", "issuer"], + "description": "A resolved view of a badge issuance, including def fields for display.", + "properties": { + "issuanceUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the place.stream.badge.issuance record." + }, + "issuanceCid": { + "type": "string", + "description": "CID of the place.stream.badge.issuance record." + }, + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#vip", + "place.stream.badge.defs#event" + ] + }, + "name": { + "type": "string", + "description": "Display name from the badge definition." + }, + "description": { + "type": "string", + "description": "Description from the badge definition." + }, + "imageUrl": { + "type": "string", + "format": "uri", + "description": "Resolved image URL for the badge icon." + }, + "issuer": { + "type": "string", + "format": "did", + "description": "DID of the badge issuer." + }, + "selected": { + "type": "boolean", + "description": "Whether this badge is currently in the user's chat profile selection." + } + } + }, + "badgeSlot": { + "type": "object", + "required": ["available"], + "description": "A display slot containing available issuance-based badges and which one (if any) is currently selected.", + "properties": { + "selected": { + "type": "ref", + "ref": "#badgeIssuanceView", + "description": "The currently selected badge in this slot, if any." + }, + "available": { + "type": "array", + "description": "All badges available for this slot.", + "items": { + "type": "ref", + "ref": "#badgeIssuanceView" + } + } + } + }, "mod": { "type": "token", "description": "This user is a moderator. Displayed with a sword icon." diff --git a/lexicons/place/stream/badge/getIssuedBadges.json b/lexicons/place/stream/badge/getIssuedBadges.json new file mode 100644 index 000000000..6a7555f26 --- /dev/null +++ b/lexicons/place/stream/badge/getIssuedBadges.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.getIssuedBadges", + "defs": { + "main": { + "type": "query", + "description": "Returns badges issued to the authenticated user, organized by display slot. Also indicates which badges are currently selected in the user's chat profile.", + "parameters": { + "type": "params", + "properties": { + "streamer": { + "type": "string", + "format": "did", + "description": "DID of the streamer channel to compute the server badge against." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["streamer", "user"], + "properties": { + "server": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeView", + "description": "Computed server badge (streamer, mod, or bot) for the given streamer context. Absent if the user has no server role." + }, + "streamer": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeSlot", + "description": "Streamer-issued badges (e.g. VIP) available to the user." + }, + "user": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeSlot", + "description": "Globally-issued cosmetic badges (e.g. event) available to the user." + } + } + } + } + } + } +} diff --git a/lexicons/place/stream/badge/issueBadge.json b/lexicons/place/stream/badge/issueBadge.json new file mode 100644 index 000000000..971611ac0 --- /dev/null +++ b/lexicons/place/stream/badge/issueBadge.json @@ -0,0 +1,69 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.issueBadge", + "defs": { + "main": { + "type": "procedure", + "description": "Create a badge definition and grant it to a recipient. Both records are written to the authenticated user's repo.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["recipientDid", "name", "badgeType"], + "properties": { + "recipientDid": { + "type": "string", + "format": "did", + "description": "The DID of the user receiving the badge." + }, + "name": { + "type": "string", + "maxLength": 64, + "description": "Display name for the badge." + }, + "description": { + "type": "string", + "maxLength": 256, + "description": "Optional description of the badge." + }, + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#vip", + "place.stream.badge.defs#event" + ], + "description": "The category of badge being issued." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["defUri", "defCid", "issuanceUri", "issuanceCid"], + "properties": { + "defUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the created badge definition record." + }, + "defCid": { + "type": "string", + "description": "CID of the created badge definition record." + }, + "issuanceUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the created badge issuance record." + }, + "issuanceCid": { + "type": "string", + "description": "CID of the created badge issuance record." + } + } + } + } + } + } +} diff --git a/pkg/badges/badges.go b/pkg/badges/badges.go index c4f9f973f..141073273 100644 --- a/pkg/badges/badges.go +++ b/pkg/badges/badges.go @@ -27,23 +27,23 @@ func GetValidBadges(ctx context.Context, userDID, streamerDID, issuerDID string, Issuer: issuerDID, Recipient: userDID, }) - return badges, nil } // Check if user has moderation permissions for this streamer - delegations, err := m.GetModerationDelegations(ctx, streamerDID, userDID) - if err != nil { - log.Error(ctx, "failed to get moderation delegations", "err", err, "userDID", userDID, "streamerDID", streamerDID) - return nil, err - } + if userDID != streamerDID { + delegations, err := m.GetModerationDelegations(ctx, streamerDID, userDID) + if err != nil { + log.Error(ctx, "failed to get moderation delegations", "err", err, "userDID", userDID, "streamerDID", streamerDID) + return nil, err + } - // If user has any delegations, they're a moderator - if len(delegations) > 0 { - badges = append(badges, &streamplace.BadgeDefs_BadgeView{ - BadgeType: constants.BadgeTypeMod, - Issuer: issuerDID, - Recipient: userDID, - }) + if len(delegations) > 0 { + badges = append(badges, &streamplace.BadgeDefs_BadgeView{ + BadgeType: constants.BadgeTypeMod, + Issuer: issuerDID, + Recipient: userDID, + }) + } } // if user "self-labels" as a bot (in chat profile), add bot badge @@ -104,7 +104,7 @@ func GetValidBadges(ctx context.Context, userDID, streamerDID, issuerDID string, default: // All other badge types (event, unknown) are globally valid but must be // issued by an authorized global badge issuer. - if !isGlobalIssuer(issuance.RepoDID) { + if !IsGlobalIssuer(issuance.RepoDID) { continue } } @@ -130,7 +130,7 @@ func GetValidBadges(ctx context.Context, userDID, streamerDID, issuerDID string, return badges, nil } -func isGlobalIssuer(did string) bool { +func IsGlobalIssuer(did string) bool { for _, authorized := range constants.GlobalBadgeIssuers { if did == authorized { return true diff --git a/pkg/badges/badges_test.go b/pkg/badges/badges_test.go index bd2123b06..0b1a87af0 100644 --- a/pkg/badges/badges_test.go +++ b/pkg/badges/badges_test.go @@ -212,7 +212,9 @@ func TestGetValidBadges_Issuance(t *testing.T) { t.Run("event badge issued by node appears in any streamer context", func(t *testing.T) { // Register the node as an authorized global badge issuer for this test. constants.GlobalBadgeIssuers = append(constants.GlobalBadgeIssuers, issuerDID) - t.Cleanup(func() { constants.GlobalBadgeIssuers = constants.GlobalBadgeIssuers[:len(constants.GlobalBadgeIssuers)-1] }) + t.Cleanup(func() { + constants.GlobalBadgeIssuers = constants.GlobalBadgeIssuers[:len(constants.GlobalBadgeIssuers)-1] + }) eventUserDID := "did:plc:eventuser" eventDefURI := "at://" + issuerDID + "/place.stream.badge.def/eventdef" diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 552fe0bc4..23ac7cbdd 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -17,8 +17,8 @@ var PLACE_STREAM_CHAT_PINNED_RECORD = "place.stream.chat.pinnedRecord" // var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all var PLACE_STREAM_LIVE_VIEWERCOUNT = "place.stream.live.viewerCount" //nolint:all //nolint:all -var PLACE_STREAM_BADGE_DEF = "place.stream.badge.def" //nolint:all -var PLACE_STREAM_BADGE_ISSUANCE = "place.stream.badge.issuance" //nolint:all +var PLACE_STREAM_BADGE_DEF = "place.stream.badge.def" //nolint:all +var PLACE_STREAM_BADGE_ISSUANCE = "place.stream.badge.issuance" //nolint:all // Streamplace badge types const ( @@ -32,7 +32,9 @@ const ( // GlobalBadgeIssuers is the list of DIDs authorized to issue globally-valid badges // (i.e. badges that appear in any streamer's chat once the recipient selects them). // Add the streamplace node DID here for event/contest badges. -var GlobalBadgeIssuers = []string{} //nolint:all +// We may want to limit this even further in the future to specific at URIs to avoid abuse +// but for now we'll trust these users +var GlobalBadgeIssuers = []string{"did:plc:2zmxikig2sj7gqaezl5gntae", "did:plc:k644h4rq5bjfzcetgsa6tuby", "did:plc:rbvrr34edl5ddpuwcubjiost"} //nolint:all const SelfLabelBot = "bot" //nolint:all diff --git a/pkg/spxrpc/place_stream_badge.go b/pkg/spxrpc/place_stream_badge.go index f744ddf32..5d3f14cd7 100644 --- a/pkg/spxrpc/place_stream_badge.go +++ b/pkg/spxrpc/place_stream_badge.go @@ -2,22 +2,27 @@ package spxrpc import ( "context" + "fmt" "net/http" + "time" + comatproto "github.com/bluesky-social/indigo/api/atproto" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/bluesky-social/indigo/xrpc" "github.com/labstack/echo/v4" "github.com/streamplace/oatproxy/pkg/oatproxy" "stream.place/streamplace/pkg/badges" + "stream.place/streamplace/pkg/constants" + "stream.place/streamplace/pkg/log" placestream "stream.place/streamplace/pkg/streamplace" ) func (s *Server) handlePlaceStreamBadgeGetValidBadges(ctx context.Context, streamer string) (*placestream.BadgeGetValidBadges_Output, error) { - // Get authenticated user's DID from OAuth session session, _ := oatproxy.GetOAuthSession(ctx) if session == nil { return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") } - // Get valid badges using shared badge logic badgeList, err := badges.GetValidBadges(ctx, session.DID, streamer, s.cli.BroadcasterDID(), s.model) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to get valid badges") @@ -27,3 +32,165 @@ func (s *Server) handlePlaceStreamBadgeGetValidBadges(ctx context.Context, strea Badges: badgeList, }, nil } + +func (s *Server) handlePlaceStreamBadgeGetIssuedBadges(ctx context.Context, streamer string) (*placestream.BadgeGetIssuedBadges_Output, error) { + session, _ := oatproxy.GetOAuthSession(ctx) + if session == nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") + } + userDID := session.DID + + // Compute server badge (streamer/mod/bot) for the given channel context. + var serverBadge *placestream.BadgeDefs_BadgeView + if streamer != "" { + computed, err := badges.GetValidBadges(ctx, userDID, streamer, s.cli.BroadcasterDID(), s.model) + if err != nil { + log.Error(ctx, "failed to compute server badges", "err", err) + } + // Take the first server badge (streamer, mod, or bot). These are always first in the list. + for _, b := range computed { + if b.BadgeType == constants.BadgeTypeStreamer || b.BadgeType == constants.BadgeTypeMod || b.BadgeType == constants.BadgeTypeBot { + serverBadge = b + break + } + } + } + + // Build a set of currently selected issuance URIs. + selectedURIs := map[string]bool{} + chatProfile, err := s.model.GetChatProfile(ctx, userDID) + if err != nil { + log.Error(ctx, "failed to get chat profile", "err", err) + } + if chatProfile != nil { + spcp, err := chatProfile.ToStreamplaceChatProfile() + if err == nil && spcp != nil { + for _, ref := range spcp.Selection { + if ref != nil { + selectedURIs[ref.Uri] = true + } + } + } + } + + // Fetch all issuances granted to this user. + issuances, err := s.model.GetBadgeIssuancesForRecipient(ctx, userDID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to fetch badge issuances") + } + + streamerSlot := &placestream.BadgeDefs_BadgeSlot{ + Available: []*placestream.BadgeDefs_BadgeIssuanceView{}, + } + userSlot := &placestream.BadgeDefs_BadgeSlot{ + Available: []*placestream.BadgeDefs_BadgeIssuanceView{}, + } + + for _, issuance := range issuances { + def, err := s.model.GetBadgeDefByURI(ctx, issuance.BadgeURI) + if err != nil { + log.Error(ctx, "failed to get badge def", "err", err, "uri", issuance.BadgeURI) + continue + } + if def == nil { + continue // def was deleted + } + + view := &placestream.BadgeDefs_BadgeIssuanceView{ + IssuanceUri: issuance.URI, + IssuanceCid: &issuance.CID, + BadgeType: def.BadgeType, + Issuer: issuance.RepoDID, + } + if def.Name != "" { + view.Name = &def.Name + } + if def.Description != "" { + view.Description = &def.Description + } + if def.ImageCID != "" { + imageUrl := fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@png", def.RepoDID, def.ImageCID) + view.ImageUrl = &imageUrl + } + isSelected := selectedURIs[issuance.URI] + view.Selected = &isSelected + + if badges.IsGlobalIssuer(issuance.RepoDID) { + userSlot.Available = append(userSlot.Available, view) + if isSelected && userSlot.Selected == nil { + userSlot.Selected = view + } + } else { + streamerSlot.Available = append(streamerSlot.Available, view) + if isSelected && streamerSlot.Selected == nil { + streamerSlot.Selected = view + } + } + } + + return &placestream.BadgeGetIssuedBadges_Output{ + Server: serverBadge, + Streamer: streamerSlot, + User: userSlot, + }, nil +} + +func (s *Server) handlePlaceStreamBadgeIssueBadge(ctx context.Context, input *placestream.BadgeIssueBadge_Input) (*placestream.BadgeIssueBadge_Output, error) { + session, client := oatproxy.GetOAuthSession(ctx) + if session == nil || client == nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session required") + } + + now := time.Now().UTC().Format(time.RFC3339) + + // Create the badge definition record in the issuer's repo. + def := &placestream.BadgeDef{ + LexiconTypeID: "place.stream.badge.def", + Name: input.Name, + BadgeType: input.BadgeType, + CreatedAt: now, + Description: input.Description, + } + + defInput := comatproto.RepoCreateRecord_Input{ + Collection: constants.PLACE_STREAM_BADGE_DEF, + Record: &lexutil.LexiconTypeDecoder{Val: def}, + Repo: session.DID, + } + defOutput := comatproto.RepoCreateRecord_Output{} + err := client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, defInput, &defOutput) + if err != nil { + log.Error(ctx, "failed to create badge def record", "err", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create badge def: %v", err)) + } + + // Create the badge issuance record referencing the new def. + issuance := &placestream.BadgeIssuance{ + LexiconTypeID: "place.stream.badge.issuance", + Did: input.RecipientDid, + Badge: &comatproto.RepoStrongRef{ + Uri: defOutput.Uri, + Cid: defOutput.Cid, + }, + CreatedAt: now, + } + + issuanceInput := comatproto.RepoCreateRecord_Input{ + Collection: constants.PLACE_STREAM_BADGE_ISSUANCE, + Record: &lexutil.LexiconTypeDecoder{Val: issuance}, + Repo: session.DID, + } + issuanceOutput := comatproto.RepoCreateRecord_Output{} + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, issuanceInput, &issuanceOutput) + if err != nil { + log.Error(ctx, "failed to create badge issuance record", "err", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create badge issuance: %v", err)) + } + + return &placestream.BadgeIssueBadge_Output{ + DefUri: defOutput.Uri, + DefCid: defOutput.Cid, + IssuanceUri: issuanceOutput.Uri, + IssuanceCid: issuanceOutput.Cid, + }, nil +} diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index bba8588e7..c9f091e8c 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -278,7 +278,9 @@ func (s *Server) HandleComAtprotoSyncListRepos(c echo.Context) error { } func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { + e.GET("/xrpc/place.stream.badge.getIssuedBadges", s.HandlePlaceStreamBadgeGetIssuedBadges) e.GET("/xrpc/place.stream.badge.getValidBadges", s.HandlePlaceStreamBadgeGetValidBadges) + e.POST("/xrpc/place.stream.badge.issueBadge", s.HandlePlaceStreamBadgeIssueBadge) e.POST("/xrpc/place.stream.branding.deleteBlob", s.HandlePlaceStreamBrandingDeleteBlob) e.GET("/xrpc/place.stream.branding.getBlob", s.HandlePlaceStreamBrandingGetBlob) e.GET("/xrpc/place.stream.branding.getBranding", s.HandlePlaceStreamBrandingGetBranding) @@ -317,6 +319,20 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { return nil } +func (s *Server) HandlePlaceStreamBadgeGetIssuedBadges(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBadgeGetIssuedBadges") + defer span.End() + streamer := c.QueryParam("streamer") + var out *placestream.BadgeGetIssuedBadges_Output + var handleErr error + // func (s *Server) handlePlaceStreamBadgeGetIssuedBadges(ctx context.Context,streamer string) (*placestream.BadgeGetIssuedBadges_Output, error) + out, handleErr = s.handlePlaceStreamBadgeGetIssuedBadges(ctx, streamer) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandlePlaceStreamBadgeGetValidBadges(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBadgeGetValidBadges") defer span.End() @@ -331,6 +347,24 @@ func (s *Server) HandlePlaceStreamBadgeGetValidBadges(c echo.Context) error { return c.JSON(200, out) } +func (s *Server) HandlePlaceStreamBadgeIssueBadge(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBadgeIssueBadge") + defer span.End() + + var body placestream.BadgeIssueBadge_Input + if err := c.Bind(&body); err != nil { + return err + } + var out *placestream.BadgeIssueBadge_Output + var handleErr error + // func (s *Server) handlePlaceStreamBadgeIssueBadge(ctx context.Context,body *placestream.BadgeIssueBadge_Input) (*placestream.BadgeIssueBadge_Output, error) + out, handleErr = s.handlePlaceStreamBadgeIssueBadge(ctx, &body) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandlePlaceStreamBrandingDeleteBlob(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingDeleteBlob") defer span.End() diff --git a/pkg/streamplace/badgedefs.go b/pkg/streamplace/badgedefs.go index 30444e0e6..9c1c47fb3 100644 --- a/pkg/streamplace/badgedefs.go +++ b/pkg/streamplace/badgedefs.go @@ -4,6 +4,37 @@ package streamplace +// BadgeDefs_BadgeIssuanceView is a "badgeIssuanceView" in the place.stream.badge.defs schema. +// +// A resolved view of a badge issuance, including def fields for display. +type BadgeDefs_BadgeIssuanceView struct { + BadgeType string `json:"badgeType" cborgen:"badgeType"` + // description: Description from the badge definition. + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + // imageUrl: Resolved image URL for the badge icon. + ImageUrl *string `json:"imageUrl,omitempty" cborgen:"imageUrl,omitempty"` + // issuanceCid: CID of the place.stream.badge.issuance record. + IssuanceCid *string `json:"issuanceCid,omitempty" cborgen:"issuanceCid,omitempty"` + // issuanceUri: AT URI of the place.stream.badge.issuance record. + IssuanceUri string `json:"issuanceUri" cborgen:"issuanceUri"` + // issuer: DID of the badge issuer. + Issuer string `json:"issuer" cborgen:"issuer"` + // name: Display name from the badge definition. + Name *string `json:"name,omitempty" cborgen:"name,omitempty"` + // selected: Whether this badge is currently in the user's chat profile selection. + Selected *bool `json:"selected,omitempty" cborgen:"selected,omitempty"` +} + +// BadgeDefs_BadgeSlot is a "badgeSlot" in the place.stream.badge.defs schema. +// +// A display slot containing available issuance-based badges and which one (if any) is currently selected. +type BadgeDefs_BadgeSlot struct { + // available: All badges available for this slot. + Available []*BadgeDefs_BadgeIssuanceView `json:"available" cborgen:"available"` + // selected: The currently selected badge in this slot, if any. + Selected *BadgeDefs_BadgeIssuanceView `json:"selected,omitempty" cborgen:"selected,omitempty"` +} + // BadgeDefs_BadgeView is a "badgeView" in the place.stream.badge.defs schema. // // View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. diff --git a/pkg/streamplace/badgegetIssuedBadges.go b/pkg/streamplace/badgegetIssuedBadges.go new file mode 100644 index 000000000..6d32d5ce7 --- /dev/null +++ b/pkg/streamplace/badgegetIssuedBadges.go @@ -0,0 +1,38 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.getIssuedBadges + +package streamplace + +import ( + "context" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// BadgeGetIssuedBadges_Output is the output of a place.stream.badge.getIssuedBadges call. +type BadgeGetIssuedBadges_Output struct { + // server: Computed server badge (streamer, mod, or bot) for the given streamer context. Absent if the user has no server role. + Server *BadgeDefs_BadgeView `json:"server,omitempty" cborgen:"server,omitempty"` + // streamer: Streamer-issued badges (e.g. VIP) available to the user. + Streamer *BadgeDefs_BadgeSlot `json:"streamer" cborgen:"streamer"` + // user: Globally-issued cosmetic badges (e.g. event) available to the user. + User *BadgeDefs_BadgeSlot `json:"user" cborgen:"user"` +} + +// BadgeGetIssuedBadges calls the XRPC method "place.stream.badge.getIssuedBadges". +// +// streamer: DID of the streamer channel to compute the server badge against. +func BadgeGetIssuedBadges(ctx context.Context, c lexutil.LexClient, streamer string) (*BadgeGetIssuedBadges_Output, error) { + var out BadgeGetIssuedBadges_Output + + params := map[string]interface{}{} + if streamer != "" { + params["streamer"] = streamer + } + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.badge.getIssuedBadges", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/pkg/streamplace/badgeissueBadge.go b/pkg/streamplace/badgeissueBadge.go new file mode 100644 index 000000000..46786d03a --- /dev/null +++ b/pkg/streamplace/badgeissueBadge.go @@ -0,0 +1,45 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.issueBadge + +package streamplace + +import ( + "context" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// BadgeIssueBadge_Input is the input argument to a place.stream.badge.issueBadge call. +type BadgeIssueBadge_Input struct { + // badgeType: The category of badge being issued. + BadgeType string `json:"badgeType" cborgen:"badgeType"` + // description: Optional description of the badge. + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + // name: Display name for the badge. + Name string `json:"name" cborgen:"name"` + // recipientDid: The DID of the user receiving the badge. + RecipientDid string `json:"recipientDid" cborgen:"recipientDid"` +} + +// BadgeIssueBadge_Output is the output of a place.stream.badge.issueBadge call. +type BadgeIssueBadge_Output struct { + // defCid: CID of the created badge definition record. + DefCid string `json:"defCid" cborgen:"defCid"` + // defUri: AT URI of the created badge definition record. + DefUri string `json:"defUri" cborgen:"defUri"` + // issuanceCid: CID of the created badge issuance record. + IssuanceCid string `json:"issuanceCid" cborgen:"issuanceCid"` + // issuanceUri: AT URI of the created badge issuance record. + IssuanceUri string `json:"issuanceUri" cborgen:"issuanceUri"` +} + +// BadgeIssueBadge calls the XRPC method "place.stream.badge.issueBadge". +func BadgeIssueBadge(ctx context.Context, c lexutil.LexClient, input *BadgeIssueBadge_Input) (*BadgeIssueBadge_Output, error) { + var out BadgeIssueBadge_Output + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.badge.issueBadge", nil, input, &out); err != nil { + return nil, err + } + + return &out, nil +}