Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+2213 -413
+6
.prettierrc
··· 6 6 "options": { 7 7 "proseWrap": "preserve" 8 8 } 9 + }, 10 + { 11 + "files": "*.md", 12 + "options": { 13 + "proseWrap": "preserve" 14 + } 9 15 } 10 16 ], 11 17 "plugins": ["prettier-plugin-organize-imports"]
+5 -1
js/app/components/live-dashboard/stream-monitor.tsx
··· 104 104 <View style={[flex.values[1], layout.flex.center, bg.neutral[900]]}> 105 105 {isLive && userProfile ? ( 106 106 isStreamVisible ? ( 107 - <Player src={userProfile.did} name={userProfile.handle}> 107 + <Player 108 + src={userProfile.did} 109 + name={userProfile.handle} 110 + muted={true} 111 + > 108 112 <DesktopUi /> 109 113 <PlayerUI.ViewerLoadingOverlay /> 110 114 <OfflineCounter isMobile={true} />
+4
js/app/components/login/login-modal.tsx
··· 16 16 }: LoginModalProps) { 17 17 const { theme, zero: z } = useTheme(); 18 18 19 + if (!visible) { 20 + return null; 21 + } 22 + 19 23 return ( 20 24 <Modal 21 25 visible={visible}
+1
js/app/components/login/login.tsx
··· 27 27 28 28 // check for stored return route on mount 29 29 useEffect(() => { 30 + if (Platform.OS !== "web") return; 30 31 storage.getItem("returnRoute").then((stored) => { 31 32 if (stored) { 32 33 try {
+1
js/app/components/mobile/desktop-ui.tsx
··· 254 254 setTitle={setTitle} 255 255 ingestStarting={ingestStarting} 256 256 toggleGoLive={toggleGoLive} 257 + isLive={isActivelyLive} 257 258 /> 258 259 )} 259 260
+16 -3
js/app/components/mobile/ui.tsx
··· 72 72 ingestStarting, 73 73 setIngestStarting, 74 74 toggleGoLive, 75 + toggleStopStream, 75 76 } = useLivestreamInfo(); 76 77 const { width, height } = usePlayerDimensions(); 77 78 const { isPlayerRatioGreater } = useSegmentDimensions(); ··· 102 103 103 104 const isSelfAndNotLive = ingest === "new"; 104 105 const isLive = ingest !== null && ingest !== "new"; 106 + 107 + useEffect(() => { 108 + if (isLive && ingestStarting) { 109 + setIngestStarting(false); 110 + } 111 + }, [isLive, ingestStarting, setIngestStarting]); 105 112 106 113 const FADE_OUT_DELAY = 4000; 107 114 const fadeOpacity = useSharedValue(1); ··· 222 229 <View 223 230 style={[ 224 231 layout.position.absolute, 225 - position.top[28], 232 + position.top[32], 226 233 position.left[0], 227 234 position.right[0], 228 235 layout.flex.column, ··· 230 237 ]} 231 238 > 232 239 <PlayerUI.MetricsPanel 233 - showMetrics={isLive || isSelfAndNotLive} 240 + showMetrics={shouldShowFloatingMetrics} 234 241 /> 235 242 </View> 236 243 )} ··· 241 248 setTitle={setTitle} 242 249 ingestStarting={ingestStarting} 243 250 toggleGoLive={toggleGoLive} 251 + isLive={isLive} 244 252 /> 245 253 )} 246 254 ··· 468 476 <Pressable onPress={doSetIngestCamera}> 469 477 <SwitchCamera color={theme.colors.foreground} size={20} /> 470 478 </Pressable> 479 + {Platform.OS === "web" && <PlayerUI.StreamContextMenu />} 471 480 </> 472 481 )} 473 482 {Platform.OS === "web" ? ( ··· 515 524 )} 516 525 </Pressable> 517 526 )} 518 - <PlayerUI.ContextMenu /> 527 + {ingest === null ? ( 528 + <PlayerUI.ContextMenu /> 529 + ) : ( 530 + <PlayerUI.StreamContextMenu /> 531 + )} 519 532 </View> 520 533 )} 521 534 {shouldShowChatSidePanel && setShowChat && (
+6 -1
js/app/features/bluesky/blueskyProvider.tsx
··· 2 2 import { storage } from "@streamplace/components"; 3 3 import { useURL } from "expo-linking"; 4 4 import { useEffect, useState } from "react"; 5 + import { Platform } from "react-native"; 5 6 import { useStore } from "store"; 6 7 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 7 8 import { navigateToRoute } from "utils/navigation"; ··· 23 24 loadOAuthClient(); 24 25 25 26 // load return route from storage on mount 27 + if (Platform.OS !== "web") { 28 + return; 29 + } 26 30 storage.getItem("returnRoute").then((stored) => { 27 31 if (stored) { 28 32 try { ··· 82 86 if ( 83 87 lastAuthStatus !== "loggedIn" && 84 88 authStatus === "loggedIn" && 85 - returnRoute 89 + returnRoute && 90 + Platform.OS === "web" 86 91 ) { 87 92 console.log( 88 93 "Login successful, navigating back to returnRoute:",
+8 -3
js/app/hooks/useBlueskyNotifications.tsx
··· 1 1 import { useToast } from "@streamplace/components"; 2 2 import { CircleX } from "lucide-react-native"; 3 3 import { useEffect } from "react"; 4 + import { Platform } from "react-native"; 5 + import clearQueryParams from "utils/clear-query-params"; 4 6 import { useStore } from "../store"; 5 7 6 8 function titleCase(str: string) { ··· 18 20 let toast = useToast(); 19 21 const notification = useStore((state) => state.notification); 20 22 const clearNotification = useStore((state) => state.clearNotification); 23 + 24 + // we've already saved the notif to the store 25 + clearQueryParams(["error", "error_description"]); 21 26 22 27 useEffect(() => { 23 28 if (notification) { ··· 41 46 { 42 47 duration: 100, 43 48 variant: notification.type, 44 - actionLabel: "Copy message", 49 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 45 50 iconLeft: CircleX, 46 51 onAction: () => { 47 52 navigator.clipboard.writeText( ··· 59 64 notification.message, 60 65 { 61 66 variant: notification.type, 62 - actionLabel: "Copy message", 67 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 63 68 onAction: () => { 64 69 navigator.clipboard.writeText(notification.message); 65 70 }, ··· 74 79 notification.message, 75 80 { 76 81 variant: notification.type, 77 - actionLabel: "Copy message", 82 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 78 83 onAction: () => { 79 84 navigator.clipboard.writeText(notification.message); 80 85 },
+1 -1
js/app/package.json
··· 1 1 { 2 2 "name": "@streamplace/app", 3 3 "main": "./src/entrypoint.tsx", 4 - "version": "0.9.8", 4 + "version": "0.9.9", 5 5 "runtimeVersion": "0.7.2", 6 6 "scripts": { 7 7 "start": "npx expo start -c --port 38081",
+2 -16
js/app/store/slices/blueskySlice.ts
··· 19 19 PlaceStreamServerSettings, 20 20 StreamplaceAgent, 21 21 } from "streamplace"; 22 + import clearQueryParams from "utils/clear-query-params"; 22 23 import { privateKeyToAccount } from "viem/accounts"; 23 24 import { StateCreator } from "zustand"; 24 25 import createOAuthClient, { ··· 117 118 createServerSettingsRecord: (debugRecording: boolean) => Promise<void>; 118 119 } 119 120 120 - const clearQueryParams = () => { 121 - if (Platform.OS !== "web") { 122 - return; 123 - } 124 - const u = new URL(document.location.href); 125 - const params = new URLSearchParams(u.search); 126 - if (u.search === "") { 127 - return; 128 - } 129 - params.delete("iss"); 130 - params.delete("state"); 131 - params.delete("code"); 132 - u.search = params.toString(); 133 - window.history.replaceState(null, "", u.toString()); 134 - }; 135 - 136 121 const uploadThumbnail = async ( 137 122 handle: string, 138 123 u: URL, ··· 217 202 notification: null, 218 203 219 204 clearNotification: () => { 205 + clearQueryParams(); 220 206 set({ notification: null }); 221 207 }, 222 208
+15
js/app/utils/clear-query-params.ts
··· 1 + import { Platform } from "react-native"; 2 + 3 + export default function clearQueryParams(par = ["iss", "state", "code"]) { 4 + if (Platform.OS !== "web") { 5 + return; 6 + } 7 + const u = new URL(document.location.href); 8 + const params = new URLSearchParams(u.search); 9 + if (u.search === "") { 10 + return; 11 + } 12 + par.forEach((p) => params.delete(p)); 13 + u.search = params.toString(); 14 + window.history.replaceState(null, "", u.toString()); 15 + }
+3 -6
js/atproto-oauth-client-react-native/README.md
··· 87 87 forwarded the port with `adb reverse`. For testing on iOS hardware, you'll 88 88 instead need to set up TLS. 89 89 90 - [react-native-quick-crypto]: 91 - https://github.com/margelo/react-native-quick-crypto 90 + [react-native-quick-crypto]: https://github.com/margelo/react-native-quick-crypto 92 91 [expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/ 93 - [README]: 94 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 95 - [example]: 96 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example 92 + [README]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 93 + [example]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example
+1 -1
js/atproto-oauth-client-react-native/package.json
··· 1 1 { 2 2 "name": "@streamplace/atproto-oauth-client-react-native", 3 - "version": "0.9.0", 3 + "version": "0.9.9", 4 4 "license": "MIT", 5 5 "description": "ATProto OAuth client for React Native", 6 6 "keywords": [
+8
js/components/locales/en-US/chat.ftl
··· 1 + censored-text-hide = Hide Text 2 + censored-text-reveal = Reveal Text 3 + censored-text-blocked-with-reasons = This text was blocked because of these reasons: { $reasons } 4 + censored-text-blocked-unknown = This text was blocked for an unknown reason 5 + 6 + category-discriminatory = Discriminatory content 7 + category-sexually-explicit = Sexually explicit content 8 + category-profanity = Profanity
+8
js/components/locales/es-ES/chat.ftl
··· 1 + censored-text-hide = Ocultar texto 2 + censored-text-reveal = Revelar texto 3 + censored-text-blocked-with-reasons = Este texto fue bloqueado por estas razones: { $reasons } 4 + censored-text-blocked-unknown = Este texto fue bloqueado por un motivo desconocido 5 + 6 + category-discriminatory = Contenido discriminatorio 7 + category-sexually-explicit = Contenido sexualmente explícito 8 + category-profanity = Blasfemia
+8
js/components/locales/fr-FR/chat.ftl
··· 1 + censored-text-hide = Masquer le texte 2 + censored-text-reveal = Révéler le texte 3 + censored-text-blocked-with-reasons = Ce texte a été bloqué pour ces raisons : { $reasons } 4 + censored-text-blocked-unknown = Ce texte a été bloqué pour une raison inconnue 5 + 6 + category-discriminatory = Contenu discriminatoire 7 + category-sexually-explicit = Contenu sexuellement explicite 8 + category-profanity = Blasphème
+8
js/components/locales/pt-BR/chat.ftl
··· 1 + censored-text-hide = Ocultar texto 2 + censored-text-reveal = Revelar texto 3 + censored-text-blocked-with-reasons = Este texto foi bloqueado por estes motivos: { $reasons } 4 + censored-text-blocked-unknown = Este texto foi bloqueado por um motivo desconhecido 5 + 6 + category-discriminatory = Conteúdo discriminatório 7 + category-sexually-explicit = Conteúdo sexualmente explícito 8 + category-profanity = Profanidade
+8
js/components/locales/zh-Hant/chat.ftl
··· 1 + censored-text-hide = 隱藏文字 2 + censored-text-reveal = 顯示文字 3 + censored-text-blocked-with-reasons = 此文字因以下原因被封鎖:{ $reasons } 4 + censored-text-blocked-unknown = 此文字因未知原因被封鎖 5 + 6 + category-discriminatory = 歧視性內容 7 + category-sexually-explicit = 色情內容 8 + category-profanity = 粗俗語言
+1 -1
js/components/package.json
··· 1 1 { 2 2 "name": "@streamplace/components", 3 - "version": "0.9.7", 3 + "version": "0.9.9", 4 4 "description": "Streamplace React (Native) Components", 5 5 "main": "dist/index.js", 6 6 "types": "src/index.tsx",
+88
js/components/src/components/chat/censored-text.tsx
··· 1 + import { TriggerRef } from "@rn-primitives/dropdown-menu"; 2 + import { useEffect, useRef, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + import { useChatFilters } from "../../streamplace-store"; 5 + import { 6 + DropdownMenu, 7 + DropdownMenuGroup, 8 + DropdownMenuInfo, 9 + DropdownMenuItem, 10 + DropdownMenuTrigger, 11 + ResponsiveDropdownMenuContent, 12 + } from "../ui/dropdown"; 13 + import { Text } from "../ui/text"; 14 + import { ChatFilterCategory } from "./chat-settings"; 15 + 16 + function getCategoryKey(category: string): string { 17 + const categoryMap: Record<string, string> = { 18 + "place.stream.richtext.defs#discriminatory": "category-discriminatory", 19 + "place.stream.richtext.defs#sexually_explicit": 20 + "category-sexually-explicit", 21 + "place.stream.richtext.defs#profanity": "category-profanity", 22 + }; 23 + return categoryMap[category] || category; 24 + } 25 + 26 + export function CensoredText({ 27 + text, 28 + reasoning, 29 + }: { 30 + text: string; 31 + reasoning?: string[]; 32 + }) { 33 + const filters = useChatFilters(); 34 + const { t } = useTranslation("chat"); 35 + const hasFilterMatch = reasoning?.some((r) => 36 + filters.has(r as ChatFilterCategory), 37 + ); 38 + const [revealed, setRevealed] = useState(!hasFilterMatch); 39 + const dropdownRef = useRef<TriggerRef>(null); 40 + const handleOpenDropdown = () => { 41 + dropdownRef.current?.open(); 42 + }; 43 + 44 + const translatedReasons = reasoning?.map((r) => t(getCategoryKey(r))); 45 + 46 + // update when filters change 47 + useEffect(() => { 48 + const match = reasoning?.some((r) => filters.has(r as ChatFilterCategory)); 49 + if (match) { 50 + setRevealed(false); 51 + } else { 52 + setRevealed(true); 53 + } 54 + }, [filters]); 55 + 56 + return ( 57 + <> 58 + <Text 59 + color={revealed ? "default" : "primary"} 60 + style={{ display: "inline" as any }} 61 + onPress={handleOpenDropdown} 62 + > 63 + {revealed ? text : text.replace(/./g, "*")} 64 + </Text> 65 + <DropdownMenu> 66 + <DropdownMenuTrigger ref={dropdownRef}></DropdownMenuTrigger> 67 + <ResponsiveDropdownMenuContent> 68 + <DropdownMenuGroup> 69 + <DropdownMenuItem onPress={() => setRevealed(!revealed)}> 70 + <Text> 71 + {revealed ? t("censored-text-hide") : t("censored-text-reveal")} 72 + </Text> 73 + </DropdownMenuItem> 74 + </DropdownMenuGroup> 75 + <DropdownMenuInfo 76 + description={ 77 + translatedReasons 78 + ? t("censored-text-blocked-with-reasons", { 79 + reasons: translatedReasons.join(", "), 80 + }) 81 + : t("censored-text-blocked-unknown") 82 + } 83 + /> 84 + </ResponsiveDropdownMenuContent> 85 + </DropdownMenu> 86 + </> 87 + ); 88 + }
+21 -2
js/components/src/components/chat/chat-box.tsx
··· 5 5 import { useEffect, useMemo, useRef, useState } from "react"; 6 6 import { Platform, Pressable, TextInput } from "react-native"; 7 7 import { ChatMessageViewHydrated } from "streamplace"; 8 - import { Button, Loader, Text, toast, useTheme, View } from "../../"; 8 + import { 9 + Button, 10 + ChatSettings, 11 + Loader, 12 + Text, 13 + toast, 14 + useTheme, 15 + View, 16 + } from "../../"; 9 17 import { handleSlashCommand } from "../../lib/slash-commands"; 10 18 import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 11 19 import { StreamNotifications } from "../../lib/stream-notifications"; ··· 31 39 useReplyToMessage, 32 40 useSetReplyToMessage, 33 41 } from "../../livestream-store"; 34 - import { useDID, usePDSAgent } from "../../streamplace-store"; 42 + import { 43 + useDID, 44 + usePDSAgent, 45 + useSetChatFilters, 46 + } from "../../streamplace-store"; 35 47 import { Textarea } from "../ui/textarea"; 36 48 import { RenderChatMessage } from "./chat-message"; 37 49 import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; ··· 71 83 const isOverLimit = graphemer.countGraphemes(message) > 300; 72 84 73 85 let linfo = useLivestream(); 86 + const setChatFilters = useSetChatFilters(); 74 87 75 88 const { theme, zero: zt } = useTheme(); 76 89 ··· 495 508 {submitting ? <Loader /> : "Send"} 496 509 </Button> 497 510 </View> 511 + {Platform.OS !== "web" && ( 512 + <ChatSettings onFiltersChange={setChatFilters} /> 513 + )} 498 514 </View> 499 515 {showSuggestions && ( 500 516 <MentionSuggestions ··· 584 600 > 585 601 <ExternalLink color={theme.colors.primaryForeground} size={16} /> 586 602 </Button> 603 + )} 604 + {Platform.OS === "web" && ( 605 + <ChatSettings onFiltersChange={setChatFilters} /> 587 606 )} 588 607 </View> 589 608 )}
+15 -1
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 { CensoredText } from "./censored-text"; 28 29 29 30 const getRgbColor = (color?: { red: number; green: number; blue: number }) => 30 31 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : colors.gray[500]; ··· 67 68 {obj.text} 68 69 </Text> 69 70 ); 71 + } else if (ftr.$type === "place.stream.richtext.defs#censor") { 72 + let censorFtr = ftr as any; 73 + return ( 74 + <CensoredText 75 + key={`censor-facet-${index}`} 76 + text={obj.text} 77 + reasoning={censorFtr.categories} 78 + /> 79 + ); 70 80 } else { 71 81 // render as normal text if we don't recognize the facet type 72 82 return <Text key={`unknown-facet-${index}`}>{obj.text}</Text>; ··· 91 101 92 102 return segs.map((seg, i) => segmentedObject(seg, i, userCache)); 93 103 }; 104 + 94 105 export const RenderChatMessage = memo( 95 106 function RenderChatMessage({ 96 107 item, ··· 147 158 fontStyle: "italic", 148 159 }} 149 160 > 150 - {replyTo.record.text} 161 + <RichTextMessage 162 + text={replyTo.record.text} 163 + facets={replyTo.record.facets || []} 164 + /> 151 165 </Text> 152 166 </Text> 153 167 </View>
+147
js/components/src/components/chat/chat-settings.tsx
··· 1 + import { EllipsisVertical } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import { Platform, Pressable, View } from "react-native"; 4 + import { Button, zero } from "../.."; 5 + import { 6 + ChatFilterCategory, 7 + useChatFilters, 8 + useSetChatFilters, 9 + } from "../../streamplace-store"; 10 + import { useTheme } from "../../ui"; 11 + import { 12 + DropdownMenu, 13 + DropdownMenuCheckboxItem, 14 + DropdownMenuGroup, 15 + DropdownMenuInfo, 16 + DropdownMenuItem, 17 + DropdownMenuSeparator, 18 + DropdownMenuSub, 19 + DropdownMenuSubContent, 20 + DropdownMenuSubTrigger, 21 + DropdownMenuTrigger, 22 + ResponsiveDropdownMenuContent, 23 + } from "../ui/dropdown"; 24 + import { Text } from "../ui/text"; 25 + 26 + export type { ChatFilterCategory }; 27 + 28 + interface ChatSettingsProps { 29 + onFiltersChange?: (filters: Set<ChatFilterCategory>) => void; 30 + } 31 + 32 + const CATEGORY_LABELS: Record<ChatFilterCategory, string> = { 33 + "place.stream.richtext.defs#discriminatory": "Discriminatory", 34 + "place.stream.richtext.defs#sexually_explicit": "Sexually Explicit", 35 + "place.stream.richtext.defs#profanity": "Profanity", 36 + }; 37 + 38 + const ALL_CATEGORIES: ChatFilterCategory[] = [ 39 + "place.stream.richtext.defs#discriminatory", 40 + "place.stream.richtext.defs#sexually_explicit", 41 + "place.stream.richtext.defs#profanity", 42 + ]; 43 + 44 + export function ChatSettings({ onFiltersChange }: ChatSettingsProps) { 45 + const { icons } = useTheme(); 46 + const storedFilters = useChatFilters(); 47 + const setStoredFilters = useSetChatFilters(); 48 + const [filters, setFilters] = 49 + useState<Set<ChatFilterCategory>>(storedFilters); 50 + 51 + const isMobile = Platform.OS === "ios" || Platform.OS === "android"; 52 + 53 + // Sync local state with stored filters on mount and when stored filters change 54 + useEffect(() => { 55 + setFilters(storedFilters); 56 + }, [storedFilters]); 57 + 58 + const toggleFilter = (category: ChatFilterCategory) => { 59 + const newFilters = new Set(filters); 60 + if (newFilters.has(category)) { 61 + newFilters.delete(category); 62 + } else { 63 + newFilters.add(category); 64 + } 65 + setFilters(newFilters); 66 + setStoredFilters(newFilters); 67 + onFiltersChange?.(newFilters); 68 + }; 69 + 70 + const allFiltersEnabled = filters.size === ALL_CATEGORIES.length; 71 + 72 + const toggleAllFilters = () => { 73 + const newFilters = allFiltersEnabled 74 + ? new Set<ChatFilterCategory>() 75 + : new Set(ALL_CATEGORIES); 76 + setFilters(newFilters); 77 + setStoredFilters(newFilters); 78 + onFiltersChange?.(newFilters); 79 + }; 80 + 81 + return ( 82 + <DropdownMenu> 83 + <DropdownMenuTrigger> 84 + <Pressable> 85 + {({ pressed }) => ( 86 + <Button 87 + variant="ghost" 88 + aria-label="Popout Chat" 89 + style={{ borderRadius: 16, maxHeight: 44, aspectRatio: 0.5 }} 90 + > 91 + <EllipsisVertical size={20} color={icons.color.muted} /> 92 + </Button> 93 + )} 94 + </Pressable> 95 + </DropdownMenuTrigger> 96 + <ResponsiveDropdownMenuContent align="end"> 97 + <DropdownMenuGroup title="Chat Settings"> 98 + <DropdownMenuSub> 99 + <DropdownMenuSubTrigger subMenuTitle="Chat Filters"> 100 + <View 101 + style={[ 102 + zero.flex.values[1], 103 + isMobile ? zero.layout.flex.row : zero.layout.flex.column, 104 + zero.layout.flex.spaceBetween, 105 + zero.pr[4], 106 + ]} 107 + > 108 + <Text>Chat Filters</Text> 109 + </View> 110 + </DropdownMenuSubTrigger> 111 + <DropdownMenuSubContent> 112 + <DropdownMenuGroup title="Content Filters"> 113 + <DropdownMenuItem onPress={toggleAllFilters}> 114 + <Text> 115 + {allFiltersEnabled ? "Disable All" : "Enable All"} 116 + </Text> 117 + </DropdownMenuItem> 118 + </DropdownMenuGroup> 119 + <DropdownMenuGroup> 120 + {( 121 + Object.entries(CATEGORY_LABELS) as [ 122 + ChatFilterCategory, 123 + string, 124 + ][] 125 + ).map(([category, label], i) => ( 126 + <> 127 + <DropdownMenuCheckboxItem 128 + key={category} 129 + checked={filters.has(category)} 130 + onCheckedChange={() => toggleFilter(category)} 131 + > 132 + <Text>{label}</Text> 133 + </DropdownMenuCheckboxItem> 134 + {i < Object.entries(CATEGORY_LABELS).length - 1 && ( 135 + <DropdownMenuSeparator /> 136 + )} 137 + </> 138 + ))} 139 + </DropdownMenuGroup> 140 + <DropdownMenuInfo description="Hide messages containing content that may be inappropriate or offensive by category." /> 141 + </DropdownMenuSubContent> 142 + </DropdownMenuSub> 143 + </DropdownMenuGroup> 144 + </ResponsiveDropdownMenuContent> 145 + </DropdownMenu> 146 + ); 147 + }
+2 -2
js/components/src/components/chat/chat.tsx
··· 261 261 262 262 useEffect(() => { 263 263 buttonOpacity.value = withTiming(isScrolledUp ? 1 : 0, { duration: 200 }); 264 - buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 20, { 264 + buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 50, { 265 265 duration: 200, 266 266 }); 267 267 }, [isScrolledUp]); ··· 345 345 onPress={scrollToBottom} 346 346 style={[ 347 347 { 348 - pointerEvents: "auto", 348 + pointerEvents: isScrolledUp ? "auto" : "none", 349 349 backgroundColor: theme.colors.primary, 350 350 opacity: 0.9, 351 351 borderRadius: 20,
+22 -1
js/components/src/components/mobile-player/player.tsx
··· 5 5 PlayerStatusTracker, 6 6 usePlayerStore, 7 7 } from "../../player-store"; 8 - import { useStreamplaceStore } from "../../streamplace-store"; 8 + import { 9 + useMuted, 10 + useSetMuted, 11 + useStreamplaceStore, 12 + } from "../../streamplace-store"; 9 13 import { Text, View } from "../ui"; 10 14 import { Fullscreen } from "./fullscreen"; 11 15 import { PlayerProps } from "./props"; ··· 28 32 const reportModalOpen = usePlayerStore((x) => x.reportModalOpen); 29 33 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 30 34 const reportSubject = usePlayerStore((x) => x.reportSubject); 35 + 36 + const setMuted = useSetMuted(); 37 + const muted = useMuted(); 38 + 39 + // if we set muted, set it and restore after 40 + useEffect(() => { 41 + let wasMuted: null | boolean = null; 42 + setTimeout(() => { 43 + if (props.muted != undefined) { 44 + wasMuted = muted; 45 + setMuted(props.muted); 46 + } 47 + }, 200); 48 + return () => { 49 + wasMuted !== null && setMuted(wasMuted); 50 + }; 51 + }, [props.muted]); 31 52 32 53 useEffect(() => { 33 54 setReportingURL(props.reportingURL ?? null);
+42 -8
js/components/src/components/mobile-player/ui/input.tsx
··· 9 9 setTitle: (title: string) => void; 10 10 ingestStarting: boolean; 11 11 toggleGoLive: () => void; 12 + isLive: boolean; 13 + toggleStopStream?: () => void; 12 14 }; 13 15 14 16 export function InputPanel({ ··· 16 18 setTitle, 17 19 ingestStarting, 18 20 toggleGoLive, 21 + isLive, 22 + toggleStopStream, 19 23 }: InputPanelProps) { 20 24 const { slideKeyboard } = useKeyboardSlide(); 21 25 return ( ··· 37 41 { padding: 10 }, 38 42 ]} 39 43 > 40 - <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 41 - <Input 42 - value={title} 43 - onChange={setTitle} 44 - placeholder="Enter stream title" 45 - onEndEditing={Keyboard.dismiss} 46 - /> 47 - </View> 44 + {!isLive && ( 45 + <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 46 + <Input 47 + value={title} 48 + onChange={setTitle} 49 + placeholder="Enter stream title" 50 + onEndEditing={Keyboard.dismiss} 51 + /> 52 + </View> 53 + )} 48 54 {ingestStarting ? ( 49 55 <Text>Starting your stream...</Text> 56 + ) : isLive ? ( 57 + <View style={[layout.flex.center]}> 58 + <Pressable 59 + onPress={toggleStopStream} 60 + style={[ 61 + px[4], 62 + py[2], 63 + layout.flex.row, 64 + layout.flex.center, 65 + gap.all[1], 66 + { 67 + backgroundColor: "rgba(64,64,64, 0.8)", 68 + borderRadius: 12, 69 + }, 70 + ]} 71 + > 72 + <View 73 + style={[ 74 + p[2], 75 + { 76 + backgroundColor: "rgba(256,0,0, 0.8)", 77 + borderRadius: 12, 78 + }, 79 + ]} 80 + /> 81 + <Text center>Stop Stream</Text> 82 + </Pressable> 83 + </View> 50 84 ) : ( 51 85 <View style={[layout.flex.center]}> 52 86 <Pressable
+138 -2
js/components/src/components/mobile-player/ui/streamer-context-menu.tsx
··· 1 - export function StreamContextMenu() { 2 - return <></>; 1 + import { ChevronRight, Cog } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import Animated, { 4 + Easing, 5 + useAnimatedStyle, 6 + useSharedValue, 7 + withDelay, 8 + withSequence, 9 + withTiming, 10 + } from "react-native-reanimated"; 11 + import { useLivestreamInfo, zero } from "../../.."; 12 + import { usePlayerStore } from "../../../player-store"; 13 + import { 14 + DropdownMenu, 15 + DropdownMenuCheckboxItem, 16 + DropdownMenuGroup, 17 + DropdownMenuItem, 18 + DropdownMenuTrigger, 19 + ResponsiveDropdownMenuContent, 20 + Text, 21 + useTheme, 22 + } from "../../ui"; 23 + 24 + export function StreamContextMenu({ 25 + dropdownPortalContainer, 26 + }: { 27 + dropdownPortalContainer?: string; 28 + }) { 29 + const th = useTheme(); 30 + const debugInfo = usePlayerStore((x) => x.showDebugInfo); 31 + const setShowDebugInfo = usePlayerStore((x) => x.setShowDebugInfo); 32 + const { toggleStopStream } = useLivestreamInfo(); 33 + const ingest = usePlayerStore((x) => x.ingestConnectionState); 34 + const isLive = ingest !== null && ingest !== "new"; 35 + 36 + const [isOpen, setIsOpen] = useState(false); 37 + const [hasShownTooltip, setHasShownTooltip] = useState(false); 38 + 39 + const tooltipOpacity = useSharedValue(0); 40 + const tooltipTranslateX = useSharedValue(20); 41 + 42 + useEffect(() => { 43 + if (isLive && !hasShownTooltip) { 44 + tooltipOpacity.value = withDelay( 45 + 500, 46 + withSequence( 47 + withTiming(1, { duration: 300 }), 48 + withDelay(10000, withTiming(0, { duration: 300 })), 49 + ), 50 + ); 51 + tooltipTranslateX.value = withDelay( 52 + 500, 53 + withSequence( 54 + withTiming(0, { duration: 300 }), 55 + withDelay(10000, withTiming(20, { duration: 300 })), 56 + ), 57 + ); 58 + setHasShownTooltip(true); 59 + } 60 + }, [isLive, hasShownTooltip]); 61 + 62 + const iconRotate = useAnimatedStyle(() => { 63 + return { 64 + transform: [ 65 + { 66 + rotateZ: withTiming(isOpen ? "240deg" : "0deg", { 67 + duration: 650, 68 + easing: Easing.out(Easing.ease), 69 + }), 70 + }, 71 + ], 72 + }; 73 + }); 74 + 75 + const tooltipStyle = useAnimatedStyle(() => { 76 + return { 77 + opacity: tooltipOpacity.value, 78 + transform: [{ translateX: tooltipTranslateX.value }], 79 + }; 80 + }); 81 + 82 + return ( 83 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 84 + <DropdownMenuTrigger> 85 + <Animated.View style={[iconRotate]}> 86 + <Cog color={th.theme.colors.foreground} /> 87 + </Animated.View> 88 + <Animated.View 89 + style={[ 90 + tooltipStyle, 91 + { 92 + position: "absolute", 93 + right: 30, 94 + top: 0, 95 + backgroundColor: "rgba(64,64,64,0.95)", 96 + borderRadius: 8, 97 + paddingHorizontal: 8, 98 + paddingRight: 12, 99 + paddingVertical: 4, 100 + flexDirection: "row", 101 + alignItems: "center", 102 + gap: 6, 103 + zIndex: 9999999, 104 + pointerEvents: "box-none", 105 + width: 120, 106 + }, 107 + ]} 108 + > 109 + <Text size="sm" color="white"> 110 + End stream here 111 + </Text> 112 + <ChevronRight color="white" size={16} style={[zero.mr[4]]} /> 113 + </Animated.View> 114 + </DropdownMenuTrigger> 115 + <ResponsiveDropdownMenuContent side="top" align="end"> 116 + {isLive && ( 117 + <DropdownMenuGroup title="Stream"> 118 + <DropdownMenuItem 119 + closeOnPress={true} 120 + onPress={() => { 121 + toggleStopStream(); 122 + }} 123 + > 124 + <Text color="destructive">Stop Stream</Text> 125 + </DropdownMenuItem> 126 + </DropdownMenuGroup> 127 + )} 128 + <DropdownMenuGroup title="Advanced"> 129 + <DropdownMenuCheckboxItem 130 + checked={debugInfo} 131 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 132 + > 133 + <Text>Show Debug Info</Text> 134 + </DropdownMenuCheckboxItem> 135 + </DropdownMenuGroup> 136 + </ResponsiveDropdownMenuContent> 137 + </DropdownMenu> 138 + ); 3 139 }
+13 -11
js/components/src/components/ui/dropdown.tsx
··· 537 537 ({ description, ...props }, ref) => { 538 538 const { theme } = useTheme(); 539 539 return ( 540 - <Text 541 - style={[ 542 - { color: theme.colors.textMuted }, 543 - pt[1], 544 - pl[2], 545 - pb[2], 546 - fontSize.sm, 547 - ]} 548 - > 549 - {description} 550 - </Text> 540 + <View ref={ref} {...props}> 541 + <Text 542 + style={[ 543 + { color: theme.colors.textMuted }, 544 + pt[1], 545 + pl[2], 546 + pb[2], 547 + fontSize.sm, 548 + ]} 549 + > 550 + {description} 551 + </Text> 552 + </View> 551 553 ); 552 554 }, 553 555 );
+2
js/components/src/components/ui/textarea.tsx
··· 40 40 { borderRadius: 10 }, 41 41 style, 42 42 ]} 43 + autoComplete={props.autoComplete || "off"} 44 + textContentType={props.textContentType || "none"} 43 45 multiline={multiline} 44 46 numberOfLines={numberOfLines} 45 47 textAlignVertical="top"
+8
js/components/src/hooks/useLivestreamInfo.ts
··· 9 9 const ingestStarting = usePlayerStore((x) => x.ingestStarting); 10 10 const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 11 11 const setIngestLive = usePlayerStore((x) => x.setIngestLive); 12 + const stopIngest = usePlayerStore((x) => x.stopIngest); 12 13 13 14 const createStreamRecord = useCreateStreamRecord(); 14 15 ··· 54 55 } 55 56 }; 56 57 58 + // Stop the current broadcast 59 + const toggleStopStream = () => { 60 + console.log("Stopping stream..."); 61 + stopIngest(); 62 + }; 63 + 57 64 return { 58 65 ingest, 59 66 profile, ··· 67 74 setIngestStarting, 68 75 handleSubmit, 69 76 toggleGoLive, 77 + toggleStopStream, 70 78 }; 71 79 }
+10
js/components/src/i18n/i18n-loader.native.ts
··· 2 2 // Metro will use this file for React Native builds 3 3 4 4 // Import all translations directly so they're bundled into the app 5 + import enUSChat from "../../public/locales/en-US/chat.json"; 5 6 import enUSCommon from "../../public/locales/en-US/common.json"; 6 7 import enUSSettings from "../../public/locales/en-US/settings.json"; 8 + import esESChat from "../../public/locales/es-ES/chat.json"; 7 9 import esESCommon from "../../public/locales/es-ES/common.json"; 8 10 import esESSettings from "../../public/locales/es-ES/settings.json"; 11 + import frFRChat from "../../public/locales/fr-FR/chat.json"; 9 12 import frFRCommon from "../../public/locales/fr-FR/common.json"; 10 13 import frFRSettings from "../../public/locales/fr-FR/settings.json"; 14 + import ptBRChat from "../../public/locales/pt-BR/chat.json"; 11 15 import ptBRCommon from "../../public/locales/pt-BR/common.json"; 12 16 import ptBRSettings from "../../public/locales/pt-BR/settings.json"; 17 + import zhHantChat from "../../public/locales/zh-Hant/chat.json"; 13 18 import zhHantCommon from "../../public/locales/zh-Hant/common.json"; 14 19 import zhHantSettings from "../../public/locales/zh-Hant/settings.json"; 15 20 16 21 const translationMap: Record<string, any> = { 22 + "en-US/chat": enUSChat, 17 23 "en-US/common": enUSCommon, 18 24 "en-US/settings": enUSSettings, 25 + "pt-BR/chat": ptBRChat, 19 26 "pt-BR/common": ptBRCommon, 20 27 "pt-BR/settings": ptBRSettings, 28 + "es-ES/chat": esESChat, 21 29 "es-ES/common": esESCommon, 22 30 "es-ES/settings": esESSettings, 31 + "zh-Hant/chat": zhHantChat, 23 32 "zh-Hant/common": zhHantCommon, 24 33 "zh-Hant/settings": zhHantSettings, 34 + "fr-FR/chat": frFRChat, 25 35 "fr-FR/common": frFRCommon, 26 36 "fr-FR/settings": frFRSettings, 27 37 };
+1 -1
js/components/src/i18n/i18next-config.ts
··· 116 116 117 117 export const I18NEXT_CONFIG = { 118 118 lng: LOCALE, 119 - ns: ["common", "settings"], // Common should be first as it's most frequently used 119 + ns: ["common", "settings", "chat"], // Common should be first as it's most frequently used 120 120 defaultNS: "common", 121 121 interpolation: { 122 122 escapeValue: false, // React already safes from XSS
+1
js/components/src/index.tsx
··· 33 33 34 34 export * from "./components/chat/chat"; 35 35 export * from "./components/chat/chat-box"; 36 + export * from "./components/chat/chat-settings"; 36 37 export * from "./components/chat/system-message"; 37 38 export * from "./components/chat/update-stream-title-dialog"; 38 39 export { default as VideoRetry } from "./components/mobile-player/video-retry";
+3
js/components/src/player-store/player-state.tsx
··· 63 63 ingestAutoStart?: boolean; 64 64 setIngestAutoStart?: (autoStart: boolean) => void; 65 65 66 + /** stop ingest process, again with a slight delay to allow UI to update */ 67 + stopIngest: () => void; 68 + 66 69 /** Timestamp (number) when ingest started, or null if not started */ 67 70 ingestStarted: number | null; 68 71
+17
js/components/src/player-store/player-store.tsx
··· 53 53 setIngestStarted: (timestamp: number | null) => 54 54 set(() => ({ ingestStarted: timestamp })), 55 55 56 + stopIngest: () => { 57 + set(() => ({ 58 + ingestLive: false, 59 + ingestConnectionState: "new", 60 + ingestStarted: null, 61 + })), 62 + setTimeout( 63 + () => 64 + set(() => ({ 65 + ingestLive: false, 66 + ingestConnectionState: "new", 67 + ingestStarted: null, 68 + })), 69 + 200, 70 + ); 71 + }, 72 + 56 73 fullscreen: false, 57 74 setFullscreen: (isFullscreen: boolean) => 58 75 set(() => ({ fullscreen: isFullscreen })),
+60 -1
js/components/src/streamplace-store/branding.tsx
··· 25 25 }); 26 26 }; 27 27 28 + const PropsInHeader = [ 29 + "siteTitle", 30 + "siteDescription", 31 + "primaryColor", 32 + "accentColor", 33 + "defaultStreamer", 34 + "mainLogo", 35 + "favicon", 36 + "sidebarBg", 37 + "legalLinks", 38 + ]; 39 + 40 + function getMetaContent(key: string): BrandingAsset | null { 41 + if (typeof window === "undefined" || !window.document) return null; 42 + const meta = document.querySelector(`meta[name="internal-brand:${key}`); 43 + if (meta && meta.getAttribute("content")) { 44 + let content = meta.getAttribute("content"); 45 + if (content) return JSON.parse(content) as BrandingAsset; 46 + } 47 + 48 + return null; 49 + } 50 + 28 51 // hook to fetch broadcaster DID (unauthenticated) 29 52 export function useFetchBroadcasterDID() { 30 53 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 31 54 const store = getStreamplaceStoreFromContext(); 55 + 56 + // prefetch from meta records, if on web 57 + useEffect(() => { 58 + if (typeof window !== "undefined" && window.document) { 59 + try { 60 + const metaRecords = PropsInHeader.reduce( 61 + (acc, key) => { 62 + const meta = document.querySelector( 63 + `meta[name="internal-brand:${key}`, 64 + ); 65 + // hrmmmmmmmmmmmm 66 + if (meta && meta.getAttribute("content")) { 67 + let content = meta.getAttribute("content"); 68 + if (content) acc[key] = JSON.parse(content) as BrandingAsset; 69 + } 70 + return acc; 71 + }, 72 + {} as Record<string, BrandingAsset>, 73 + ); 74 + 75 + console.log("Found meta records for broadcaster DID:", metaRecords); 76 + // filter out all non-text values, can get on second fetch? 77 + for (const key of Object.keys(metaRecords)) { 78 + if (metaRecords[key].mimeType != "text/plain") { 79 + delete metaRecords[key]; 80 + } 81 + } 82 + } catch (e) { 83 + console.warn("Failed to parse broadcaster DID from meta tags", e); 84 + } 85 + } 86 + }, []); 32 87 33 88 return useCallback(async () => { 34 89 try { ··· 140 195 141 196 // hook to get a specific branding asset by key 142 197 export function useBrandingAsset(key: string): BrandingAsset | undefined { 143 - return useStreamplaceStore((state) => state.branding?.[key]); 198 + return ( 199 + useStreamplaceStore((state) => state.branding?.[key]) || 200 + getMetaContent(key) || 201 + undefined 202 + ); 144 203 } 145 204 146 205 // convenience hook for main logo
+39
js/components/src/streamplace-store/streamplace-store.tsx
··· 76 76 setDanmuSpeed: (speed: number) => void; 77 77 setDanmuLaneCount: (laneCount: number) => void; 78 78 setDanmuMaxMessages: (maxMessages: number) => void; 79 + 80 + // Chat filter settings 81 + chatFilters: Set<ChatFilterCategory>; 82 + setChatFilters: (filters: Set<ChatFilterCategory>) => void; 79 83 } 84 + 85 + export type ChatFilterCategory = 86 + | "place.stream.richtext.defs#discriminatory" 87 + | "place.stream.richtext.defs#sexually_explicit" 88 + | "place.stream.richtext.defs#profanity"; 80 89 81 90 export type StreamplaceStore = StoreApi<StreamplaceState>; 82 91 ··· 93 102 const DANMU_SPEED_KEY = "danmuSpeed"; 94 103 const DANMU_LANE_COUNT_KEY = "danmuLaneCount"; 95 104 const DANMU_MAX_MESSAGES_KEY = "danmuMaxMessages"; 105 + const CHAT_FILTERS_KEY = "chatFilters"; 96 106 97 107 const store = createStore<StreamplaceState>()((set) => ({ 98 108 url, ··· 210 220 set({ danmuMaxMessages: clamped }); 211 221 storage 212 222 .setItem(DANMU_MAX_MESSAGES_KEY, clamped.toString()) 223 + .catch(console.error); 224 + }, 225 + 226 + // Chat filter settings - start with defaults 227 + chatFilters: new Set(), 228 + 229 + setChatFilters: (filters: Set<ChatFilterCategory>) => { 230 + set({ chatFilters: filters }); 231 + storage 232 + .setItem(CHAT_FILTERS_KEY, JSON.stringify(Array.from(filters))) 213 233 .catch(console.error); 214 234 }, 215 235 })); ··· 227 247 const storedDanmuMaxMessages = await storage.getItem( 228 248 DANMU_MAX_MESSAGES_KEY, 229 249 ); 250 + const storedChatFilters = await storage.getItem(CHAT_FILTERS_KEY); 230 251 231 252 let initialVolume = 1.0; 232 253 let initialMuted = false; ··· 236 257 let initialDanmuSpeed = 1; 237 258 let initialDanmuLaneCount = 12; 238 259 let initialDanmuMaxMessages = 50; 260 + let initialChatFilters = new Set<ChatFilterCategory>(); 239 261 240 262 if (storedVolume) { 241 263 const parsedVolume = parseFloat(storedVolume); ··· 288 310 } 289 311 } 290 312 313 + if (storedChatFilters) { 314 + try { 315 + const parsed = JSON.parse(storedChatFilters); 316 + if (Array.isArray(parsed)) { 317 + initialChatFilters = new Set(parsed); 318 + } 319 + } catch (error) { 320 + console.error("Failed to parse stored chat filters:", error); 321 + } 322 + } 323 + 291 324 store.setState({ 292 325 volume: initialVolume, 293 326 muted: initialMuted, ··· 297 330 danmuSpeed: initialDanmuSpeed, 298 331 danmuLaneCount: initialDanmuLaneCount, 299 332 danmuMaxMessages: initialDanmuMaxMessages, 333 + chatFilters: initialChatFilters, 300 334 }); 301 335 } catch (error) { 302 336 console.error("Failed to load state from storage:", error); ··· 409 443 setDanmuMaxMessages, 410 444 }; 411 445 }; 446 + 447 + // Chat filter convenience hooks 448 + export const useChatFilters = () => useStreamplaceStore((x) => x.chatFilters); 449 + export const useSetChatFilters = () => 450 + useStreamplaceStore((x) => x.setChatFilters); 412 451 413 452 export { useCreateStreamRecord, useUpdateStreamRecord } from "./stream";
+63 -36
js/docs/astro.config.mjs
··· 2 2 import starlight from "@astrojs/starlight"; 3 3 import { defineConfig, passthroughImageService } from "astro/config"; 4 4 import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi"; 5 + import starlightSidebarSwipe from "starlight-sidebar-swipe"; 6 + import starlightSidebarTopics from "starlight-sidebar-topics"; 5 7 6 8 // https://astro.build/config 7 9 export default defineConfig({ ··· 32 34 }, 33 35 favicon: "/favicon.ico", 34 36 plugins: [ 37 + //starlightLinksValidator(), 38 + starlightSidebarSwipe(), 35 39 starlightOpenAPI([ 36 40 { 37 - base: "api", 41 + base: "/api", 38 42 label: "Related XRPC API endpoints", 39 43 schema: "./src/content/docs/lex-reference/openapi.json", // or your json generated from swagger 40 44 sidebar: { ··· 45 49 }, 46 50 }, 47 51 ]), 48 - ], 49 - sidebar: [ 50 - { label: "← Back to Streamplace", link: "/back-to-home" }, 51 - { 52 - label: "How Streamplace Works (Blog)", 53 - link: "https://blog.stream.place/", 54 - attrs: { target: "_blank" }, 55 - }, 56 - { 57 - label: "Guides", 58 - items: [ 52 + starlightSidebarTopics( 53 + [ 59 54 { 60 - label: "Start Streaming", 61 - autogenerate: { directory: "guides/start-streaming" }, 55 + label: "For Streamers & Viewers", 56 + link: "/", 57 + icon: "open-book", 58 + items: [ 59 + { 60 + label: "Start Streaming", 61 + autogenerate: { directory: "guides/start-streaming" }, 62 + }, 63 + { 64 + label: "Features", 65 + autogenerate: { directory: "features" }, 66 + }, 67 + ], 62 68 }, 63 69 { 64 - label: "Installing Streamplace", 65 - autogenerate: { directory: "guides/installing" }, 70 + label: "For Developers", 71 + link: "/developers/", 72 + icon: "seti:config", 73 + id: "developers", 74 + items: [ 75 + { 76 + label: "Start Contributing", 77 + autogenerate: { directory: "guides/start-contributing" }, 78 + }, 79 + { 80 + label: "Installing Streamplace", 81 + autogenerate: { directory: "guides/installing" }, 82 + }, 83 + { 84 + label: "Video Metadata", 85 + autogenerate: { directory: "video-metadata" }, 86 + }, 87 + { 88 + label: "Components", 89 + autogenerate: { directory: "components" }, 90 + }, 91 + { 92 + label: "Localize Streamplace", 93 + autogenerate: { directory: "guides/localizing" }, 94 + }, 95 + ], 66 96 }, 67 97 { 68 - label: "Start Contributing", 69 - autogenerate: { directory: "guides/start-contributing" }, 98 + label: "API Reference", 99 + link: "/reference/", 100 + icon: "seti:json", 101 + id: "ref", 102 + items: [ 103 + { 104 + label: "Lexicon Reference", 105 + autogenerate: { directory: "lex-reference" }, 106 + }, 107 + ...openAPISidebarGroups, 108 + ], 70 109 }, 71 110 ], 72 - }, 73 - { 74 - label: "Features", 75 - autogenerate: { directory: "features" }, 76 - }, 77 - { 78 - label: "Video Metadata", 79 - autogenerate: { directory: "video-metadata" }, 80 - }, 81 - { 82 - label: "Components", 83 - autogenerate: { directory: "components" }, 84 - }, 85 - { 86 - label: "Lexicon Reference", 87 - autogenerate: { directory: "lex-reference" }, 88 - }, 89 - ...openAPISidebarGroups, 111 + { 112 + topics: { 113 + ref: ["/api", "/api/**/*"], 114 + }, 115 + }, 116 + ), 90 117 ], 91 118 }), 92 119 ],
+8 -2
js/docs/package.json
··· 1 1 { 2 2 "name": "streamplace-docs", 3 3 "type": "module", 4 - "version": "0.9.8", 4 + "version": "0.9.9", 5 5 "scripts": { 6 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 7 "start": "astro dev --host 0.0.0.0 --port 38082", ··· 15 15 "@streamplace/app": "workspace:*", 16 16 "astro": "^5.6.1", 17 17 "sharp": "^0.32.5", 18 + "starlight-links-validator": "^0.19.2", 18 19 "starlight-openapi": "^0.17.0", 19 20 "starlight-openapi-rapidoc": "^0.8.1-beta", 21 + "starlight-sidebar-swipe": "^0.1.1", 20 22 "streamplace": "workspace:*" 21 - } 23 + }, 24 + "devDependencies": { 25 + "starlight-sidebar-topics": "^0.6.2" 26 + }, 27 + "private": true 22 28 }
+60
js/docs/src/components/HelpDesk.astro
··· 1 + --- 2 + import { Card, CardGrid } from "@astrojs/starlight/components"; 3 + 4 + interface Props { 5 + searchPlaceholder?: string; 6 + } 7 + --- 8 + 9 + <div class="helpdesk"> 10 + 11 + <h2>How can we help?</h2> 12 + <p>Search the knowledge base, or check out topics below.</p> 13 + 14 + <CardGrid> 15 + <Card title="Getting Started" icon="rocket"> 16 + <p>New to Streamplace? Start here to set up your first stream.</p> 17 + <ul> 18 + <li><a href="/docs/guides/start-streaming/quick-start">Quick start guide</a></li> 19 + <li><a href="/docs/guides/start-streaming/obs">Stream with OBS</a></li> 20 + </ul> 21 + </Card> 22 + 23 + <Card title="Developers & Self-Hosters" icon="laptop"> 24 + <p>Building with Streamplace or running your own node?</p> 25 + <ul> 26 + <li><a href="/docs/developers">Developer documentation</a></li> 27 + </ul> 28 + </Card> 29 + </CardGrid> 30 + </div> 31 + 32 + <style> 33 + .helpdesk { 34 + margin: 0 auto; 35 + } 36 + 37 + .helpdesk-search { 38 + margin-bottom: 2rem; 39 + } 40 + 41 + .search-input { 42 + width: 100%; 43 + padding: 1rem 1.5rem; 44 + font-size: 1.125rem; 45 + border: 2px solid var(--sl-color-gray-5); 46 + border-radius: 0.5rem; 47 + background: var(--sl-color-bg); 48 + color: var(--sl-color-text); 49 + transition: border-color 0.2s; 50 + } 51 + 52 + .search-input:focus { 53 + outline: none; 54 + border-color: var(--sl-color-accent); 55 + } 56 + 57 + .helpdesk h2 { 58 + margin-bottom: 1.5rem; 59 + } 60 + </style>
+1 -2
js/docs/src/content/docs/components/custom_ui.md
··· 1 1 --- 2 2 title: Creating your own player UI 3 - description: 4 - How to set up your player UI with components from @streamplace/components. 3 + description: How to set up your player UI with components from @streamplace/components. 5 4 --- 6 5 7 6 # Building a Custom Player UI
+40
js/docs/src/content/docs/developers.mdx
··· 1 + --- 2 + title: Developers & Self-Hosters 3 + description: Build with Streamplace or run your own infrastructure. 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + ## Learn how to deploy, or contribute to Streamplace. 10 + 11 + <br /> 12 + 13 + <CardGrid stagger> 14 + <Card title="Building an Application" icon="laptop"> 15 + Integrate live video into your project. - [API 16 + reference](/docs/lex-reference/place-stream-defs) - [Our component 17 + library](/docs/components/custom_ui/) 18 + </Card> 19 + 20 + {" "} 21 + 22 + <Card title="Self-Hosting" icon="seti:config"> 23 + Run your own Streamplace infrastructure. - [Installation 24 + guide](/docs/guides/installing/installing-streamplace) 25 + </Card> 26 + 27 + {" "} 28 + 29 + <Card title="Contributing" icon="github"> 30 + Help improve Streamplace. - [Development 31 + setup](/docs/guides/streamplace-dev-setup) - [Video 32 + signing](/docs/video-metadata/intro/) 33 + </Card> 34 + 35 + <Card title="Support & Community" icon="information"> 36 + Get help and connect with other developers. - [GitHub 37 + issues](https://github.com/streamplace/streamplace/issues) - [Discord 38 + community](https://discord.stream.place) 39 + </Card> 40 + </CardGrid>
+3 -1
js/docs/src/content/docs/features/danmu.md
··· 3 3 description: Add flying bullet-style chat comments to the player, or your stream 4 4 --- 5 5 6 - :::note This feature is experimental and may change in future releases. ::: 6 + :::note 7 + This feature is experimental and may change in future releases. 8 + ::: 7 9 8 10 [Danmu (or Danmaku)](https://en.wikipedia.org/wiki/Danmaku_subtitling) (弹幕, 9 11 "bullet curtain") is a comment style where messages fly across the video
+27
js/docs/src/content/docs/features/embed.md
··· 1 + --- 2 + title: Embedding your livestream 3 + description: How to embed your livestream on your website, blog, etc. 4 + --- 5 + 6 + Streamplace provides an easy way to embed your livestream on any website or 7 + blog. 8 + 9 + You can access the embedded livestream page by putting `/embed` in the URL of 10 + your livestream. For example, if your livestream URL is 11 + `https://stream.place/iame.li`, the embed URL will be 12 + `https://stream.place/embed/iame.li`. 13 + 14 + You can use the following HTML snippet to embed your livestream: 15 + 16 + ```html 17 + <iframe 18 + src="https://stream.place/embed/your-handle" 19 + width="560" 20 + height="315" 21 + frameborder="0" 22 + allowfullscreen 23 + ></iframe> 24 + ``` 25 + 26 + Alternatively, you can use the share sheet located on your livestream page. 27 + Click the "Share" button, and you'll find the embed code ready to copy.
+52
js/docs/src/content/docs/features/multistreaming.md
··· 1 + --- 2 + title: Multistreaming 3 + description: Forward your Streamplace stream to other providers. 4 + --- 5 + 6 + :::note 7 + This guide isn't about setting up Streamplace as an OBS destination. See [OBS Multistreaming to Streamplace](/docs/guides/start-streaming/obs-multistreaming/) for information on that. 8 + ::: 9 + 10 + Multistreaming lets you forward your Streamplace stream to multiple platforms at the same time. Instead of streaming only to Streamplace, you can forward your stream to any platform that accepts RTMP input. 11 + 12 + ## Setting up multistream targets 13 + 14 + 1. Go to **Settings** > **Streaming** > **Multistream Targets** 15 + 2. Click **Create Multistream Target** 16 + 3. Enter the RTMP or RTMPS URL from your destination platform 17 + 4. Optionally give it a name to identify it later 18 + 5. Click **Create** 19 + 20 + ### Finding your multistream URL 21 + 22 + Different platforms will provide their own RTMP URLs. Some common examples: 23 + 24 + - **YouTube Live**: Format `rtmp://a.rtmp.youtube.com/live2/your-stream-key` 25 + - Find your stream key at https://studio.youtube.com/channel/UC/livestreaming (click the copy icon in the top right corner of the 'connect your encoder to go live' box) 26 + - **Twitch**: Format `rtmp://usw20.contribute.live-video.net/app/your-stream-key` 27 + - You can get a valid RTMPS url at https://help.twitch.tv/s/twitch-ingest-recommendation 28 + - Find your stream key at https://dashboard.twitch.tv/settings/stream (your 'primary stream key') 29 + 30 + :::note 31 + Your stream key should automatically be hidden once you confirm. Make sure you've entered it correctly! 32 + ::: 33 + 34 + ## Managing targets during a stream 35 + 36 + When you're live, you can see all your multistream targets on the Live Dashboard with their current status: 37 + 38 + - **Green (Active)**: Successfully streaming to this target 39 + - **Yellow (Pending)**: Connecting to this target 40 + - **Red (Error)**: Connection failed; check your URL and credentials 41 + - **Gray (Inactive)**: This target is disabled 42 + 43 + You can toggle any target on or off with the switch next to its name. Changes take effect immediately. 44 + 45 + ## Limits 46 + 47 + - **Maximum targets**: 100 total per account 48 + - **Maximum active targets**: 5 simultaneous streams 49 + 50 + ### Credits 51 + 52 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
+83
js/docs/src/content/docs/features/webhooks.md
··· 1 + --- 2 + title: Discord Webhooks 3 + description: Configure Discord webhooks for livestream announcements and chat 4 + sidebar: 5 + order: 30 6 + --- 7 + 8 + Streamplace supports Discord webhooks for receiving livestream 9 + notifications and chat messages. You can create, manage, and configure webhooks 10 + to customize how events are delivered to your Discord channels. 11 + 12 + ## Webhook Events 13 + 14 + You can configure webhooks to listen for specific events. For right now, the 15 + following events are supported: 16 + 17 + - `Chat`: Triggered when a chat message is sent. 18 + - `Livestream`: Triggered when a livestream starts. 19 + 20 + ## Creating a Webhook 21 + 22 + To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 + navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 + fields are required: 25 + 26 + - Name: Webhook URL. For example, 27 + `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 + - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 + `Livestream Started`). `Livestream Started` is pre-checked by default. 30 + 31 + We'd recommend also filling out these optional fields: 32 + 33 + - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 + you can remember. 35 + - Description: A description of what this webhook is for (e.g., "Sends 36 + livestream start notifications to Discord channel"). 37 + - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 + "[Streamplace] "). Will apply to both Chat and Livestream events! 39 + - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 + live!"). Will apply to both Chat and Livestream events! 41 + - Text replacements: A list of text replacements to apply to chat messages sent 42 + by this webhook. Each replacement consists of a "from" string and a "to" 43 + string. For example, you could replace all instances of "foo" with "bar". 44 + 45 + After filling out the form, click "Create" to save your webhook. You should see 46 + it listed in the "Webhooks" section. 47 + 48 + ## Updating a Webhook 49 + 50 + To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 + navigate to the "Webhooks" section. Find the webhook you want to update and 52 + click on the "pen" icon next to it. This will open the webhook edit form, where 53 + you can modify the fields as needed. After making your changes, click "Update" 54 + to save your changes. 55 + 56 + ## Deleting a Webhook 57 + 58 + To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 + navigate to the "Webhooks" section. Find the webhook you want to delete and 60 + click on the "trash" icon next to it. A confirmation dialog will appear; click 61 + "Delete" to confirm. The webhook will be removed from the list. 62 + 63 + ## Recommendations 64 + 65 + We'd recommend: 66 + 67 + - Creating separate Discord channels for livestream notifications and chat 68 + messages to keep them organized. 69 + - If you want to have one webhook for both chat and livestream events, you can 70 + create multiple webhooks with the same URL but different event subscriptions 71 + and prefixes/suffixes/replacements. 72 + - Testing your webhook by starting a livestream or sending a chat message to 73 + ensure that notifications are being sent correctly. 74 + 75 + ## API Documentation 76 + 77 + See these endpoint pages: 78 + 79 + - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 + - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 + - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 + - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 + - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
+1 -2
js/docs/src/content/docs/guides/start-contributing/styling-quick-reference.md
··· 1 1 --- 2 2 title: ZeroCSS Quick Reference 3 - description: 4 - Quick reference for Streamplace ZeroCSS - common patterns and utilities. 3 + description: Quick reference for Streamplace ZeroCSS - common patterns and utilities. 5 4 sidebar: 6 5 order: 31 7 6 ---
-83
js/docs/src/content/docs/guides/start-streaming/discord-hooks.md
··· 1 - --- 2 - title: Discord Webhooks 3 - description: Configure Discord webhooks for livestream announcements and chat 4 - sidebar: 5 - order: 30 6 - --- 7 - 8 - Streamplace supports Discord webhook integration for receiving livestream 9 - notifications and chat messages. You can create, manage, and configure webhooks 10 - to customize how events are delivered to your Discord channels. 11 - 12 - ## Webhook Events 13 - 14 - You can configure webhooks to listen for specific events. For right now, the 15 - following events are supported: 16 - 17 - - `Chat`: Triggered when a chat message is sent. 18 - - `Livestream`: Triggered when a livestream starts. 19 - 20 - ## Creating a Webhook 21 - 22 - To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 - navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 - fields are required: 25 - 26 - - Name: Webhook URL. For example, 27 - `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 - - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 - `Livestream Started`). `Livestream Started` is pre-checked by default. 30 - 31 - We'd recommend also filling out these optional fields: 32 - 33 - - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 - you can remember. 35 - - Description: A description of what this webhook is for (e.g., "Sends 36 - livestream start notifications to Discord channel"). 37 - - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 - "[Streamplace] "). Will apply to both Chat and Livestream events! 39 - - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 - live!"). Will apply to both Chat and Livestream events! 41 - - Text replacements: A list of text replacements to apply to chat messages sent 42 - by this webhook. Each replacement consists of a "from" string and a "to" 43 - string. For example, you could replace all instances of "foo" with "bar". 44 - 45 - After filling out the form, click "Create" to save your webhook. You should see 46 - it listed in the "Webhooks" section. 47 - 48 - ## Updating a Webhook 49 - 50 - To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 - navigate to the "Webhooks" section. Find the webhook you want to update and 52 - click on the "pen" icon next to it. This will open the webhook edit form, where 53 - you can modify the fields as needed. After making your changes, click "Update" 54 - to save your changes. 55 - 56 - ## Deleting a Webhook 57 - 58 - To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 - navigate to the "Webhooks" section. Find the webhook you want to delete and 60 - click on the "trash" icon next to it. A confirmation dialog will appear; click 61 - "Delete" to confirm. The webhook will be removed from the list. 62 - 63 - ## Recommendations 64 - 65 - We'd recommend: 66 - 67 - - Creating separate Discord channels for livestream notifications and chat 68 - messages to keep them organized. 69 - - If you want to have one webhook for both chat and livestream events, you can 70 - create multiple webhooks with the same URL but different event subscriptions 71 - and prefixes/suffixes/replacements. 72 - - Testing your webhook by starting a livestream or sending a chat message to 73 - ensure that notifications are being sent correctly. 74 - 75 - ## API Documentation 76 - 77 - See these endpoint pages: 78 - 79 - - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 - - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 - - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 - - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 - - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
-27
js/docs/src/content/docs/guides/start-streaming/embed.md
··· 1 - --- 2 - title: Embedding your livestream 3 - description: How to embed your livestream on your website, blog, etc. 4 - --- 5 - 6 - Streamplace provides an easy way to embed your livestream on any website or 7 - blog. 8 - 9 - You can access the embedded livestream page by putting `/embed` in the URL of 10 - your livestream. For example, if your livestream URL is 11 - `https://stream.place/iame.li`, the embed URL will be 12 - `https://stream.place/embed/iame.li`. 13 - 14 - You can use the following HTML snippet to embed your livestream: 15 - 16 - ```html 17 - <iframe 18 - src="https://stream.place/embed/your-handle" 19 - width="560" 20 - height="315" 21 - frameborder="0" 22 - allowfullscreen 23 - ></iframe> 24 - ``` 25 - 26 - Alternatively, you can use the share sheet located on your livestream page. 27 - Click the "Share" button, and you'll find the embed code ready to copy.
+7 -1
js/docs/src/content/docs/guides/start-streaming/obs-multistreaming.md
··· 1 1 --- 2 - title: OBS Multistreaming with Streamplace 2 + title: OBS Multistreaming to Streamplace 3 3 description: 4 4 Configure OBS for multistreaming to Streamplace and other platforms using the 5 5 obs-multi-rtmp plugin. 6 6 sidebar: 7 7 order: 20 8 8 --- 9 + 10 + :::note 11 + This guide is not about the multistreaming feature. Check 12 + [the multistreaming guide](/docs/features/multistreaming) out for more 13 + information. 14 + ::: 9 15 10 16 This guide explains how to configure Open Broadcaster Software (OBS) for 11 17 simultaneous streaming to Streamplace and other platforms using the
+8 -1
js/docs/src/content/docs/guides/start-streaming/obs.md
··· 66 66 67 67 - Video Encoder: x264/h264 (**must** be an x/h.264 encoder) 68 68 - Rate Control: `CBR` 69 - - Keyframe Interval: `1s` 69 + - Keyframe Interval: `1s` (or anything less than once every ~7s) 70 70 - This is _one keyframe per second_ 71 71 - In some situations (e.g. 'keyframe interval (**frames**)'), this should be 72 72 set to your FPS. 73 73 - x264 Options: `bframes=0` 74 74 - If available, there also may be a 'bframes' checkbox which should **NOT** be 75 75 checked 76 + 77 + :::caution 78 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 79 + ::: 76 80 77 81 ### 3. Announce your stream 78 82 ··· 90 94 - [OBS Multistreaming Guide](guides/obs-multistreaming) 91 95 92 96 2. [**Aitum Multistream Plugin**](https://aitum.tv/products/multi) 97 + 98 + Alternatively, you can 99 + [multistream through Streamplace itself.](/docs/features/multistreaming) 93 100 94 101 ## Best Practices 95 102
+73
js/docs/src/content/docs/guides/start-streaming/quick-start.md
··· 1 + --- 2 + title: Quick Start 3 + description: Get up and streaming on Streamplace quickly. 4 + sidebar: 5 + order: 1 6 + --- 7 + 8 + This guide gets you from zero to streaming. If you get stuck, check out the full [OBS setup guide](/docs/guides/start-streaming/obs). 9 + 10 + :::tip 11 + You will want to check out our [community guidelines](https://blog.stream.place/3mcqwibo4ks2w) first for guidance on what you can and cannot do on Streamplace. 12 + ::: 13 + 14 + ## So, what is Streamplace? 15 + 16 + Streamplace is a video streaming service built on top of the AT Protocol (Authenticated Transfer Protocol), the same protocol Bluesky is built on. 17 + 18 + ## Step 1: Create your account 19 + 20 + 1. Go to [stream.place](https://stream.place) 21 + 2. Click "Sign in" in the top right. 22 + 3. Use your Atmosphere credentials to log in (ex. your Bluesky handle) 23 + - You'll need to use your actual password here - we're using OAuth so you enter your password on your PDS. We do not receive your password at all. 24 + 4. You're done! Your stream profile is live at `stream.place/your-handle` 25 + 26 + ## Step 2: Get your stream key 27 + 28 + 1. Click **Live Dashboard** (or go to [stream.place/dashboard](https://stream.place/dashboard)) 29 + 2. Click **Stream from OBS** 30 + 3. Click **Generate Stream Key** 31 + 4. Your key is copied to clipboard automatically 32 + 33 + Keep this key private. It's like a password, but for your stream. 34 + 35 + ## Step 3: Configure OBS 36 + 37 + Open OBS and go to **Settings → Stream**: 38 + 39 + - **Service**: `Custom...` 40 + - **Server**: `rtmps://stream.place:1935/live` 41 + - **Stream Key**: Paste what you copied in Step 2 42 + 43 + Then go to **Settings → Output → Streaming**: 44 + 45 + - **Video Encoder**: `libx264` (or `NVIDIA NVENC H.264` if you have an NVIDIA GPU) 46 + - **Rate Control**: `CBR` 47 + - **Bitrate**: `6000` Kbps (adjust down if you drop frames) 48 + - **Keyframe Interval**: `1` 49 + - **x264 Options**: `bframes=0`. If there's a 'bframes' option, you'll want to have that at '0' or unchecked. 50 + 51 + :::caution 52 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 53 + ::: 54 + 55 + ## Step 4: Go live 56 + 57 + 1. In OBS, click **Start Streaming** 58 + 2. Go back to the Live Dashboard at stream.place 59 + 3. Fill in your stream title and optionally pick a thumbnail8 60 + 4. If needed, turn on content warnings. ("Metadata" tab in Stream Settings) 61 + 5. Click **Announce Livestream** 62 + 6. Your stream is now live and visible to the world! 63 + 64 + ## Next steps 65 + 66 + - **Customize your chat**: Change your name color in Settings > Account 67 + - **Stream to other platforms too**: Set your Twitch/YouTube URLs in Settings > Multistream Targets to push your stream there automatically. See the [Multistreaming guide](/docs/features/multistreaming) for more information 68 + - **Improve stream quality**: See the [OBS guide](/docs/guides/start-streaming/obs) for encoder settings and troubleshooting 69 + - **Join the Discord!**: If you need any help, or just want to chat, check out our discord at https://discord.stream.place. 70 + 71 + ### Credits 72 + 73 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s excellent [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
+2 -32
js/docs/src/content/docs/index.mdx
··· 2 2 title: Welcome to Streamplace! 3 3 description: Begin your development journey with the Streamplace documentation. 4 4 template: doc 5 - hero: 6 - tagline: Solve live video for your project with Streamplace. 7 - image: 8 - file: ../../assets/cube.png 9 - alt: Streamplace logo. A pink 3d box viewed from a top corner. 10 - actions: 11 - - text: Get Started 12 - link: /docs/guides/start-streaming/obs 13 - icon: right-arrow 14 - - text: Visit Streamplace 15 - link: / 16 - icon: external 17 - variant: minimal 18 5 --- 19 6 20 - import { Card, CardGrid } from "@astrojs/starlight/components"; 21 - 22 - ## Next Steps 7 + import HelpDesk from "../../components/HelpDesk.astro"; 23 8 24 - <CardGrid> 25 - <Card title="Read the Docs" icon="open-book"> 26 - Learn how to start streaming with 27 - [Streamplace](/docs/guides/start-streaming/obs). 28 - </Card> 29 - <Card title="Install Streamplace" icon="download"> 30 - [Run your own Streamplace 31 - node](/docs/guides/installing/installing-streamplace). 32 - </Card> 33 - <Card title="API Reference" icon="document"> 34 - Explore the [Lexicon API reference](/docs/lex-reference/place-stream-defs). 35 - </Card> 36 - <Card title="Developer Setup" icon="setting"> 37 - Set up your [development environment](/docs/guides/streamplace-dev-setup). 38 - </Card> 39 - </CardGrid> 9 + <HelpDesk />
+2 -1
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getblob.md
··· 28 28 - **Description:** Raw blob data with appropriate content-type 29 29 - **Schema:** 30 30 31 - _Schema not defined._ **Possible Errors:** 31 + _Schema not defined._ 32 + **Possible Errors:** 32 33 33 34 - `BrandingNotFound`: The requested branding asset does not exist 34 35
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-origin.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Record indicating a livestream is published and available for replication at a 17 - given address. By convention, the record key is streamer::server 16 + Record indicating a livestream is published and available for replication at a given address. By convention, the record key is streamer::server 18 17 19 18 **Record Key:** `any` 20 19
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-syndication.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Record created by a Streamplace broadcaster to indicate that they will be 17 - replicating a livestream. NYI 16 + Record created by a Streamplace broadcaster to indicate that they will be replicating a livestream. NYI 18 17 19 18 **Record Key:** `tid` 20 19
+58 -2
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
··· 20 20 | `uri` | `string` | ✅ | | Format: `at-uri` | 21 21 | `cid` | `string` | ✅ | | Format: `cid` | 22 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` | ✅ | | | 23 + | `record` | Union of:<br/>&nbsp;&nbsp;[`#messageRecordView`](#messagerecordview) | ✅ | | | 24 24 | `indexedAt` | `string` | ✅ | | Format: `datetime` | 25 25 | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | 26 26 | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | ❌ | | | ··· 28 28 29 29 --- 30 30 31 + <a name="messagerecordview"></a> 32 + 33 + ### `messageRecordView` 34 + 35 + **Type:** `object` 36 + 37 + The content of a chat message. 38 + 39 + **Properties:** 40 + 41 + | Name | Type | Req'd | Description | Constraints | 42 + | ----------- | ------------------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------- | --------------------------------------- | 43 + | `text` | `string` | ✅ | The primary message content. May be an empty string, if there are embeds. | Max Length: 3000<br/>Max Graphemes: 300 | 44 + | `createdAt` | `string` | ✅ | Client-declared timestamp when this message was originally created. | Format: `datetime` | 45 + | `facets` | Array of [`place.stream.richtext.defs#facetView`](/lex-reference/place-stream-richtext-defs#facetview) | ❌ | Annotations of text (mentions, URLs, etc) | | 46 + | `streamer` | `string` | ✅ | The DID of the streamer whose chat this is. | Format: `did` | 47 + | `reply` | [`place.stream.chat.message#replyRef`](/lex-reference/place-stream-chat-message#replyref) | ❌ | | | 48 + 49 + --- 50 + 31 51 ## Lexicon Source 32 52 33 53 ```json ··· 52 72 "ref": "app.bsky.actor.defs#profileViewBasic" 53 73 }, 54 74 "record": { 55 - "type": "unknown" 75 + "type": "union", 76 + "refs": ["#messageRecordView"] 56 77 }, 57 78 "indexedAt": { 58 79 "type": "string", ··· 69 90 "deleted": { 70 91 "type": "boolean", 71 92 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 93 + } 94 + } 95 + }, 96 + "messageRecordView": { 97 + "type": "object", 98 + "description": "The content of a chat message.", 99 + "required": ["text", "createdAt", "streamer"], 100 + "properties": { 101 + "text": { 102 + "type": "string", 103 + "maxLength": 3000, 104 + "maxGraphemes": 300, 105 + "description": "The primary message content. May be an empty string, if there are embeds." 106 + }, 107 + "createdAt": { 108 + "type": "string", 109 + "format": "datetime", 110 + "description": "Client-declared timestamp when this message was originally created." 111 + }, 112 + "facets": { 113 + "type": "array", 114 + "description": "Annotations of text (mentions, URLs, etc)", 115 + "items": { 116 + "type": "ref", 117 + "ref": "place.stream.richtext.defs#facetView" 118 + } 119 + }, 120 + "streamer": { 121 + "type": "string", 122 + "format": "did", 123 + "description": "The DID of the streamer whose chat this is." 124 + }, 125 + "reply": { 126 + "type": "ref", 127 + "ref": "place.stream.chat.message#replyRef" 72 128 } 73 129 } 74 130 }
+2 -1
js/docs/src/content/docs/lex-reference/live/place-stream-live-getprofilecard.md
··· 26 26 - **Encoding:** `*/*` 27 27 - **Schema:** 28 28 29 - _Schema not defined._ **Possible Errors:** 29 + _Schema not defined._ 30 + **Possible Errors:** 30 31 31 32 - `RepoNotFound` 32 33
+1 -2
js/docs/src/content/docs/lex-reference/live/place-stream-live-searchactorstypeahead.md
··· 13 13 14 14 **Type:** `query` 15 15 16 - Find actor suggestions for a prefix search term. Expected use is for 17 - auto-completion during text field entry. 16 + Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. 18 17 19 18 **Parameters:** 20 19
+1 -2
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-configuration.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Default metadata record for livestream including content warnings, rights, and 17 - distribution policy 16 + Default metadata record for livestream including content warnings, rights, and distribution policy 18 17 19 18 **Record Key:** `literal:self` 20 19
+8 -19
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentrights.md
··· 33 33 34 34 **Type:** `token` 35 35 36 - All rights reserved to the creator — others cannot use, modify, or share without 37 - explicit authorization. 36 + All rights reserved to the creator — others cannot use, modify, or share without explicit authorization. 38 37 39 38 --- 40 39 ··· 44 43 45 44 **Type:** `token` 46 45 47 - Public domain dedication. You waive all copyright and related rights where 48 - possible. Others may copy, modify, distribute, or perform your work for any 49 - purpose without attribution. 46 + Public domain dedication. You waive all copyright and related rights where possible. Others may copy, modify, distribute, or perform your work for any purpose without attribution. 50 47 51 48 --- 52 49 ··· 56 53 57 54 **Type:** `token` 58 55 59 - Attribution required. Others may copy, distribute, remix, and build upon your 60 - work, even commercially, if they credit you. 56 + Attribution required. Others may copy, distribute, remix, and build upon your work, even commercially, if they credit you. 61 57 62 58 --- 63 59 ··· 67 63 68 64 **Type:** `token` 69 65 70 - Attribution + share-alike. Others may adapt and build upon your work, even 71 - commercially, if they credit you and license their new creations under identical 72 - terms. 66 + Attribution + share-alike. Others may adapt and build upon your work, even commercially, if they credit you and license their new creations under identical terms. 73 67 74 68 --- 75 69 ··· 79 73 80 74 **Type:** `token` 81 75 82 - Attribution + non-commercial. Others may adapt and build upon your work for 83 - non-commercial purposes only, and must credit you. 76 + Attribution + non-commercial. Others may adapt and build upon your work for non-commercial purposes only, and must credit you. 84 77 85 78 --- 86 79 ··· 90 83 91 84 **Type:** `token` 92 85 93 - Attribution + non-commercial + share-alike. Others may adapt and build upon your 94 - work for non-commercial purposes only, must credit you, and must license their 95 - new creations under identical terms. 86 + Attribution + non-commercial + share-alike. Others may adapt and build upon your work for non-commercial purposes only, must credit you, and must license their new creations under identical terms. 96 87 97 88 --- 98 89 ··· 102 93 103 94 **Type:** `token` 104 95 105 - Attribution + no derivatives. Others may reuse your work, even commercially, but 106 - it must remain unchanged and you must be credited. 96 + Attribution + no derivatives. Others may reuse your work, even commercially, but it must remain unchanged and you must be credited. 107 97 108 98 --- 109 99 ··· 113 103 114 104 **Type:** `token` 115 105 116 - Attribution + non-commercial + no derivatives. Others may download and share 117 - your work with credit, but cannot change it or use it commercially. 106 + Attribution + non-commercial + no derivatives. Others may download and share your work with credit, but cannot change it or use it commercially. 118 107 119 108 --- 120 109
+8 -18
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentwarnings.md
··· 29 29 30 30 **Type:** `token` 31 31 32 - The content could be perceived as offensive due to the discussion or display of 33 - death. 32 + The content could be perceived as offensive due to the discussion or display of death. 34 33 35 34 --- 36 35 ··· 40 39 41 40 **Type:** `token` 42 41 43 - The content contains a portrayal of the use or abuse of mind altering 44 - substances. 42 + The content contains a portrayal of the use or abuse of mind altering substances. 45 43 46 44 --- 47 45 ··· 51 49 52 50 **Type:** `token` 53 51 54 - The content contains violent actions of a fantasy nature, involving human or 55 - non-human characters in situations easily distinguishable from real life. 52 + The content contains violent actions of a fantasy nature, involving human or non-human characters in situations easily distinguishable from real life. 56 53 57 54 --- 58 55 ··· 62 59 63 60 **Type:** `token` 64 61 65 - The content contains flashing lights that could be harmful to viewers with 66 - seizure disorders such as photosensitive epilepsy. 62 + The content contains flashing lights that could be harmful to viewers with seizure disorders such as photosensitive epilepsy. 67 63 68 64 --- 69 65 ··· 93 89 94 90 **Type:** `token` 95 91 96 - The content contains information that can be used to identify a particular 97 - individual, such as a name, phone number, email address, physical address, or IP 98 - address. 92 + The content contains information that can be used to identify a particular individual, such as a name, phone number, email address, physical address, or IP address. 99 93 100 94 --- 101 95 ··· 105 99 106 100 **Type:** `token` 107 101 108 - The content could be perceived as offensive due to the discussion or display of 109 - sexuality. 102 + The content could be perceived as offensive due to the discussion or display of sexuality. 110 103 111 104 --- 112 105 ··· 116 109 117 110 **Type:** `token` 118 111 119 - The content could be perceived as distressing due to the discussion or display 120 - of suffering or triggering topics, including suicide, eating disorders or self 121 - harm. 112 + The content could be perceived as distressing due to the discussion or display of suffering or triggering topics, including suicide, eating disorders or self harm. 122 113 123 114 --- 124 115 ··· 128 119 129 120 **Type:** `token` 130 121 131 - The content could be perceived as offensive due to the discussion or display of 132 - violence. 122 + The content could be perceived as offensive due to the discussion or display of violence. 133 123 134 124 --- 135 125
+3 -6
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createblock.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates 17 - an app.bsky.graph.block record in the streamer's repository. 16 + Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository. 18 17 19 18 **Parameters:** _(None defined)_ 20 19 ··· 46 45 **Possible Errors:** 47 46 48 47 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to create blocks for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 48 + - `Forbidden`: The caller does not have permission to create blocks for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 53 50 54 51 --- 55 52
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-creategate.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Create a gate (hide message) on behalf of a streamer. Requires 'hide' 17 - permission. Creates a place.stream.chat.gate record in the streamer's 18 - repository. 16 + Create a gate (hide message) on behalf of a streamer. Requires 'hide' permission. Creates a place.stream.chat.gate record in the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 46 44 **Possible Errors:** 47 45 48 46 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to hide messages for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 47 + - `Forbidden`: The caller does not have permission to hide messages for this streamer. 48 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 53 49 54 50 --- 55 51
+5 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deleteblock.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. 17 - Deletes an app.bsky.graph.block record from the streamer's repository. 16 + Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository. 18 17 19 18 **Parameters:** _(None defined)_ 20 19 ··· 37 36 38 37 **Schema Type:** `object` 39 38 40 - _(No properties defined)_ **Possible Errors:** 39 + _(No properties defined)_ 40 + **Possible Errors:** 41 41 42 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 - - `Forbidden`: The caller does not have permission to delete blocks for this 44 - streamer. 45 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 46 - invalid. 43 + - `Forbidden`: The caller does not have permission to delete blocks for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 47 45 48 46 --- 49 47
+5 -8
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletegate.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' 17 - permission. Deletes a place.stream.chat.gate record from the streamer's 18 - repository. 16 + Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 38 36 39 37 **Schema Type:** `object` 40 38 41 - _(No properties defined)_ **Possible Errors:** 39 + _(No properties defined)_ 40 + **Possible Errors:** 42 41 43 42 - `Unauthorized`: The request lacks valid authentication credentials. 44 - - `Forbidden`: The caller does not have permission to unhide messages for this 45 - streamer. 46 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 47 - invalid. 43 + - `Forbidden`: The caller does not have permission to unhide messages for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 48 45 49 46 --- 50 47
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-updatelivestream.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 - permission. Updates a place.stream.livestream record in the streamer's 18 - repository. 16 + Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 47 45 **Possible Errors:** 48 46 49 47 - `Unauthorized`: The request lacks valid authentication credentials. 50 - - `Forbidden`: The caller does not have permission to update livestream metadata 51 - for this streamer. 52 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 - invalid. 48 + - `Forbidden`: The caller does not have permission to update livestream metadata for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 54 50 - `RecordNotFound`: The specified livestream record does not exist. 55 51 56 52 ---
+1 -2
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-createtarget.md
··· 33 33 - **Encoding:** `application/json` 34 34 - **Schema:** 35 35 36 - **Schema Type:** 37 - [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 36 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 38 37 39 38 **Possible Errors:** 40 39
+1 -2
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-puttarget.md
··· 34 34 - **Encoding:** `application/json` 35 35 - **Schema:** 36 36 37 - **Schema Type:** 38 - [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 37 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 39 38 40 39 **Possible Errors:** 41 40
+138
js/docs/src/content/docs/lex-reference/richtext/place-stream-richtext-defs.md
··· 1 + --- 2 + title: place.stream.richtext.defs 3 + description: Reference for the place.stream.richtext.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="facetview"></a> 11 + 12 + ### `facetView` 13 + 14 + **Type:** `object` 15 + 16 + Annotation of a sub-string within rich text. 17 + 18 + **Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 22 + | `index` | [`app.bsky.richtext.facet#byteSlice`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#byteSlice) | ✅ | | | 23 + | `features` | Array of Union of:<br/>&nbsp;&nbsp;[`app.bsky.richtext.facet#mention`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#mention)<br/>&nbsp;&nbsp;[`app.bsky.richtext.facet#link`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#link)<br/>&nbsp;&nbsp;[`#censor`](#censor) | ✅ | | | 24 + 25 + --- 26 + 27 + <a name="censor"></a> 28 + 29 + ### `censor` 30 + 31 + **Type:** `object` 32 + 33 + Indicates that the text in the given index has been censored. 34 + 35 + **Properties:** 36 + 37 + | Name | Type | Req'd | Description | Constraints | 38 + | ------------ | ----------------- | ----- | ------------------------------ | ----------- | 39 + | `reason` | `string` | ❌ | | | 40 + | `categories` | Array of `string` | ❌ | Categories of censored content | | 41 + 42 + --- 43 + 44 + <a name="discriminatory"></a> 45 + 46 + ### `discriminatory` 47 + 48 + **Type:** `token` 49 + 50 + Indicates that the text has been censored due to discriminatory content. 51 + 52 + --- 53 + 54 + <a name="sexuallyexplicit"></a> 55 + 56 + ### `sexually_explicit` 57 + 58 + **Type:** `token` 59 + 60 + Indicates that the text has been censored due to sexually explicit content. 61 + 62 + --- 63 + 64 + <a name="profanity"></a> 65 + 66 + ### `profanity` 67 + 68 + **Type:** `token` 69 + 70 + Indicates that the text has been censored due to profanity. 71 + 72 + --- 73 + 74 + ## Lexicon Source 75 + 76 + ```json 77 + { 78 + "lexicon": 1, 79 + "id": "place.stream.richtext.defs", 80 + "defs": { 81 + "facetView": { 82 + "type": "object", 83 + "description": "Annotation of a sub-string within rich text.", 84 + "required": ["index", "features"], 85 + "properties": { 86 + "index": { 87 + "type": "ref", 88 + "ref": "app.bsky.richtext.facet#byteSlice" 89 + }, 90 + "features": { 91 + "type": "array", 92 + "items": { 93 + "type": "union", 94 + "refs": [ 95 + "app.bsky.richtext.facet#mention", 96 + "app.bsky.richtext.facet#link", 97 + "#censor" 98 + ] 99 + } 100 + } 101 + } 102 + }, 103 + "censor": { 104 + "type": "object", 105 + "description": "Indicates that the text in the given index has been censored.", 106 + "properties": { 107 + "reason": { 108 + "type": "string" 109 + }, 110 + "categories": { 111 + "type": "array", 112 + "items": { 113 + "type": "string", 114 + "knownValues": [ 115 + "place.stream.richtext.defs#discriminatory", 116 + "place.stream.richtext.defs#sexually_explicit", 117 + "place.stream.richtext.defs#profanity" 118 + ] 119 + }, 120 + "description": "Categories of censored content" 121 + } 122 + } 123 + }, 124 + "discriminatory": { 125 + "type": "token", 126 + "description": "Indicates that the text has been censored due to discriminatory content." 127 + }, 128 + "sexually_explicit": { 129 + "type": "token", 130 + "description": "Indicates that the text has been censored due to sexually explicit content." 131 + }, 132 + "profanity": { 133 + "type": "token", 134 + "description": "Indicates that the text has been censored due to profanity." 135 + } 136 + } 137 + } 138 + ```
+9
js/docs/src/content/docs/reference.mdx
··· 1 + --- 2 + title: API Reference 3 + description: Our XRPC and OpenAPI Reference documentation 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + Here contains our XRPC and OpenAPI Reference documentation.
+1 -1
lerna.json
··· 1 1 { 2 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 - "version": "0.9.8", 3 + "version": "0.9.9", 4 4 "npmClient": "pnpm" 5 5 }
+39 -1
lexicons/place/stream/chat/defs.json
··· 12 12 "type": "ref", 13 13 "ref": "app.bsky.actor.defs#profileViewBasic" 14 14 }, 15 - "record": { "type": "unknown" }, 15 + "record": { 16 + "type": "union", 17 + "refs": ["#messageRecordView"] 18 + }, 16 19 "indexedAt": { "type": "string", "format": "datetime" }, 17 20 "chatProfile": { 18 21 "type": "ref", ··· 25 28 "deleted": { 26 29 "type": "boolean", 27 30 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 31 + } 32 + } 33 + }, 34 + "messageRecordView": { 35 + "type": "object", 36 + "description": "The content of a chat message.", 37 + "required": ["text", "createdAt", "streamer"], 38 + "properties": { 39 + "text": { 40 + "type": "string", 41 + "maxLength": 3000, 42 + "maxGraphemes": 300, 43 + "description": "The primary message content. May be an empty string, if there are embeds." 44 + }, 45 + "createdAt": { 46 + "type": "string", 47 + "format": "datetime", 48 + "description": "Client-declared timestamp when this message was originally created." 49 + }, 50 + "facets": { 51 + "type": "array", 52 + "description": "Annotations of text (mentions, URLs, etc)", 53 + "items": { 54 + "type": "ref", 55 + "ref": "place.stream.richtext.defs#facetView" 56 + } 57 + }, 58 + "streamer": { 59 + "type": "string", 60 + "format": "did", 61 + "description": "The DID of the streamer whose chat this is." 62 + }, 63 + "reply": { 64 + "type": "ref", 65 + "ref": "place.stream.chat.message#replyRef" 28 66 } 29 67 } 30 68 }
+56
lexicons/place/stream/richtext/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.richtext.defs", 4 + "defs": { 5 + "facetView": { 6 + "type": "object", 7 + "description": "Annotation of a sub-string within rich text.", 8 + "required": ["index", "features"], 9 + "properties": { 10 + "index": { "type": "ref", "ref": "app.bsky.richtext.facet#byteSlice" }, 11 + "features": { 12 + "type": "array", 13 + "items": { 14 + "type": "union", 15 + "refs": [ 16 + "app.bsky.richtext.facet#mention", 17 + "app.bsky.richtext.facet#link", 18 + "#censor" 19 + ] 20 + } 21 + } 22 + } 23 + }, 24 + "censor": { 25 + "type": "object", 26 + "description": "Indicates that the text in the given index has been censored.", 27 + "properties": { 28 + "reason": { "type": "string" }, 29 + "categories": { 30 + "type": "array", 31 + "items": { 32 + "type": "string", 33 + "knownValues": [ 34 + "place.stream.richtext.defs#discriminatory", 35 + "place.stream.richtext.defs#sexually_explicit", 36 + "place.stream.richtext.defs#profanity" 37 + ] 38 + }, 39 + "description": "Categories of censored content" 40 + } 41 + } 42 + }, 43 + "discriminatory": { 44 + "type": "token", 45 + "description": "Indicates that the text has been censored due to discriminatory content." 46 + }, 47 + "sexually_explicit": { 48 + "type": "token", 49 + "description": "Indicates that the text has been censored due to sexually explicit content." 50 + }, 51 + "profanity": { 52 + "type": "token", 53 + "description": "Indicates that the text has been censored due to profanity." 54 + } 55 + } 56 + }
+1 -1
pkg/api/api.go
··· 272 272 if err != nil { 273 273 return nil, err 274 274 } 275 - linker, err := linking.NewLinker(ctx, bs) 275 + linker, err := linking.NewLinker(ctx, bs, a.StatefulDB, a.CLI) 276 276 if err != nil { 277 277 return nil, err 278 278 }
+1 -1
pkg/api/api_internal.go
··· 418 418 errors.WriteHTTPInternalServerError(w, "unable to get chat posts", err) 419 419 return 420 420 } 421 - spmsg, err := msg.ToStreamplaceMessageView() 421 + spmsg, err := msg.ToStreamplaceMessageView(nil) 422 422 if err != nil { 423 423 errors.WriteHTTPInternalServerError(w, "unable to convert chat message to streamplace message view", err) 424 424 return
+30 -7
pkg/aqtime/aqtime.go
··· 7 7 "time" 8 8 ) 9 9 10 + // RE matches the canonical internal format: 2006-01-02T15:04:05.000Z 11 + // It also accepts the file-safe variant with dashes/dots swapped, for backward compat. 10 12 var RE *regexp.Regexp 11 - var Pattern string = `^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z$` 12 - 13 - type AQTime string 13 + var Pattern string = `(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z` 14 14 15 15 func init() { 16 16 RE = regexp.MustCompile(fmt.Sprintf(`^%s$`, Pattern)) 17 17 } 18 18 19 19 var fstr = "2006-01-02T15:04:05.000Z" 20 + 21 + type AQTime string 20 22 21 23 // return a consistently formatted timestamp 22 24 func FromMillis(ms int64) AQTime { ··· 29 31 } 30 32 31 33 func FromString(str string) (AQTime, error) { 32 - bits := RE.FindStringSubmatch(str) 33 - if bits == nil { 34 - return "", fmt.Errorf("bad time format, expected=%s got=%s", fstr, str) 34 + // Reject -00:00 (valid RFC 3339 but disallowed by ATProto) 35 + if strings.HasSuffix(str, "-00:00") { 36 + return "", fmt.Errorf("bad time format, -00:00 timezone offset is not allowed, got=%s", str) 37 + } 38 + 39 + t, err := time.Parse(time.RFC3339Nano, str) 40 + if err != nil { 41 + // Fall back to file-safe variant (e.g. 2024-09-13T18-10-17-090Z) 42 + if bits := RE.FindStringSubmatch(str); bits != nil { 43 + if bits[2] < "01" || bits[2] > "12" || bits[3] < "01" || bits[3] > "31" || 44 + bits[4] > "23" || bits[5] > "59" || bits[6] > "60" { 45 + return "", fmt.Errorf("bad time format, invalid date/time values in %s", str) 46 + } 47 + return AQTime(str), nil 48 + } 49 + return "", fmt.Errorf("bad time format: %w", err) 35 50 } 36 - return AQTime(str), nil 51 + 52 + // Reject if UTC normalization results in a negative year 53 + utc := t.UTC() 54 + if utc.Year() < 0 { 55 + return "", fmt.Errorf("bad time format, datetime normalizes to negative year: %s", str) 56 + } 57 + 58 + // Normalize to canonical UTC millisecond format 59 + return AQTime(utc.Format(fstr)), nil 37 60 } 38 61 39 62 func FromTime(t time.Time) AQTime {
+63 -2
pkg/aqtime/aqtime_test.go
··· 35 35 } 36 36 } 37 37 38 + // Valid ATProto datetime examples from the spec 39 + // https://atproto.com/specs/lexicon#datetime 40 + func TestATProtoValidCases(t *testing.T) { 41 + tests := []struct { 42 + input string 43 + wantMs string // expected millisecond portion after normalization 44 + wantHr string // expected hour after UTC normalization 45 + wantMin string 46 + }{ 47 + {"1985-04-12T23:20:50.123Z", "123", "23", "20"}, 48 + {"1985-04-12T23:20:50.123456Z", "123", "23", "20"}, 49 + {"1985-04-12T23:20:50.120Z", "120", "23", "20"}, 50 + {"1985-04-12T23:20:50.120000Z", "120", "23", "20"}, 51 + {"0001-01-01T00:00:00.000Z", "000", "00", "00"}, 52 + {"0000-01-01T00:00:00.000Z", "000", "00", "00"}, 53 + {"1985-04-12T23:20:50.12345678912345Z", "123", "23", "20"}, 54 + {"1985-04-12T23:20:50Z", "000", "23", "20"}, 55 + {"1985-04-12T23:20:50.0Z", "000", "23", "20"}, 56 + {"1985-04-12T23:20:50.123+00:00", "123", "23", "20"}, 57 + {"1985-04-12T23:20:50.123-07:00", "123", "06", "20"}, // 23+7=30 -> next day 06:20 58 + } 59 + for _, tt := range tests { 60 + t.Run(tt.input, func(t *testing.T) { 61 + aqt, err := FromString(tt.input) 62 + require.NoError(t, err, "input: %s", tt.input) 63 + _, _, _, hr, min, _, ms := aqt.Parts() 64 + require.Equal(t, tt.wantMs, ms, "millis mismatch for %s", tt.input) 65 + require.Equal(t, tt.wantHr, hr, "hour mismatch for %s", tt.input) 66 + require.Equal(t, tt.wantMin, min, "minute mismatch for %s", tt.input) 67 + }) 68 + } 69 + } 70 + 38 71 func TestBadCases(t *testing.T) { 39 72 for _, str := range []string{ 73 + // existing cases 40 74 "prefix2024-09-13T18:10:17.090Z", 41 75 "2024-09-13T18-10-17-090Zsuffix", 42 76 "2024-09-13T18-10-17-090ZZZZ", 43 77 "2024-09-13T18-10-17*090ZZZZ", 78 + // ATProto spec invalid examples 79 + "1985-04-12", 80 + "1985-04-12T23:20Z", 81 + "1985-04-12T23:20:5Z", 82 + "1985-04-12T23:20:50.123", 83 + "+001985-04-12T23:20:50.123Z", 84 + "23:20:50.123Z", 85 + "-1985-04-12T23:20:50.123Z", 86 + "1985-4-12T23:20:50.123Z", 87 + "01985-04-12T23:20:50.123Z", 88 + "1985-04-12T23:20:50.123+00", 89 + "1985-04-12T23:20:50.123+0000", 90 + // ISO-8601 strict capitalization 91 + "1985-04-12t23:20:50.123Z", 92 + "1985-04-12T23:20:50.123z", 93 + // RFC-3339, but not ISO-8601 94 + "1985-04-12T23:20:50.123-00:00", 95 + "1985-04-12 23:20:50.123Z", 96 + // timezone is required 97 + "1985-04-12T23:20:50.123", 98 + // syntax looks ok, but datetime is not valid 99 + "1985-04-12T23:99:50.123Z", 100 + "1985-00-12T23:20:50.123Z", 101 + // ISO-8601, but normalizes to a negative time 102 + "0000-01-01T00:00:00+01:00", 44 103 } { 45 - _, err := FromString(str) 46 - require.Error(t, err) 104 + t.Run(str, func(t *testing.T) { 105 + _, err := FromString(str) 106 + require.Error(t, err, "expected error for: %s", str) 107 + }) 47 108 } 48 109 }
+9 -9
pkg/atproto/chat_message_test.go
··· 112 112 }) 113 113 // Reverse the messages slice to match expected order (most recent first) 114 114 slices.SortFunc(messages, func(a, b *streamplace.ChatDefs_MessageView) int { 115 - aTime := a.Record.Val.(*streamplace.ChatMessage).CreatedAt 116 - bTime := b.Record.Val.(*streamplace.ChatMessage).CreatedAt 115 + aTime := a.Record.ChatDefs_MessageRecordView.CreatedAt 116 + bTime := b.Record.ChatDefs_MessageRecordView.CreatedAt 117 117 if aTime < bTime { 118 118 return -1 119 119 } else if aTime > bTime { ··· 122 122 return 0 123 123 }) 124 124 slices.SortFunc(busMessages, func(a, b bus.Message) int { 125 - aTime := a.(*streamplace.ChatDefs_MessageView).Record.Val.(*streamplace.ChatMessage).CreatedAt 126 - bTime := b.(*streamplace.ChatDefs_MessageView).Record.Val.(*streamplace.ChatMessage).CreatedAt 125 + aTime := a.(*streamplace.ChatDefs_MessageView).Record.ChatDefs_MessageRecordView.CreatedAt 126 + bTime := b.(*streamplace.ChatDefs_MessageView).Record.ChatDefs_MessageRecordView.CreatedAt 127 127 if aTime < bTime { 128 128 return -1 129 129 } else if aTime > bTime { ··· 131 131 } 132 132 return 0 133 133 }) 134 - require.Equal(t, msg.Text, messages[0].Record.Val.(*streamplace.ChatMessage).Text) 135 - require.Equal(t, msg2.Text, messages[1].Record.Val.(*streamplace.ChatMessage).Text) 134 + require.Equal(t, msg.Text, messages[0].Record.ChatDefs_MessageRecordView.Text) 135 + require.Equal(t, msg2.Text, messages[1].Record.ChatDefs_MessageRecordView.Text) 136 136 busMessage1 := busMessages[0].(*streamplace.ChatDefs_MessageView) 137 137 busMessage2 := busMessages[1].(*streamplace.ChatDefs_MessageView) 138 - require.Equal(t, msg.Text, busMessage1.Record.Val.(*streamplace.ChatMessage).Text) 139 - require.Equal(t, msg2.Text, busMessage2.Record.Val.(*streamplace.ChatMessage).Text) 138 + require.Equal(t, msg.Text, busMessage1.Record.ChatDefs_MessageRecordView.Text) 139 + require.Equal(t, msg2.Text, busMessage2.Record.ChatDefs_MessageRecordView.Text) 140 140 141 141 rkey := strings.TrimPrefix(rec1.Uri, fmt.Sprintf("at://%s/place.stream.chat.message/", user.DID)) 142 142 ··· 162 162 return nil 163 163 }) 164 164 require.NoError(t, err) 165 - require.Equal(t, msg2.Text, messages[0].Record.Val.(*streamplace.ChatMessage).Text) 165 + require.Equal(t, msg2.Text, messages[0].Record.ChatDefs_MessageRecordView.Text) 166 166 busMessage3 := busMessages[2].(*streamplace.ChatDefs_MessageView) 167 167 require.Equal(t, true, *busMessage3.Deleted) 168 168
+1 -1
pkg/atproto/firehose.go
··· 295 295 log.Error(ctx, "failed to delete chat message", "err", err) 296 296 continue 297 297 } 298 - mv, err := msg.ToStreamplaceMessageView() 298 + mv, err := msg.ToStreamplaceMessageView(nil) 299 299 if err != nil { 300 300 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 301 301 continue
+1 -1
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 - chatView, err := msg.ToStreamplaceMessageView() 185 + chatView, err := msg.ToStreamplaceMessageView(nil) 186 186 if err != nil { 187 187 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 188 188 continue
+1 -1
pkg/atproto/sync.go
··· 145 145 log.Error(ctx, "failed to retrieve just-saved chat message", "err", err) 146 146 return nil 147 147 } 148 - scm, err := mcm.ToStreamplaceMessageView() 148 + scm, err := mcm.ToStreamplaceMessageView(nil) 149 149 if err != nil { 150 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 151 return nil
+1 -4
pkg/integrations/discord/send-chat.go
··· 17 17 18 18 func SendChat(ctx context.Context, w *discordtypes.Webhook, did string, scm *streamplace.ChatDefs_MessageView) error { 19 19 20 - msg, ok := scm.Record.Val.(*streamplace.ChatMessage) 21 - if !ok { 22 - return fmt.Errorf("failed to cast chat message to streamplace chat message") 23 - } 20 + msg := scm.Record.ChatDefs_MessageRecordView 24 21 25 22 avatarURL, err := GetAvatarURL(ctx, did) 26 23 if err != nil {
+1 -1
pkg/integrations/discord/send-livestream.go
··· 67 67 log.Warn(ctx, "failed to parse URL", "err", err) 68 68 } else { 69 69 suffix = fmt.Sprintf(" on %s!", u.Host) 70 - payload.Embeds[0].URL = fmt.Sprintf("%s/%s", *ls.Url, lsv.Author.Handle) 70 + payload.Embeds[0].URL = *ls.Url 71 71 } 72 72 } 73 73
+7 -8
pkg/integrations/webhook/manager.go
··· 14 14 // SendChatWebhook sends chat message to a specific webhook 15 15 func SendChatWebhook(ctx context.Context, webhook *streamplace.ServerDefs_Webhook, authorDID string, scm *streamplace.ChatDefs_MessageView) error { 16 16 // Check if message should be muted 17 - if msg, ok := scm.Record.Val.(*streamplace.ChatMessage); ok { 18 - if len(webhook.MuteWords) > 0 { 19 - messageText := strings.ToLower(msg.Text) 20 - for _, muteWord := range webhook.MuteWords { 21 - if strings.Contains(messageText, strings.ToLower(muteWord)) { 22 - // Message contains a mute word, skip forwarding 23 - return nil 24 - } 17 + msg := scm.Record.ChatDefs_MessageRecordView 18 + if len(webhook.MuteWords) > 0 { 19 + messageText := strings.ToLower(msg.Text) 20 + for _, muteWord := range webhook.MuteWords { 21 + if strings.Contains(messageText, strings.ToLower(muteWord)) { 22 + // Message contains a mute word, skip forwarding 23 + return nil 25 24 } 26 25 } 27 26 }
+139 -11
pkg/linking/linking.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/json" 6 7 "errors" 7 8 "fmt" 9 + "log" 8 10 "net/url" 9 11 10 12 "golang.org/x/net/html" 13 + "stream.place/streamplace/pkg/config" 14 + "stream.place/streamplace/pkg/statedb" 11 15 "stream.place/streamplace/pkg/streamplace" 12 16 ) 13 17 14 18 type Linker struct { 15 19 BaseHTML []byte 20 + sdb *statedb.StatefulDB 21 + cli *config.CLI 16 22 } 17 23 18 - func NewLinker(ctx context.Context, baseHTML []byte) (*Linker, error) { 24 + func NewLinker(ctx context.Context, baseHTML []byte, sdb *statedb.StatefulDB, cli *config.CLI) (*Linker, error) { 19 25 _, err := html.Parse(bytes.NewReader(baseHTML)) 20 26 if err != nil { 21 27 return nil, err 22 28 } 23 29 24 - return &Linker{BaseHTML: baseHTML}, nil 30 + return &Linker{BaseHTML: baseHTML, sdb: sdb, cli: cli}, nil 25 31 } 26 32 27 33 type PageConfig struct { 28 34 Title string 29 35 Metas []MetaTag 30 36 SentryDSN string 37 + Branding []string 31 38 } 32 39 33 40 // Define all meta tags in a structured way ··· 37 44 Content string 38 45 } 39 46 47 + var BrandingAssetList = [...]string{ 48 + "siteTitle", 49 + "siteDescription", 50 + "primaryColor", 51 + "accentColor", 52 + "defaultStreamer", 53 + "mainLogo", 54 + "favicon", 55 + "sidebarBg", 56 + "legalLinks", 57 + } 58 + 59 + // fetch branding assets for a given broadcaster DID 60 + func (l *Linker) getBrandingAssets(broadcasterDid string) ([]streamplace.BrandingGetBranding_BrandingAsset, error) { 61 + ret := make([]streamplace.BrandingGetBranding_BrandingAsset, 0) 62 + for _, asset := range BrandingAssetList { 63 + blob, err := l.sdb.GetBrandingBlob(broadcasterDid, asset) 64 + if err != nil { 65 + // this can probably include a 'record not found' error, in which case we skip 66 + log.Printf("error fetching branding asset %s for broadcaster %s: %v", asset, broadcasterDid, err) 67 + continue 68 + } 69 + asset := streamplace.BrandingGetBranding_BrandingAsset{ 70 + Key: blob.Key, 71 + MimeType: blob.MimeType, 72 + } 73 + 74 + if blob.Width != nil { 75 + w := int64(*blob.Width) 76 + asset.Width = &w 77 + } 78 + if blob.Height != nil { 79 + h := int64(*blob.Height) 80 + asset.Height = &h 81 + } 82 + 83 + // process based on mime type 84 + if blob.MimeType == "text/plain" { 85 + str := string(blob.Data) 86 + asset.Data = &str 87 + } else { 88 + url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", blob.Key, broadcasterDid) 89 + asset.Url = &url 90 + } 91 + ret = append(ret, asset) 92 + } 93 + 94 + return ret, nil 95 + } 96 + 40 97 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 41 98 if u == nil { 42 99 return nil, errors.New("url is nil") ··· 49 106 return nil, errors.New("livestream view is not a livestream") 50 107 } 51 108 52 - titleStr := fmt.Sprintf("@%s's livestream on %s", lsv.Author.Handle, u.Host) 109 + titleStr := fmt.Sprintf("@%s's livestream on ", lsv.Author.Handle) 53 110 outURL := u.String() 54 - 55 - pageTitle := fmt.Sprintf("@%s | %s", lsv.Author.Handle, u.Host) 56 111 57 112 thumbURL, _ := url.Parse(u.String()) 58 113 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 66 121 // Facebook Meta Tags 67 122 {Type: "property", Key: "og:url", Content: u.String()}, 68 123 {Type: "property", Key: "og:type", Content: "website"}, 69 - {Type: "property", Key: "og:title", Content: titleStr}, 70 124 {Type: "property", Key: "og:description", Content: ls.Title}, 71 125 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 72 126 ··· 74 128 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 75 129 {Type: "property", Key: "twitter:domain", Content: u.Host}, 76 130 {Type: "property", Key: "twitter:url", Content: outURL}, 77 - {Type: "name", Key: "twitter:title", Content: titleStr}, 78 131 {Type: "name", Key: "twitter:description", Content: ls.Title}, 79 132 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 80 133 } 134 + brandingTitle := "streamplace node" 135 + if l.sdb != nil && l.cli != nil { 136 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 137 + if err == nil { 138 + for i := range branding { 139 + val := branding[i] 140 + if val.Key == "siteTitle" && val.Data != nil { 141 + brandingTitle = *val.Data 142 + } 143 + marshalledJson, err := json.Marshal(val) 144 + if err != nil { 145 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 146 + continue 147 + } 148 + metaTags = append(metaTags, MetaTag{ 149 + Type: "name", 150 + Key: "internal-brand:" + val.Key, 151 + Content: string(marshalledJson), 152 + }) 153 + } 154 + } else { 155 + // log but we should not block rendering 156 + fmt.Printf("error fetching branding assets: %v\n", err) 157 + } 158 + } 159 + 160 + // do twitter/og title after 161 + metaTags = append(metaTags, MetaTag{ 162 + Type: "property", 163 + Key: "og:title", 164 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 165 + }) 166 + metaTags = append(metaTags, MetaTag{ 167 + Type: "name", 168 + Key: "twitter:title", 169 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 170 + }) 81 171 82 172 return l.GenerateHTML(ctx, &PageConfig{ 83 - Title: pageTitle, 173 + Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), 84 174 Metas: metaTags, 85 175 SentryDSN: sentryDSN, 86 176 }) ··· 103 193 {Type: "property", Key: "og:url", Content: u.String()}, 104 194 {Type: "property", Key: "og:type", Content: "website"}, 105 195 {Type: "property", Key: "og:title", Content: "Stream.place"}, 106 - {Type: "property", Key: "og:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 196 + {Type: "property", Key: "og:description", Content: "Open-source livestreaming on the AT Protocol."}, 107 197 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 108 198 109 199 // Twitter Meta Tags ··· 111 201 {Type: "property", Key: "twitter:domain", Content: u.Host}, 112 202 {Type: "property", Key: "twitter:url", Content: u.String()}, 113 203 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 114 - {Type: "name", Key: "twitter:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 204 + {Type: "name", Key: "twitter:description", Content: "Open-source livestreaming on the AT Protocol."}, 115 205 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 116 206 } 117 207 208 + brandingTitle := "streamplace node" 209 + if l.sdb != nil && l.cli != nil { 210 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 211 + if err == nil { 212 + for i := range branding { 213 + val := branding[i] 214 + if val.Key == "siteTitle" && val.Data != nil { 215 + brandingTitle = *val.Data 216 + } 217 + marshalledJson, err := json.Marshal(val) 218 + if err != nil { 219 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 220 + continue 221 + } 222 + metaTags = append(metaTags, MetaTag{ 223 + Type: "name", 224 + Key: "internal-brand:" + val.Key, 225 + Content: string(marshalledJson), 226 + }) 227 + } 228 + } else { 229 + // log but we should not block rendering 230 + fmt.Printf("error fetching branding assets: %v\n", err) 231 + } 232 + } 233 + 234 + // do twitter/og title after 235 + metaTags = append(metaTags, MetaTag{ 236 + Type: "property", 237 + Key: "og:title", 238 + Content: brandingTitle, 239 + }) 240 + metaTags = append(metaTags, MetaTag{ 241 + Type: "name", 242 + Key: "twitter:title", 243 + Content: brandingTitle, 244 + }) 245 + 118 246 return l.GenerateHTML(ctx, &PageConfig{ 119 - Title: "Stream.place", 247 + Title: brandingTitle, 120 248 Metas: metaTags, 121 249 SentryDSN: sentryDSN, 122 250 })
+2 -2
pkg/linking/linking_test.go
··· 29 29 30 30 func TestNewLinker(t *testing.T) { 31 31 index := IndexHTML(t) 32 - linker, err := NewLinker(context.Background(), index) 32 + linker, err := NewLinker(context.Background(), index, nil, nil) 33 33 require.NoError(t, err) 34 34 require.NotNil(t, linker) 35 35 } 36 36 37 37 func TestGenerateLinkCard(t *testing.T) { 38 38 index := IndexHTML(t) 39 - linker, err := NewLinker(context.Background(), index) 39 + linker, err := NewLinker(context.Background(), index, nil, nil) 40 40 require.NoError(t, err) 41 41 require.NotNil(t, linker) 42 42
+58 -14
pkg/model/chat_message.go
··· 12 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 13 "github.com/rivo/uniseg" 14 14 "gorm.io/gorm" 15 + "stream.place/streamplace/pkg/stars" 15 16 "stream.place/streamplace/pkg/streamplace" 16 17 ) 17 18 ··· 38 39 return int(h.Sum32()) 39 40 } 40 41 41 - func (m *ChatMessage) ToStreamplaceMessageView() (*streamplace.ChatDefs_MessageView, error) { 42 + func (m *ChatMessage) ToStreamplaceMessageView(starrer *stars.Starrer) (*streamplace.ChatDefs_MessageView, error) { 42 43 rec, err := lexutil.CborDecodeValue(*m.ChatMessage) 43 44 if err != nil { 44 45 return nil, fmt.Errorf("error decoding feed post: %w", err) 45 46 } 46 - // Truncate message text if it is a ChatMessage 47 - if msg, ok := rec.(*streamplace.ChatMessage); ok { 48 - graphemeCount := uniseg.GraphemeClusterCount(msg.Text) 49 - if graphemeCount > 300 { 50 - gr := uniseg.NewGraphemes(msg.Text) 51 - var result strings.Builder 52 - for count := 0; count < 300 && gr.Next(); count++ { 53 - result.WriteString(gr.Str()) 47 + 48 + msg, ok := rec.(*streamplace.ChatMessage) 49 + if !ok { 50 + return nil, fmt.Errorf("expected *streamplace.ChatMessage, got %T", rec) 51 + } 52 + 53 + // Truncate message text if needed 54 + text := msg.Text 55 + graphemeCount := uniseg.GraphemeClusterCount(text) 56 + if graphemeCount > 300 { 57 + gr := uniseg.NewGraphemes(text) 58 + var result strings.Builder 59 + for count := 0; count < 300 && gr.Next(); count++ { 60 + result.WriteString(gr.Str()) 61 + } 62 + text = result.String() 63 + } 64 + 65 + // Convert facets to facet views if needed 66 + var facetViews []*streamplace.RichtextDefs_FacetView 67 + for _, facet := range msg.Facets { 68 + var features []*streamplace.RichtextDefs_FacetView_Features_Elem 69 + for _, feature := range facet.Features { 70 + viewFeature := &streamplace.RichtextDefs_FacetView_Features_Elem{ 71 + RichtextFacet_Mention: feature.RichtextFacet_Mention, 72 + RichtextFacet_Link: feature.RichtextFacet_Link, 54 73 } 55 - msg.Text = result.String() 74 + features = append(features, viewFeature) 56 75 } 76 + facetViews = append(facetViews, &streamplace.RichtextDefs_FacetView{ 77 + Index: facet.Index, 78 + Features: features, 79 + }) 80 + } 81 + 82 + // Create the message record view 83 + recordView := &streamplace.ChatDefs_MessageRecordView{ 84 + LexiconTypeID: "place.stream.chat.defs#messageRecordView", 85 + Text: text, 86 + CreatedAt: msg.CreatedAt, 87 + Streamer: msg.Streamer, 88 + Facets: facetViews, 89 + Reply: msg.Reply, 57 90 } 58 91 59 92 message := &streamplace.ChatDefs_MessageView{ ··· 67 100 if m.Repo != nil { 68 101 message.Author.Handle = m.Repo.Handle 69 102 } 70 - message.Record = &lexutil.LexiconTypeDecoder{Val: rec} 103 + message.Record = &streamplace.ChatDefs_MessageView_Record{ 104 + ChatDefs_MessageRecordView: recordView, 105 + } 71 106 message.IndexedAt = m.IndexedAt.UTC().Format(time.RFC3339Nano) 72 107 if m.ChatProfile != nil { 73 108 scp, err := m.ChatProfile.ToStreamplaceChatProfile() ··· 84 119 85 120 } 86 121 if m.ReplyTo != nil { 87 - replyTo, err := m.ReplyTo.ToStreamplaceMessageView() 122 + replyTo, err := m.ReplyTo.ToStreamplaceMessageView(starrer) 88 123 if err != nil { 89 124 return nil, fmt.Errorf("error converting reply to to streamplace message view: %w", err) 90 125 } ··· 92 127 ChatDefs_MessageView: replyTo, 93 128 } 94 129 } 130 + 131 + if starrer != nil { 132 + censoredMsg, err := starrer.CensorMessageView(message) 133 + if err != nil { 134 + return nil, fmt.Errorf("error censoring message: %w", err) 135 + } 136 + return censoredMsg, nil 137 + } 138 + 95 139 return message, nil 96 140 } 97 141 ··· 158 202 return nil, fmt.Errorf("error retrieving replies: %w", err) 159 203 } 160 204 spmessages := []*streamplace.ChatDefs_MessageView{} 161 - for _, m := range dbmessages { 162 - spmessage, err := m.ToStreamplaceMessageView() 205 + for _, msg := range dbmessages { 206 + spmessage, err := msg.ToStreamplaceMessageView(m.starrer) 163 207 if err != nil { 164 208 return nil, fmt.Errorf("error converting feed post to bsky post view: %w", err) 165 209 }
+9 -2
pkg/model/model.go
··· 15 15 "gorm.io/plugin/prometheus" 16 16 "stream.place/streamplace/pkg/config" 17 17 "stream.place/streamplace/pkg/log" 18 + "stream.place/streamplace/pkg/stars" 18 19 "stream.place/streamplace/pkg/streamplace" 19 20 ) 20 21 21 22 type DBModel struct { 22 - DB *gorm.DB 23 + DB *gorm.DB 24 + starrer *stars.Starrer 23 25 } 24 26 25 27 type Model interface { ··· 158 160 return nil, fmt.Errorf("error using prometheus plugin: %w", err) 159 161 } 160 162 163 + starrer, err := stars.NewDefaultStarrer() 164 + if err != nil { 165 + return nil, fmt.Errorf("error creating default starrer: %w", err) 166 + } 167 + 161 168 sqlDB, err := db.DB() 162 169 if err != nil { 163 170 return nil, fmt.Errorf("error getting database: %w", err) ··· 189 196 return nil, err 190 197 } 191 198 } 192 - return &DBModel{DB: db}, nil 199 + return &DBModel{DB: db, starrer: starrer}, nil 193 200 }
+4 -4
pkg/spxrpc/place_stream_branding.go
··· 38 38 return s.cli.BroadcasterHost 39 39 } 40 40 41 - func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 41 + func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 42 // cache miss - fetch from db 43 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 44 if err == gorm.ErrRecordNotFound { ··· 61 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 - data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key) 64 + data, _, _, _, err := s.GetBrandingBlob(ctx, broadcasterID, key) 65 65 if err != nil { 66 66 return nil, err 67 67 } ··· 94 94 // build output 95 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 96 for key := range allKeys { 97 - data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key) 97 + data, mimeType, width, height, err := s.GetBrandingBlob(ctx, broadcasterID, key) 98 98 if err != nil { 99 99 continue // skip if error 100 100 } ··· 238 238 239 239 broadcasterID := s.cli.BroadcasterHost 240 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 - data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 241 + data, mimeType, _, _, err := s.GetBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 242 243 243 if err != nil || data == nil { 244 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
+156
pkg/stars/stars.go
··· 1 + package stars 2 + 3 + // censors a message (with stars) based on regex patterns 4 + import ( 5 + "encoding/json" 6 + "os" 7 + "regexp" 8 + "strings" 9 + 10 + appbsky "github.com/bluesky-social/indigo/api/bsky" 11 + "stream.place/streamplace/pkg/streamplace" 12 + ) 13 + 14 + // PatternDef defines a pattern with its associated categories 15 + type PatternDef struct { 16 + Pattern string 17 + Categories []string 18 + } 19 + 20 + // Default patterns for common profanity and slurs (case-insensitive) 21 + var DefaultPatterns = []PatternDef{ 22 + {`(?i)\b[cCϲсᴄⅽcçćčĉċ¢©🅒🅲𝐜𝑐𝒄𝒸𝓬𝔠𝕔𝖈𝖼𝗰𝘤𝙘𝚌ⓒⒸᶜ\(\[\{<ⲥꮯ€🇨][uUυսuùúûüũūŭůűųưᴜᵘᵤ🅤🆄𝐮𝑢𝒖𝓊𝓾𝔲𝕦𝖚𝗎𝘂𝘶𝙪𝚞ⓤⓊʋꞟꭎꭒ𑣘ט𑜆🇺𝖀vμ][nNոռnñńņňʼnṅṇṉṋɴⁿ🅝🅽𝐧𝑛𝒏𝓃𝓷𝔫𝕟𝖓𝗇𝗻𝘯𝙣𝚗ⓝⓃ🇳ηŋℕ𝕹][tTтtţťŧṫṭṯṱᴛᵗ7\+🅣🆃𝐭𝑡𝒕𝓉𝓽𝔱𝕥𝖙𝗍𝘁𝘵𝙩𝚝ⓣⓉ†✝]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 23 + {`(?i)\b(n|\|\\||🇳|ո|ռ|🅝|𝕹)+(i|1|!|\||l|🇮|ℹ️|ı|ɩ|ɪ|ӏ|Ꭵ|ꙇ|ꭵ|ǀ|Ι|І|Ӏ|׀|ו|ן|١|۱|ا|Ⲓ|ⵏ|ꓲ|𐊊|𐌉|𐌠|𖼨|ﺍ|ﺎ|│|🅘|𝕴)+(g|9|🇬|ƍ|ɡ|ᶃ|🅖|𝕲)`, []string{"place.stream.richtext.defs#discriminatory"}}, 24 + {`(?i)\b(f|ƒ|£|🇫|ẝ|ꞙ|ꬵ|🅕|𝖿|𝕱)+(a|4|@|∆|/-\\|/_\\|Д|🇦|🅰️|ɑ|а|🅐|𝖺|𝕬)+(g|9|🇬|ƍ|ɡ|ᶃ|🅖|𝕲)+`, []string{"place.stream.richtext.defs#discriminatory"}}, 25 + {`(?i)\b[rRᎡꓣ𝐑𝐫𝑹𝒓ℛℜℝ𝓇𝓡𝓻𝔯𝕣𝕽𝖗𝖱𝗋𝗥𝗿𝘙𝘳𝙍𝙧𝚁𝚛ⓇⓡʀᴿʳŕŗřȑȓɍɹɻɼɽɾṙṛṝṟгГ®🅡🆁🄡][eEЕеᎬꓰ𝐄𝐞𝑬𝒆ℰℯ𝓔𝓮𝔈𝔢𝔼𝕖𝕰𝖊𝖤𝖾𝗘𝗲𝘌𝘦𝙀𝙚𝙴𝚎ⒺⓔᴇᴱᵉₑɛεΕёЁèéêëēĕėęěȅȇȩɇѐєҽ3€🅔🅴🄴℮ǝƎ∃][tTТтᎢꓔ𝐓𝐭𝑻𝒕𝒯𝓉𝓣𝓽𝔗𝔱𝕋𝕥𝕿𝖙𝖳𝗍𝗧𝘁𝘛𝘵𝙏𝙩𝚃𝚝Ⓣⓣᴛᵀᵗţťŧțȶṫṭṯṱ7\+†✝🅣🆃🄣][aAАаᎪꓮꭺ𐊠𝐀𝐚𝑨𝒂𝒜𝒶𝓐𝓪𝔄𝔞𝔸𝕒𝕬𝖆𝖠𝖺𝗔𝗮𝘈𝘢𝘼𝙖𝙰𝚊𝚨ⒶⓐᴀᴬᵃᵅₐɐɑαΑäàáâãåāăąǎǟǡǻȁȃȧӑӓᾀᾁᾂᾃᾄᾅᾆᾇᾰᾱᾲᾳᾴᾶᾷὰά@4🅐🅰🄰∀Λλ][rRᎡꓣ𝐑𝐫𝑹𝒓ℛℜℝ𝓇𝓡𝓻𝔯𝕣𝕽𝖗𝖱𝗋𝗥𝗿𝘙𝘳𝙍𝙧𝚁𝚛ⓇⓡʀᴿʳŕŗřȑȓɍɹɻɼɽɾṙṛṝṟгГ®🅡🆁🄡][dDᎠꓓ𝐃𝐝𝑫𝒅𝒟𝒹𝓓𝓭𝔇𝔡𝔻d𝕯𝖉𝖣𝖽𝗗𝗱𝘋𝘥𝘿𝙙𝙳𝚍ⒹⓓᴅᴰᵈԀԁɖɗďđðḋḍḏḑḓ🅓🅳🄳ⅅⅆ]+`, []string{"place.stream.richtext.defs#discriminatory"}}, 26 + {`(?i)\b[bBВвᏴꓐ𐊂𐊡𐌁𝐁𝐛𝑩𝒃𝒷𝓑𝓫𝔅𝔟𝔹𝕓𝕭𝖇𝖡𝖻𝗕𝗯𝘉𝘣𝘽𝙗𝙱𝚋𝚩ⒷⓑᴃᴮᵇƀɓЬьβΒḃḅḇ68ßþ🅑🅱🄱ℬ][iIІіӀꓲ𝐈𝐢𝑰𝒊ℐℑ𝒾𝓘𝓲𝔦𝕀𝕚𝕴𝖎𝖨𝗂𝗜𝗶𝘐𝘪𝙄𝙞𝙸𝚒𝚤Ⓘⓘɪᴵⁱᵢìíîïĩīĭįıǐȉȋḭḯỉịӏ1l\|!¡🅘🅸🄸ⅰⅠ][tTТтᎢꓔ𝐓𝐭𝑻𝒕𝒯𝓉𝓣𝓽𝔗𝔱𝕋𝕥𝕿𝖙𝖳𝗍𝗧𝘁𝘛𝘵𝙏𝙩𝚃𝚝Ⓣⓣᴛᵀᵗţťŧțȶṫṭṯṱ7\+†✝🅣🆃🄣][cCСсᏟꓚꮯ𐊢𐌂𐐕𐐽𝐂𝐜𝑪𝒄𝒞𝒸𝓒𝓬ℭ𝔠ℂ𝕔𝕮𝖈𝖢𝖼𝗖𝗰𝘊𝘤𝘾𝙘𝙲𝚌ⒸⓒᴄↃↄϲϹçćĉċčƈȼ¢©€🅒🅲🄲⊂⊃ᑕᑢ\(<\[\{][hHНнᎻꓧ𝐇𝐡𝑯𝒉ℋℌℍ𝒽𝓗𝓱𝔥𝕙𝕳𝖍𝖧𝗁𝗛𝗵𝘏𝘩𝙃𝙝𝙷𝚑Ⓗⓗʜᴴʰĥħȟḣḥḧḩḫհңһ#🅗🅷🄷♄]+`, []string{"place.stream.richtext.defs#profanity"}}, 27 + {`(?i)\b[dDԁɗḋḍḏḑḓdᴅᵈ🅓🅳𝐝𝑑𝒅𝒹𝓭𝔡𝕕𝖉𝖽𝗱𝘥𝙙𝚍ⓓⒹđð][iIіıɪiìíîïĩīĭįǐ1l\|!🅘🅸𝐢𝑖𝒊𝒾𝓲𝔦𝕚𝖎𝗂𝗶𝘪𝙞𝚒ⓘⒾᵢⁱ¡ǃ][cCϲсᴄⅽcçćčĉċ¢©🅒🅲𝐜𝑐𝒄𝒸𝓬𝔠𝕔𝖈𝖼𝗰𝘤𝙘𝚌ⓒⒸᶜ\(\[\{<ⲥꮯ€🇨][kKκkḱḳḵķᴋᵏ🅚🅺𝐤𝑘𝒌𝓀𝓴𝔨𝕜𝖐𝗄𝗸𝘬𝙠𝚔ⓚⓀꮶ]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 28 + {`(?i)\b[pPрρpṕṗᴘᵖ🅟🅿𝐩𝑝𝒑𝓅𝓹𝔭𝕡𝖕𝗉𝗽𝘱𝙥𝚙ⓟⓅ℘][uUυսuùúûüũūŭůűųưᴜᵘᵤ🅤🆄𝐮𝑢𝒖𝓊𝓾𝔲𝕦𝖚𝗎𝘂𝘶𝙪𝚞ⓤⓊʋꞟꭎꭒ𑣘ט𑜆🇺𝖀vμ][sSѕꜱsśŝşšṡṣṥṧṩˢ\$5🅢🆂𝐬𝑠𝒔𝓈𝓼𝔰𝕤𝖘𝗌𝘀𝘴𝙨𝚜ⓢⓈ§][sSѕꜱsśŝşšṡṣṥṧṩˢ\$5🅢🆂𝐬𝑠𝒔𝓈𝓼𝔰𝕤𝖘𝗌𝘀𝘴𝙨𝚜ⓢⓈ§][yYуүγyỳýŷÿỹȳɣʏყᶌỿℽꭚ𑣄¥🅨🆈𝐲𝑦𝒚𝓎𝔂𝔶𝕪𝖞𝗒𝘆𝘺𝙮𝚢ⓨⓎʸ🇾𝖄]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 29 + {`(?i)\b(f|ƒ|£|🇫|ẝ|ꞙ|ꬵ|🅕|𝖿|𝕱)+(u|v|🇺|ʋ|υ|ս|ᴜ|ꞟ|ꭎ|ꭒ|𑣘|ט|𑜆|🅤|𝗎|𝖀)+(c|\(|€|🇨|©️|ϲ|с|ᴄ|ⲥ|ꮯ|🅒|𝖢|𝕮)+(k|\|<|🇰|🅚|𝕶)+`, []string{"place.stream.richtext.defs#profanity"}}, 30 + // from https://github.com/bluesky-social/atproto/blob/7b9a98a763636c5f66a06da11fe6013f29dd9157/lexicons/app/bsky/richtext/facet.json 31 + {`/\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][hĤĥȞȟḦḧḢḣḨḩḤḥḪḫH̱ẖĦħⱧⱨꞪɦꞕΗНн][iÍíi̇́Ììi̇̀Ĭĭcccccbvnnuugtbekdkibdcrbceidjbticigulkbikbbl 32 + ÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 33 + {`/\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0]{2}[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 34 + {`/\b[fḞḟƑƒꞘꞙᵮᶂ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa@4][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGg]{1,2}([ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeiÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ]{1,2}([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 35 + {`/\b[kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLlyÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]*\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 36 + {`/\b[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeaÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ]?|n[ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]|[a4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa]?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 37 + {`/[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?/`, []string{"place.stream.richtext.defs#discriminatory"}}, 38 + {`/\b[tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa4]+[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn]{1,2}([iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]|[yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 39 + {`(?i)\bANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86\w*`, []string{"place.stream.richtext.defs#profanity"}}, 40 + } 41 + 42 + type patternWithCategories struct { 43 + pattern *regexp.Regexp 44 + categories []string 45 + } 46 + 47 + type Starrer struct { 48 + patterns []patternWithCategories 49 + } 50 + 51 + func NewStarrer(patternDefs []PatternDef) (*Starrer, error) { 52 + patterns := make([]patternWithCategories, 0, len(patternDefs)) 53 + for _, pd := range patternDefs { 54 + re, err := regexp.Compile(pd.Pattern) 55 + if err != nil { 56 + return nil, err 57 + } 58 + patterns = append(patterns, patternWithCategories{ 59 + pattern: re, 60 + categories: pd.Categories, 61 + }) 62 + } 63 + return &Starrer{patterns: patterns}, nil 64 + } 65 + 66 + // NewDefaultStarrer creates a Starrer with the default profanity patterns 67 + func NewDefaultStarrer() (*Starrer, error) { 68 + return NewStarrer(DefaultPatterns) 69 + } 70 + 71 + // LoadPatternsFromJSON loads pattern definitions from a JSON file 72 + func LoadPatternsFromJSON(filepath string) ([]PatternDef, error) { 73 + data, err := os.ReadFile(filepath) 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + var patterns []PatternDef 79 + if err := json.Unmarshal(data, &patterns); err != nil { 80 + return nil, err 81 + } 82 + 83 + return patterns, nil 84 + } 85 + 86 + // NewStarrerFromJSON creates a Starrer from a JSON file containing pattern definitions 87 + func NewStarrerFromJSON(filepath string) (*Starrer, error) { 88 + patterns, err := LoadPatternsFromJSON(filepath) 89 + if err != nil { 90 + return nil, err 91 + } 92 + return NewStarrer(patterns) 93 + } 94 + 95 + func (s *Starrer) CensorMessageView(msg *streamplace.ChatDefs_MessageView) (*streamplace.ChatDefs_MessageView, error) { 96 + if msg.Record == nil || msg.Record.ChatDefs_MessageRecordView == nil { 97 + return msg, nil 98 + } 99 + 100 + record := msg.Record.ChatDefs_MessageRecordView 101 + text := record.Text 102 + 103 + // Find all matches across all patterns and create censor facets 104 + var newFacets []*streamplace.RichtextDefs_FacetView 105 + 106 + for _, pwc := range s.patterns { 107 + indices := pwc.pattern.FindAllStringIndex(text, -1) 108 + for _, idx := range indices { 109 + matchedText := text[idx[0]:idx[1]] 110 + byteStart := len([]byte(text[:idx[0]])) 111 + byteEnd := len([]byte(text[:idx[1]])) 112 + 113 + censorFacet := &streamplace.RichtextDefs_FacetView{ 114 + Index: &appbsky.RichtextFacet_ByteSlice{ 115 + ByteStart: int64(byteStart), 116 + ByteEnd: int64(byteEnd), 117 + }, 118 + Features: []*streamplace.RichtextDefs_FacetView_Features_Elem{ 119 + { 120 + RichtextDefs_Censor: &streamplace.RichtextDefs_Censor{ 121 + LexiconTypeID: "place.stream.richtext.defs#censor", 122 + Reason: &matchedText, 123 + Categories: pwc.categories, 124 + }, 125 + }, 126 + }, 127 + } 128 + newFacets = append(newFacets, censorFacet) 129 + } 130 + } 131 + 132 + if len(newFacets) == 0 { 133 + return msg, nil 134 + } 135 + 136 + // Copy the message and add censor facets 137 + censoredMsg := *msg 138 + censoredRecord := *record 139 + censoredRecord.Facets = append(censoredRecord.Facets, newFacets...) 140 + censoredMsg.Record = &streamplace.ChatDefs_MessageView_Record{ 141 + ChatDefs_MessageRecordView: &censoredRecord, 142 + } 143 + 144 + return &censoredMsg, nil 145 + } 146 + 147 + // Censor returns the censored version of the input string 148 + func (s *Starrer) Censor(input string) string { 149 + censored := input 150 + for _, pwc := range s.patterns { 151 + censored = pwc.pattern.ReplaceAllStringFunc(censored, func(match string) string { 152 + return strings.Repeat("*", len(match)) 153 + }) 154 + } 155 + return censored 156 + }
+1 -4
pkg/statedb/queue_processor.go
··· 151 151 return err 152 152 } 153 153 scm := chatTask.MessageView 154 - rec, ok := scm.Record.Val.(*streamplace.ChatMessage) 155 - if !ok { 156 - return fmt.Errorf("invalid chat message record") 157 - } 154 + rec := scm.Record.ChatDefs_MessageRecordView 158 155 159 156 // Send to webhooks using webhook manager 160 157 webhooks, err := state.GetActiveWebhooksForUser(rec.Streamer, "chat")
+44 -1
pkg/streamplace/chatdefs.go
··· 12 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 13 ) 14 14 15 + // ChatDefs_MessageRecordView is a "messageRecordView" in the place.stream.chat.defs schema. 16 + // 17 + // The content of a chat message. 18 + type ChatDefs_MessageRecordView struct { 19 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageRecordView"` 20 + // createdAt: Client-declared timestamp when this message was originally created. 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // facets: Annotations of text (mentions, URLs, etc) 23 + Facets []*RichtextDefs_FacetView `json:"facets,omitempty" cborgen:"facets,omitempty"` 24 + Reply *ChatMessage_ReplyRef `json:"reply,omitempty" cborgen:"reply,omitempty"` 25 + // streamer: The DID of the streamer whose chat this is. 26 + Streamer string `json:"streamer" cborgen:"streamer"` 27 + // text: The primary message content. May be an empty string, if there are embeds. 28 + Text string `json:"text" cborgen:"text"` 29 + } 30 + 15 31 // ChatDefs_MessageView is a "messageView" in the place.stream.chat.defs schema. 16 32 type ChatDefs_MessageView struct { 17 33 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` ··· 21 37 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 22 38 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 23 39 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"` 24 - Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` 40 + Record *ChatDefs_MessageView_Record `json:"record" cborgen:"record"` 25 41 ReplyTo *ChatDefs_MessageView_ReplyTo `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 42 Uri string `json:"uri" cborgen:"uri"` 43 + } 44 + 45 + type ChatDefs_MessageView_Record struct { 46 + ChatDefs_MessageRecordView *ChatDefs_MessageRecordView 47 + } 48 + 49 + func (t *ChatDefs_MessageView_Record) MarshalJSON() ([]byte, error) { 50 + if t.ChatDefs_MessageRecordView != nil { 51 + t.ChatDefs_MessageRecordView.LexiconTypeID = "place.stream.chat.defs#messageRecordView" 52 + return json.Marshal(t.ChatDefs_MessageRecordView) 53 + } 54 + return nil, fmt.Errorf("can not marshal empty union as JSON") 55 + } 56 + 57 + func (t *ChatDefs_MessageView_Record) UnmarshalJSON(b []byte) error { 58 + typ, err := lexutil.TypeExtract(b) 59 + if err != nil { 60 + return err 61 + } 62 + 63 + switch typ { 64 + case "place.stream.chat.defs#messageRecordView": 65 + t.ChatDefs_MessageRecordView = new(ChatDefs_MessageRecordView) 66 + return json.Unmarshal(b, t.ChatDefs_MessageRecordView) 67 + default: 68 + return nil 69 + } 27 70 } 28 71 29 72 type ChatDefs_MessageView_ReplyTo struct {
+74
pkg/streamplace/richtextdefs.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.richtext.defs 4 + 5 + package streamplace 6 + 7 + import ( 8 + "encoding/json" 9 + "fmt" 10 + 11 + appbsky "github.com/bluesky-social/indigo/api/bsky" 12 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 + ) 14 + 15 + // RichtextDefs_Censor is a "censor" in the place.stream.richtext.defs schema. 16 + // 17 + // Indicates that the text in the given index has been censored. 18 + type RichtextDefs_Censor struct { 19 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.richtext.defs#censor"` 20 + // categories: Categories of censored content 21 + Categories []string `json:"categories,omitempty" cborgen:"categories,omitempty"` 22 + Reason *string `json:"reason,omitempty" cborgen:"reason,omitempty"` 23 + } 24 + 25 + // RichtextDefs_FacetView is a "facetView" in the place.stream.richtext.defs schema. 26 + // 27 + // Annotation of a sub-string within rich text. 28 + type RichtextDefs_FacetView struct { 29 + Features []*RichtextDefs_FacetView_Features_Elem `json:"features" cborgen:"features"` 30 + Index *appbsky.RichtextFacet_ByteSlice `json:"index" cborgen:"index"` 31 + } 32 + 33 + type RichtextDefs_FacetView_Features_Elem struct { 34 + RichtextFacet_Mention *appbsky.RichtextFacet_Mention 35 + RichtextFacet_Link *appbsky.RichtextFacet_Link 36 + RichtextDefs_Censor *RichtextDefs_Censor 37 + } 38 + 39 + func (t *RichtextDefs_FacetView_Features_Elem) MarshalJSON() ([]byte, error) { 40 + if t.RichtextFacet_Mention != nil { 41 + t.RichtextFacet_Mention.LexiconTypeID = "app.bsky.richtext.facet#mention" 42 + return json.Marshal(t.RichtextFacet_Mention) 43 + } 44 + if t.RichtextFacet_Link != nil { 45 + t.RichtextFacet_Link.LexiconTypeID = "app.bsky.richtext.facet#link" 46 + return json.Marshal(t.RichtextFacet_Link) 47 + } 48 + if t.RichtextDefs_Censor != nil { 49 + t.RichtextDefs_Censor.LexiconTypeID = "place.stream.richtext.defs#censor" 50 + return json.Marshal(t.RichtextDefs_Censor) 51 + } 52 + return nil, fmt.Errorf("can not marshal empty union as JSON") 53 + } 54 + 55 + func (t *RichtextDefs_FacetView_Features_Elem) UnmarshalJSON(b []byte) error { 56 + typ, err := lexutil.TypeExtract(b) 57 + if err != nil { 58 + return err 59 + } 60 + 61 + switch typ { 62 + case "app.bsky.richtext.facet#mention": 63 + t.RichtextFacet_Mention = new(appbsky.RichtextFacet_Mention) 64 + return json.Unmarshal(b, t.RichtextFacet_Mention) 65 + case "app.bsky.richtext.facet#link": 66 + t.RichtextFacet_Link = new(appbsky.RichtextFacet_Link) 67 + return json.Unmarshal(b, t.RichtextFacet_Link) 68 + case "place.stream.richtext.defs#censor": 69 + t.RichtextDefs_Censor = new(RichtextDefs_Censor) 70 + return json.Unmarshal(b, t.RichtextDefs_Censor) 71 + default: 72 + return nil 73 + } 74 + }
+97
pnpm-lock.yaml
··· 728 728 sharp: 729 729 specifier: ^0.32.5 730 730 version: 0.32.6 731 + starlight-links-validator: 732 + specifier: ^0.19.2 733 + version: 0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 731 734 starlight-openapi: 732 735 specifier: ^0.17.0 733 736 version: 0.17.0(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 734 737 starlight-openapi-rapidoc: 735 738 specifier: ^0.8.1-beta 736 739 version: 0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 740 + starlight-sidebar-swipe: 741 + specifier: ^0.1.1 742 + version: 0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 737 743 streamplace: 738 744 specifier: workspace:* 739 745 version: link:../streamplace 746 + devDependencies: 747 + starlight-sidebar-topics: 748 + specifier: ^0.6.2 749 + version: 0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 740 750 741 751 js/streamplace: 742 752 dependencies: ··· 4905 4915 4906 4916 '@types/normalize-package-data@2.4.4': 4907 4917 resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} 4918 + 4919 + '@types/picomatch@3.0.2': 4920 + resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} 4908 4921 4909 4922 '@types/prop-types@15.7.12': 4910 4923 resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} ··· 7905 7918 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 7906 7919 engines: {node: '>=8'} 7907 7920 7921 + has-flag@5.0.1: 7922 + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} 7923 + engines: {node: '>=12'} 7924 + 7908 7925 has-property-descriptors@1.0.2: 7909 7926 resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 7910 7927 ··· 8354 8371 8355 8372 iron-webcrypto@1.2.1: 8356 8373 resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} 8374 + 8375 + is-absolute-url@4.0.1: 8376 + resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} 8377 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 8357 8378 8358 8379 is-alphabetical@2.0.1: 8359 8380 resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} ··· 11757 11778 standard-as-callback@2.1.0: 11758 11779 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} 11759 11780 11781 + starlight-links-validator@0.19.2: 11782 + resolution: {integrity: sha512-IHeK3R78fsmv53VfRkGbXkwK1CQEUBHM9QPzBEyoAxjZ/ssi5gjV+F4oNNUppTR48iPp+lEY0MTAmvkX7yNnkw==} 11783 + engines: {node: '>=18.17.1'} 11784 + peerDependencies: 11785 + '@astrojs/starlight': '>=0.32.0' 11786 + astro: '>=5.1.5' 11787 + 11760 11788 starlight-openapi-rapidoc@0.8.1-beta: 11761 11789 resolution: {integrity: sha512-CicjuydKZsO8jZvhrtzz+GGWyfHnOZCZY/ww562H58CP/zSnskWcmcgVnCuD7DkKfi0ofvqumyyee1KgfkVipQ==} 11762 11790 engines: {node: '>=18.14.1'} ··· 11771 11799 '@astrojs/markdown-remark': '>=6.0.1' 11772 11800 '@astrojs/starlight': '>=0.34.0' 11773 11801 astro: '>=5.5.0' 11802 + 11803 + starlight-sidebar-swipe@0.1.1: 11804 + resolution: {integrity: sha512-Q+xv7LSpSLCG3yQaEmZX4Qpks9dcIEc+FBA0Ql+LbLMO9IMBXt8S2zK5wJDhjJn5lbI0i0ipyP375T1GrVS8ig==} 11805 + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} 11806 + peerDependencies: 11807 + '@astrojs/starlight': '>=0.30' 11808 + 11809 + starlight-sidebar-topics@0.6.2: 11810 + resolution: {integrity: sha512-SNCTUZS/hcVor0ZcaXbaSVU37+V+qtvzNirkvnOg3Mqu/awuGpthkH5+uKpiZqWxLffp6TrOlsv5E5QsxrndNg==} 11811 + engines: {node: '>=18'} 11812 + peerDependencies: 11813 + '@astrojs/starlight': '>=0.32.0' 11774 11814 11775 11815 statuses@1.5.0: 11776 11816 resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} ··· 11979 12019 resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} 11980 12020 engines: {node: '>= 8.0'} 11981 12021 12022 + supports-color@10.2.2: 12023 + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} 12024 + engines: {node: '>=18'} 12025 + 11982 12026 supports-color@5.5.0: 11983 12027 resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} 11984 12028 engines: {node: '>=4'} ··· 11994 12038 supports-hyperlinks@2.3.0: 11995 12039 resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} 11996 12040 engines: {node: '>=8'} 12041 + 12042 + supports-hyperlinks@4.4.0: 12043 + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} 12044 + engines: {node: '>=20'} 11997 12045 11998 12046 supports-preserve-symlinks-flag@1.0.0: 11999 12047 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} ··· 12058 12106 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} 12059 12107 engines: {node: '>=8'} 12060 12108 12109 + terminal-link@5.0.0: 12110 + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} 12111 + engines: {node: '>=20'} 12112 + 12061 12113 terser-webpack-plugin@5.3.10: 12062 12114 resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} 12063 12115 engines: {node: '>= 10.13.0'} ··· 19470 19522 19471 19523 '@types/normalize-package-data@2.4.4': {} 19472 19524 19525 + '@types/picomatch@3.0.2': {} 19526 + 19473 19527 '@types/prop-types@15.7.12': {} 19474 19528 19475 19529 '@types/qrcode@1.5.5': ··· 23321 23375 23322 23376 has-flag@4.0.0: {} 23323 23377 23378 + has-flag@5.0.1: {} 23379 + 23324 23380 has-property-descriptors@1.0.2: 23325 23381 dependencies: 23326 23382 es-define-property: 1.0.0 ··· 23994 24050 ipaddr.js@2.2.0: {} 23995 24051 23996 24052 iron-webcrypto@1.2.1: {} 24053 + 24054 + is-absolute-url@4.0.1: {} 23997 24055 23998 24056 is-alphabetical@2.0.1: {} 23999 24057 ··· 28427 28485 28428 28486 standard-as-callback@2.1.0: {} 28429 28487 28488 + starlight-links-validator@0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)): 28489 + dependencies: 28490 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28491 + '@types/picomatch': 3.0.2 28492 + astro: 5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2) 28493 + github-slugger: 2.0.0 28494 + hast-util-from-html: 2.0.3 28495 + hast-util-has-property: 3.0.0 28496 + is-absolute-url: 4.0.1 28497 + kleur: 4.1.5 28498 + mdast-util-mdx-jsx: 3.2.0 28499 + mdast-util-to-string: 4.0.0 28500 + picomatch: 4.0.2 28501 + terminal-link: 5.0.0 28502 + unist-util-visit: 5.0.0 28503 + transitivePeerDependencies: 28504 + - supports-color 28505 + 28430 28506 starlight-openapi-rapidoc@0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3): 28431 28507 dependencies: 28432 28508 '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) ··· 28447 28523 url-template: 3.1.1 28448 28524 transitivePeerDependencies: 28449 28525 - openapi-types 28526 + 28527 + starlight-sidebar-swipe@0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28528 + dependencies: 28529 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28530 + 28531 + starlight-sidebar-topics@0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28532 + dependencies: 28533 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28534 + picomatch: 4.0.2 28450 28535 28451 28536 statuses@1.5.0: {} 28452 28537 ··· 28661 28746 transitivePeerDependencies: 28662 28747 - supports-color 28663 28748 28749 + supports-color@10.2.2: {} 28750 + 28664 28751 supports-color@5.5.0: 28665 28752 dependencies: 28666 28753 has-flag: 3.0.0 ··· 28677 28764 dependencies: 28678 28765 has-flag: 4.0.0 28679 28766 supports-color: 7.2.0 28767 + 28768 + supports-hyperlinks@4.4.0: 28769 + dependencies: 28770 + has-flag: 5.0.1 28771 + supports-color: 10.2.2 28680 28772 28681 28773 supports-preserve-symlinks-flag@1.0.0: {} 28682 28774 ··· 28772 28864 dependencies: 28773 28865 ansi-escapes: 4.3.2 28774 28866 supports-hyperlinks: 2.3.0 28867 + 28868 + terminal-link@5.0.0: 28869 + dependencies: 28870 + ansi-escapes: 7.0.0 28871 + supports-hyperlinks: 4.4.0 28775 28872 28776 28873 terser-webpack-plugin@5.3.10(@swc/core@1.15.4(@swc/helpers@0.5.17))(webpack@5.94.0(@swc/core@1.15.4(@swc/helpers@0.5.17))): 28777 28874 dependencies: