Live video on the AT Protocol

Merge pull request #979 from streamplace/natb/pinners

feat: chat pinning

authored by

Eli Mallon and committed by
GitHub
fbebb3cd bb730e0e

+2017 -62
+4 -1
js/app/src/screens/chat-popout.native.tsx
··· 4 4 ChatBox, 5 5 LivestreamProvider, 6 6 PlayerProvider, 7 + StreamNotificationProvider, 7 8 Text, 8 9 useKeyboard, 9 10 useLivestreamInfo, ··· 171 172 </View> 172 173 </View> 173 174 <View style={[zero.flex.values[1], zero.p[4]]}> 174 - <Chat /> 175 + <StreamNotificationProvider position="top"> 176 + <Chat /> 177 + </StreamNotificationProvider> 175 178 {profile && <ChatBox emojiData={emojiData} isPopout={true} />} 176 179 </View> 177 180 </View>
+59 -42
js/app/src/screens/chat-popout.tsx
··· 3 3 ChatBox, 4 4 LivestreamProvider, 5 5 PlayerProvider, 6 + StreamNotificationProvider, 6 7 Text, 7 8 tokens, 8 9 usePlayerStore, ··· 21 22 reverse?: string; 22 23 hideAfter?: string; 23 24 hideChatBox?: string; 25 + showNotifications?: string; 24 26 } 25 27 26 28 export default function PopoutChat({ route }) { ··· 63 65 ? parseInt(params.hideAfter, 10) 64 66 : undefined; 65 67 const hideChatBox = params.hideChatBox === "true"; 68 + const showNotifications = params.showNotifications === "true"; 66 69 67 70 useEffect(() => { 68 71 setSrc(params.user); 69 72 }, [params.user, setSrc]); 70 73 74 + const chat = ( 75 + <Chat 76 + reverse={reverseChat} 77 + hideAfter={hideAfter} 78 + style={[zero.flex.values[1]]} 79 + /> 80 + ); 81 + 71 82 return ( 72 - <View style={[{ position: "relative" }, zero.flex.values[1], zero.m[2]]}> 73 - <View 74 - style={[ 75 - zero.flex.values[1], 76 - { position: "absolute", width: "100%", minHeight: "100%", bottom: 0 }, 77 - ]} 78 - > 79 - <Chat reverse={reverseChat} hideAfter={hideAfter} /> 80 - {!hideChatBox && 81 - (profile ? ( 82 - <ChatBox 83 - emojiData={emojiData} 84 - isPopout={true} 85 - emojiPicker={(isOpen, onClose, onSelect) => ( 86 - <EmojiPicker 87 - isOpen={isOpen} 88 - onClose={onClose} 89 - onSelect={onSelect} 90 - customEmoji={[]} 91 - /> 92 - )} 93 - /> 94 - ) : ( 95 - <Pressable 96 - onPress={() => openLoginModal({ name: "ChatPopout" })} 97 - style={[ 98 - zero.layout.flex.row, 99 - zero.layout.flex.center, 100 - zero.gap.all[4], 101 - zero.r.xl, 102 - { 103 - padding: 18, 104 - backgroundColor: "rgba(255, 255, 255, 0.1)", 105 - maxWidth: tokens.breakpoints.sm, 106 - }, 107 - ]} 108 - > 109 - <Text>Log in or sign up to chat</Text> 110 - <ArrowRight /> 111 - </Pressable> 112 - ))} 113 - </View> 83 + <View 84 + style={[ 85 + zero.flex.values[1], 86 + zero.layout.flex.column, 87 + zero.m[2], 88 + { maxHeight: "100vh" }, 89 + ]} 90 + > 91 + {showNotifications ? ( 92 + <StreamNotificationProvider position="top"> 93 + {chat} 94 + </StreamNotificationProvider> 95 + ) : ( 96 + chat 97 + )} 98 + {!hideChatBox && 99 + (profile ? ( 100 + <ChatBox 101 + emojiData={emojiData} 102 + isPopout={true} 103 + emojiPicker={(isOpen, onClose, onSelect) => ( 104 + <EmojiPicker 105 + isOpen={isOpen} 106 + onClose={onClose} 107 + onSelect={onSelect} 108 + customEmoji={[]} 109 + /> 110 + )} 111 + /> 112 + ) : ( 113 + <Pressable 114 + onPress={() => openLoginModal({ name: "ChatPopout" })} 115 + style={[ 116 + zero.layout.flex.row, 117 + zero.layout.flex.center, 118 + zero.gap.all[4], 119 + zero.r.xl, 120 + { 121 + padding: 18, 122 + backgroundColor: "rgba(255, 255, 255, 0.1)", 123 + maxWidth: tokens.breakpoints.sm, 124 + }, 125 + ]} 126 + > 127 + <Text>Log in or sign up to chat</Text> 128 + <ArrowRight /> 129 + </Pressable> 130 + ))} 114 131 </View> 115 132 ); 116 133 }
+82 -3
js/components/src/components/chat/mod-view.tsx
··· 17 17 import { 18 18 useDeleteChatMessage, 19 19 useLivestreamStore, 20 + usePinChatMessage, 20 21 } from "../../livestream-store"; 21 22 import { useStreamplaceStore } from "../../streamplace-store"; 22 23 import { formatHandle, formatHandleWithAt } from "../../utils/format-handle"; ··· 25 26 DropdownMenu, 26 27 DropdownMenuGroup, 27 28 DropdownMenuItem, 29 + DropdownMenuSub, 30 + DropdownMenuSubContent, 31 + DropdownMenuSubTrigger, 28 32 DropdownMenuTrigger, 29 33 layout, 30 34 ResponsiveDropdownMenuContent, ··· 55 59 let [messageRemoved, setMessageRemoved] = useState(false); 56 60 let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); 57 61 let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); 62 + const pinChatMessage = usePinChatMessage(); 58 63 59 64 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 60 65 const setReportSubject = usePlayerStore((x) => x.setReportSubject); ··· 91 96 message && 92 97 agent?.did && 93 98 ((modPermissions.canHide && message.author.did !== streamerDID) || 94 - (modPermissions.canBan && 95 - message.author.did !== agent.did && 96 - message.author.did !== streamerDID)) 99 + (modPermissions.canPin && message.author.did !== streamerDID) || 100 + modPermissions.canBan) 97 101 ); 98 102 99 103 return ( ··· 124 128 setMessageRemoved={setMessageRemoved} 125 129 createHideChat={createHideChat} 126 130 createBlock={createBlock} 131 + pinChatMessage={pinChatMessage} 127 132 toast={toast} 128 133 setReportModalOpen={setReportModalOpen} 129 134 setReportSubject={setReportSubject} ··· 148 153 setMessageRemoved: (removed: boolean) => void; 149 154 createHideChat: (uri: string, streamerDID?: string) => Promise<any>; 150 155 createBlock: (did: string, streamerDID?: string) => Promise<any>; 156 + pinChatMessage: ( 157 + messageUri: string, 158 + streamerDID: string, 159 + expiresAt?: string, 160 + ) => Promise<any>; 151 161 toast: ReturnType<typeof useToast>; 152 162 setReportModalOpen: (open: boolean) => void; 153 163 setReportSubject: (subject: any) => void; ··· 166 176 setMessageRemoved, 167 177 createHideChat, 168 178 createBlock, 179 + pinChatMessage, 169 180 toast, 170 181 setReportModalOpen, 171 182 setReportSubject, ··· 222 233 : "Hide this message"} 223 234 </Text> 224 235 </DropdownMenuItem> 236 + )} 237 + {modPermissions.canPin && ( 238 + <DropdownMenuGroup key="pin-actions"> 239 + <DropdownMenuSub> 240 + <DropdownMenuSubTrigger 241 + subMenuTitle="Pin message" 242 + style={{ padding: 0, margin: 0 }} 243 + > 244 + <Text color="primary">Pin this message</Text> 245 + </DropdownMenuSubTrigger> 246 + <DropdownMenuSubContent> 247 + <DropdownMenuGroup title="Pin duration"> 248 + <DropdownMenuItem 249 + onPress={() => { 250 + if (!streamerDID) return; 251 + pinChatMessage(message.uri, streamerDID) 252 + .then(() => { 253 + toast.show("Comment pinned", "", { duration: 3 }); 254 + onOpenChange?.(false); 255 + }) 256 + .catch((e) => { 257 + toast.show( 258 + "Error pinning comment", 259 + e instanceof Error ? e.message : "Failed to pin", 260 + { duration: 5 }, 261 + ); 262 + }); 263 + }} 264 + > 265 + <Text color="primary">Until stream end</Text> 266 + </DropdownMenuItem> 267 + {[5, 10, 15, 30, 60].map((minutes) => ( 268 + <DropdownMenuItem 269 + key={minutes} 270 + onPress={() => { 271 + if (!streamerDID) return; 272 + const expiresAt = new Date( 273 + Date.now() + minutes * 60 * 1000, 274 + ); 275 + pinChatMessage( 276 + message.uri, 277 + streamerDID, 278 + expiresAt.toISOString(), 279 + ) 280 + .then(() => { 281 + toast.show("Comment pinned", "", { duration: 3 }); 282 + onOpenChange?.(false); 283 + }) 284 + .catch((e) => { 285 + toast.show( 286 + "Error pinning comment", 287 + e instanceof Error 288 + ? e.message 289 + : "Failed to pin", 290 + { duration: 5 }, 291 + ); 292 + }); 293 + }} 294 + > 295 + <Text color="primary"> 296 + {minutes < 60 ? `${minutes} min` : "1 hour"} 297 + </Text> 298 + </DropdownMenuItem> 299 + ))} 300 + </DropdownMenuGroup> 301 + </DropdownMenuSubContent> 302 + </DropdownMenuSub> 303 + </DropdownMenuGroup> 225 304 )} 226 305 {modPermissions.canBan && 227 306 agent?.did &&
+13 -2
js/components/src/components/dashboard/moderator-panel.tsx
··· 372 372 ban: false, 373 373 hide: false, 374 374 "livestream.manage": false, 375 + "message.pin": false, 375 376 }); 376 377 const [error, setError] = useState<string | null>(null); 377 378 const toast = useToast(); ··· 380 381 useEffect(() => { 381 382 if (!visible) { 382 383 setModeratorDID(""); 383 - setPermissions({ ban: false, hide: false, "livestream.manage": false }); 384 + setPermissions({ 385 + ban: false, 386 + hide: false, 387 + "livestream.manage": false, 388 + "message.pin": false, 389 + }); 384 390 setError(null); 385 391 } 386 392 }, [visible]); ··· 401 407 402 408 const selectedPermissions = Object.entries(permissions) 403 409 .filter(([_, enabled]) => enabled) 404 - .map(([perm]) => perm) as ("ban" | "hide" | "livestream.manage")[]; 410 + .map(([perm]) => perm) as ( 411 + | "ban" 412 + | "hide" 413 + | "livestream.manage" 414 + | "message.pin" 415 + )[]; 405 416 406 417 if (selectedPermissions.length === 0) { 407 418 setError("Please select at least one permission");
+135
js/components/src/components/stream-notification/pin-notification.tsx
··· 1 + import { EyeOff, Pin, X } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import { Linking, Pressable, View } from "react-native"; 4 + import { PinnedRecordViewHydrated, PlaceStreamChatProfile } from "streamplace"; 5 + import { 6 + Text, 7 + useCanModerate, 8 + useLivestreamStore, 9 + useTheme, 10 + zero, 11 + } from "../../"; 12 + import { RichtextSegment, segmentize } from "../../lib/facet"; 13 + import { formatHandleWithAt } from "../../utils/format-handle"; 14 + 15 + const getRgbColor = (color?: PlaceStreamChatProfile.Color) => 16 + color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : undefined; 17 + 18 + function renderSegment(segment: RichtextSegment, index: number) { 19 + if (segment.features && segment.features.length > 0) { 20 + const ftr = segment.features[0]; 21 + if (ftr.$type === "app.bsky.richtext.facet#link") { 22 + return ( 23 + <Text 24 + key={index} 25 + style={[{ color: "#007AFF" }]} 26 + onPress={() => Linking.openURL((ftr as any).uri || "")} 27 + > 28 + {segment.text} 29 + </Text> 30 + ); 31 + } 32 + if (ftr.$type === "app.bsky.richtext.facet#mention") { 33 + return ( 34 + <Text key={index} style={[{ color: "#007AFF" }]}> 35 + {segment.text} 36 + </Text> 37 + ); 38 + } 39 + } 40 + return <Text key={index}>{segment.text}</Text>; 41 + } 42 + 43 + export function PinnedCommentNotification({ 44 + pinnedComment, 45 + onDismiss, 46 + onUnpin, 47 + }: { 48 + pinnedComment: PinnedRecordViewHydrated; 49 + onDismiss: () => void; 50 + onUnpin: () => void; 51 + }) { 52 + const z = useTheme(); 53 + const message = pinnedComment.message; 54 + const pinnedByColor = (pinnedComment.pinnedBy as any)?.color || "#bebebe"; 55 + const record = pinnedComment.record; 56 + 57 + const currentStreamer = useLivestreamStore((state) => state.profile?.did); 58 + 59 + console.log("checking if we can mod", currentStreamer); 60 + 61 + const canActuallyPin = useCanModerate(currentStreamer)?.canPin; 62 + 63 + const messageRecord = message?.record as any; 64 + 65 + const [expiresAt] = useState<Date | null>( 66 + record.expiresAt ? new Date(record.expiresAt) : null, 67 + ); 68 + 69 + useEffect(() => { 70 + if (!expiresAt) return; 71 + const remaining = expiresAt.getTime() - Date.now(); 72 + if (remaining <= 0) { 73 + onDismiss(); 74 + return; 75 + } 76 + const timeout = setTimeout(onDismiss, remaining); 77 + return () => clearTimeout(timeout); 78 + }, [expiresAt, onDismiss]); 79 + 80 + const authorName = message ? formatHandleWithAt(message.author) : "unknown"; 81 + const authorColor = getRgbColor((message as any)?.chatProfile?.color); 82 + 83 + const segments = messageRecord 84 + ? segmentize(messageRecord.text, messageRecord.facets) 85 + : []; 86 + 87 + return ( 88 + <View style={[zero.bg.neutral[900], zero.r.lg, { overflow: "hidden" }]}> 89 + <View 90 + style={[ 91 + zero.layout.flex.row, 92 + zero.layout.flex.alignCenter, 93 + zero.px[3], 94 + zero.py[2], 95 + { gap: 8 }, 96 + ]} 97 + > 98 + <View style={{ transform: [{ rotate: "-25deg" }] }}> 99 + <Pin 100 + size={24} 101 + color={authorColor || z.theme.colors.primary} 102 + fill={authorColor || z.theme.colors.primary} 103 + /> 104 + </View> 105 + <View 106 + style={[zero.layout.flex.column, zero.flex.values[1], { gap: 4 }]} 107 + > 108 + <View style={[zero.layout.flex.row, { gap: 4, flexWrap: "wrap" }]}> 109 + <Text 110 + style={[ 111 + { 112 + fontWeight: "600", 113 + color: authorColor || zero.colors.gray[200], 114 + }, 115 + ]} 116 + > 117 + {authorName} 118 + </Text> 119 + {segments.map((seg, i) => renderSegment(seg, i))} 120 + </View> 121 + </View> 122 + <View style={[zero.layout.flex.row, { gap: 4 }]}> 123 + {canActuallyPin && ( 124 + <Pressable onPress={onUnpin} style={{ padding: 4 }}> 125 + <X size={16} color={zero.colors.gray[400]} /> 126 + </Pressable> 127 + )} 128 + <Pressable onPress={onDismiss} style={{ padding: 4 }}> 129 + <EyeOff size={16} color={zero.colors.gray[400]} /> 130 + </Pressable> 131 + </View> 132 + </View> 133 + </View> 134 + ); 135 + }
+3 -2
js/components/src/components/ui/dropdown.tsx
··· 74 74 inset?: boolean; 75 75 children?: React.ReactNode; 76 76 } 77 - >(({ inset, children, subMenuTitle, ...props }, ref) => { 77 + >(({ inset, children, subMenuTitle, style, ...props }, ref) => { 78 78 const { icons } = useTheme(); 79 79 const { open } = DropdownMenuPrimitive.useSubContext(); 80 80 const Icon = ··· 96 96 layout.flex.alignCenter, 97 97 p[2], 98 98 pr[8], 99 + style, 99 100 ]} 100 101 > 101 102 {children} ··· 513 514 const { theme } = useTheme(); 514 515 const { inset, title, children, ...rest } = props; 515 516 return ( 516 - <View style={[pt[2], inset && gap[2]]} ref={ref} {...rest}> 517 + <View style={[inset && gap[2]]} ref={ref} {...rest}> 517 518 {title && ( 518 519 <Text style={[{ color: theme.colors.textMuted }, pb[1], pl[2]]}> 519 520 {title}
+2 -2
js/components/src/components/ui/resizeable.tsx
··· 26 26 const { height: SCREEN_HEIGHT } = Dimensions.get("window"); 27 27 28 28 const TIMING_CONFIG = { 29 - duration: 300, 30 - easing: Easing.inOut(Easing.quad), 29 + duration: 400, 30 + easing: Easing.out(Easing.quad), 31 31 }; 32 32 33 33 type ResizableChatSheetProps = {
+28
js/components/src/lib/stream-notifications.ts
··· 1 1 import React from "react"; 2 + import { PinnedRecordViewHydrated } from "streamplace"; 2 3 import { streamNotification } from "../components/stream-notification"; 4 + import { PinnedCommentNotification } from "../components/stream-notification/pin-notification"; 3 5 import { TeleportNotification } from "../components/stream-notification/teleport-notification"; 4 6 5 7 export const StreamNotifications = { 8 + pinnedComment: (params: { 9 + pinnedComment: PinnedRecordViewHydrated; 10 + onDismiss?: (reason?: "user" | "auto") => void; 11 + onUnpin?: () => void; 12 + }) => { 13 + streamNotification.show({ 14 + id: "pinned-comment", 15 + render: (isExiting, onDismiss) => { 16 + return React.createElement(PinnedCommentNotification, { 17 + pinnedComment: params.pinnedComment, 18 + onDismiss: () => onDismiss("user"), 19 + onUnpin: () => { 20 + params.onUnpin?.(); 21 + onDismiss("user"); 22 + }, 23 + }); 24 + }, 25 + duration: 0, // manually dismissed or auto-dismissed by TTL 26 + onDismiss: params.onDismiss, 27 + }); 28 + }, 29 + 30 + pinnedCommentDismiss: () => { 31 + streamNotification.hide("pinned-comment"); 32 + }, 33 + 6 34 teleport: (params: { 7 35 targetHandle: string; 8 36 targetDID: string;
+38 -2
js/components/src/livestream-provider/index.tsx
··· 4 4 import { StreamNotifications } from "../lib/stream-notifications"; 5 5 import { 6 6 LivestreamContext, 7 + getStoreFromContext, 7 8 makeLivestreamStore, 8 9 useLivestreamStore, 10 + usePinnedComment, 11 + useUnpinChatMessage, 9 12 } from "../livestream-store"; 10 13 import { useDID, usePDSAgent } from "../streamplace-store"; 11 14 import { useLivestreamWebsocket } from "./websocket"; ··· 134 137 return <></>; 135 138 } 136 139 140 + export function PinnedCommentWatcher() { 141 + const pinnedComment = usePinnedComment(); 142 + const streamerDID = useLivestreamStore((state) => state.profile?.did); 143 + const unpinChatMessage = useUnpinChatMessage(); 144 + const store = getStoreFromContext(); 145 + const prevPinnedRef = useRef<string | null>(null); 146 + 147 + // Show/hide notification when pinned comment changes 148 + useEffect(() => { 149 + const currentUri = pinnedComment?.uri ?? null; 150 + if (currentUri === prevPinnedRef.current) return; 151 + prevPinnedRef.current = currentUri; 152 + 153 + if (pinnedComment) { 154 + StreamNotifications.pinnedComment({ 155 + pinnedComment, 156 + onDismiss: () => { 157 + store.setState({ pinnedComment: null }); 158 + }, 159 + onUnpin: () => { 160 + if (!streamerDID) return; 161 + unpinChatMessage(pinnedComment.uri, streamerDID).catch((e) => { 162 + console.error("Failed to unpin:", e); 163 + }); 164 + }, 165 + }); 166 + } else { 167 + StreamNotifications.pinnedCommentDismiss(); 168 + } 169 + }, [pinnedComment, streamerDID, unpinChatMessage]); 170 + 171 + return <></>; 172 + } 173 + 137 174 export function LivestreamPoller({ 138 175 children, 139 176 src, ··· 143 180 src: string; 144 181 onTeleport?: (targetHandle: string, targetDID: string) => void; 145 182 }) { 146 - // Websocket watcher is a sibling instead of a parent to avoid 147 - // re-rendering when the websocket does stuff 148 183 return ( 149 184 <> 150 185 <WebsocketWatcher src={src} /> 151 186 <TeleportWatcher onTeleport={onTeleport} /> 187 + <PinnedCommentWatcher /> 152 188 {children} 153 189 </> 154 190 );
+92
js/components/src/livestream-store/chat.tsx
··· 437 437 }; 438 438 439 439 export const reduceChat = reduceChatIncremental; 440 + 441 + export const usePinChatMessage = () => { 442 + const agent = usePDSAgent(); 443 + const store = getStoreFromContext(); 444 + 445 + return async ( 446 + messageUri: string, 447 + streamerDID: string, 448 + expiresAt?: string, 449 + ) => { 450 + if (!agent || !agent.did) { 451 + throw new Error("No PDS agent or user DID found"); 452 + } 453 + 454 + // If streamer, create directly 455 + if (agent.did === streamerDID) { 456 + // First delete any existing pinned records 457 + const listResult = await agent.com.atproto.repo.listRecords({ 458 + repo: streamerDID, 459 + collection: "place.stream.chat.pinnedRecord", 460 + }); 461 + for (const rec of listResult.data.records) { 462 + const rkey = rec.uri.split("/").pop(); 463 + if (rkey) { 464 + await agent.com.atproto.repo.deleteRecord({ 465 + repo: streamerDID, 466 + collection: "place.stream.chat.pinnedRecord", 467 + rkey, 468 + }); 469 + } 470 + } 471 + 472 + const record = { 473 + $type: "place.stream.chat.pinnedRecord", 474 + pinnedMessage: messageUri, 475 + createdAt: new Date().toISOString(), 476 + ...(expiresAt ? { expiresAt } : {}), 477 + }; 478 + 479 + const result = await agent.com.atproto.repo.createRecord({ 480 + repo: streamerDID, 481 + collection: "place.stream.chat.pinnedRecord", 482 + record, 483 + }); 484 + return result; 485 + } 486 + 487 + // Otherwise, use delegated moderation endpoint 488 + const result = await agent.place.stream.moderation.createPin({ 489 + streamer: streamerDID, 490 + messageUri, 491 + ...(expiresAt ? { expiresAt } : {}), 492 + }); 493 + return result; 494 + }; 495 + }; 496 + 497 + export const useUnpinChatMessage = () => { 498 + const agent = usePDSAgent(); 499 + const store = getStoreFromContext(); 500 + 501 + return async (pinUri: string, streamerDID: string) => { 502 + if (!agent || !agent.did) { 503 + throw new Error("No PDS agent or user DID found"); 504 + } 505 + 506 + // If streamer, delete directly 507 + if (agent.did === streamerDID) { 508 + const rkey = pinUri.split("/").pop(); 509 + if (!rkey) { 510 + throw new Error("Invalid pin URI"); 511 + } 512 + 513 + await agent.com.atproto.repo.deleteRecord({ 514 + repo: streamerDID, 515 + collection: "place.stream.chat.pinnedRecord", 516 + rkey, 517 + }); 518 + // Optimistically clear the pinned comment 519 + store.setState({ pinnedComment: null }); 520 + return; 521 + } 522 + 523 + // Otherwise, use delegated moderation endpoint 524 + await agent.place.stream.moderation.deletePin({ 525 + streamer: streamerDID, 526 + pinUri, 527 + }); 528 + // Optimistically clear the pinned comment 529 + store.setState({ pinnedComment: null }); 530 + }; 531 + };
+2
js/components/src/livestream-store/livestream-state.tsx
··· 2 2 import { 3 3 ChatMessageViewHydrated, 4 4 LivestreamViewHydrated, 5 + PinnedRecordViewHydrated, 5 6 PlaceStreamDefs, 6 7 PlaceStreamLiveTeleport, 7 8 PlaceStreamModerationPermission, ··· 28 29 setActiveTeleportUri: (uri: string | null) => void; 29 30 websocketConnected: boolean; 30 31 hasReceivedSegment: boolean; 32 + pinnedComment: PinnedRecordViewHydrated | null; 31 33 moderationPermissions: PlaceStreamModerationPermission.Record[]; 32 34 setModerationPermissions: ( 33 35 permissions: PlaceStreamModerationPermission.Record[],
+4
js/components/src/livestream-store/livestream-store.tsx
··· 27 27 setActiveTeleportUri: (uri) => set({ activeTeleportUri: uri }), 28 28 websocketConnected: false, 29 29 hasReceivedSegment: false, 30 + pinnedComment: null, 30 31 moderationPermissions: [], 31 32 setModerationPermissions: (perms) => set({ moderationPermissions: perms }), 32 33 localLivestreamURI: null, ··· 59 60 }; 60 61 61 62 export const useChat = () => useLivestreamStore((x) => x.chat); 63 + 64 + export const usePinnedComment = () => 65 + useLivestreamStore((x) => x.pinnedComment); 62 66 63 67 export const useProfile = () => useLivestreamStore((x) => x.profile); 64 68
+15
js/components/src/livestream-store/websocket-consumer.tsx
··· 2 2 import { 3 3 ChatMessageViewHydrated, 4 4 LivestreamViewHydrated, 5 + PinnedRecordViewHydrated, 5 6 PlaceStreamChatDefs, 6 7 PlaceStreamChatGate, 7 8 PlaceStreamChatMessage, ··· 123 124 pendingHides: newPendingHides, 124 125 }; 125 126 state = reduceChat(state, [], [], [hiddenMessageUri]); 127 + } else if (PlaceStreamChatDefs.isPinnedRecordView(message)) { 128 + const pinnedView = message as PinnedRecordViewHydrated; 129 + state = { 130 + ...state, 131 + pinnedComment: pinnedView, 132 + }; 133 + } else if ( 134 + (message as any).$type === "place.stream.chat.pinnedRecord" && 135 + (message as any).deleted === true 136 + ) { 137 + state = { 138 + ...state, 139 + pinnedComment: null, 140 + }; 126 141 } else if (PlaceStreamLiveTeleport.isRecord(message)) { 127 142 const teleportRecord = message as PlaceStreamLiveTeleport.Record; 128 143 state = {
+2
js/components/src/streamplace-store/moderation.tsx
··· 6 6 export interface ModerationPermissions { 7 7 canBan: boolean; 8 8 canHide: boolean; 9 + canPin: boolean; 9 10 canManageLivestream: boolean; 10 11 isOwner: boolean; 11 12 isLoading: boolean; ··· 177 178 return { 178 179 canBan: isOwner || permissions.includes("ban"), 179 180 canHide: isOwner || permissions.includes("hide"), 181 + canPin: isOwner || permissions.includes("message.pin"), 180 182 canManageLivestream: isOwner || permissions.includes("livestream.manage"), 181 183 isOwner, 182 184 isLoading,
+1 -1
js/components/src/streamplace-store/moderator-management.tsx
··· 81 81 82 82 interface AddModeratorParams { 83 83 moderatorDID: string; 84 - permissions: ("ban" | "hide" | "livestream.manage")[]; 84 + permissions: ("ban" | "hide" | "livestream.manage" | "message.pin")[]; 85 85 expirationTime?: string; // ISO 8601 datetime string 86 86 } 87 87
+52
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
··· 29 29 30 30 --- 31 31 32 + <a name="pinnedrecordview"></a> 33 + 34 + ### `pinnedRecordView` 35 + 36 + **Type:** `object` 37 + 38 + View of a pinned chat record with hydrated message data. 39 + 40 + **Properties:** 41 + 42 + | Name | Type | Req'd | Description | Constraints | 43 + | ----------- | --------------------------------------------------------------------------------- | ----- | ----------- | ------------------ | 44 + | `uri` | `string` | ✅ | | Format: `at-uri` | 45 + | `cid` | `string` | ✅ | | Format: `cid` | 46 + | `record` | [`place.stream.chat.pinnedRecord`](/lex-reference/place-stream-chat-pinnedrecord) | ✅ | | | 47 + | `indexedAt` | `string` | ✅ | | Format: `datetime` | 48 + | `pinnedBy` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | 49 + | `message` | [`#messageView`](#messageview) | ❌ | | | 50 + 51 + --- 52 + 32 53 ## Lexicon Source 33 54 34 55 ```json ··· 79 100 "type": "ref", 80 101 "ref": "place.stream.badge.defs#badgeView" 81 102 } 103 + } 104 + } 105 + }, 106 + "pinnedRecordView": { 107 + "type": "object", 108 + "description": "View of a pinned chat record with hydrated message data.", 109 + "required": ["uri", "cid", "record", "indexedAt"], 110 + "properties": { 111 + "uri": { 112 + "type": "string", 113 + "format": "at-uri" 114 + }, 115 + "cid": { 116 + "type": "string", 117 + "format": "cid" 118 + }, 119 + "record": { 120 + "type": "ref", 121 + "ref": "place.stream.chat.pinnedRecord" 122 + }, 123 + "indexedAt": { 124 + "type": "string", 125 + "format": "datetime" 126 + }, 127 + "pinnedBy": { 128 + "type": "ref", 129 + "ref": "place.stream.chat.profile" 130 + }, 131 + "message": { 132 + "type": "ref", 133 + "ref": "#messageView" 82 134 } 83 135 } 84 136 }
+71
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-pinnedrecord.md
··· 1 + --- 2 + title: place.stream.chat.pinnedRecord 3 + description: Reference for the place.stream.chat.pinnedRecord lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + Record pinning a chat message for prominent display. 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | --------------- | -------- | ----- | --------------------------------------------------------------------------------- | ------------------ | 24 + | `pinnedMessage` | `string` | ✅ | AT-URI of the pinned chat message. | Format: `at-uri` | 25 + | `pinnedBy` | `string` | ❌ | DID of the user who pinned the message. | Format: `did` | 26 + | `createdAt` | `string` | ✅ | When this pin was created. | Format: `datetime` | 27 + | `expiresAt` | `string` | ❌ | Optional expiration time. If set, the pin is considered inactive after this time. | Format: `datetime` | 28 + 29 + --- 30 + 31 + ## Lexicon Source 32 + 33 + ```json 34 + { 35 + "lexicon": 1, 36 + "id": "place.stream.chat.pinnedRecord", 37 + "defs": { 38 + "main": { 39 + "type": "record", 40 + "key": "tid", 41 + "description": "Record pinning a chat message for prominent display.", 42 + "record": { 43 + "type": "object", 44 + "required": ["pinnedMessage", "createdAt"], 45 + "properties": { 46 + "pinnedMessage": { 47 + "type": "string", 48 + "format": "at-uri", 49 + "description": "AT-URI of the pinned chat message." 50 + }, 51 + "pinnedBy": { 52 + "type": "string", 53 + "format": "did", 54 + "description": "DID of the user who pinned the message." 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime", 59 + "description": "When this pin was created." 60 + }, 61 + "expiresAt": { 62 + "type": "string", 63 + "format": "datetime", 64 + "description": "Optional expiration time. If set, the pin is considered inactive after this time." 65 + } 66 + } 67 + } 68 + } 69 + } 70 + } 71 + ```
+123
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createpin.md
··· 1 + --- 2 + title: place.stream.moderation.createPin 3 + description: Reference for the place.stream.moderation.createPin lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin. 17 + 18 + **Parameters:** _(None defined)_ 19 + 20 + **Input:** 21 + 22 + - **Encoding:** `application/json` 23 + - **Schema:** 24 + 25 + **Schema Type:** `object` 26 + 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ------------ | -------- | ----- | -------------------------------------- | ------------------ | 29 + | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 30 + | `messageUri` | `string` | ✅ | The AT-URI of the chat message to pin. | Format: `at-uri` | 31 + | `expiresAt` | `string` | ❌ | Optional expiration time for this pin. | Format: `datetime` | 32 + 33 + **Output:** 34 + 35 + - **Encoding:** `application/json` 36 + - **Schema:** 37 + 38 + **Schema Type:** `object` 39 + 40 + | Name | Type | Req'd | Description | Constraints | 41 + | ----- | -------- | ----- | ---------------------------------------- | ---------------- | 42 + | `uri` | `string` | ✅ | The AT-URI of the created pinned record. | Format: `at-uri` | 43 + | `cid` | `string` | ✅ | The CID of the created pinned record. | Format: `cid` | 44 + 45 + **Possible Errors:** 46 + 47 + - `Unauthorized`: The request lacks valid authentication credentials. 48 + - `Forbidden`: The caller does not have permission to pin messages for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 50 + 51 + --- 52 + 53 + ## Lexicon Source 54 + 55 + ```json 56 + { 57 + "lexicon": 1, 58 + "id": "place.stream.moderation.createPin", 59 + "defs": { 60 + "main": { 61 + "type": "procedure", 62 + "description": "Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin.", 63 + "input": { 64 + "encoding": "application/json", 65 + "schema": { 66 + "type": "object", 67 + "required": ["streamer", "messageUri"], 68 + "properties": { 69 + "streamer": { 70 + "type": "string", 71 + "format": "did", 72 + "description": "The DID of the streamer." 73 + }, 74 + "messageUri": { 75 + "type": "string", 76 + "format": "at-uri", 77 + "description": "The AT-URI of the chat message to pin." 78 + }, 79 + "expiresAt": { 80 + "type": "string", 81 + "format": "datetime", 82 + "description": "Optional expiration time for this pin." 83 + } 84 + } 85 + } 86 + }, 87 + "output": { 88 + "encoding": "application/json", 89 + "schema": { 90 + "type": "object", 91 + "required": ["uri", "cid"], 92 + "properties": { 93 + "uri": { 94 + "type": "string", 95 + "format": "at-uri", 96 + "description": "The AT-URI of the created pinned record." 97 + }, 98 + "cid": { 99 + "type": "string", 100 + "format": "cid", 101 + "description": "The CID of the created pinned record." 102 + } 103 + } 104 + } 105 + }, 106 + "errors": [ 107 + { 108 + "name": "Unauthorized", 109 + "description": "The request lacks valid authentication credentials." 110 + }, 111 + { 112 + "name": "Forbidden", 113 + "description": "The caller does not have permission to pin messages for this streamer." 114 + }, 115 + { 116 + "name": "SessionNotFound", 117 + "description": "The streamer's OAuth session could not be found or is invalid." 118 + } 119 + ] 120 + } 121 + } 122 + } 123 + ```
+101
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletepin.md
··· 1 + --- 2 + title: place.stream.moderation.deletePin 3 + description: Reference for the place.stream.moderation.deletePin lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo. 17 + 18 + **Parameters:** _(None defined)_ 19 + 20 + **Input:** 21 + 22 + - **Encoding:** `application/json` 23 + - **Schema:** 24 + 25 + **Schema Type:** `object` 26 + 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ---------- | -------- | ----- | ------------------------------------------ | ---------------- | 29 + | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 30 + | `pinUri` | `string` | ✅ | The AT-URI of the pinned record to delete. | Format: `at-uri` | 31 + 32 + **Output:** 33 + 34 + - **Encoding:** `application/json` 35 + - **Schema:** 36 + 37 + **Schema Type:** `object` 38 + 39 + _(No properties defined)_ 40 + **Possible Errors:** 41 + 42 + - `Unauthorized`: The request lacks valid authentication credentials. 43 + - `Forbidden`: The caller does not have permission to unpin messages for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 45 + 46 + --- 47 + 48 + ## Lexicon Source 49 + 50 + ```json 51 + { 52 + "lexicon": 1, 53 + "id": "place.stream.moderation.deletePin", 54 + "defs": { 55 + "main": { 56 + "type": "procedure", 57 + "description": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.", 58 + "input": { 59 + "encoding": "application/json", 60 + "schema": { 61 + "type": "object", 62 + "required": ["streamer", "pinUri"], 63 + "properties": { 64 + "streamer": { 65 + "type": "string", 66 + "format": "did", 67 + "description": "The DID of the streamer." 68 + }, 69 + "pinUri": { 70 + "type": "string", 71 + "format": "at-uri", 72 + "description": "The AT-URI of the pinned record to delete." 73 + } 74 + } 75 + } 76 + }, 77 + "output": { 78 + "encoding": "application/json", 79 + "schema": { 80 + "type": "object", 81 + "properties": {} 82 + } 83 + }, 84 + "errors": [ 85 + { 86 + "name": "Unauthorized", 87 + "description": "The request lacks valid authentication credentials." 88 + }, 89 + { 90 + "name": "Forbidden", 91 + "description": "The caller does not have permission to unpin messages for this streamer." 92 + }, 93 + { 94 + "name": "SessionNotFound", 95 + "description": "The streamer's OAuth session could not be found or is invalid." 96 + } 97 + ] 98 + } 99 + } 100 + } 101 + ```
+1 -1
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
··· 52 52 "type": "array", 53 53 "items": { 54 54 "type": "string", 55 - "enum": ["ban", "hide", "livestream.manage"] 55 + "enum": ["ban", "hide", "livestream.manage", "message.pin"] 56 56 }, 57 57 "description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata." 58 58 },
+163
js/docs/src/content/docs/lex-reference/openapi.json
··· 979 979 } 980 980 } 981 981 }, 982 + "/xrpc/place.stream.moderation.createPin": { 983 + "post": { 984 + "summary": "Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin.", 985 + "operationId": "place.stream.moderation.createPin", 986 + "tags": ["place.stream.moderation"], 987 + "responses": { 988 + "200": { 989 + "description": "Success", 990 + "content": { 991 + "application/json": { 992 + "schema": { 993 + "type": "object", 994 + "properties": { 995 + "uri": { 996 + "type": "string", 997 + "description": "The AT-URI of the created pinned record.", 998 + "format": "uri" 999 + }, 1000 + "cid": { 1001 + "type": "string", 1002 + "description": "The CID of the created pinned record.", 1003 + "format": "cid" 1004 + } 1005 + }, 1006 + "required": ["uri", "cid"] 1007 + } 1008 + } 1009 + } 1010 + }, 1011 + "400": { 1012 + "description": "Bad Request", 1013 + "content": { 1014 + "application/json": { 1015 + "schema": { 1016 + "type": "object", 1017 + "required": ["error", "message"], 1018 + "properties": { 1019 + "error": { 1020 + "type": "string", 1021 + "oneOf": [ 1022 + { 1023 + "const": "Unauthorized" 1024 + }, 1025 + { 1026 + "const": "Forbidden" 1027 + }, 1028 + { 1029 + "const": "SessionNotFound" 1030 + } 1031 + ] 1032 + }, 1033 + "message": { 1034 + "type": "string" 1035 + } 1036 + } 1037 + } 1038 + } 1039 + } 1040 + } 1041 + }, 1042 + "requestBody": { 1043 + "required": true, 1044 + "content": { 1045 + "application/json": { 1046 + "schema": { 1047 + "type": "object", 1048 + "properties": { 1049 + "streamer": { 1050 + "type": "string", 1051 + "description": "The DID of the streamer.", 1052 + "format": "did" 1053 + }, 1054 + "messageUri": { 1055 + "type": "string", 1056 + "description": "The AT-URI of the chat message to pin.", 1057 + "format": "uri" 1058 + }, 1059 + "expiresAt": { 1060 + "type": "string", 1061 + "description": "Optional expiration time for this pin.", 1062 + "format": "date-time" 1063 + } 1064 + }, 1065 + "required": ["streamer", "messageUri"] 1066 + } 1067 + } 1068 + } 1069 + } 1070 + } 1071 + }, 982 1072 "/xrpc/place.stream.moderation.deleteBlock": { 983 1073 "post": { 984 1074 "summary": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.", ··· 1119 1209 } 1120 1210 }, 1121 1211 "required": ["streamer", "gateUri"] 1212 + } 1213 + } 1214 + } 1215 + } 1216 + } 1217 + }, 1218 + "/xrpc/place.stream.moderation.deletePin": { 1219 + "post": { 1220 + "summary": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.", 1221 + "operationId": "place.stream.moderation.deletePin", 1222 + "tags": ["place.stream.moderation"], 1223 + "responses": { 1224 + "200": { 1225 + "description": "Success", 1226 + "content": { 1227 + "application/json": { 1228 + "schema": { 1229 + "type": "object", 1230 + "properties": {} 1231 + } 1232 + } 1233 + } 1234 + }, 1235 + "400": { 1236 + "description": "Bad Request", 1237 + "content": { 1238 + "application/json": { 1239 + "schema": { 1240 + "type": "object", 1241 + "required": ["error", "message"], 1242 + "properties": { 1243 + "error": { 1244 + "type": "string", 1245 + "oneOf": [ 1246 + { 1247 + "const": "Unauthorized" 1248 + }, 1249 + { 1250 + "const": "Forbidden" 1251 + }, 1252 + { 1253 + "const": "SessionNotFound" 1254 + } 1255 + ] 1256 + }, 1257 + "message": { 1258 + "type": "string" 1259 + } 1260 + } 1261 + } 1262 + } 1263 + } 1264 + } 1265 + }, 1266 + "requestBody": { 1267 + "required": true, 1268 + "content": { 1269 + "application/json": { 1270 + "schema": { 1271 + "type": "object", 1272 + "properties": { 1273 + "streamer": { 1274 + "type": "string", 1275 + "description": "The DID of the streamer.", 1276 + "format": "did" 1277 + }, 1278 + "pinUri": { 1279 + "type": "string", 1280 + "description": "The AT-URI of the pinned record to delete.", 1281 + "format": "uri" 1282 + } 1283 + }, 1284 + "required": ["streamer", "pinUri"] 1122 1285 } 1123 1286 } 1124 1287 }
+5 -4
js/docs/src/content/docs/lex-reference/place-stream-livestream.md
··· 123 123 124 124 **Properties:** 125 125 126 - | Name | Type | Req'd | Description | Constraints | 127 - | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 128 - | `livestream` | Union of:<br/>&nbsp;&nbsp;[`#livestreamView`](#livestreamview)<br/>&nbsp;&nbsp;[`#viewerCount`](#viewercount)<br/>&nbsp;&nbsp;[`#teleportArrival`](#teleportarrival)<br/>&nbsp;&nbsp;[`#teleportCanceled`](#teleportcanceled)<br/>&nbsp;&nbsp;[`place.stream.defs#blockView`](/lex-reference/place-stream-defs#blockview)<br/>&nbsp;&nbsp;[`place.stream.defs#renditions`](/lex-reference/place-stream-defs#renditions)<br/>&nbsp;&nbsp;[`place.stream.defs#rendition`](/lex-reference/place-stream-defs#rendition)<br/>&nbsp;&nbsp;[`place.stream.chat.defs#messageView`](/lex-reference/place-stream-chat-defs#messageview) | ✅ | | | 126 + | Name | Type | Req'd | Description | Constraints | 127 + | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 128 + | `livestream` | Union of:<br/>&nbsp;&nbsp;[`#livestreamView`](#livestreamview)<br/>&nbsp;&nbsp;[`#viewerCount`](#viewercount)<br/>&nbsp;&nbsp;[`#teleportArrival`](#teleportarrival)<br/>&nbsp;&nbsp;[`#teleportCanceled`](#teleportcanceled)<br/>&nbsp;&nbsp;[`place.stream.defs#blockView`](/lex-reference/place-stream-defs#blockview)<br/>&nbsp;&nbsp;[`place.stream.defs#renditions`](/lex-reference/place-stream-defs#renditions)<br/>&nbsp;&nbsp;[`place.stream.defs#rendition`](/lex-reference/place-stream-defs#rendition)<br/>&nbsp;&nbsp;[`place.stream.chat.defs#messageView`](/lex-reference/place-stream-chat-defs#messageview)<br/>&nbsp;&nbsp;[`place.stream.chat.defs#pinnedRecordView`](/lex-reference/place-stream-chat-defs#pinnedrecordview) | ✅ | | | 129 129 130 130 --- 131 131 ··· 309 309 "place.stream.defs#blockView", 310 310 "place.stream.defs#renditions", 311 311 "place.stream.defs#rendition", 312 - "place.stream.chat.defs#messageView" 312 + "place.stream.chat.defs#messageView", 313 + "place.stream.chat.defs#pinnedRecordView" 313 314 ] 314 315 } 315 316 }
+6
js/streamplace/src/useful-types.ts
··· 1 1 import { 2 2 PlaceStreamChatDefs, 3 3 PlaceStreamChatMessage, 4 + PlaceStreamChatPinnedRecord, 4 5 PlaceStreamLivestream, 5 6 } from "./lexicons"; 6 7 ··· 13 14 extends PlaceStreamChatDefs.MessageView { 14 15 record: PlaceStreamChatMessage.Record; 15 16 } 17 + 18 + export interface PinnedRecordViewHydrated 19 + extends PlaceStreamChatDefs.PinnedRecordView { 20 + record: PlaceStreamChatPinnedRecord.Record; 21 + }
+19
lexicons/place/stream/chat/defs.json
··· 36 36 } 37 37 } 38 38 } 39 + }, 40 + "pinnedRecordView": { 41 + "type": "object", 42 + "description": "View of a pinned chat record with hydrated message data.", 43 + "required": ["uri", "cid", "record", "indexedAt"], 44 + "properties": { 45 + "uri": { "type": "string", "format": "at-uri" }, 46 + "cid": { "type": "string", "format": "cid" }, 47 + "record": { "type": "ref", "ref": "place.stream.chat.pinnedRecord" }, 48 + "indexedAt": { "type": "string", "format": "datetime" }, 49 + "pinnedBy": { 50 + "type": "ref", 51 + "ref": "place.stream.chat.profile" 52 + }, 53 + "message": { 54 + "type": "ref", 55 + "ref": "#messageView" 56 + } 57 + } 39 58 } 40 59 } 41 60 }
+37
lexicons/place/stream/chat/pinnedRecord.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.chat.pinnedRecord", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record pinning a chat message for prominent display.", 9 + "record": { 10 + "type": "object", 11 + "required": ["pinnedMessage", "createdAt"], 12 + "properties": { 13 + "pinnedMessage": { 14 + "type": "string", 15 + "format": "at-uri", 16 + "description": "AT-URI of the pinned chat message." 17 + }, 18 + "pinnedBy": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the user who pinned the message." 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "When this pin was created." 27 + }, 28 + "expiresAt": { 29 + "type": "string", 30 + "format": "datetime", 31 + "description": "Optional expiration time. If set, the pin is considered inactive after this time." 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+2 -1
lexicons/place/stream/livestream.json
··· 162 162 "place.stream.defs#blockView", 163 163 "place.stream.defs#renditions", 164 164 "place.stream.defs#rendition", 165 - "place.stream.chat.defs#messageView" 165 + "place.stream.chat.defs#messageView", 166 + "place.stream.chat.defs#pinnedRecordView" 166 167 ] 167 168 } 168 169 }
+67
lexicons/place/stream/moderation/createPin.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.createPin", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["streamer", "messageUri"], 13 + "properties": { 14 + "streamer": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "The DID of the streamer." 18 + }, 19 + "messageUri": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "The AT-URI of the chat message to pin." 23 + }, 24 + "expiresAt": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Optional expiration time for this pin." 28 + } 29 + } 30 + } 31 + }, 32 + "output": { 33 + "encoding": "application/json", 34 + "schema": { 35 + "type": "object", 36 + "required": ["uri", "cid"], 37 + "properties": { 38 + "uri": { 39 + "type": "string", 40 + "format": "at-uri", 41 + "description": "The AT-URI of the created pinned record." 42 + }, 43 + "cid": { 44 + "type": "string", 45 + "format": "cid", 46 + "description": "The CID of the created pinned record." 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "Unauthorized", 54 + "description": "The request lacks valid authentication credentials." 55 + }, 56 + { 57 + "name": "Forbidden", 58 + "description": "The caller does not have permission to pin messages for this streamer." 59 + }, 60 + { 61 + "name": "SessionNotFound", 62 + "description": "The streamer's OAuth session could not be found or is invalid." 63 + } 64 + ] 65 + } 66 + } 67 + }
+50
lexicons/place/stream/moderation/deletePin.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.deletePin", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["streamer", "pinUri"], 13 + "properties": { 14 + "streamer": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "The DID of the streamer." 18 + }, 19 + "pinUri": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "The AT-URI of the pinned record to delete." 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "properties": {} 32 + } 33 + }, 34 + "errors": [ 35 + { 36 + "name": "Unauthorized", 37 + "description": "The request lacks valid authentication credentials." 38 + }, 39 + { 40 + "name": "Forbidden", 41 + "description": "The caller does not have permission to unpin messages for this streamer." 42 + }, 43 + { 44 + "name": "SessionNotFound", 45 + "description": "The streamer's OAuth session could not be found or is invalid." 46 + } 47 + ] 48 + } 49 + } 50 + }
+1 -1
lexicons/place/stream/moderation/permission.json
··· 19 19 "type": "array", 20 20 "items": { 21 21 "type": "string", 22 - "enum": ["ban", "hide", "livestream.manage"] 22 + "enum": ["ban", "hide", "livestream.manage", "message.pin"] 23 23 }, 24 24 "description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata." 25 25 },
+53
pkg/api/websocket.go
··· 259 259 } 260 260 }() 261 261 262 + // get the latest active pinned message for the repo 263 + go func() { 264 + pin, err := a.Model.GetActivePinnedRecord(ctx, repoDID) 265 + if err != nil { 266 + log.Error(ctx, "could not get pinned record", "error", err) 267 + return 268 + } 269 + if pin != nil { 270 + prv, err := pin.ToStreamplacePinnedRecordView() 271 + if err != nil { 272 + log.Error(ctx, "could not convert pinned record to streamplace view", "error", err) 273 + return 274 + } 275 + // look up the original message, pinner 276 + msg, err := a.Model.GetChatMessage(prv.Record.PinnedMessage) 277 + if err != nil { 278 + log.Error(ctx, "failed to get pinned message", err) 279 + return 280 + } 281 + // if the message was deleted, treat as no pinned message 282 + if msg != nil && msg.DeletedAt != nil { 283 + log.Log(ctx, "pinned message was deleted, skipping", "uri", msg.URI) 284 + return 285 + } 286 + // if no pinned by, use the repo owner as the pinner 287 + if prv.Record.PinnedBy == nil { 288 + prv.Record.PinnedBy = &repoDID 289 + } 290 + profile, err := a.Model.GetChatProfile(ctx, *prv.Record.PinnedBy) 291 + if err != nil { 292 + log.Error(ctx, "failed to get chat profile", err) 293 + return 294 + } 295 + if msg != nil { 296 + msgView, err := msg.ToStreamplaceMessageView() 297 + if err != nil { 298 + log.Error(ctx, "failed to convert chat message: %w", err) 299 + return 300 + } 301 + prv.Message = msgView 302 + } 303 + if profile != nil { 304 + profileView, err := profile.ToStreamplaceChatProfile() 305 + if err != nil { 306 + log.Error(ctx, "failed to convert chat profile: %w", err) 307 + return 308 + } 309 + prv.PinnedBy = profileView 310 + } 311 + initialBurst <- prv 312 + } 313 + }() 314 + 262 315 go func() { 263 316 teleports, err := a.Model.GetActiveTeleportsToRepo(repoDID) 264 317 if err != nil {
+14
pkg/atproto/firehose.go
··· 340 340 } 341 341 } 342 342 343 + if collection.String() == constants.PLACE_STREAM_CHAT_PINNED_RECORD { 344 + log.Debug(ctx, "deleting pinned record", "userDID", evt.Repo, "rkey", rkey.String()) 345 + err := atsync.Model.DeletePinnedRecord(ctx, rkey.String()) 346 + if err != nil { 347 + log.Error(ctx, "failed to delete pinned record", "err", err) 348 + } 349 + deletedPin := map[string]any{ 350 + "$type": constants.PLACE_STREAM_CHAT_PINNED_RECORD, 351 + "rkey": rkey.String(), 352 + "deleted": true, 353 + } 354 + go atsync.Bus.Publish(evt.Repo, deletedPin) 355 + } 356 + 343 357 default: 344 358 log.Error(ctx, "unexpected record op kind") 345 359 }
+4
pkg/atproto/labeler_firehose.go
··· 182 182 log.Error(ctx, "failed to get chat message for label", "err", err) 183 183 continue 184 184 } 185 + if msg == nil { 186 + log.Debug(ctx, "chat message not found for label, skipping", "uri", l.URI) 187 + continue 188 + } 185 189 chatView, err := msg.ToStreamplaceMessageView() 186 190 if err != nil { 187 191 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err)
+82
pkg/atproto/sync.go
··· 217 217 } 218 218 go atsync.Bus.Publish(userDID, streamplaceGate) 219 219 220 + case *streamplace.ChatPinnedRecord: 221 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 222 + if err != nil { 223 + return fmt.Errorf("failed to sync bluesky repo: %w", err) 224 + } 225 + if r == nil { 226 + return nil 227 + } 228 + log.Debug(ctx, "creating pinned record", "userDID", userDID, "pinnedMessage", rec.PinnedMessage) 229 + // err = atsync.Model.DeleteAllPinnedRecords(ctx, userDID) 230 + // if err != nil { 231 + // log.Error(ctx, "failed to delete existing pinned records", "err", err) 232 + // } 233 + // Parse optional expiresAt 234 + var expiresAt *time.Time 235 + if rec.ExpiresAt != nil { 236 + t, err := time.Parse(time.RFC3339, *rec.ExpiresAt) 237 + if err == nil { 238 + expiresAt = &t 239 + } 240 + } 241 + // serialise createdAt 242 + createdAt, err := time.Parse(time.RFC3339, rec.CreatedAt) 243 + if err != nil { 244 + return fmt.Errorf("failed to parse createdAt: %w", err) 245 + } 246 + 247 + var pinnedBy string 248 + if rec.PinnedBy == nil { 249 + pinnedBy = userDID 250 + } else { 251 + pinnedBy = *rec.PinnedBy 252 + } 253 + 254 + pin := &model.PinnedRecord{ 255 + Uri: aturi.String(), 256 + RepoDID: userDID, 257 + PinnedMessage: rec.PinnedMessage, 258 + PinnedBy: pinnedBy, 259 + IndexedAt: &now, 260 + CID: cid, 261 + CreatedAt: createdAt, 262 + Repo: repo, 263 + ExpiresAt: expiresAt, 264 + } 265 + err = atsync.Model.CreatePinnedRecord(ctx, pin) 266 + if err != nil { 267 + return fmt.Errorf("failed to create pinned record: %w", err) 268 + } 269 + pin, err = atsync.Model.GetPinnedRecord(ctx, pin.Uri) 270 + if err != nil { 271 + return fmt.Errorf("failed to get pinned record after we just saved it: %w", err) 272 + } 273 + pinnedView, err := pin.ToStreamplacePinnedRecordView() 274 + if err != nil { 275 + return fmt.Errorf("failed to convert pinned record: %w", err) 276 + } 277 + // look up the original message, pinner 278 + msg, err := atsync.Model.GetChatMessage(pinnedView.Record.PinnedMessage) 279 + if err != nil { 280 + return fmt.Errorf("failed to get chat message: %w", err) 281 + } 282 + profile, err := atsync.Model.GetChatProfile(ctx, pinnedBy) 283 + if err != nil { 284 + return fmt.Errorf("failed to get chat profile: %w", err) 285 + } 286 + if msg != nil { 287 + msgView, err := msg.ToStreamplaceMessageView() 288 + if err != nil { 289 + return fmt.Errorf("failed to convert chat message: %w", err) 290 + } 291 + pinnedView.Message = msgView 292 + } 293 + if profile != nil { 294 + profileView, err := profile.ToStreamplaceChatProfile() 295 + if err != nil { 296 + return fmt.Errorf("failed to convert chat profile: %w", err) 297 + } 298 + pinnedView.PinnedBy = profileView 299 + } 300 + go atsync.Bus.Publish(userDID, pinnedView) 301 + 220 302 case *streamplace.ChatProfile: 221 303 repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 222 304 if err != nil {
+1
pkg/constants/constants.go
··· 13 13 var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all 14 14 var APP_BSKY_ACTOR_PROFILE = "app.bsky.actor.profile" //nolint:all 15 15 var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all 16 + var PLACE_STREAM_CHAT_PINNED_RECORD = "place.stream.chat.pinnedRecord" //nolint:all 16 17 var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 17 18 var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 18 19 var PLACE_STREAM_LIVE_VIEWERCOUNT = "place.stream.live.viewerCount" //nolint:all
+1
pkg/gen/gen.go
··· 26 26 streamplace.ChatMessage_ReplyRef{}, 27 27 streamplace.ServerSettings{}, 28 28 streamplace.ChatGate{}, 29 + streamplace.ChatPinnedRecord{}, 29 30 streamplace.MultistreamTarget{}, 30 31 streamplace.BroadcastOrigin{}, 31 32 streamplace.BroadcastSyndication{},
+7
pkg/model/model.go
··· 85 85 GetGate(ctx context.Context, rkey string) (*Gate, error) 86 86 GetUserGates(ctx context.Context, userDID string) ([]*Gate, error) 87 87 88 + CreatePinnedRecord(ctx context.Context, pin *PinnedRecord) error 89 + DeletePinnedRecord(ctx context.Context, uri string) error 90 + DeleteAllPinnedRecords(ctx context.Context, streamerDID string) error 91 + GetPinnedRecord(ctx context.Context, uri string) (*PinnedRecord, error) 92 + GetActivePinnedRecord(ctx context.Context, streamerDID string) (*PinnedRecord, error) 93 + 88 94 CreateChatProfile(ctx context.Context, profile *ChatProfile) error 89 95 GetChatProfile(ctx context.Context, repoDID string) (*ChatProfile, error) 90 96 ··· 179 185 ChatMessage{}, 180 186 ChatProfile{}, 181 187 Gate{}, 188 + PinnedRecord{}, 182 189 ServerSettings{}, 183 190 Labeler{}, 184 191 Label{},
+96
pkg/model/pinned_record.go
··· 1 + package model 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/util" 9 + "gorm.io/gorm" 10 + "stream.place/streamplace/pkg/streamplace" 11 + ) 12 + 13 + type PinnedRecord struct { 14 + Uri string `gorm:"primaryKey;column:uri"` 15 + CID string `gorm:"column:cid"` 16 + RepoDID string `json:"repoDID" gorm:"column:repo_did"` 17 + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 18 + PinnedMessage string `gorm:"column:pinned_message" json:"pinnedMessage"` 19 + PinnedBy string `gorm:"column:pinned_by" json:"pinnedBy"` 20 + IndexedAt *time.Time `gorm:"column:indexed_at" json:"indexedAt"` 21 + ExpiresAt *time.Time `gorm:"column:expires_at" json:"expiresAt"` 22 + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` 23 + } 24 + 25 + func (p *PinnedRecord) ToStreamplacePinnedRecord() (*streamplace.ChatPinnedRecord, error) { 26 + rec := &streamplace.ChatPinnedRecord{ 27 + LexiconTypeID: "place.stream.chat.pinnedRecord", 28 + PinnedMessage: p.PinnedMessage, 29 + CreatedAt: p.CreatedAt.UTC().Format(util.ISO8601), 30 + } 31 + if p.ExpiresAt != nil { 32 + s := p.ExpiresAt.UTC().Format(util.ISO8601) 33 + rec.ExpiresAt = &s 34 + } 35 + return rec, nil 36 + } 37 + 38 + func (p *PinnedRecord) ToStreamplacePinnedRecordView() (*streamplace.ChatDefs_PinnedRecordView, error) { 39 + pr := &streamplace.ChatPinnedRecord{ 40 + LexiconTypeID: "place.stream.chat.pinnedRecord", 41 + PinnedMessage: p.PinnedMessage, 42 + CreatedAt: p.CreatedAt.UTC().Format(util.ISO8601), 43 + } 44 + if p.ExpiresAt != nil { 45 + s := p.ExpiresAt.UTC().Format(util.ISO8601) 46 + pr.ExpiresAt = &s 47 + } 48 + rec := &streamplace.ChatDefs_PinnedRecordView{ 49 + LexiconTypeID: "place.stream.chat.defs#pinnedRecordView", 50 + Record: pr, 51 + Cid: p.CID, 52 + IndexedAt: p.CreatedAt.UTC().Format(time.RFC3339Nano), 53 + // message, pinnedby not included, will fill in later 54 + Uri: p.Uri, 55 + } 56 + return rec, nil 57 + } 58 + func (m *DBModel) CreatePinnedRecord(ctx context.Context, pin *PinnedRecord) error { 59 + return m.DB.Create(pin).Error 60 + } 61 + 62 + func (m *DBModel) GetPinnedRecord(ctx context.Context, uri string) (*PinnedRecord, error) { 63 + var pin PinnedRecord 64 + err := m.DB.Preload("Repo").Where("uri = ?", uri).First(&pin).Error 65 + if errors.Is(err, gorm.ErrRecordNotFound) { 66 + return nil, nil 67 + } 68 + if err != nil { 69 + return nil, err 70 + } 71 + return &pin, nil 72 + } 73 + 74 + func (m *DBModel) DeletePinnedRecord(ctx context.Context, uri string) error { 75 + return m.DB.Where("uri = ?", uri).Delete(&PinnedRecord{}).Error 76 + } 77 + 78 + func (m *DBModel) DeleteAllPinnedRecords(ctx context.Context, streamerDID string) error { 79 + return m.DB.Where("repo_did = ?", streamerDID).Delete(&PinnedRecord{}).Error 80 + } 81 + 82 + func (m *DBModel) GetActivePinnedRecord(ctx context.Context, streamerDID string) (*PinnedRecord, error) { 83 + var pin PinnedRecord 84 + now := time.Now() 85 + err := m.DB.Preload("Repo"). 86 + Where("repo_did = ? AND (expires_at IS NULL OR expires_at > ?)", streamerDID, now). 87 + Order("created_at DESC"). 88 + First(&pin).Error 89 + if errors.Is(err, gorm.ErrRecordNotFound) { 90 + return nil, nil 91 + } 92 + if err != nil { 93 + return nil, err 94 + } 95 + return &pin, nil 96 + }
+3
pkg/moderation/permissions.go
··· 17 17 PermissionBan = "ban" 18 18 PermissionHide = "hide" 19 19 PermissionLivestreamManage = "livestream.manage" 20 + PermissionMessagePin = "message.pin" 20 21 ) 21 22 22 23 // ActionPermissions maps moderation actions to required permissions ··· 26 27 "createGate": PermissionHide, 27 28 "deleteGate": PermissionHide, 28 29 "updateLivestream": PermissionLivestreamManage, 30 + "createPin": PermissionMessagePin, 31 + "deletePin": PermissionMessagePin, 29 32 } 30 33 31 34 // PermissionChecker validates moderation permissions
+143
pkg/spxrpc/place_stream_moderation.go
··· 360 360 361 361 return s.statefulDB.CreateAuditLog(ctx, auditLog) 362 362 } 363 + 364 + func (s *Server) handlePlaceStreamModerationCreatePin(ctx context.Context, input *streamplace.ModerationCreatePin_Input) (*streamplace.ModerationCreatePin_Output, error) { 365 + // Validate input 366 + if err := validateDID(input.Streamer); err != nil { 367 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err)) 368 + } 369 + if err := validateATURI(input.MessageUri); err != nil { 370 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid messageUri: %v", err)) 371 + } 372 + 373 + // Get delegated moderation context (validates OAuth, permission, and returns client) 374 + modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "createPin") 375 + if err != nil { 376 + return nil, err 377 + } 378 + 379 + // Check that the streamer has an active livestream 380 + ls, err := s.model.GetLatestLivestreamForRepo(input.Streamer) 381 + if err != nil { 382 + log.Error(ctx, "failed to get livestream for streamer", "err", err) 383 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check livestream status") 384 + } 385 + if ls == nil || ls.Livestream == nil { 386 + return nil, echo.NewHTTPError(http.StatusBadRequest, "no active livestream for this streamer") 387 + } 388 + lsRec, err := lexutil.CborDecodeValue(*ls.Livestream) 389 + if err != nil { 390 + log.Error(ctx, "failed to decode livestream record", "err", err) 391 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check livestream status") 392 + } 393 + lsTyped, ok := lsRec.(*streamplace.Livestream) 394 + if ok && lsTyped.EndedAt != nil { 395 + return nil, echo.NewHTTPError(http.StatusBadRequest, "livestream has ended, cannot pin comments") 396 + } 397 + 398 + // Delete existing pinned records for this streamer (single-pin semantics) 399 + var listOutput comatproto.RepoListRecords_Output 400 + err = modCtx.StreamerClient.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.listRecords", 401 + map[string]any{ 402 + "repo": input.Streamer, 403 + "collection": constants.PLACE_STREAM_CHAT_PINNED_RECORD, 404 + }, nil, &listOutput) 405 + if err != nil { 406 + log.Error(ctx, "failed to list existing pinned records", "err", err) 407 + } else { 408 + for _, rec := range listOutput.Records { 409 + rkey, delErr := extractRKey(rec.Uri) 410 + if delErr != nil { 411 + continue 412 + } 413 + deleteInput := comatproto.RepoDeleteRecord_Input{ 414 + Collection: constants.PLACE_STREAM_CHAT_PINNED_RECORD, 415 + Rkey: rkey, 416 + Repo: input.Streamer, 417 + } 418 + deleteOutput := comatproto.RepoDeleteRecord_Output{} 419 + if err := modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput); err != nil { 420 + log.Error(ctx, "failed to delete existing pinned record", "rkey", rkey, "err", err) 421 + } 422 + } 423 + } 424 + 425 + // Create the pinned record 426 + pinnedRecord := &streamplace.ChatPinnedRecord{ 427 + LexiconTypeID: "place.stream.chat.pinnedRecord", 428 + PinnedMessage: input.MessageUri, 429 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 430 + ExpiresAt: input.ExpiresAt, 431 + } 432 + 433 + createInput := comatproto.RepoCreateRecord_Input{ 434 + Collection: constants.PLACE_STREAM_CHAT_PINNED_RECORD, 435 + Record: &lexutil.LexiconTypeDecoder{Val: pinnedRecord}, 436 + Repo: input.Streamer, 437 + } 438 + createOutput := comatproto.RepoCreateRecord_Output{} 439 + 440 + err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, createInput, &createOutput) 441 + if err != nil { 442 + log.Error(ctx, "failed to create pinned record", "err", err) 443 + if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createPin", input.MessageUri, "", "", false, err.Error()); auditErr != nil { 444 + log.Error(ctx, "failed to create audit log", "error", auditErr) 445 + } 446 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create pinned record: %v", err)) 447 + } 448 + 449 + // Log successful audit entry 450 + if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createPin", input.MessageUri, "", createOutput.Uri, true, ""); err != nil { 451 + log.Error(ctx, "failed to create audit log", "error", err) 452 + } 453 + 454 + return &streamplace.ModerationCreatePin_Output{ 455 + Uri: createOutput.Uri, 456 + Cid: createOutput.Cid, 457 + }, nil 458 + } 459 + 460 + func (s *Server) handlePlaceStreamModerationDeletePin(ctx context.Context, input *streamplace.ModerationDeletePin_Input) (*streamplace.ModerationDeletePin_Output, error) { 461 + // Validate input 462 + if err := validateDID(input.Streamer); err != nil { 463 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err)) 464 + } 465 + if err := validateATURI(input.PinUri); err != nil { 466 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid pinUri: %v", err)) 467 + } 468 + 469 + // Get delegated moderation context (validates OAuth, permission, and returns client) 470 + modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "deletePin") 471 + if err != nil { 472 + return nil, err 473 + } 474 + 475 + // Parse pinUri to extract rkey 476 + rkey, err := extractRKey(input.PinUri) 477 + if err != nil { 478 + log.Error(ctx, "failed to extract rkey from pinUri", "uri", input.PinUri, "err", err) 479 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid pinUri format") 480 + } 481 + 482 + // Delete pinned record from streamer's repo 483 + deleteInput := comatproto.RepoDeleteRecord_Input{ 484 + Collection: constants.PLACE_STREAM_CHAT_PINNED_RECORD, 485 + Rkey: rkey, 486 + Repo: input.Streamer, 487 + } 488 + deleteOutput := comatproto.RepoDeleteRecord_Output{} 489 + 490 + err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput) 491 + if err != nil { 492 + log.Error(ctx, "failed to delete pinned record", "err", err) 493 + if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deletePin", input.PinUri, "", "", false, err.Error()); auditErr != nil { 494 + log.Error(ctx, "failed to create audit log", "error", auditErr) 495 + } 496 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete pinned record: %v", err)) 497 + } 498 + 499 + // Log successful audit entry 500 + if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deletePin", input.PinUri, "", "", true, ""); err != nil { 501 + log.Error(ctx, "failed to create audit log", "error", err) 502 + } 503 + 504 + return &streamplace.ModerationDeletePin_Output{}, nil 505 + }
+38
pkg/spxrpc/stubs.go
··· 296 296 e.POST("/xrpc/place.stream.live.stopLivestream", s.HandlePlaceStreamLiveStopLivestream) 297 297 e.POST("/xrpc/place.stream.moderation.createBlock", s.HandlePlaceStreamModerationCreateBlock) 298 298 e.POST("/xrpc/place.stream.moderation.createGate", s.HandlePlaceStreamModerationCreateGate) 299 + e.POST("/xrpc/place.stream.moderation.createPin", s.HandlePlaceStreamModerationCreatePin) 299 300 e.POST("/xrpc/place.stream.moderation.deleteBlock", s.HandlePlaceStreamModerationDeleteBlock) 300 301 e.POST("/xrpc/place.stream.moderation.deleteGate", s.HandlePlaceStreamModerationDeleteGate) 302 + e.POST("/xrpc/place.stream.moderation.deletePin", s.HandlePlaceStreamModerationDeletePin) 301 303 e.POST("/xrpc/place.stream.moderation.updateLivestream", s.HandlePlaceStreamModerationUpdateLivestream) 302 304 e.POST("/xrpc/place.stream.multistream.createTarget", s.HandlePlaceStreamMultistreamCreateTarget) 303 305 e.POST("/xrpc/place.stream.multistream.deleteTarget", s.HandlePlaceStreamMultistreamDeleteTarget) ··· 627 629 return c.JSON(200, out) 628 630 } 629 631 632 + func (s *Server) HandlePlaceStreamModerationCreatePin(c echo.Context) error { 633 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationCreatePin") 634 + defer span.End() 635 + 636 + var body placestream.ModerationCreatePin_Input 637 + if err := c.Bind(&body); err != nil { 638 + return err 639 + } 640 + var out *placestream.ModerationCreatePin_Output 641 + var handleErr error 642 + // func (s *Server) handlePlaceStreamModerationCreatePin(ctx context.Context,body *placestream.ModerationCreatePin_Input) (*placestream.ModerationCreatePin_Output, error) 643 + out, handleErr = s.handlePlaceStreamModerationCreatePin(ctx, &body) 644 + if handleErr != nil { 645 + return handleErr 646 + } 647 + return c.JSON(200, out) 648 + } 649 + 630 650 func (s *Server) HandlePlaceStreamModerationDeleteBlock(c echo.Context) error { 631 651 ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeleteBlock") 632 652 defer span.End() ··· 657 677 var handleErr error 658 678 // func (s *Server) handlePlaceStreamModerationDeleteGate(ctx context.Context,body *placestream.ModerationDeleteGate_Input) (*placestream.ModerationDeleteGate_Output, error) 659 679 out, handleErr = s.handlePlaceStreamModerationDeleteGate(ctx, &body) 680 + if handleErr != nil { 681 + return handleErr 682 + } 683 + return c.JSON(200, out) 684 + } 685 + 686 + func (s *Server) HandlePlaceStreamModerationDeletePin(c echo.Context) error { 687 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeletePin") 688 + defer span.End() 689 + 690 + var body placestream.ModerationDeletePin_Input 691 + if err := c.Bind(&body); err != nil { 692 + return err 693 + } 694 + var out *placestream.ModerationDeletePin_Output 695 + var handleErr error 696 + // func (s *Server) handlePlaceStreamModerationDeletePin(ctx context.Context,body *placestream.ModerationDeletePin_Input) (*placestream.ModerationDeletePin_Output, error) 697 + out, handleErr = s.handlePlaceStreamModerationDeletePin(ctx, &body) 660 698 if handleErr != nil { 661 699 return handleErr 662 700 }
+279
pkg/streamplace/cbor_gen.go
··· 3634 3634 3635 3635 return nil 3636 3636 } 3637 + func (t *ChatPinnedRecord) MarshalCBOR(w io.Writer) error { 3638 + if t == nil { 3639 + _, err := w.Write(cbg.CborNull) 3640 + return err 3641 + } 3642 + 3643 + cw := cbg.NewCborWriter(w) 3644 + fieldCount := 5 3645 + 3646 + if t.ExpiresAt == nil { 3647 + fieldCount-- 3648 + } 3649 + 3650 + if t.PinnedBy == nil { 3651 + fieldCount-- 3652 + } 3653 + 3654 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3655 + return err 3656 + } 3657 + 3658 + // t.LexiconTypeID (string) (string) 3659 + if len("$type") > 1000000 { 3660 + return xerrors.Errorf("Value in field \"$type\" was too long") 3661 + } 3662 + 3663 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 3664 + return err 3665 + } 3666 + if _, err := cw.WriteString(string("$type")); err != nil { 3667 + return err 3668 + } 3669 + 3670 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.chat.pinnedRecord"))); err != nil { 3671 + return err 3672 + } 3673 + if _, err := cw.WriteString(string("place.stream.chat.pinnedRecord")); err != nil { 3674 + return err 3675 + } 3676 + 3677 + // t.PinnedBy (string) (string) 3678 + if t.PinnedBy != nil { 3679 + 3680 + if len("pinnedBy") > 1000000 { 3681 + return xerrors.Errorf("Value in field \"pinnedBy\" was too long") 3682 + } 3683 + 3684 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedBy"))); err != nil { 3685 + return err 3686 + } 3687 + if _, err := cw.WriteString(string("pinnedBy")); err != nil { 3688 + return err 3689 + } 3690 + 3691 + if t.PinnedBy == nil { 3692 + if _, err := cw.Write(cbg.CborNull); err != nil { 3693 + return err 3694 + } 3695 + } else { 3696 + if len(*t.PinnedBy) > 1000000 { 3697 + return xerrors.Errorf("Value in field t.PinnedBy was too long") 3698 + } 3699 + 3700 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.PinnedBy))); err != nil { 3701 + return err 3702 + } 3703 + if _, err := cw.WriteString(string(*t.PinnedBy)); err != nil { 3704 + return err 3705 + } 3706 + } 3707 + } 3708 + 3709 + // t.CreatedAt (string) (string) 3710 + if len("createdAt") > 1000000 { 3711 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 3712 + } 3713 + 3714 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 3715 + return err 3716 + } 3717 + if _, err := cw.WriteString(string("createdAt")); err != nil { 3718 + return err 3719 + } 3720 + 3721 + if len(t.CreatedAt) > 1000000 { 3722 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 3723 + } 3724 + 3725 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 3726 + return err 3727 + } 3728 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 3729 + return err 3730 + } 3731 + 3732 + // t.ExpiresAt (string) (string) 3733 + if t.ExpiresAt != nil { 3734 + 3735 + if len("expiresAt") > 1000000 { 3736 + return xerrors.Errorf("Value in field \"expiresAt\" was too long") 3737 + } 3738 + 3739 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("expiresAt"))); err != nil { 3740 + return err 3741 + } 3742 + if _, err := cw.WriteString(string("expiresAt")); err != nil { 3743 + return err 3744 + } 3745 + 3746 + if t.ExpiresAt == nil { 3747 + if _, err := cw.Write(cbg.CborNull); err != nil { 3748 + return err 3749 + } 3750 + } else { 3751 + if len(*t.ExpiresAt) > 1000000 { 3752 + return xerrors.Errorf("Value in field t.ExpiresAt was too long") 3753 + } 3754 + 3755 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ExpiresAt))); err != nil { 3756 + return err 3757 + } 3758 + if _, err := cw.WriteString(string(*t.ExpiresAt)); err != nil { 3759 + return err 3760 + } 3761 + } 3762 + } 3763 + 3764 + // t.PinnedMessage (string) (string) 3765 + if len("pinnedMessage") > 1000000 { 3766 + return xerrors.Errorf("Value in field \"pinnedMessage\" was too long") 3767 + } 3768 + 3769 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedMessage"))); err != nil { 3770 + return err 3771 + } 3772 + if _, err := cw.WriteString(string("pinnedMessage")); err != nil { 3773 + return err 3774 + } 3775 + 3776 + if len(t.PinnedMessage) > 1000000 { 3777 + return xerrors.Errorf("Value in field t.PinnedMessage was too long") 3778 + } 3779 + 3780 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.PinnedMessage))); err != nil { 3781 + return err 3782 + } 3783 + if _, err := cw.WriteString(string(t.PinnedMessage)); err != nil { 3784 + return err 3785 + } 3786 + return nil 3787 + } 3788 + 3789 + func (t *ChatPinnedRecord) UnmarshalCBOR(r io.Reader) (err error) { 3790 + *t = ChatPinnedRecord{} 3791 + 3792 + cr := cbg.NewCborReader(r) 3793 + 3794 + maj, extra, err := cr.ReadHeader() 3795 + if err != nil { 3796 + return err 3797 + } 3798 + defer func() { 3799 + if err == io.EOF { 3800 + err = io.ErrUnexpectedEOF 3801 + } 3802 + }() 3803 + 3804 + if maj != cbg.MajMap { 3805 + return fmt.Errorf("cbor input should be of type map") 3806 + } 3807 + 3808 + if extra > cbg.MaxLength { 3809 + return fmt.Errorf("ChatPinnedRecord: map struct too large (%d)", extra) 3810 + } 3811 + 3812 + n := extra 3813 + 3814 + nameBuf := make([]byte, 13) 3815 + for i := uint64(0); i < n; i++ { 3816 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3817 + if err != nil { 3818 + return err 3819 + } 3820 + 3821 + if !ok { 3822 + // Field doesn't exist on this type, so ignore it 3823 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3824 + return err 3825 + } 3826 + continue 3827 + } 3828 + 3829 + switch string(nameBuf[:nameLen]) { 3830 + // t.LexiconTypeID (string) (string) 3831 + case "$type": 3832 + 3833 + { 3834 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3835 + if err != nil { 3836 + return err 3837 + } 3838 + 3839 + t.LexiconTypeID = string(sval) 3840 + } 3841 + // t.PinnedBy (string) (string) 3842 + case "pinnedBy": 3843 + 3844 + { 3845 + b, err := cr.ReadByte() 3846 + if err != nil { 3847 + return err 3848 + } 3849 + if b != cbg.CborNull[0] { 3850 + if err := cr.UnreadByte(); err != nil { 3851 + return err 3852 + } 3853 + 3854 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3855 + if err != nil { 3856 + return err 3857 + } 3858 + 3859 + t.PinnedBy = (*string)(&sval) 3860 + } 3861 + } 3862 + // t.CreatedAt (string) (string) 3863 + case "createdAt": 3864 + 3865 + { 3866 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3867 + if err != nil { 3868 + return err 3869 + } 3870 + 3871 + t.CreatedAt = string(sval) 3872 + } 3873 + // t.ExpiresAt (string) (string) 3874 + case "expiresAt": 3875 + 3876 + { 3877 + b, err := cr.ReadByte() 3878 + if err != nil { 3879 + return err 3880 + } 3881 + if b != cbg.CborNull[0] { 3882 + if err := cr.UnreadByte(); err != nil { 3883 + return err 3884 + } 3885 + 3886 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3887 + if err != nil { 3888 + return err 3889 + } 3890 + 3891 + t.ExpiresAt = (*string)(&sval) 3892 + } 3893 + } 3894 + // t.PinnedMessage (string) (string) 3895 + case "pinnedMessage": 3896 + 3897 + { 3898 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3899 + if err != nil { 3900 + return err 3901 + } 3902 + 3903 + t.PinnedMessage = string(sval) 3904 + } 3905 + 3906 + default: 3907 + // Field doesn't exist on this type, so ignore it 3908 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3909 + return err 3910 + } 3911 + } 3912 + } 3913 + 3914 + return nil 3915 + } 3637 3916 func (t *MultistreamTarget) MarshalCBOR(w io.Writer) error { 3638 3917 if t == nil { 3639 3918 _, err := w.Write(cbg.CborNull)
+13
pkg/streamplace/chatdefs.go
··· 54 54 return nil 55 55 } 56 56 } 57 + 58 + // ChatDefs_PinnedRecordView is a "pinnedRecordView" in the place.stream.chat.defs schema. 59 + // 60 + // View of a pinned chat record with hydrated message data. 61 + type ChatDefs_PinnedRecordView struct { 62 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#pinnedRecordView"` 63 + Cid string `json:"cid" cborgen:"cid"` 64 + IndexedAt string `json:"indexedAt" cborgen:"indexedAt"` 65 + Message *ChatDefs_MessageView `json:"message,omitempty" cborgen:"message,omitempty"` 66 + PinnedBy *ChatProfile `json:"pinnedBy,omitempty" cborgen:"pinnedBy,omitempty"` 67 + Record *ChatPinnedRecord `json:"record" cborgen:"record"` 68 + Uri string `json:"uri" cborgen:"uri"` 69 + }
+25
pkg/streamplace/chatpinnedRecord.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.chat.pinnedRecord 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.chat.pinnedRecord", &ChatPinnedRecord{}) 13 + } 14 + 15 + type ChatPinnedRecord struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.pinnedRecord"` 17 + // createdAt: When this pin was created. 18 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 19 + // expiresAt: Optional expiration time. If set, the pin is considered inactive after this time. 20 + ExpiresAt *string `json:"expiresAt,omitempty" cborgen:"expiresAt,omitempty"` 21 + // pinnedBy: DID of the user who pinned the message. 22 + PinnedBy *string `json:"pinnedBy,omitempty" cborgen:"pinnedBy,omitempty"` 23 + // pinnedMessage: AT-URI of the pinned chat message. 24 + PinnedMessage string `json:"pinnedMessage" cborgen:"pinnedMessage"` 25 + }
+39
pkg/streamplace/moderationcreatePin.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.createPin 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // ModerationCreatePin_Input is the input argument to a place.stream.moderation.createPin call. 14 + type ModerationCreatePin_Input struct { 15 + // expiresAt: Optional expiration time for this pin. 16 + ExpiresAt *string `json:"expiresAt,omitempty" cborgen:"expiresAt,omitempty"` 17 + // messageUri: The AT-URI of the chat message to pin. 18 + MessageUri string `json:"messageUri" cborgen:"messageUri"` 19 + // streamer: The DID of the streamer. 20 + Streamer string `json:"streamer" cborgen:"streamer"` 21 + } 22 + 23 + // ModerationCreatePin_Output is the output of a place.stream.moderation.createPin call. 24 + type ModerationCreatePin_Output struct { 25 + // cid: The CID of the created pinned record. 26 + Cid string `json:"cid" cborgen:"cid"` 27 + // uri: The AT-URI of the created pinned record. 28 + Uri string `json:"uri" cborgen:"uri"` 29 + } 30 + 31 + // ModerationCreatePin calls the XRPC method "place.stream.moderation.createPin". 32 + func ModerationCreatePin(ctx context.Context, c lexutil.LexClient, input *ModerationCreatePin_Input) (*ModerationCreatePin_Output, error) { 33 + var out ModerationCreatePin_Output 34 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.createPin", nil, input, &out); err != nil { 35 + return nil, err 36 + } 37 + 38 + return &out, nil 39 + }
+33
pkg/streamplace/moderationdeletePin.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.deletePin 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // ModerationDeletePin_Input is the input argument to a place.stream.moderation.deletePin call. 14 + type ModerationDeletePin_Input struct { 15 + // pinUri: The AT-URI of the pinned record to delete. 16 + PinUri string `json:"pinUri" cborgen:"pinUri"` 17 + // streamer: The DID of the streamer. 18 + Streamer string `json:"streamer" cborgen:"streamer"` 19 + } 20 + 21 + // ModerationDeletePin_Output is the output of a place.stream.moderation.deletePin call. 22 + type ModerationDeletePin_Output struct { 23 + } 24 + 25 + // ModerationDeletePin calls the XRPC method "place.stream.moderation.deletePin". 26 + func ModerationDeletePin(ctx context.Context, c lexutil.LexClient, input *ModerationDeletePin_Input) (*ModerationDeletePin_Output, error) { 27 + var out ModerationDeletePin_Output 28 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.deletePin", nil, input, &out); err != nil { 29 + return nil, err 30 + } 31 + 32 + return &out, nil 33 + }
+8
pkg/streamplace/streamlivestream.go
··· 73 73 Defs_Renditions *Defs_Renditions 74 74 Defs_Rendition *Defs_Rendition 75 75 ChatDefs_MessageView *ChatDefs_MessageView 76 + ChatDefs_PinnedRecordView *ChatDefs_PinnedRecordView 76 77 } 77 78 78 79 func (t *Livestream_StreamplaceAnything_Livestream) MarshalJSON() ([]byte, error) { ··· 108 109 t.ChatDefs_MessageView.LexiconTypeID = "place.stream.chat.defs#messageView" 109 110 return json.Marshal(t.ChatDefs_MessageView) 110 111 } 112 + if t.ChatDefs_PinnedRecordView != nil { 113 + t.ChatDefs_PinnedRecordView.LexiconTypeID = "place.stream.chat.defs#pinnedRecordView" 114 + return json.Marshal(t.ChatDefs_PinnedRecordView) 115 + } 111 116 return nil, fmt.Errorf("can not marshal empty union as JSON") 112 117 } 113 118 ··· 142 147 case "place.stream.chat.defs#messageView": 143 148 t.ChatDefs_MessageView = new(ChatDefs_MessageView) 144 149 return json.Unmarshal(b, t.ChatDefs_MessageView) 150 + case "place.stream.chat.defs#pinnedRecordView": 151 + t.ChatDefs_PinnedRecordView = new(ChatDefs_PinnedRecordView) 152 + return json.Unmarshal(b, t.ChatDefs_PinnedRecordView) 145 153 default: 146 154 return nil 147 155 }