diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 00000000..6c40e953 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,21 @@ +## Design Context + +### Users + +Streamers going live and viewers watching streams are the primary audience. Developers building on AT Protocol video infrastructure are a secondary audience. Users open the app to watch live content, chat with communities, or broadcast themselves. The experience should feel like opening a social space, not a utility. + +### Brand Personality + +Warm, social, inviting. The interface should feel like walking into a friendly room where people are hanging out, not a cold control panel. Approachable without being childish, social without being noisy. + +### Aesthetic Direction + +Dark-first experience (light mode supported as secondary). The existing pink/rose crystal logo is a strong anchor — lean into warm tones rather than the current default indigo/violet. Avoid generic web3 aesthetics (no gradients, glassmorphism, neon accents on dark backgrounds) and the corporate ad-heavy feel of YouTube. Reference: Arc browser, Figma — distinctive, personality-forward, creative without being chaotic. The UI should have a clear point of view and feel intentionally crafted, not assembled from a template. + +### Design Principles + +1. **Content-first**: The video and the community come first. UI chrome should recede and let streams and chat breathe. +2. **Warm competence**: Technically capable without feeling cold. Warm tones, soft edges, human touch in every detail. +3. **Distinctive identity**: This should never be confused with Twitch, YouTube, or a generic streaming app. Own the pink crystal energy. +4. **Accessible by default**: Atkinson Hyperlegible is the right foundation. Maintain high contrast ratios, generous touch targets, and clear hierarchy. +5. **Platform-native feel**: Respect iOS, Android, web, and desktop conventions while maintaining brand consistency across all. diff --git a/js/app/components/activity-picker.tsx b/js/app/components/activity-picker.tsx index e4dddb3d..e6a26bf0 100644 --- a/js/app/components/activity-picker.tsx +++ b/js/app/components/activity-picker.tsx @@ -1,8 +1,26 @@ -import { Text } from "@streamplace/components"; -import { usePossiblyUnauthedPDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; -import { useEffect, useRef, useState } from "react"; -import { Pressable, ScrollView, TextInput, View } from "react-native"; -import { PlaceStreamDefs, PlaceStreamLivestream } from "streamplace"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuTrigger, + Input, + ResponsiveDropdownMenuContent, + Text, + useTheme, + zero, +} from "@streamplace/components"; +import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import { Image } from "expo-image"; +import { X } from "lucide-react-native"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Platform, Pressable, View } from "react-native"; +import { + GamesGamesgamesgamesgamesDefs, + PlaceStreamDefs, + PlaceStreamLivestream, +} from "streamplace"; +import { getDidFromAtUri, getGameCoverUrl } from "../utils/game"; + +const { p, px, r, layout, borders, gap, flex } = zero; const ACTIVITY_LABELS: Array<{ value: PlaceStreamDefs.ActivityLabel["label"]; @@ -20,6 +38,8 @@ const ACTIVITY_LABELS: Array<{ interface GameResult { uri: string; name: string; + coverUrl?: string; + genres?: string[]; } interface ActivityPickerProps { @@ -29,19 +49,33 @@ interface ActivityPickerProps { ) => void; } +interface DropdownPos { + top: number; + left: number; + width: number; +} + export default function ActivityPicker({ value, onChange, }: ActivityPickerProps) { - const agent = usePossiblyUnauthedPDSAgent(); + const agent = usePDSAgent(); + const { theme, zero: z } = useTheme(); + const c = theme.colors; + const [mode, setMode] = useState<"game" | "label">( value?.$type === "place.stream.defs#activityLabel" ? "label" : "game", ); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); - const [showResults, setShowResults] = useState(false); + const [selectedCoverUrl, setSelectedCoverUrl] = useState< + string | undefined + >(); + const [selectedGenres, setSelectedGenres] = useState([]); + const [dropdownPos, setDropdownPos] = useState(null); const debounceRef = useRef | null>(null); + const inputContainerRef = useRef(null); const selectedGame = value?.$type === "place.stream.defs#activityGame" @@ -52,10 +86,36 @@ export default function ActivityPicker({ ? (value as PlaceStreamDefs.ActivityLabel) : null; + const showResults = searching || results.length > 0; + + useEffect(() => { + if (!selectedGame || !agent || selectedCoverUrl !== undefined) return; + agent.place.stream.game + .getGame({ uri: selectedGame.uri }) + .then((res) => { + setSelectedCoverUrl(res.data.coverUrl); + setSelectedGenres(res.data.genres ?? []); + }) + .catch(() => {}); + }, [selectedGame?.uri]); + + useLayoutEffect(() => { + if (Platform.OS !== "web" || !inputContainerRef.current || !showResults) { + setDropdownPos(null); + return; + } + const el = inputContainerRef.current as unknown as HTMLElement; + const rect = el.getBoundingClientRect(); + setDropdownPos({ + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }); + }, [showResults]); + useEffect(() => { if (!query.trim() || !agent) { setResults([]); - setShowResults(false); return; } @@ -69,18 +129,20 @@ export default function ActivityPicker({ }); const games: GameResult[] = []; for (const result of res.data.results) { - if ( - result.$type === "games.gamesgamesgamesgames.defs#gameSummaryView" - ) { - const view = result as { uri: string; name: string }; - if (view.uri && view.name) { - games.push({ uri: view.uri, name: view.name }); - } - } + if (!GamesGamesgamesgamesgamesDefs.isGameSummaryView(result)) + continue; + const did = getDidFromAtUri(result.uri); + const cover = getGameCoverUrl(result.media, did); + games.push({ + uri: result.uri, + name: result.name, + coverUrl: cover, + genres: result.genres, + }); } setResults(games); - setShowResults(true); - } catch { + } catch (e) { + console.error("game search error:", e); setResults([]); } finally { setSearching(false); @@ -98,149 +160,253 @@ export default function ActivityPicker({ uri: game.uri, name: game.name, }); + setSelectedCoverUrl(game.coverUrl); + setSelectedGenres(game.genres ?? []); setQuery(""); - setShowResults(false); + setResults([]); }; const clearActivity = () => { onChange(undefined); + setSelectedCoverUrl(undefined); + setSelectedGenres([]); setQuery(""); + setResults([]); }; - return ( - - {/* Mode toggle */} - + const resultsList = ( + + {searching && ( + + Searching... + + )} + {results.map((game) => ( { - setMode("game"); - if (selectedLabel) onChange(undefined); - }} - style={{ - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 6, - borderWidth: 1, - borderColor: mode === "game" ? "#0066cc" : "#ccc", - backgroundColor: mode === "game" ? "#e8f0fe" : "transparent", - }} + key={game.uri} + onPress={() => selectGame(game)} + style={[layout.flex.row, layout.flex.alignCenter, p[2], { gap: 10 }]} > - - Game - + + + {game.name} + {game.genres && game.genres.length > 0 && ( + + {game.genres.join(" · ")} + + )} + - { - setMode("label"); - if (selectedGame) onChange(undefined); - }} - style={{ - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 6, - borderWidth: 1, - borderColor: mode === "label" ? "#0066cc" : "#ccc", - backgroundColor: mode === "label" ? "#e8f0fe" : "transparent", - }} - > - + ); + + return ( + + + {(["game", "label"] as const).map((m) => ( + { + setMode(m); + if (m === "game" && selectedLabel) onChange(undefined); + if (m === "label" && selectedGame) onChange(undefined); }} + style={[ + px[3], + borders.width.thin, + { paddingVertical: 6, borderRadius: 6 }, + { borderColor: mode === m ? c.border : c.border }, + { + backgroundColor: mode === m ? c.card : "transparent", + }, + ]} > - Other Activity - - + + {m === "game" ? "Game" : "Other Activity"} + + + ))} {mode === "game" && ( - + {selectedGame ? ( - - {selectedGame.name} - + {selectedCoverUrl && ( + + )} + + {selectedGame.name} + {selectedGenres.length > 0 && ( + + {selectedGenres.map((g) => ( + + + {g} + + + ))} + + )} + - - × - + + ) : Platform.OS === "web" ? ( + + + {showResults && + dropdownPos && + // kind of hacky, but using a portal to avoid z-index and overflow issues vs + // rendering the dropdown absolutely within the container + require("react-dom").createPortal( + + {resultsList} + , + document.body, + )} + ) : ( - - )} - {showResults && results.length > 0 && ( - - + + + + + Search for a game... + + + + + + + {searching && ( + + Searching... + + )} + {results.map((game) => ( - selectGame(game)} - style={({ pressed }) => ({ - padding: 10, - backgroundColor: pressed ? "#f0f0f0" : "white", - borderBottomWidth: 1, - borderBottomColor: "#eee", - })} > - {game.name} - + + + + {game.name} + + + ))} - - - )} - {searching && ( - - Searching... - + + )} )} {mode === "label" && ( - + {ACTIVITY_LABELS.map(({ value: labelValue, display }) => { const selected = selectedLabel?.label === labelValue; return ( @@ -256,17 +422,19 @@ export default function ActivityPicker({ }, ) } - style={{ - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - borderWidth: 1, - borderColor: selected ? "#0066cc" : "#ccc", - backgroundColor: selected ? "#0066cc" : "transparent", - }} + style={[ + px[3], + borders.width.thin, + { paddingVertical: 6, borderRadius: 16 }, + { borderColor: selected ? c.primary : c.border }, + { backgroundColor: selected ? c.primary : "transparent" }, + ]} > {display} diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index f0657fb1..d7eeb232 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -23,8 +23,10 @@ import { ImagePlus, X } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { useUserProfile } from "store/hooks"; +import type { PlaceStreamLivestream } from "streamplace"; import { useCaptureVideoFrame } from "../../hooks/useCaptureVideoFrame"; import { useLiveUser } from "../../hooks/useLiveUser"; +import ActivityPicker from "../activity-picker"; const { flex, p, px, py, gap, layout, bg, borders, text, r, w, typography } = zero; @@ -220,6 +222,12 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { "create", ); + const [activity, setActivity] = useState< + PlaceStreamLivestream.Record["activity"] | undefined + >(undefined); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(""); + const [createPost, setCreatePost] = useState(true); const [idleTimeout, setIdleTimeout] = useState(true); const [sendPushNotification, setSendPushNotification] = useState(true); @@ -240,6 +248,17 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { setTitle(livestream.record.title); } + if (livestream.record.activity) { + setActivity( + livestream.record + .activity as PlaceStreamLivestream.Record["activity"], + ); + } + + if (livestream.record.tags) { + setTags(livestream.record.tags as string[]); + } + if ( livestream.record.canonicalUrl && livestream.record.canonicalUrl !== defaultCanonicalUrl @@ -297,12 +316,16 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { }, canonicalUrl: canonicalUrl || undefined, idleTimeoutSeconds: idleTimeout ? 300 : 0, + activity, + tags, }); } else { await updateStreamRecord( title.trim(), livestream, thumbnailToUse as Blob | undefined, + activity, + tags, ); } @@ -316,6 +339,9 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { if (mode === "create") { setTitle(""); setSelectedImage(undefined); + setActivity(undefined); + setTags([]); + setTagInput(""); } } catch (error) { console.error("Error with livestream:", error); @@ -558,6 +584,116 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { + + + Activity + + + + + + + {/* + + Tags + + + + {tags.map((tag) => ( + setTags(tags.filter((t) => t !== tag))} + style={{ + flexDirection: "row", + alignItems: "center", + backgroundColor: "#1e3a5f", + borderRadius: 16, + paddingHorizontal: 10, + paddingVertical: 4, + gap: 4, + }} + > + + {tag} + + + × + + + ))} + + {tags.length < 10 && ( + setTagInput(e.target.value)} + placeholder="Add a tag, press Enter" + onKeyDown={(e) => { + if (e.key === "Enter") { + const trimmed = tagInput.trim(); + if (trimmed && !tags.includes(trimmed)) { + setTags([...tags, trimmed]); + } + setTagInput(""); + } + }} + style={ + { + borderWidth: 1, + borderColor: "#4b5563", + borderRadius: 8, + padding: 10, + backgroundColor: "#1f2937", + color: "white", + fontSize: 14, + width: "100%", + outline: "none", + } as any + } + /> + )} + + */} + | null { const atUri = parseAtUriPath(path); - const toast = useToast(); if (!atUri) return null; // if just authority, redirect to stream page if (!atUri.collection) { diff --git a/js/app/utils/game.ts b/js/app/utils/game.ts new file mode 100644 index 00000000..58934d63 --- /dev/null +++ b/js/app/utils/game.ts @@ -0,0 +1,20 @@ +import { GamesGamesgamesgamesgamesDefs } from "streamplace"; + +const COVER_MEDIA_TYPES: Set< + GamesGamesgamesgamesgamesDefs.MediaItem["mediaType"] +> = new Set(["cover", "coverSquare"]); + +export function getGameCoverUrl( + media: GamesGamesgamesgamesgamesDefs.MediaItem[] | undefined, + did: string, +): string | undefined { + const coverItem = + media?.find((m) => COVER_MEDIA_TYPES.has(m.mediaType)) ?? media?.[0]; + const cid = coverItem?.blob?.ref?.toString(); + if (!cid) return undefined; + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`; +} + +export function getDidFromAtUri(uri: string): string { + return uri.split("/")[2] ?? ""; +} diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index ec5881ea..6de8cf08 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -134,6 +134,8 @@ export function useCreateStreamRecord() { canonicalUrl, notificationSettings, idleTimeoutSeconds, + activity, + tags, }: { title: string; customThumbnail?: Blob; @@ -141,6 +143,8 @@ export function useCreateStreamRecord() { canonicalUrl?: string; notificationSettings?: PlaceStreamLivestream.NotificationSettings; idleTimeoutSeconds?: number; + activity?: PlaceStreamLivestream.Record["activity"]; + tags?: string[]; }) => { if (!agent) { throw new Error("No PDS agent found"); @@ -176,6 +180,8 @@ export function useCreateStreamRecord() { // e.g. `@streamplace/components/0.1.0 (ios, 32.0)` agent: `@streamplace/components/${PackageJson.version} (${platform}, ${platVersion})`, idleTimeoutSeconds: idleTimeoutSeconds, + activity: activity, + tags: tags?.length ? tags : undefined, }; if (notificationSettings) { @@ -214,6 +220,8 @@ export function useUpdateStreamRecord(customUrl: string | null = null) { title: string, livestream: LivestreamViewHydrated | null, customThumbnail?: Blob, + activity?: PlaceStreamLivestream.Record["activity"], + tags?: string[], ) => { if (!agent) { throw new Error("No PDS agent found"); @@ -255,6 +263,8 @@ export function useUpdateStreamRecord(customUrl: string | null = null) { createdAt: new Date().toISOString(), post: oldRecordValue.post, thumb: thumbnail, + activity: activity, + tags: tags?.length ? tags : undefined, }; await agent.com.atproto.repo.putRecord({ diff --git a/js/docs/src/content/docs/lex-reference/game/place-stream-game-getgame.md b/js/docs/src/content/docs/lex-reference/game/place-stream-game-getgame.md new file mode 100644 index 00000000..824afb01 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/game/place-stream-game-getgame.md @@ -0,0 +1,89 @@ +--- +title: place.stream.game.getGame +description: Reference for the place.stream.game.getGame lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `query` + +**Parameters:** + +| Name | Type | Req'd | Description | Constraints | +| ----- | -------- | ----- | ----------- | ---------------- | +| `uri` | `string` | ✅ | | Format: `at-uri` | + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| ---------- | ----------------- | ----- | ----------- | ---------------- | +| `uri` | `string` | ✅ | | Format: `at-uri` | +| `name` | `string` | ✅ | | | +| `summary` | `string` | ❌ | | | +| `coverUrl` | `string` | ❌ | | | +| `genres` | Array of `string` | ❌ | | | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.game.getGame", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "name"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "name": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "coverUrl": { + "type": "string" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 85895432..3cedfd0c 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -1967,6 +1967,57 @@ ] } }, + "/xrpc/place.stream.game.getGame": { + "get": { + "operationId": "place.stream.game.getGame", + "tags": ["place.stream.game"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "coverUrl": { + "type": "string" + }, + "genres": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["uri", "name"] + } + } + } + } + }, + "parameters": [ + { + "name": "uri", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uri" + } + } + ] + } + }, "/xrpc/place.stream.game.search": { "get": { "summary": "Search for games and other entities via the games.gamesgamesgamesgames catalog. Proxied from the configured games API.", diff --git a/js/streamplace/src/agent.ts b/js/streamplace/src/agent.ts index 6911d40c..1ca4ca0d 100644 --- a/js/streamplace/src/agent.ts +++ b/js/streamplace/src/agent.ts @@ -16,6 +16,15 @@ export class StreamplaceAgent extends Agent { x.id.startsWith("place.stream"), ); - this.lex = new Lexicons([...parentSchemas, ...streamplaceSchemas]); + // for game search + const pentaractSchemas = appSchemas.filter((x) => + x.id.startsWith("games.gamesgamesgamesgames"), + ); + + this.lex = new Lexicons([ + ...parentSchemas, + ...streamplaceSchemas, + ...pentaractSchemas, + ]); } } diff --git a/lexicons/place/stream/game/getGame.json b/lexicons/place/stream/game/getGame.json new file mode 100644 index 00000000..9d5706ba --- /dev/null +++ b/lexicons/place/stream/game/getGame.json @@ -0,0 +1,33 @@ +{ + "lexicon": 1, + "id": "place.stream.game.getGame", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "at-uri" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "name"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "name": { "type": "string" }, + "summary": { "type": "string" }, + "coverUrl": { "type": "string" }, + "genres": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } +} diff --git a/localhost-key.pem b/localhost-key.pem new file mode 100644 index 00000000..859a87d1 --- /dev/null +++ b/localhost-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtw5aP10+0BPXB +sVtgdIHMRbE1xc+uQxsrH5Brcy29pj/gJ0mPywML3gPBuBYls5rGzmk9R1aDsZLH +Z6eZoFwfoiJK8ng7mRGpksSeRZd65dSrqkUJ7vnkvSK5HX5NDB/7C4dcxe3cmuqO +5I9v+chhemfJMA8o0sYAASPLOTdqIBeQj66KrmHiFiaS/izT2VEtlkTr3+LuhI6Q +3a1gBwlhI1EuHAngPGJMkKKNTM9fmVYg5DE5SKLiRtAlXs1O9OqpaieF6girb5wy +5rwRQQYUWsD7NwCXTdWIaSRiG43A5KoWUrpVe3RqWf5VNbfyPusDvh/G0De2uXQD +zyC6FdMFAgMBAAECggEADAvswsEjGfBkF08T7i51lzNXs0oo+URWvFkeWoysJaNC +d2hR3cGtzuNP8FyyUF/QwaqquiBZe5zSd7eUc36eFGVZqkeAFWWpx09RCUX61/7a +DSKrUaJ7OaPxJdNJH89Q4kcs1b41HN+ylkB7P0CaZ0OksVp/Go3uQy7RC/RGBrng +gVpb6irUz53E4TFpsXqaAo5X1rEV5yQSxqcmx7Q6Zoz3OzSFOUhfcTq9wXYl/lLx +/QgI7rmVGDsvHHyzjKoTGPq15y5xxzEmfZikpdC35J06Uh4577wvRPls12+rtiaW +J5aek7GVRvHN/2i/+w7y7usZpTxgRXe1/aVJNqoqiwKBgQDCiNBWKeOUakz1dLlr +edsvQXutZgi3AhFtF+gN3Qm4HHPUKyGv1fpwplQIqWz0xtsKMcsgNKS1DR+OmM7H +kBJiK+skt8aCdvkckSz7YvxNfKd2lLudXNFoA0IxKMVdBpv+VR+3D6WGT3kE0xg6 +Nh9YqFUUgZnBfsnQmCq3ib8kZwKBgQDkqrsTfuwo3Hfxl54YcAjsTUccGUwxdfWL +TWjSmIps+VUnE7nl3o6k2K7BYEJ8rCEUIFdp1IWDaKzM/cv/sjqHMPabs42Jxm04 +Os4p5+qHx51Dgb/7z5Don5EOgJyHHQnbqmSAeu5bYhCsXGguf1wrnu/fRlaiSdrD +4gF/3sJJswKBgQCQWWDubsrWeEJ+6Iwl+hfwatDRDCNvWPOBVfn1P41UtpgkWZT3 +mvno2SMGAmI9B8nFOMmXLjkBt6kw8KaPYpKhkiE777o3WPzke707FGpPPS4uSZMl +45fnbHOTcsNwkdTy1ktgVRXoIdSVBea2Wy7LZK0tODXVZLVwOVmJM4dIBQKBgA/H +IAVmWpEvNS98ULJK+LKlWmS78h/vjbPA8ZymXdbLFW8O02LjCmChet1o8O19SwMC +gEWTHmtEy7eRQ4QvHg65+CoiJ3/8KwkYNaV6lRotUdKYn1CWr979M5sWkLZZ8JYx +maGr2cqAZ7oc3itnKkrwOojjZ5LnGasuawARtMI1AoGAHpC/7yPNqVZUboIGoguy +e9/Kmd/q3DRIpqd0vjPPLznjLaJIyvbABkfd1jLM7a7OfEplQ0xuaHvZLjtFb4wL +57ae5OoDyJom1MsUBo5rbkgvr6XiWG68uFqTGZuWIWapaw6SQRV6ALdcow/dOBmL +GpROjCj+xPZ8G7ljXsFQ8lg= +-----END PRIVATE KEY----- diff --git a/localhost.pem b/localhost.pem new file mode 100644 index 00000000..a22c9871 --- /dev/null +++ b/localhost.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEXzCCAsegAwIBAgIRAMcGjTnXSRrqq5AzPisEsaEwDQYJKoZIhvcNAQELBQAw +gZUxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE1MDMGA1UECwwsbmF0 +YWxpZUBOYXRhbGllcy1NYWNCb29rLVByby5sb2NhbCAoTmF0YWxpZSkxPDA6BgNV +BAMMM21rY2VydCBuYXRhbGllQE5hdGFsaWVzLU1hY0Jvb2stUHJvLmxvY2FsIChO +YXRhbGllKTAeFw0yNjA0MTcyMDUyNTNaFw0yODA3MTcyMDUyNTNaMGAxJzAlBgNV +BAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE1MDMGA1UECwwsbmF0 +YWxpZUBOYXRhbGllcy1NYWNCb29rLVByby5sb2NhbCAoTmF0YWxpZSkwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtw5aP10+0BPXBsVtgdIHMRbE1xc+u +QxsrH5Brcy29pj/gJ0mPywML3gPBuBYls5rGzmk9R1aDsZLHZ6eZoFwfoiJK8ng7 +mRGpksSeRZd65dSrqkUJ7vnkvSK5HX5NDB/7C4dcxe3cmuqO5I9v+chhemfJMA8o +0sYAASPLOTdqIBeQj66KrmHiFiaS/izT2VEtlkTr3+LuhI6Q3a1gBwlhI1EuHAng +PGJMkKKNTM9fmVYg5DE5SKLiRtAlXs1O9OqpaieF6girb5wy5rwRQQYUWsD7NwCX +TdWIaSRiG43A5KoWUrpVe3RqWf5VNbfyPusDvh/G0De2uXQDzyC6FdMFAgMBAAGj +XjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSME +GDAWgBTQPVn97uQ7DN3NEjo31gmx9SKsaTAUBgNVHREEDTALgglsb2NhbGhvc3Qw +DQYJKoZIhvcNAQELBQADggGBAHuQTWVrCqni38bvFHiGG1gVM55Xg1Zs0wy7RKy5 +s8g/BqB8xQrEDvsNXpKL3SjnRN+0zs0SXe4X/bHHHzRV7SobHWZHZHfI0TMlY+Ur +nOpt0CR6qaziiPdkrzjzYSPPuWT4hLJDaPd5hm5Pe9yYfNz7gqA/RADkaA6Jrln+ +4c7Gpg966X52UcNBxs6WHX+eO61ZUeNhwW0a+m1i4ykUwNngHSoz5sp2Lj1KiXwz +0EozsmviX4fbAl9Z43lfoFnNha0334fosgIDnwqMDkfBpcOu9bFsHauLtq2gPW6A +AtBSBF6AffWZLWjeB+r95ikSMpRVWwCJ8WAEpLDgKIDhbmsOz3hc8M/TtGVYQ3ee +6RPl1pKO0GpiMob4id23qreQLGM4qNgWqFTpbCSZS9RIMvGm85nbPPT+K89o1PkP +8ZULeGg+38ODe7LpOmhcRV4kn/bpZm0e4bWjOOAu1pIAxJWuxTnawCrb+odfAk/L +xCAlXYL8HWQSe3629saL4g/gUQ== +-----END CERTIFICATE----- diff --git a/pkg/spxrpc/place_stream_game.go b/pkg/spxrpc/place_stream_game.go index 5c5bf5ce..82b13e23 100644 --- a/pkg/spxrpc/place_stream_game.go +++ b/pkg/spxrpc/place_stream_game.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/labstack/echo/v4" placestream "stream.place/streamplace/pkg/streamplace" @@ -62,3 +63,87 @@ func (s *Server) handlePlaceStreamGameSearch(ctx context.Context, cursor string, s.GameSearchCache.SetDefault(cacheKey, &out) return &out, nil } + +func (s *Server) handlePlaceStreamGameGetGame(ctx context.Context, uri string) (*placestream.GameGetGame_Output, error) { + if s.cli.GamesAPIURL == "" { + return nil, echo.NewHTTPError(http.StatusServiceUnavailable, "games API not configured") + } + + cacheKey := "game:" + uri + if cached, found := s.GameSearchCache.Get(cacheKey); found { + return cached.(*placestream.GameGetGame_Output), nil + } + + // Parse AT URI: at://authority/collection/rkey + withoutPrefix := strings.TrimPrefix(uri, "at://") + parts := strings.SplitN(withoutPrefix, "/", 3) + if len(parts) != 3 { + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid AT URI") + } + did, collection, rkey := parts[0], parts[1], parts[2] + + params := url.Values{} + params.Set("repo", did) + params.Set("collection", collection) + params.Set("rkey", rkey) + reqURL := s.cli.GamesAPIURL + "/xrpc/com.atproto.repo.getRecord?" + params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to build request") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadGateway, "games API unreachable") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("games API returned %d", resp.StatusCode)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadGateway, "failed to read response") + } + + var record struct { + Value struct { + Name string `json:"name"` + Summary string `json:"summary"` + Genres []string `json:"genres"` + Media []struct { + MediaType string `json:"mediaType"` + Blob struct { + Ref struct { + Link string `json:"$link"` + } `json:"ref"` + } `json:"blob"` + } `json:"media"` + } `json:"value"` + } + if err := json.Unmarshal(body, &record); err != nil { + return nil, echo.NewHTTPError(http.StatusBadGateway, "failed to parse response") + } + + var coverUrl *string + for _, m := range record.Value.Media { + if (m.MediaType == "cover" || m.MediaType == "coverSquare") && m.Blob.Ref.Link != "" { + u := "https://cdn.bsky.app/img/feed_thumbnail/plain/" + did + "/" + m.Blob.Ref.Link + "@jpeg" + coverUrl = &u + break + } + } + + out := &placestream.GameGetGame_Output{ + Uri: uri, + Name: record.Value.Name, + Summary: &record.Value.Summary, + Genres: record.Value.Genres, + CoverUrl: coverUrl, + } + + s.GameSearchCache.SetDefault(cacheKey, out) + return out, nil +} diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index 65704a8d..f655a8b2 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -290,6 +290,7 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { e.POST("/xrpc/place.stream.branding.updateBlob", s.HandlePlaceStreamBrandingUpdateBlob) e.GET("/xrpc/place.stream.broadcast.getBroadcaster", s.HandlePlaceStreamBroadcastGetBroadcaster) e.GET("/xrpc/place.stream.config.getEnv", s.HandlePlaceStreamConfigGetEnv) + e.GET("/xrpc/place.stream.game.getGame", s.HandlePlaceStreamGameGetGame) e.GET("/xrpc/place.stream.game.search", s.HandlePlaceStreamGameSearch) e.GET("/xrpc/place.stream.graph.getFollowingUser", s.HandlePlaceStreamGraphGetFollowingUser) e.GET("/xrpc/place.stream.ingest.getIngestUrls", s.HandlePlaceStreamIngestGetIngestUrls) @@ -442,6 +443,20 @@ func (s *Server) HandlePlaceStreamConfigGetEnv(c echo.Context) error { return c.JSON(200, out) } +func (s *Server) HandlePlaceStreamGameGetGame(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamGameGetGame") + defer span.End() + uri := c.QueryParam("uri") + var out *placestream.GameGetGame_Output + var handleErr error + // func (s *Server) handlePlaceStreamGameGetGame(ctx context.Context,uri string) (*placestream.GameGetGame_Output, error) + out, handleErr = s.handlePlaceStreamGameGetGame(ctx, uri) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandlePlaceStreamGameSearch(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamGameSearch") defer span.End() diff --git a/pkg/streamplace/gamegetGame.go b/pkg/streamplace/gamegetGame.go new file mode 100644 index 00000000..cc48d947 --- /dev/null +++ b/pkg/streamplace/gamegetGame.go @@ -0,0 +1,33 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.game.getGame + +package streamplace + +import ( + "context" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// GameGetGame_Output is the output of a place.stream.game.getGame call. +type GameGetGame_Output struct { + CoverUrl *string `json:"coverUrl,omitempty" cborgen:"coverUrl,omitempty"` + Genres []string `json:"genres,omitempty" cborgen:"genres,omitempty"` + Name string `json:"name" cborgen:"name"` + Summary *string `json:"summary,omitempty" cborgen:"summary,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// GameGetGame calls the XRPC method "place.stream.game.getGame". +func GameGetGame(ctx context.Context, c lexutil.LexClient, uri string) (*GameGetGame_Output, error) { + var out GameGetGame_Output + + params := map[string]interface{}{} + params["uri"] = uri + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.game.getGame", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +}