Live video on the AT Protocol

Merge pull request #910 from streamplace/natb/badges

feat: mod, VIP badges

authored by

natalie and committed by
GitHub
cccc4fce 2629221f

+1173 -60
js/components/assets/badges/live.png

This is a binary file and will not be displayed.

js/components/assets/badges/live_2x.png

This is a binary file and will not be displayed.

js/components/assets/badges/mod.png

This is a binary file and will not be displayed.

js/components/assets/badges/mod_2x.png

This is a binary file and will not be displayed.

js/components/assets/badges/vip.png

This is a binary file and will not be displayed.

js/components/assets/badges/vip_2x.png

This is a binary file and will not be displayed.

+45
js/components/src/components/chat/badge.tsx
··· 1 + import { Image } from "react-native"; 2 + import { ChatMessageViewHydrated } from "streamplace"; 3 + 4 + export const BADGE_IMAGES: Record<string, ReturnType<typeof require>> = { 5 + "place.stream.badge.defs#mod": require("../../../assets/badges/mod_2x.png"), 6 + "place.stream.badge.defs#streamer": require("../../../assets/badges/live_2x.png"), 7 + "place.stream.badge.defs#vip": require("../../../assets/badges/vip_2x.png"), 8 + }; 9 + 10 + export const Badge = ({ 11 + badgeType, 12 + size = 18, 13 + }: { 14 + badgeType: string; 15 + size?: number; 16 + }) => { 17 + const source = BADGE_IMAGES[badgeType]; 18 + if (!source) return null; 19 + return ( 20 + <Image 21 + source={source} 22 + style={{ 23 + height: size, 24 + width: size, 25 + marginBottom: -size / 5, 26 + marginRight: 2, 27 + }} 28 + /> 29 + ); 30 + }; 31 + 32 + export const BadgeDisplayRow = ({ 33 + badges, 34 + }: { 35 + badges: ChatMessageViewHydrated["badges"]; 36 + }) => { 37 + if (!badges?.length) return null; 38 + return ( 39 + <> 40 + {badges.map((badge, index) => ( 41 + <Badge key={index} badgeType={badge.badgeType} /> 42 + ))} 43 + </> 44 + ); 45 + };
+25 -21
js/components/src/components/chat/chat-message.tsx
··· 25 25 26 26 import { useLivestreamStore } from "../../livestream-store"; 27 27 import { Text } from "../ui/text"; 28 + import { BadgeDisplayRow } from "./badge"; 29 + import { UserProfileCard } from "./user-profile-card"; 28 30 29 31 const getRgbColor = (color?: { red: number; green: number; blue: number }) => 30 32 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : colors.gray[500]; ··· 152 154 </Text> 153 155 </View> 154 156 )} 155 - <View 156 - style={[ 157 - gap.all[2], 158 - layout.flex.row, 159 - { minWidth: 0, maxWidth: "100%" }, 160 - ]} 161 - > 157 + <View style={[layout.flex.row, { minWidth: 0, maxWidth: "100%" }]}> 162 158 {showTime && ( 163 159 <Text 164 160 style={{ 165 161 fontVariant: ["tabular-nums"], 166 162 color: colors.gray[400], 163 + width: 44, 164 + marginRight: 8, 167 165 }} 168 166 > 169 167 {formatTime(item.record.createdAt)} 170 168 </Text> 171 169 )} 172 - <Text 173 - weight="bold" 174 - color="default" 175 - style={[flex.shrink[1], { minWidth: 0, overflow: "hidden" }]} 176 - > 177 - <Text 178 - style={[ 179 - { 180 - cursor: "pointer", 181 - color: getRgbColor(item.chatProfile?.color), 182 - }, 183 - ]} 170 + <Text style={[flex.shrink[1], { minWidth: 0 }]}> 171 + <UserProfileCard 172 + uri={item.uri} 173 + author={item.author} 174 + badges={item.badges} 184 175 > 185 - {formatHandleWithAt(item.author)} 176 + <Text> 177 + <BadgeDisplayRow badges={item.badges} /> 178 + <Text 179 + weight="bold" 180 + style={{ 181 + cursor: "pointer", 182 + color: getRgbColor(item.chatProfile?.color), 183 + }} 184 + > 185 + {formatHandleWithAt(item.author)} 186 + </Text> 187 + </Text> 188 + </UserProfileCard> 189 + <Text weight="bold" color="default"> 190 + {": "} 186 191 </Text> 187 - :{" "} 188 192 <RichTextMessage 189 193 text={item.record.text} 190 194 facets={item.record.facets || []}
+30 -27
js/components/src/components/chat/chat.tsx
··· 26 26 import { bg, flex, layout, mr, px, py } from "../../lib/theme/atoms"; 27 27 import { RenderChatMessage } from "./chat-message"; 28 28 import { ModView } from "./mod-view"; 29 + import { ProfileCardProvider } from "./user-profile-card"; 29 30 30 31 function RightAction(prog: SharedValue<number>, drag: SharedValue<number>) { 31 32 const styleAnimation = useAnimatedStyle(() => { ··· 315 316 }, 316 317 ].concat(propsStyle || [])} 317 318 > 318 - <FlatList 319 - ref={flatListRef} 320 - style={[ 321 - flex.grow[1], 322 - flex.shrink[1], 323 - { minWidth: 0, maxWidth: "100%" }, 324 - ]} 325 - data={chat.slice(0, shownMessages)} 326 - inverted={true} 327 - keyExtractor={keyExtractor} 328 - renderItem={({ item }) => ( 329 - <ChatLine 330 - item={item} 331 - isHovered={hoveredMessageUri === item.uri} 332 - onHoverIn={() => handleHoverIn(item.uri)} 333 - onHoverOut={handleHoverOut} 334 - hoverTimeoutRef={hoverTimeoutRef} 335 - /> 336 - )} 337 - removeClippedSubviews={true} 338 - maxToRenderPerBatch={10} 339 - initialNumToRender={10} 340 - updateCellsBatchingPeriod={50} 341 - onScroll={handleScroll} 342 - scrollEventThrottle={16} 343 - nestedScrollEnabled={true} 344 - /> 319 + <ProfileCardProvider> 320 + <FlatList 321 + ref={flatListRef} 322 + style={[ 323 + flex.grow[1], 324 + flex.shrink[1], 325 + { minWidth: 0, maxWidth: "100%" }, 326 + ]} 327 + data={chat.slice(0, shownMessages)} 328 + inverted={true} 329 + keyExtractor={keyExtractor} 330 + renderItem={({ item }) => ( 331 + <ChatLine 332 + item={item} 333 + isHovered={hoveredMessageUri === item.uri} 334 + onHoverIn={() => handleHoverIn(item.uri)} 335 + onHoverOut={handleHoverOut} 336 + hoverTimeoutRef={hoverTimeoutRef} 337 + /> 338 + )} 339 + removeClippedSubviews={true} 340 + maxToRenderPerBatch={10} 341 + initialNumToRender={10} 342 + updateCellsBatchingPeriod={50} 343 + onScroll={handleScroll} 344 + scrollEventThrottle={16} 345 + nestedScrollEnabled={true} 346 + /> 347 + </ProfileCardProvider> 345 348 <Reanimated.View 346 349 style={[ 347 350 {
+275
js/components/src/components/chat/user-profile-card.tsx
··· 1 + import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 2 + import { TriggerRef } from "@rn-primitives/dropdown-menu"; 3 + import { 4 + createContext, 5 + useCallback, 6 + useContext, 7 + useEffect, 8 + useMemo, 9 + useRef, 10 + useState, 11 + } from "react"; 12 + import { Image, Platform, Pressable, View } from "react-native"; 13 + import { ChatMessageViewHydrated } from "streamplace"; 14 + import { useAvatars } from "../../hooks/useAvatars"; 15 + import { useLivestreamStore } from "../../livestream-store"; 16 + import { useUrl } from "../../streamplace-store"; 17 + import { useTheme } from "../../ui"; 18 + import { formatHandleWithAt } from "../../utils/format-handle"; 19 + import { 20 + DropdownMenu, 21 + DropdownMenuContent, 22 + DropdownMenuTrigger, 23 + } from "../ui/dropdown"; 24 + import { Text } from "../ui/text"; 25 + import { Badge } from "./badge"; 26 + 27 + interface BadgeMeta { 28 + label: string; 29 + description: string; 30 + issuedBy?: string; 31 + } 32 + 33 + const BADGE_META: Record<string, BadgeMeta> = { 34 + "place.stream.badge.defs#mod": { 35 + label: "Moderator", 36 + description: "This user is a moderator.", 37 + issuedBy: "{issuer} for {streamer}", 38 + }, 39 + "place.stream.badge.defs#streamer": { 40 + label: "Streamer", 41 + description: "This user is the streamer.", 42 + }, 43 + "place.stream.badge.defs#vip": { 44 + label: "VIP", 45 + description: "This user is a very important person.", 46 + }, 47 + }; 48 + 49 + interface OpenCardContextValue { 50 + openUri: string | null; 51 + setOpenUri: (uri: string | null) => void; 52 + } 53 + 54 + const OpenCardContext = createContext<OpenCardContextValue>({ 55 + openUri: null, 56 + setOpenUri: () => {}, 57 + }); 58 + 59 + export const ProfileCardProvider = ({ 60 + children, 61 + }: { 62 + children: React.ReactNode; 63 + }) => { 64 + const [openUri, setOpenUri] = useState<string | null>(null); 65 + return ( 66 + <OpenCardContext.Provider value={{ openUri, setOpenUri }}> 67 + {children} 68 + </OpenCardContext.Provider> 69 + ); 70 + }; 71 + 72 + const BadgeRow = ({ 73 + badge, 74 + serviceDid, 75 + }: { 76 + badge: NonNullable<ChatMessageViewHydrated["badges"]>[number]; 77 + serviceDid: string; 78 + }) => { 79 + const streamer = useLivestreamStore((x) => x.livestream?.author); 80 + const isServiceIssued = badge.issuer === serviceDid; 81 + const issuerDids = useMemo( 82 + () => (isServiceIssued ? [] : [badge.issuer]), 83 + [isServiceIssued, badge.issuer], 84 + ); 85 + const issuerProfiles = useAvatars(issuerDids); 86 + const meta = BADGE_META[badge.badgeType]; 87 + 88 + if (!meta) return null; 89 + 90 + let issuerLabel = isServiceIssued 91 + ? "Streamplace" 92 + : issuerProfiles[badge.issuer]?.handle 93 + ? `@${issuerProfiles[badge.issuer].handle}` 94 + : badge.issuer; 95 + if (meta.issuedBy) { 96 + issuerLabel = meta.issuedBy 97 + .replace("{issuer}", issuerLabel) 98 + .replace( 99 + "{streamer}", 100 + streamer?.handle ? formatHandleWithAt(streamer) : "the streamer", 101 + ); 102 + } 103 + 104 + return ( 105 + <View 106 + style={{ 107 + flexDirection: "row", 108 + alignItems: "center", 109 + gap: 12, 110 + paddingLeft: 6, 111 + }} 112 + > 113 + <Badge badgeType={badge.badgeType} size={32} /> 114 + <View style={{ flex: 1, gap: 2 }}> 115 + <Text size="xs">{meta.label}</Text> 116 + <Text size="xs" color="muted"> 117 + Issued by {issuerLabel} 118 + </Text> 119 + <Text size="xs" color="muted"> 120 + {meta.description} 121 + </Text> 122 + </View> 123 + </View> 124 + ); 125 + }; 126 + 127 + export const UserProfileCard = ({ 128 + uri, 129 + author, 130 + badges, 131 + children, 132 + }: { 133 + uri: string; 134 + author: ProfileViewBasic; 135 + badges: ChatMessageViewHydrated["badges"]; 136 + children: React.ReactNode; 137 + }) => { 138 + const { theme } = useTheme(); 139 + const nodeUrl = useUrl(); 140 + const serviceDid = nodeUrl 141 + ? `did:web:${nodeUrl.replace(/^https?:\/\//, "")}` 142 + : null; 143 + 144 + const { openUri, setOpenUri } = useContext(OpenCardContext); 145 + const isOpen = openUri === uri; 146 + const thisRef = useRef<TriggerRef>(null); 147 + const [hovered, setHovered] = useState(false); 148 + 149 + const profiles = useAvatars(author.did ? [author.did] : []); 150 + const profile = profiles[author.did]; 151 + 152 + useEffect(() => { 153 + isOpen ? thisRef.current?.open() : thisRef.current?.close(); 154 + }, [isOpen]); 155 + 156 + const onOpenChange = useCallback( 157 + (open: boolean) => { 158 + setOpenUri(open ? uri : null); 159 + }, 160 + [uri, setOpenUri], 161 + ); 162 + 163 + const serviceBadges = useMemo( 164 + () => badges?.filter((b) => serviceDid && b.issuer === serviceDid) ?? [], 165 + [badges, serviceDid], 166 + ); 167 + 168 + return ( 169 + <DropdownMenu onOpenChange={onOpenChange}> 170 + <DropdownMenuTrigger ref={thisRef} asChild> 171 + <Pressable 172 + onPress={() => {}} 173 + {...(Platform.OS === "web" 174 + ? { 175 + onHoverIn: () => setHovered(true), 176 + onHoverOut: () => setHovered(false), 177 + } 178 + : {})} 179 + style={{ 180 + paddingHorizontal: 3, 181 + flexDirection: "row", 182 + gap: 4, 183 + marginLeft: -3, 184 + paddingLeft: 3, 185 + marginRight: -2, 186 + ...(Platform.OS === "web" && hovered 187 + ? { backgroundColor: "rgba(255,255,255,0.15)", borderRadius: 6 } 188 + : {}), 189 + }} 190 + > 191 + {children} 192 + </Pressable> 193 + </DropdownMenuTrigger> 194 + <DropdownMenuContent style={{ minWidth: 280, maxWidth: 320 }}> 195 + <View> 196 + {profile?.banner ? ( 197 + <Image 198 + source={{ uri: profile.banner }} 199 + style={{ 200 + width: "100%", 201 + height: 80, 202 + borderRadius: theme.borderRadius.md, 203 + }} 204 + /> 205 + ) : ( 206 + <View 207 + style={{ 208 + width: "100%", 209 + height: 80, 210 + borderRadius: theme.borderRadius.md, 211 + backgroundColor: theme.colors.muted, 212 + }} 213 + /> 214 + )} 215 + <View 216 + style={{ 217 + flexDirection: "row", 218 + alignItems: "flex-end", 219 + marginTop: -24, 220 + paddingHorizontal: 12, 221 + }} 222 + > 223 + {profile?.avatar ? ( 224 + <Image 225 + source={{ uri: profile.avatar }} 226 + style={{ 227 + width: 48, 228 + height: 48, 229 + borderRadius: 24, 230 + borderWidth: 2, 231 + borderColor: theme.colors.card, 232 + }} 233 + /> 234 + ) : ( 235 + <View 236 + style={{ 237 + width: 48, 238 + height: 48, 239 + borderRadius: 24, 240 + backgroundColor: theme.colors.mutedForeground, 241 + borderWidth: 2, 242 + borderColor: theme.colors.card, 243 + }} 244 + /> 245 + )} 246 + </View> 247 + <View style={{ paddingHorizontal: 12 }}> 248 + <Text>@{author.handle}</Text> 249 + {profile?.description ? ( 250 + <Text size="sm" color="muted" numberOfLines={4}> 251 + {profile.description} 252 + </Text> 253 + ) : null} 254 + </View> 255 + {serviceBadges.length > 0 && serviceDid ? ( 256 + <View 257 + style={{ 258 + marginTop: 12, 259 + paddingHorizontal: 12, 260 + paddingBottom: 8, 261 + borderTopWidth: 1, 262 + borderTopColor: theme.colors.border, 263 + paddingTop: 8, 264 + }} 265 + > 266 + {serviceBadges.map((badge, i) => ( 267 + <BadgeRow key={i} badge={badge} serviceDid={serviceDid} /> 268 + ))} 269 + </View> 270 + ) : null} 271 + </View> 272 + </DropdownMenuContent> 273 + </DropdownMenu> 274 + ); 275 + };
+1
js/components/src/livestream-store/websocket-consumer.tsx
··· 80 80 chatProfile: (message as any).chatProfile, 81 81 replyTo: (message as any).replyTo, 82 82 deleted: message.deleted, 83 + badges: message.badges, 83 84 }; 84 85 state = reduceChat(state, [hydrated], [], []); 85 86 } else if (PlaceStreamSegment.isRecord(message)) {
+4
js/docs/astro.config.mjs
··· 81 81 autogenerate: { directory: "guides/installing" }, 82 82 }, 83 83 { 84 + label: "Features (Dev)", 85 + autogenerate: { directory: "features-dev" }, 86 + }, 87 + { 84 88 label: "Video Metadata", 85 89 autogenerate: { directory: "video-metadata" }, 86 90 },
+39
js/docs/src/content/docs/features-dev/badges.md
··· 1 + --- 2 + title: badges system 3 + description: user badges for chat messages 4 + --- 5 + 6 + ## Overview 7 + 8 + Badges appear next to usernames in chat messages. they're small icons that indicate status (streamer, mod, vip, etc.). There will be max 3 badges shown at once. One of the badges is server-based (e.g. streamer, mod, node staff badge), but the other two can be selected from a pool of cosmetic badges (such as subscription badges, event badges et al.). These cosmetic badges are cryptographically signed by the issuing party, and all the user needs to do is apply them to their chat profile. Note that certain badges may appear/disappear based on the current streamer's chat tktk. 9 + 10 + ## Lexicon schemas 11 + 12 + We have three relevant lexicons. 13 + 14 + 1. **`place.stream.badge.defs`** - badge definitions and view model 15 + 16 + - defines known badge types: `mod`, `streamer`, `vip` 17 + - `badgeView` object: `{badgeType, issuer, recipient, signature?}` 18 + 19 + 2. **`place.stream.badge.issuance`** - record of badge grant 20 + 21 + - stored as atproto record (key: tid) 22 + - issued by streamer or other authorized entity 23 + - example: streamer issues vip badge to a user 24 + 25 + 3. **`place.stream.badge.display`** - user's badge selection 26 + - user-controlled record defining which badges to show 27 + - array of up to 3 `badgeSelection` objects 28 + - first slot server-controlled (mod/streamer/staff), second slot is streamer-specific (vip, subscription), third slot is user-set (event, staff2, node subscription, etc.) 29 + 30 + :::note 31 + This may get changed to be in the user's chat profile? Maybe we could have a "main" chat profile and a streamer-specific profile? 32 + ::: 33 + 34 + ## TODO 35 + 36 + - [ ] implement cryptographic signatures for badge issuance 37 + - [ ] implement badge issuance ui (streamer grants vip badges) 38 + - [ ] implement badge selection ui (users choose which badges to display) 39 + - [ ] add more badge types (subscriber, founder, staff, etc)
+108
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-defs.md
··· 1 + --- 2 + title: place.stream.badge.defs 3 + description: Reference for the place.stream.badge.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="badgeview"></a> 11 + 12 + ### `badgeView` 13 + 14 + **Type:** `object` 15 + 16 + 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. 17 + 18 + **Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | 22 + | `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#streamer` | 23 + | `issuer` | `string` | ✅ | DID of the badge issuer. | Format: `did` | 24 + | `recipient` | `string` | ✅ | DID of the badge recipient. | Format: `did` | 25 + | `signature` | `string` | ❌ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | 26 + 27 + --- 28 + 29 + <a name="mod"></a> 30 + 31 + ### `mod` 32 + 33 + **Type:** `token` 34 + 35 + This user is a moderator. Displayed with a sword icon. 36 + 37 + --- 38 + 39 + <a name="streamer"></a> 40 + 41 + ### `streamer` 42 + 43 + **Type:** `token` 44 + 45 + This user is the streamer. Displayed with a star icon. 46 + 47 + --- 48 + 49 + <a name="vip"></a> 50 + 51 + ### `vip` 52 + 53 + **Type:** `token` 54 + 55 + This user is a very important person. 56 + 57 + --- 58 + 59 + ## Lexicon Source 60 + 61 + ```json 62 + { 63 + "lexicon": 1, 64 + "id": "place.stream.badge.defs", 65 + "defs": { 66 + "badgeView": { 67 + "type": "object", 68 + "required": ["badgeType", "issuer", "recipient"], 69 + "description": "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.", 70 + "properties": { 71 + "badgeType": { 72 + "type": "string", 73 + "knownValues": [ 74 + "place.stream.badge.defs#mod", 75 + "place.stream.badge.defs#streamer" 76 + ] 77 + }, 78 + "issuer": { 79 + "type": "string", 80 + "format": "did", 81 + "description": "DID of the badge issuer." 82 + }, 83 + "recipient": { 84 + "type": "string", 85 + "format": "did", 86 + "description": "DID of the badge recipient." 87 + }, 88 + "signature": { 89 + "type": "string", 90 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 91 + } 92 + } 93 + }, 94 + "mod": { 95 + "type": "token", 96 + "description": "This user is a moderator. Displayed with a sword icon." 97 + }, 98 + "streamer": { 99 + "type": "token", 100 + "description": "This user is the streamer. Displayed with a star icon." 101 + }, 102 + "vip": { 103 + "type": "token", 104 + "description": "This user is a very important person." 105 + } 106 + } 107 + } 108 + ```
+76
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getvalidbadges.md
··· 1 + --- 2 + title: place.stream.badge.getValidBadges 3 + description: Reference for the place.stream.badge.getValidBadges lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `query` 15 + 16 + Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat 17 + 18 + **Parameters:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ---------- | -------- | ----- | ------------------------------------------------------------------------ | ------------- | 22 + | `streamer` | `string` | ❌ | Optional DID of the streamer for context-specific badges (mod, vip, etc) | Format: `did` | 23 + 24 + **Output:** 25 + 26 + - **Encoding:** `application/json` 27 + - **Schema:** 28 + 29 + **Schema Type:** `object` 30 + 31 + | Name | Type | Req'd | Description | Constraints | 32 + | -------- | ------------------------------------------------------------------------------------------------ | ----- | ----------- | ----------- | 33 + | `badges` | Array of [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | ✅ | | | 34 + 35 + --- 36 + 37 + ## Lexicon Source 38 + 39 + ```json 40 + { 41 + "lexicon": 1, 42 + "id": "place.stream.badge.getValidBadges", 43 + "defs": { 44 + "main": { 45 + "type": "query", 46 + "description": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", 47 + "parameters": { 48 + "type": "params", 49 + "properties": { 50 + "streamer": { 51 + "type": "string", 52 + "format": "did", 53 + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)" 54 + } 55 + } 56 + }, 57 + "output": { 58 + "encoding": "application/json", 59 + "schema": { 60 + "type": "object", 61 + "required": ["badges"], 62 + "properties": { 63 + "badges": { 64 + "type": "array", 65 + "items": { 66 + "type": "ref", 67 + "ref": "place.stream.badge.defs#badgeView" 68 + } 69 + } 70 + } 71 + } 72 + } 73 + } 74 + } 75 + } 76 + ```
+20 -10
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
··· 15 15 16 16 **Properties:** 17 17 18 - | Name | Type | Req'd | Description | Constraints | 19 - | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------------------------------------------------------------- | ------------------ | 20 - | `uri` | `string` | ✅ | | Format: `at-uri` | 21 - | `cid` | `string` | ✅ | | Format: `cid` | 22 - | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | | 23 - | `record` | `unknown` | ✅ | | | 24 - | `indexedAt` | `string` | ✅ | | Format: `datetime` | 25 - | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | 26 - | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | ❌ | | | 27 - | `deleted` | `boolean` | ❌ | If true, this message has been deleted or labeled and should be cleared from the cache | | 18 + | Name | Type | Req'd | Description | Constraints | 19 + | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | 20 + | `uri` | `string` | ✅ | | Format: `at-uri` | 21 + | `cid` | `string` | ✅ | | Format: `cid` | 22 + | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | | 23 + | `record` | `unknown` | ✅ | | | 24 + | `indexedAt` | `string` | ✅ | | Format: `datetime` | 25 + | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | 26 + | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | ❌ | | | 27 + | `deleted` | `boolean` | ❌ | If true, this message has been deleted or labeled and should be cleared from the cache | | 28 + | `badges` | Array of [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | ❌ | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. | Max Items: 3 | 28 29 29 30 --- 30 31 ··· 69 70 "deleted": { 70 71 "type": "boolean", 71 72 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 73 + }, 74 + "badges": { 75 + "type": "array", 76 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", 77 + "maxLength": 3, 78 + "items": { 79 + "type": "ref", 80 + "ref": "place.stream.badge.defs#badgeView" 81 + } 72 82 } 73 83 } 74 84 }
+65
js/docs/src/content/docs/lex-reference/openapi.json
··· 1870 1870 } 1871 1871 } 1872 1872 }, 1873 + "/xrpc/place.stream.badge.getValidBadges": { 1874 + "get": { 1875 + "summary": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", 1876 + "operationId": "place.stream.badge.getValidBadges", 1877 + "tags": ["place.stream.badge"], 1878 + "responses": { 1879 + "200": { 1880 + "description": "Success", 1881 + "content": { 1882 + "application/json": { 1883 + "schema": { 1884 + "type": "object", 1885 + "properties": { 1886 + "badges": { 1887 + "type": "array", 1888 + "items": { 1889 + "$ref": "#/components/schemas/place.stream.badge.defs_badgeView" 1890 + } 1891 + } 1892 + }, 1893 + "required": ["badges"] 1894 + } 1895 + } 1896 + } 1897 + } 1898 + }, 1899 + "parameters": [ 1900 + { 1901 + "name": "streamer", 1902 + "in": "query", 1903 + "required": false, 1904 + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)", 1905 + "schema": { 1906 + "type": "string", 1907 + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)", 1908 + "format": "did" 1909 + } 1910 + } 1911 + ] 1912 + } 1913 + }, 1873 1914 "/xrpc/com.atproto.sync.getRecord": { 1874 1915 "get": { 1875 1916 "summary": "Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.", ··· 3428 3469 } 3429 3470 }, 3430 3471 "required": ["key", "mimeType"] 3472 + }, 3473 + "place.stream.badge.defs_badgeView": { 3474 + "type": "object", 3475 + "description": "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.", 3476 + "properties": { 3477 + "badgeType": { 3478 + "type": "string" 3479 + }, 3480 + "issuer": { 3481 + "type": "string", 3482 + "description": "DID of the badge issuer.", 3483 + "format": "did" 3484 + }, 3485 + "recipient": { 3486 + "type": "string", 3487 + "description": "DID of the badge recipient.", 3488 + "format": "did" 3489 + }, 3490 + "signature": { 3491 + "type": "string", 3492 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 3493 + } 3494 + }, 3495 + "required": ["badgeType", "issuer", "recipient"] 3431 3496 }, 3432 3497 "com.atproto.sync.listRepos_repo": { 3433 3498 "type": "object",
+46
lexicons/place/stream/badge/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.defs", 4 + "defs": { 5 + "badgeView": { 6 + "type": "object", 7 + "required": ["badgeType", "issuer", "recipient"], 8 + "description": "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.", 9 + "properties": { 10 + "badgeType": { 11 + "type": "string", 12 + "knownValues": [ 13 + "place.stream.badge.defs#mod", 14 + "place.stream.badge.defs#streamer" 15 + ] 16 + }, 17 + "issuer": { 18 + "type": "string", 19 + "format": "did", 20 + "description": "DID of the badge issuer." 21 + }, 22 + "recipient": { 23 + "type": "string", 24 + "format": "did", 25 + "description": "DID of the badge recipient." 26 + }, 27 + "signature": { 28 + "type": "string", 29 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 30 + } 31 + } 32 + }, 33 + "mod": { 34 + "type": "token", 35 + "description": "This user is a moderator. Displayed with a sword icon." 36 + }, 37 + "streamer": { 38 + "type": "token", 39 + "description": "This user is the streamer. Displayed with a star icon." 40 + }, 41 + "vip": { 42 + "type": "token", 43 + "description": "This user is a very important person." 44 + } 45 + } 46 + }
+36
lexicons/place/stream/badge/getValidBadges.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.getValidBadges", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "streamer": { 12 + "type": "string", 13 + "format": "did", 14 + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)" 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["badges"], 23 + "properties": { 24 + "badges": { 25 + "type": "array", 26 + "items": { 27 + "type": "ref", 28 + "ref": "place.stream.badge.defs#badgeView" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+9
lexicons/place/stream/chat/defs.json
··· 25 25 "deleted": { 26 26 "type": "boolean", 27 27 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 28 + }, 29 + "badges": { 30 + "type": "array", 31 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", 32 + "maxLength": 3, 33 + "items": { 34 + "type": "ref", 35 + "ref": "place.stream.badge.defs#badgeView" 36 + } 28 37 } 29 38 } 30 39 }
+9
pkg/api/websocket.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "net" 7 8 "net/http" 8 9 "time" ··· 12 13 "github.com/gorilla/websocket" 13 14 "github.com/julienschmidt/httprouter" 14 15 16 + "stream.place/streamplace/pkg/atproto" 15 17 apierrors "stream.place/streamplace/pkg/errors" 16 18 "stream.place/streamplace/pkg/log" 17 19 "stream.place/streamplace/pkg/renditions" ··· 237 239 log.Error(ctx, "could not get chat messages", "error", err) 238 240 return 239 241 } 242 + 243 + // Add mod badges to messages 244 + issuerDID := fmt.Sprintf("did:web:%s", a.CLI.BroadcasterHost) 240 245 for _, message := range messages { 246 + err := atproto.AddModBadgeIfApplicable(ctx, message, repoDID, issuerDID, a.Model) 247 + if err != nil { 248 + log.Error(ctx, "failed to add mod badge to message", "error", err) 249 + } 241 250 initialBurst <- message 242 251 } 243 252 }()
+39
pkg/atproto/badges.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "stream.place/streamplace/pkg/badges" 8 + "stream.place/streamplace/pkg/model" 9 + "stream.place/streamplace/pkg/streamplace" 10 + ) 11 + 12 + // AddModBadgeIfApplicable checks if a message author has mod permissions for the streamer 13 + // and adds a mod or streamer badge as the first badge (server-controlled). 14 + // - If the author is the streamer, adds a "streamer" badge 15 + // - If the author has moderation permissions, adds a "mod" badge 16 + func AddModBadgeIfApplicable(ctx context.Context, message *streamplace.ChatDefs_MessageView, streamerDID string, issuerDID string, m model.Model) error { 17 + if message == nil { 18 + return fmt.Errorf("message is nil") 19 + } 20 + 21 + authorDID := message.Author.Did 22 + 23 + // Get valid badges for this user 24 + validBadges, err := badges.GetValidBadges(ctx, authorDID, streamerDID, issuerDID, m) 25 + if err != nil { 26 + return err 27 + } 28 + 29 + // Prepend server-controlled badges (first badge slot is reserved for server) 30 + if len(validBadges) > 0 { 31 + if message.Badges == nil { 32 + message.Badges = validBadges 33 + } else { 34 + message.Badges = append(validBadges, message.Badges...) 35 + } 36 + } 37 + 38 + return nil 39 + }
+101
pkg/atproto/badges_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/util" 11 + "github.com/stretchr/testify/require" 12 + "stream.place/streamplace/pkg/constants" 13 + "stream.place/streamplace/pkg/model" 14 + "stream.place/streamplace/pkg/streamplace" 15 + ) 16 + 17 + func TestAddModBadge(t *testing.T) { 18 + ctx := context.Background() 19 + 20 + mod, err := model.MakeDB(":memory:") 21 + require.NoError(t, err) 22 + 23 + streamerDID := "did:plc:streamer" 24 + moderatorDID := "did:plc:moderator" 25 + issuerDID := "did:web:example.com" 26 + 27 + // Create a chat message 28 + message := &streamplace.ChatDefs_MessageView{ 29 + LexiconTypeID: "place.stream.chat.defs#messageView", 30 + Uri: "at://test/place.stream.chat.message/123", 31 + Cid: "test-cid", 32 + Author: &bsky.ActorDefs_ProfileViewBasic{ 33 + Did: moderatorDID, 34 + Handle: "moderator.test", 35 + }, 36 + IndexedAt: "2024-01-01T00:00:00Z", 37 + } 38 + 39 + t.Run("no badge when user is not a moderator", func(t *testing.T) { 40 + msg := *message // copy 41 + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 42 + require.NoError(t, err) 43 + require.Nil(t, msg.Badges, "should not have badges when user is not a moderator") 44 + }) 45 + 46 + t.Run("adds streamer badge when user is the streamer", func(t *testing.T) { 47 + msg := *message // copy 48 + msg.Author = &bsky.ActorDefs_ProfileViewBasic{ 49 + Did: streamerDID, 50 + Handle: "streamer.test", 51 + } 52 + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 53 + require.NoError(t, err) 54 + require.Len(t, msg.Badges, 1, "should have 1 badge when user is the streamer") 55 + require.Equal(t, constants.BadgeTypeStreamer, msg.Badges[0].BadgeType) 56 + require.Equal(t, issuerDID, msg.Badges[0].Issuer) 57 + require.Equal(t, streamerDID, msg.Badges[0].Recipient) 58 + }) 59 + 60 + t.Run("adds mod badge when user has moderation permissions", func(t *testing.T) { 61 + // Grant moderation permissions to the moderator 62 + perm := &streamplace.ModerationPermission{ 63 + LexiconTypeID: "place.stream.moderation.permission", 64 + Moderator: moderatorDID, 65 + Permissions: []string{"ban", "hide"}, 66 + CreatedAt: time.Now().Format(util.ISO8601), 67 + } 68 + aturi, err := syntax.ParseATURI("at://" + streamerDID + "/place.stream.moderation.permission/test123") 69 + require.NoError(t, err) 70 + 71 + // Sync the permission to the model 72 + err = mod.CreateModerationDelegation(ctx, perm, aturi) 73 + require.NoError(t, err) 74 + 75 + msg := *message // copy 76 + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 77 + require.NoError(t, err) 78 + require.Len(t, msg.Badges, 1, "should have 1 badge when user is a moderator") 79 + require.Equal(t, constants.BadgeTypeMod, msg.Badges[0].BadgeType) 80 + require.Equal(t, issuerDID, msg.Badges[0].Issuer) 81 + require.Equal(t, moderatorDID, msg.Badges[0].Recipient) 82 + }) 83 + 84 + t.Run("prepends mod badge to existing badges", func(t *testing.T) { 85 + // Create message with existing user-settable badge 86 + msg := *message // copy 87 + msg.Badges = []*streamplace.BadgeDefs_BadgeView{ 88 + { 89 + BadgeType: constants.BadgeTypeVIP, 90 + Issuer: "did:web:other.com", 91 + Recipient: moderatorDID, 92 + }, 93 + } 94 + 95 + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 96 + require.NoError(t, err) 97 + require.Len(t, msg.Badges, 2, "should have 2 badges") 98 + require.Equal(t, constants.BadgeTypeMod, msg.Badges[0].BadgeType, "mod badge should be first") 99 + require.Equal(t, constants.BadgeTypeVIP, msg.Badges[1].BadgeType, "vip badge should be second") 100 + }) 101 + }
+8
pkg/atproto/sync.go
··· 150 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 151 return nil 152 152 } 153 + 154 + // Add mod badge if the author is a moderator 155 + issuerDID := fmt.Sprintf("did:web:%s", atsync.CLI.BroadcasterHost) 156 + err = AddModBadgeIfApplicable(ctx, scm, rec.Streamer, issuerDID, atsync.Model) 157 + if err != nil { 158 + log.Error(ctx, "failed to add mod badge", "err", err) 159 + } 160 + 153 161 go atsync.Bus.Publish(rec.Streamer, scm) 154 162 155 163 if !isUpdate && !isFirstSync {
+54
pkg/badges/badges.go
··· 1 + package badges 2 + 3 + import ( 4 + "context" 5 + 6 + "stream.place/streamplace/pkg/constants" 7 + "stream.place/streamplace/pkg/log" 8 + "stream.place/streamplace/pkg/model" 9 + "stream.place/streamplace/pkg/streamplace" 10 + ) 11 + 12 + // GetValidBadges returns valid badges for a user in the context of a streamer's chat. 13 + // Returns server-controlled badges (streamer, mod) based on permissions. 14 + func GetValidBadges(ctx context.Context, userDID, streamerDID, issuerDID string, m model.Model) ([]*streamplace.BadgeDefs_BadgeView, error) { 15 + badges := []*streamplace.BadgeDefs_BadgeView{} 16 + 17 + // If no streamer context, return empty badges 18 + if streamerDID == "" { 19 + return badges, nil 20 + } 21 + 22 + // Check if user is the streamer 23 + if userDID == streamerDID { 24 + badges = append(badges, &streamplace.BadgeDefs_BadgeView{ 25 + BadgeType: constants.BadgeTypeStreamer, 26 + Issuer: issuerDID, 27 + Recipient: userDID, 28 + }) 29 + return badges, nil 30 + } 31 + 32 + // Check if user has moderation permissions for this streamer 33 + delegations, err := m.GetModerationDelegations(ctx, streamerDID, userDID) 34 + if err != nil { 35 + log.Error(ctx, "failed to get moderation delegations", "err", err, "userDID", userDID, "streamerDID", streamerDID) 36 + return nil, err 37 + } 38 + 39 + // If user has any delegations, they're a moderator 40 + if len(delegations) > 0 { 41 + badges = append(badges, &streamplace.BadgeDefs_BadgeView{ 42 + BadgeType: constants.BadgeTypeMod, 43 + Issuer: issuerDID, 44 + Recipient: userDID, 45 + }) 46 + } 47 + 48 + // TODO: Add badge issuance records when implemented 49 + // - Query place.stream.badge.issuance records for this user 50 + // - Verify signatures if issuer is not the current node 51 + // - Add VIP badges, subscriber badges, etc. 52 + 53 + return badges, nil 54 + }
+77
pkg/badges/badges_test.go
··· 1 + package badges 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/bluesky-social/indigo/util" 10 + "github.com/stretchr/testify/require" 11 + "stream.place/streamplace/pkg/constants" 12 + "stream.place/streamplace/pkg/model" 13 + "stream.place/streamplace/pkg/streamplace" 14 + ) 15 + 16 + func TestGetValidBadges(t *testing.T) { 17 + ctx := context.Background() 18 + 19 + mod, err := model.MakeDB(":memory:") 20 + require.NoError(t, err) 21 + 22 + issuerDID := "did:web:node.example.com" 23 + streamerDID := "did:plc:streamer" 24 + moderatorDID := "did:plc:moderator" 25 + regularUserDID := "did:plc:regular" 26 + 27 + t.Run("returns empty when no streamer context", func(t *testing.T) { 28 + badges, err := GetValidBadges(ctx, regularUserDID, "", issuerDID, mod) 29 + require.NoError(t, err) 30 + require.Empty(t, badges) 31 + }) 32 + 33 + t.Run("returns streamer badge for streamer", func(t *testing.T) { 34 + badges, err := GetValidBadges(ctx, streamerDID, streamerDID, issuerDID, mod) 35 + require.NoError(t, err) 36 + require.Len(t, badges, 1) 37 + require.Equal(t, constants.BadgeTypeStreamer, badges[0].BadgeType) 38 + require.Equal(t, issuerDID, badges[0].Issuer) 39 + require.Equal(t, streamerDID, badges[0].Recipient) 40 + }) 41 + 42 + t.Run("returns no badges for regular user", func(t *testing.T) { 43 + badges, err := GetValidBadges(ctx, regularUserDID, streamerDID, issuerDID, mod) 44 + require.NoError(t, err) 45 + require.Empty(t, badges) 46 + }) 47 + 48 + t.Run("returns mod badge for moderator", func(t *testing.T) { 49 + // Grant moderation permissions 50 + perm := &streamplace.ModerationPermission{ 51 + LexiconTypeID: "place.stream.moderation.permission", 52 + Moderator: moderatorDID, 53 + Permissions: []string{"ban", "hide"}, 54 + CreatedAt: time.Now().Format(util.ISO8601), 55 + } 56 + aturi, err := syntax.ParseATURI("at://" + streamerDID + "/place.stream.moderation.permission/test123") 57 + require.NoError(t, err) 58 + 59 + err = mod.CreateModerationDelegation(ctx, perm, aturi) 60 + require.NoError(t, err) 61 + 62 + badges, err := GetValidBadges(ctx, moderatorDID, streamerDID, issuerDID, mod) 63 + require.NoError(t, err) 64 + require.Len(t, badges, 1) 65 + require.Equal(t, constants.BadgeTypeMod, badges[0].BadgeType) 66 + require.Equal(t, issuerDID, badges[0].Issuer) 67 + require.Equal(t, moderatorDID, badges[0].Recipient) 68 + }) 69 + 70 + t.Run("streamer badge takes priority over mod", func(t *testing.T) { 71 + // Even if streamer has mod permissions for themselves, they get streamer badge 72 + badges, err := GetValidBadges(ctx, streamerDID, streamerDID, issuerDID, mod) 73 + require.NoError(t, err) 74 + require.Len(t, badges, 1) 75 + require.Equal(t, constants.BadgeTypeStreamer, badges[0].BadgeType) 76 + }) 77 + }
+7
pkg/constants/constants.go
··· 15 15 var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 16 16 var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 17 17 18 + // Streamplace badge types 19 + const ( 20 + BadgeTypeMod = "place.stream.badge.defs#mod" 21 + BadgeTypeStreamer = "place.stream.badge.defs#streamer" 22 + BadgeTypeVIP = "place.stream.badge.defs#vip" 23 + ) 24 + 18 25 const DID_KEY_PREFIX = "did:key" //nolint:all 19 26 const ADDRESS_KEY_PREFIX = "0x" //nolint:all 20 27
+29
pkg/spxrpc/place_stream_badge.go
··· 1 + package spxrpc 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/streamplace/oatproxy/pkg/oatproxy" 9 + "stream.place/streamplace/pkg/badges" 10 + placestream "stream.place/streamplace/pkg/streamplace" 11 + ) 12 + 13 + func (s *Server) handlePlaceStreamBadgeGetValidBadges(ctx context.Context, streamer string) (*placestream.BadgeGetValidBadges_Output, error) { 14 + // Get authenticated user's DID from OAuth session 15 + session, _ := oatproxy.GetOAuthSession(ctx) 16 + if session == nil { 17 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 18 + } 19 + 20 + // Get valid badges using shared badge logic 21 + badgeList, err := badges.GetValidBadges(ctx, session.DID, streamer, s.cli.MyDID(), s.model) 22 + if err != nil { 23 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to get valid badges") 24 + } 25 + 26 + return &placestream.BadgeGetValidBadges_Output{ 27 + Badges: badgeList, 28 + }, nil 29 + }
+15
pkg/spxrpc/stubs.go
··· 278 278 } 279 279 280 280 func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { 281 + e.GET("/xrpc/place.stream.badge.getValidBadges", s.HandlePlaceStreamBadgeGetValidBadges) 281 282 e.POST("/xrpc/place.stream.branding.deleteBlob", s.HandlePlaceStreamBrandingDeleteBlob) 282 283 e.GET("/xrpc/place.stream.branding.getBlob", s.HandlePlaceStreamBrandingGetBlob) 283 284 e.GET("/xrpc/place.stream.branding.getBranding", s.HandlePlaceStreamBrandingGetBranding) ··· 306 307 e.GET("/xrpc/place.stream.server.listWebhooks", s.HandlePlaceStreamServerListWebhooks) 307 308 e.POST("/xrpc/place.stream.server.updateWebhook", s.HandlePlaceStreamServerUpdateWebhook) 308 309 return nil 310 + } 311 + 312 + func (s *Server) HandlePlaceStreamBadgeGetValidBadges(c echo.Context) error { 313 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBadgeGetValidBadges") 314 + defer span.End() 315 + streamer := c.QueryParam("streamer") 316 + var out *placestream.BadgeGetValidBadges_Output 317 + var handleErr error 318 + // func (s *Server) handlePlaceStreamBadgeGetValidBadges(ctx context.Context,streamer string) (*placestream.BadgeGetValidBadges_Output, error) 319 + out, handleErr = s.handlePlaceStreamBadgeGetValidBadges(ctx, streamer) 320 + if handleErr != nil { 321 + return handleErr 322 + } 323 + return c.JSON(200, out) 309 324 } 310 325 311 326 func (s *Server) HandlePlaceStreamBrandingDeleteBlob(c echo.Context) error {
+18
pkg/streamplace/badgedefs.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.defs 4 + 5 + package streamplace 6 + 7 + // BadgeDefs_BadgeView is a "badgeView" in the place.stream.badge.defs schema. 8 + // 9 + // 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. 10 + type BadgeDefs_BadgeView struct { 11 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 12 + // issuer: DID of the badge issuer. 13 + Issuer string `json:"issuer" cborgen:"issuer"` 14 + // recipient: DID of the badge recipient. 15 + Recipient string `json:"recipient" cborgen:"recipient"` 16 + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). 17 + Signature *string `json:"signature,omitempty" cborgen:"signature,omitempty"` 18 + }
+33
pkg/streamplace/badgegetValidBadges.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.getValidBadges 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // BadgeGetValidBadges_Output is the output of a place.stream.badge.getValidBadges call. 14 + type BadgeGetValidBadges_Output struct { 15 + Badges []*BadgeDefs_BadgeView `json:"badges" cborgen:"badges"` 16 + } 17 + 18 + // BadgeGetValidBadges calls the XRPC method "place.stream.badge.getValidBadges". 19 + // 20 + // streamer: Optional DID of the streamer for context-specific badges (mod, vip, etc) 21 + func BadgeGetValidBadges(ctx context.Context, c lexutil.LexClient, streamer string) (*BadgeGetValidBadges_Output, error) { 22 + var out BadgeGetValidBadges_Output 23 + 24 + params := map[string]interface{}{} 25 + if streamer != "" { 26 + params["streamer"] = streamer 27 + } 28 + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.badge.getValidBadges", params, nil, &out); err != nil { 29 + return nil, err 30 + } 31 + 32 + return &out, nil 33 + }
+4 -2
pkg/streamplace/chatdefs.go
··· 16 16 type ChatDefs_MessageView struct { 17 17 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` 18 18 Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` 19 - ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 20 - Cid string `json:"cid" cborgen:"cid"` 19 + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. 20 + Badges []*BadgeDefs_BadgeView `json:"badges,omitempty" cborgen:"badges,omitempty"` 21 + ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 22 + Cid string `json:"cid" cborgen:"cid"` 21 23 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 22 24 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 23 25 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`