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
+}