diff --git a/docs/pinned-comment-plan.md b/docs/pinned-comment-plan.md
new file mode 100644
index 00000000..a6babef4
--- /dev/null
+++ b/docs/pinned-comment-plan.md
@@ -0,0 +1,460 @@
+# Pinned Comment Feature Plan
+
+## Overview
+
+Allow the streamer or a delegated moderator to pin an existing chat message. The pinned comment appears prominently in the chat UI and auto-expires when a TTL is reached or the stream ends.
+
+## Design Decisions
+
+- **Pinnable content**: Existing chat messages only (referenced by AT-URI)
+- **Who can pin**: Streamer + mods with new `message.pin` permission
+- **Expiration**: Optional TTL (`expiresAt` datetime) + auto-clear at stream end (client-side)
+- **Storage pattern**: Record in streamer's AT Protocol repo, modeled after `place.stream.chat.gate`
+- **Single active pin**: Only one pinned comment at a time. Creating a new pin replaces the previous one.
+- **Viewer dismiss**: Any viewer can hide the pin from their own view (local state only). The pin remains for everyone else.
+
+---
+
+## 1. New Lexicon: `place.stream.chat.pinnedRecord`
+
+**File**: `lexicons/place/stream/chat/pinnedRecord.json`
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.chat.pinnedRecord",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record pinning a chat message for prominent display.",
+ "record": {
+ "type": "object",
+ "required": ["pinnedMessage", "createdAt"],
+ "properties": {
+ "pinnedMessage": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the pinned chat message."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "When this pin was created."
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Optional expiration time. If set, the pin is considered inactive after this time."
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+---
+
+## 2. Pinned Record View + Defs
+
+### 2a. Add `pinnedRecordView` to chat defs
+
+**File**: `lexicons/place/stream/chat/defs.json` (add new definition)
+
+```json
+"pinnedRecordView": {
+ "type": "object",
+ "description": "View of a pinned chat record with hydrated message data.",
+ "required": ["uri", "cid", "record", "indexedAt"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" },
+ "record": { "type": "ref", "ref": "place.stream.chat.pinnedRecord" },
+ "indexedAt": { "type": "string", "format": "datetime" },
+ "pinnedBy": { "type": "ref", "ref": "app.bsky.actor.defs#profileViewBasic" },
+ "message": { "type": "ref", "ref": "place.stream.chat.defs#messageView" }
+ }
+}
+```
+
+This gives us a hydrated view that includes the full message data, so the frontend doesn't need to look it up from the chat index.
+
+### 2b. Go struct for bus delivery
+
+The bus will deliver a `PinnedRecordView` (not just the raw record) so the frontend can render the message text directly. Similar to how `ChatGate` records are published raw but the frontend processes them.
+
+---
+
+## 3. New Permission: `message.pin`
+
+### 3a. Add to lexicon enum
+
+**File**: `lexicons/place/stream/moderation/permission.json`
+
+Add `"message.pin"` to the permissions enum:
+
+```json
+"enum": ["ban", "hide", "livestream.manage", "message.pin"]
+```
+
+### 3b. Register in Go permission system
+
+**File**: `pkg/moderation/permissions.go`
+
+```go
+const PermissionMessagePin = "message.pin"
+
+var ActionPermissions = map[string]string{
+ // ... existing entries ...
+ "createPin": PermissionMessagePin,
+ "deletePin": PermissionMessagePin,
+}
+```
+
+### 3c. Update frontend permissions
+
+**File**: `js/components/src/streamplace-store/moderation.tsx`
+
+Add `canPin: boolean` to `ModerationPermissions` interface. Derive from permissions array:
+
+```ts
+canPin: isOwner || permissions.includes("message.pin"),
+```
+
+---
+
+## 4. New RPC Procedures
+
+### 4a. Lexicon: `place.stream.moderation.createPin`
+
+**File**: `lexicons/place/stream/moderation/createPin.json`
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.createPin",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "messageUri"],
+ "properties": {
+ "streamer": { "type": "string", "format": "did" },
+ "messageUri": { "type": "string", "format": "at-uri" },
+ "expiresAt": { "type": "string", "format": "datetime" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" }
+ }
+ }
+ },
+ "errors": [
+ { "name": "Unauthorized" },
+ { "name": "Forbidden" },
+ { "name": "SessionNotFound" }
+ ]
+ }
+ }
+}
+```
+
+### 4b. Lexicon: `place.stream.moderation.deletePin`
+
+**File**: `lexicons/place/stream/moderation/deletePin.json`
+
+Same pattern as `deleteGate`:
+
+- Input: `streamer` (DID), `pinUri` (at-uri)
+- Output: empty
+- Errors: Unauthorized, Forbidden, SessionNotFound
+
+### 4c. Go handlers
+
+**File**: `pkg/spxrpc/place_stream_moderation.go`
+
+Add `handlePlaceStreamModerationCreatePin`:
+
+1. Validate input (DID, AT-URI)
+2. `GetDelegatedModerationContext(ctx, input.Streamer, "createPin")`
+3. Before creating: list existing `place.stream.chat.pinnedRecord` records in streamer's repo, delete any existing ones (single-pin semantics)
+4. Build `streamplace.ChatPinnedRecord` struct
+5. Create via `com.atproto.repo.createRecord` on streamer's repo
+6. Audit log
+7. Return URI + CID
+
+Add `handlePlaceStreamModerationDeletePin`:
+
+1. Validate input
+2. `GetDelegatedModerationContext(ctx, input.Streamer, "deletePin")`
+3. Extract rkey, delete record via `com.atproto.repo.deleteRecord`
+4. Audit log
+
+### 4d. Register handlers
+
+In the XRPC server setup where `createGate`/`deleteGate` are registered, add `createPin` and `deletePin`.
+
+---
+
+## 5. Backend: Model + DB
+
+**File**: `pkg/model/pinned_record.go`
+
+```go
+type PinnedRecord struct {
+ RKey string `gorm:"primaryKey;column:rkey"`
+ CID string `gorm:"column:cid"`
+ RepoDID string `gorm:"column:repo_did"`
+ Repo *Repo `gorm:"foreignKey:DID;references:RepoDID"`
+ PinnedMessage string `gorm:"column:pinned_message"`
+ ExpiresAt *time.Time `gorm:"column:expires_at"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+}
+```
+
+Methods:
+
+- `CreatePinnedRecord(ctx, pin)` - insert
+- `GetPinnedRecord(ctx, rkey)` - single lookup
+- `DeletePinnedRecord(ctx, rkey)` - delete by rkey
+- `GetActivePinnedRecord(ctx, streamerDID)` - returns the most recent non-expired pin for a streamer
+- `DeleteAllPinnedRecords(ctx, streamerDID)` - bulk delete (called before creating new pin)
+
+Add `PinnedRecord{}` to the AutoMigrate list in `pkg/model/model.go`.
+
+---
+
+## 6. Constants + Firehose
+
+### 6a. Constants
+
+**File**: `pkg/constants/constants.go`
+
+Add: `var PLACE_STREAM_CHAT_PINNED_RECORD = "place.stream.chat.pinnedRecord"`
+
+### 6b. Sync (create/update)
+
+**File**: `pkg/atproto/sync.go` - `handleCreateUpdate`
+
+Add case `*streamplace.ChatPinnedRecord`:
+
+- Sync bluesky repo
+- Delete existing pinned records for this streamer (single-pin enforcement at DB level)
+- Create new `PinnedRecord` model entry
+- Build a hydrated view (resolve the pinned message, include author info) and publish to bus on streamer's channel
+
+### 6c. Firehose (delete)
+
+**File**: `pkg/atproto/firehose.go` - `EvtKindDeleteRecord`
+
+Add handling for `constants.PLACE_STREAM_CHAT_PINNED_RECORD`:
+
+- `DeletePinnedRecord(ctx, rkey)`
+- Publish deletion marker to bus
+
+---
+
+## 7. Bus / WebSocket Delivery
+
+### Create event
+
+Publish a `PinnedRecordView`-like object to the streamer's bus channel:
+
+```json
+{
+ "$type": "place.stream.chat.defs#pinnedRecordView",
+ "uri": "at://...",
+ "cid": "...",
+ "record": {
+ "pinnedMessage": "at://...",
+ "createdAt": "...",
+ "expiresAt": "..."
+ },
+ "indexedAt": "...",
+ "message": {
+ /* hydrated messageView with author, text, facets, etc */
+ }
+}
+```
+
+### Delete event
+
+Publish a deletion marker:
+
+```json
+{
+ "$type": "place.stream.chat.pinnedRecord",
+ "deleted": true,
+ "rkey": "..."
+}
+```
+
+---
+
+## 8. Frontend State
+
+### LivestreamState additions
+
+**File**: `js/components/src/livestream-store/livestream-state.tsx`
+
+```ts
+pinnedComment: PinnedRecordView | null;
+```
+
+Where `PinnedRecordView` is a type with the hydrated message + pin metadata.
+
+### Store init
+
+**File**: `js/components/src/livestream-store/livestream-store.tsx`
+
+Add `pinnedComment: null` to initial state.
+
+### Chat hooks
+
+**File**: `js/components/src/livestream-store/chat.tsx`
+
+Add:
+
+- `usePinnedComment()` - selector: `state.pinnedComment`
+- `usePinChatMessage()` - hook to pin a message:
+ - If streamer: direct `com.atproto.repo.createRecord` for `place.stream.chat.pinnedRecord`
+ - If mod: call `place.stream.moderation.createPin` XRPC
+ - On success, the bus event will update state automatically
+- `useUnpinChatMessage()` - hook to unpin:
+ - If streamer: direct `com.atproto.repo.deleteRecord`
+ - If mod: call `place.stream.moderation.deletePin` XRPC
+
+### WebSocket consumer
+
+**File**: `js/components/src/livestream-store/websocket-consumer.tsx`
+
+Add handler for `place.stream.chat.defs#pinnedRecordView`:
+
+- Set `state.pinnedComment` to the hydrated view
+
+Add handler for `place.stream.chat.pinnedRecord` with `deleted: true`:
+
+- Set `state.pinnedComment` to `null`
+
+### Expiration
+
+In the Chat component, `useEffect` checks `pinnedComment.record.expiresAt`. If current time passes it, clear `pinnedComment` locally. Also clear when `livestream.record.endedAt` is set (stream ended).
+
+---
+
+## 9. Frontend UI
+
+### Pinned comment as persistent stream notification
+
+The pinned comment uses the existing `StreamNotificationProvider` + `streamNotificationManager` system.
+
+**File**: `js/components/src/components/stream-notification/pinned-comment-notification.tsx` (new)
+
+A custom render function passed to `streamNotificationManager.show()` with `duration: 0` (manual dismiss only), making it a persistent notification that sits at the top of the notification stack. Other temporary notifications (teleport, etc.) will appear above/below it and dismiss independently.
+
+The render function receives `(isExiting, onDismiss, startTime)` and renders:
+
+- Pin icon + author name (with chatProfile color) + message text (with facets rendered)
+- Two dismiss actions:
+ - **Unpin** (X icon, only visible if `canPin`): calls `useUnpinChatMessage()`, removes the pin globally, then calls `onDismiss("user")` to dismiss the notification
+ - **Hide** (eye-off icon, visible to all viewers): calls `onDismiss("user")` to dismiss the notification locally only. Does NOT call the server - the pin remains for other viewers
+- Does NOT link to or scroll to the original message (chat history is limited)
+- Auto-dismisses when `expiresAt` is reached (a `useEffect` watching the pinned comment state calls `streamNotificationManager.requestDismiss("pinned-comment", "auto")`)
+- Auto-dismisses when stream ends
+
+**Integration point**: The `TeleportWatcher` / livestream provider already manages notification lifecycle. The pinned comment notification is managed similarly - when `state.pinnedComment` changes in the Zustand store, a hook or effect calls `streamNotificationManager.show()` to create/update the notification, or `streamNotificationManager.hide()` to remove it.
+
+The notification ID should be fixed (e.g., `"pinned-comment"`) so that replacing a pin updates the same notification rather than creating a new one.
+
+### Pin action in mod menu
+
+**File**: `js/components/src/components/chat/mod-view.tsx`
+
+Add "Pin this message" in the moderation actions group (visible when `canPin` is true):
+
+```tsx
+{
+ modPermissions.canPin && (
+ {
+ /* pin message */
+ }}
+ >
+ Pin this message
+
+ );
+}
+```
+
+---
+
+## 10. Moderator Panel
+
+**File**: `js/components/src/components/dashboard/moderator-panel.tsx`
+
+Add `"message.pin"` to the list of assignable permissions when adding/editing moderators.
+
+---
+
+## 11. Code Generation
+
+After creating lexicon JSON files, run `lexgen` to generate Go types:
+
+- `pkg/streamplace/chatpinnedrecord.go` (auto-generated)
+- Update generated TypeScript types in the `streamplace` package
+
+---
+
+## File Change Summary
+
+| File | Action |
+| ---------------------------------------------------------------------------------- | ------------------------------------------------------- |
+| `lexicons/place/stream/chat/pinnedRecord.json` | **New** |
+| `lexicons/place/stream/chat/defs.json` | **Edit** - add `pinnedRecordView` |
+| `lexicons/place/stream/moderation/createPin.json` | **New** |
+| `lexicons/place/stream/moderation/deletePin.json` | **New** |
+| `lexicons/place/stream/moderation/permission.json` | **Edit** - add `message.pin` |
+| `pkg/constants/constants.go` | **Edit** |
+| `pkg/moderation/permissions.go` | **Edit** |
+| `pkg/model/pinned_record.go` | **New** |
+| `pkg/model/model.go` | **Edit** |
+| `pkg/atproto/sync.go` | **Edit** |
+| `pkg/atproto/firehose.go` | **Edit** |
+| `pkg/spxrpc/place_stream_moderation.go` | **Edit** |
+| `js/components/src/streamplace-store/moderation.tsx` | **Edit** |
+| `js/components/src/streamplace-store/block.tsx` | **Edit** - add pin/unpin hooks |
+| `js/components/src/livestream-store/livestream-state.tsx` | **Edit** |
+| `js/components/src/livestream-store/livestream-store.tsx` | **Edit** |
+| `js/components/src/livestream-store/chat.tsx` | **Edit** |
+| `js/components/src/livestream-store/websocket-consumer.tsx` | **Edit** |
+| `js/components/src/components/stream-notification/pinned-comment-notification.tsx` | **New** - notification render function |
+| `js/components/src/livestream-provider/index.tsx` | **Edit** - manage pinned comment notification lifecycle |
+| `js/components/src/components/chat/mod-view.tsx` | **Edit** - add pin action |
+| `js/components/src/components/dashboard/moderator-panel.tsx` | **Edit** |
+
+---
+
+## Implementation Order
+
+1. Lexicons (pinnedRecord, createPin, deletePin, permission, defs)
+2. Code generation (lexgen)
+3. Backend: constants, permissions, model, DB migration
+4. Backend: sync/firehose integration
+5. Backend: XRPC handlers + registration
+6. Frontend: state types, store init, hooks
+7. Frontend: WebSocket consumer handlers
+8. Frontend: pinned-comment notification render function + lifecycle management
+9. Frontend: mod-view pin action
+10. Frontend: moderator panel permission
diff --git a/js/components/src/components/chat/mod-view.tsx b/js/components/src/components/chat/mod-view.tsx
index cdcaffae..4d0431be 100644
--- a/js/components/src/components/chat/mod-view.tsx
+++ b/js/components/src/components/chat/mod-view.tsx
@@ -17,6 +17,7 @@ import { ChatMessageViewHydrated } from "streamplace";
import {
useDeleteChatMessage,
useLivestreamStore,
+ usePinChatMessage,
} from "../../livestream-store";
import { useStreamplaceStore } from "../../streamplace-store";
import { formatHandle, formatHandleWithAt } from "../../utils/format-handle";
@@ -55,6 +56,7 @@ export const ModView = forwardRef(() => {
let [messageRemoved, setMessageRemoved] = useState(false);
let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord();
let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord();
+ const pinChatMessage = usePinChatMessage();
const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen);
const setReportSubject = usePlayerStore((x) => x.setReportSubject);
@@ -91,6 +93,7 @@ export const ModView = forwardRef(() => {
message &&
agent?.did &&
((modPermissions.canHide && message.author.did !== streamerDID) ||
+ (modPermissions.canPin && message.author.did !== streamerDID) ||
(modPermissions.canBan &&
message.author.did !== agent.did &&
message.author.did !== streamerDID))
@@ -124,6 +127,7 @@ export const ModView = forwardRef(() => {
setMessageRemoved={setMessageRemoved}
createHideChat={createHideChat}
createBlock={createBlock}
+ pinChatMessage={pinChatMessage}
toast={toast}
setReportModalOpen={setReportModalOpen}
setReportSubject={setReportSubject}
@@ -148,6 +152,11 @@ interface ModViewContentProps {
setMessageRemoved: (removed: boolean) => void;
createHideChat: (uri: string, streamerDID?: string) => Promise;
createBlock: (did: string, streamerDID?: string) => Promise;
+ pinChatMessage: (
+ messageUri: string,
+ streamerDID: string,
+ expiresAt?: string,
+ ) => Promise;
toast: ReturnType;
setReportModalOpen: (open: boolean) => void;
setReportSubject: (subject: any) => void;
@@ -166,6 +175,7 @@ function ModViewContent({
setMessageRemoved,
createHideChat,
createBlock,
+ pinChatMessage,
toast,
setReportModalOpen,
setReportSubject,
@@ -223,6 +233,27 @@ function ModViewContent({
)}
+ {modPermissions.canPin && message.author.did !== streamerDID && (
+ {
+ if (!streamerDID) return;
+ pinChatMessage(message.uri, streamerDID)
+ .then(() => {
+ toast.show("Comment pinned", "", { duration: 3 });
+ onOpenChange?.(false);
+ })
+ .catch((e) => {
+ toast.show(
+ "Error pinning comment",
+ e instanceof Error ? e.message : "Failed to pin",
+ { duration: 5 },
+ );
+ });
+ }}
+ >
+ Pin this message
+
+ )}
{modPermissions.canBan &&
agent?.did &&
message.author.did !== agent.did &&
diff --git a/js/components/src/components/dashboard/moderator-panel.tsx b/js/components/src/components/dashboard/moderator-panel.tsx
index 8916610b..f13ce7d2 100644
--- a/js/components/src/components/dashboard/moderator-panel.tsx
+++ b/js/components/src/components/dashboard/moderator-panel.tsx
@@ -372,6 +372,7 @@ function AddModeratorDialog({
ban: false,
hide: false,
"livestream.manage": false,
+ "message.pin": false,
});
const [error, setError] = useState(null);
const toast = useToast();
@@ -380,7 +381,12 @@ function AddModeratorDialog({
useEffect(() => {
if (!visible) {
setModeratorDID("");
- setPermissions({ ban: false, hide: false, "livestream.manage": false });
+ setPermissions({
+ ban: false,
+ hide: false,
+ "livestream.manage": false,
+ "message.pin": false,
+ });
setError(null);
}
}, [visible]);
@@ -401,7 +407,12 @@ function AddModeratorDialog({
const selectedPermissions = Object.entries(permissions)
.filter(([_, enabled]) => enabled)
- .map(([perm]) => perm) as ("ban" | "hide" | "livestream.manage")[];
+ .map(([perm]) => perm) as (
+ | "ban"
+ | "hide"
+ | "livestream.manage"
+ | "message.pin"
+ )[];
if (selectedPermissions.length === 0) {
setError("Please select at least one permission");
diff --git a/js/components/src/components/ui/resizeable.tsx b/js/components/src/components/ui/resizeable.tsx
index a59d07ed..fc44e9e8 100644
--- a/js/components/src/components/ui/resizeable.tsx
+++ b/js/components/src/components/ui/resizeable.tsx
@@ -26,8 +26,8 @@ const AnimatedView = Animated.createAnimatedComponent(View);
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
const TIMING_CONFIG = {
- duration: 300,
- easing: Easing.inOut(Easing.quad),
+ duration: 400,
+ easing: Easing.out(Easing.quad),
};
type ResizableChatSheetProps = {
diff --git a/js/components/src/livestream-store/chat.tsx b/js/components/src/livestream-store/chat.tsx
index de064166..2ece439b 100644
--- a/js/components/src/livestream-store/chat.tsx
+++ b/js/components/src/livestream-store/chat.tsx
@@ -437,3 +437,95 @@ export const useReportChatMessage = () => {
};
export const reduceChat = reduceChatIncremental;
+
+export const usePinChatMessage = () => {
+ const agent = usePDSAgent();
+ const store = getStoreFromContext();
+
+ return async (
+ messageUri: string,
+ streamerDID: string,
+ expiresAt?: string,
+ ) => {
+ if (!agent || !agent.did) {
+ throw new Error("No PDS agent or user DID found");
+ }
+
+ // If streamer, create directly
+ if (agent.did === streamerDID) {
+ // First delete any existing pinned records
+ const listResult = await agent.com.atproto.repo.listRecords({
+ repo: streamerDID,
+ collection: "place.stream.chat.pinnedRecord",
+ });
+ for (const rec of listResult.data.records) {
+ const rkey = rec.uri.split("/").pop();
+ if (rkey) {
+ await agent.com.atproto.repo.deleteRecord({
+ repo: streamerDID,
+ collection: "place.stream.chat.pinnedRecord",
+ rkey,
+ });
+ }
+ }
+
+ const record = {
+ $type: "place.stream.chat.pinnedRecord",
+ pinnedMessage: messageUri,
+ createdAt: new Date().toISOString(),
+ ...(expiresAt ? { expiresAt } : {}),
+ };
+
+ const result = await agent.com.atproto.repo.createRecord({
+ repo: streamerDID,
+ collection: "place.stream.chat.pinnedRecord",
+ record,
+ });
+ return result;
+ }
+
+ // Otherwise, use delegated moderation endpoint
+ const result = await agent.place.stream.moderation.createPin({
+ streamer: streamerDID,
+ messageUri,
+ ...(expiresAt ? { expiresAt } : {}),
+ });
+ return result;
+ };
+};
+
+export const useUnpinChatMessage = () => {
+ const agent = usePDSAgent();
+ const store = getStoreFromContext();
+
+ return async (pinUri: string, streamerDID: string) => {
+ if (!agent || !agent.did) {
+ throw new Error("No PDS agent or user DID found");
+ }
+
+ // If streamer, delete directly
+ if (agent.did === streamerDID) {
+ const rkey = pinUri.split("/").pop();
+ if (!rkey) {
+ throw new Error("Invalid pin URI");
+ }
+
+ await agent.com.atproto.repo.deleteRecord({
+ repo: streamerDID,
+ collection: "place.stream.chat.pinnedRecord",
+ rkey,
+ });
+ // Optimistically clear the pinned comment
+ store.setState({ pinnedComment: null });
+ return;
+ }
+
+ // Otherwise, use delegated moderation endpoint
+ await agent.place.stream.moderation.deletePin({
+ streamer: streamerDID,
+ pinUri,
+ });
+ // Optimistically clear the pinned comment
+ store.setState({ pinnedComment: null });
+ };
+};
diff --git a/js/components/src/livestream-store/livestream-state.tsx b/js/components/src/livestream-store/livestream-state.tsx
index 9f7a4ba6..2a14b6a7 100644
--- a/js/components/src/livestream-store/livestream-state.tsx
+++ b/js/components/src/livestream-store/livestream-state.tsx
@@ -2,6 +2,7 @@ import { AppBskyActorDefs } from "@atproto/api";
import {
ChatMessageViewHydrated,
LivestreamViewHydrated,
+ PinnedRecordViewHydrated,
PlaceStreamDefs,
PlaceStreamLiveTeleport,
PlaceStreamModerationPermission,
@@ -28,6 +29,7 @@ export interface LivestreamState {
setActiveTeleportUri: (uri: string | null) => void;
websocketConnected: boolean;
hasReceivedSegment: boolean;
+ pinnedComment: PinnedRecordViewHydrated | null;
moderationPermissions: PlaceStreamModerationPermission.Record[];
setModerationPermissions: (
permissions: PlaceStreamModerationPermission.Record[],
diff --git a/js/components/src/livestream-store/livestream-store.tsx b/js/components/src/livestream-store/livestream-store.tsx
index 97e4ddb7..61b205be 100644
--- a/js/components/src/livestream-store/livestream-store.tsx
+++ b/js/components/src/livestream-store/livestream-store.tsx
@@ -27,6 +27,7 @@ export const makeLivestreamStore = (): StoreApi => {
setActiveTeleportUri: (uri) => set({ activeTeleportUri: uri }),
websocketConnected: false,
hasReceivedSegment: false,
+ pinnedComment: null,
moderationPermissions: [],
setModerationPermissions: (perms) => set({ moderationPermissions: perms }),
localLivestreamURI: null,
@@ -60,6 +61,9 @@ export const useHandleWebsocketMessages = () => {
export const useChat = () => useLivestreamStore((x) => x.chat);
+export const usePinnedComment = () =>
+ useLivestreamStore((x) => x.pinnedComment);
+
export const useProfile = () => useLivestreamStore((x) => x.profile);
export const useViewers = () => useLivestreamStore((x) => x.viewers);
diff --git a/js/components/src/livestream-store/websocket-consumer.tsx b/js/components/src/livestream-store/websocket-consumer.tsx
index 1b188f34..0cc3ba6a 100644
--- a/js/components/src/livestream-store/websocket-consumer.tsx
+++ b/js/components/src/livestream-store/websocket-consumer.tsx
@@ -2,6 +2,7 @@ import { AppBskyActorDefs } from "@atproto/api";
import {
ChatMessageViewHydrated,
LivestreamViewHydrated,
+ PinnedRecordViewHydrated,
PlaceStreamChatDefs,
PlaceStreamChatGate,
PlaceStreamChatMessage,
@@ -123,6 +124,20 @@ export const handleWebSocketMessages = (
pendingHides: newPendingHides,
};
state = reduceChat(state, [], [], [hiddenMessageUri]);
+ } else if (PlaceStreamChatDefs.isPinnedRecordView(message)) {
+ const pinnedView = message as PinnedRecordViewHydrated;
+ state = {
+ ...state,
+ pinnedComment: pinnedView,
+ };
+ } else if (
+ (message as any).$type === "place.stream.chat.pinnedRecord" &&
+ (message as any).deleted === true
+ ) {
+ state = {
+ ...state,
+ pinnedComment: null,
+ };
} else if (PlaceStreamLiveTeleport.isRecord(message)) {
const teleportRecord = message as PlaceStreamLiveTeleport.Record;
state = {
diff --git a/js/components/src/streamplace-store/moderation.tsx b/js/components/src/streamplace-store/moderation.tsx
index 7169b8d2..34f071f9 100644
--- a/js/components/src/streamplace-store/moderation.tsx
+++ b/js/components/src/streamplace-store/moderation.tsx
@@ -6,6 +6,7 @@ import { usePDSAgent } from "./xrpc";
export interface ModerationPermissions {
canBan: boolean;
canHide: boolean;
+ canPin: boolean;
canManageLivestream: boolean;
isOwner: boolean;
isLoading: boolean;
@@ -177,6 +178,7 @@ export function useCanModerate(
return {
canBan: isOwner || permissions.includes("ban"),
canHide: isOwner || permissions.includes("hide"),
+ canPin: isOwner || permissions.includes("message.pin"),
canManageLivestream: isOwner || permissions.includes("livestream.manage"),
isOwner,
isLoading,
diff --git a/js/components/src/streamplace-store/moderator-management.tsx b/js/components/src/streamplace-store/moderator-management.tsx
index 3888da20..15ce5e96 100644
--- a/js/components/src/streamplace-store/moderator-management.tsx
+++ b/js/components/src/streamplace-store/moderator-management.tsx
@@ -81,7 +81,7 @@ export function useListModerators(): ListModeratorsResult {
interface AddModeratorParams {
moderatorDID: string;
- permissions: ("ban" | "hide" | "livestream.manage")[];
+ permissions: ("ban" | "hide" | "livestream.manage" | "message.pin")[];
expirationTime?: string; // ISO 8601 datetime string
}
diff --git a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
index 382fe718..bb73679b 100644
--- a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
+++ b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
@@ -29,6 +29,27 @@ description: Reference for the place.stream.chat.defs lexicon
---
+
+
+### `pinnedRecordView`
+
+**Type:** `object`
+
+View of a pinned chat record with hydrated message data.
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------- | ------------------ |
+| `uri` | `string` | ✅ | | Format: `at-uri` |
+| `cid` | `string` | ✅ | | Format: `cid` |
+| `record` | [`place.stream.chat.pinnedRecord`](/lex-reference/place-stream-chat-pinnedrecord) | ✅ | | |
+| `indexedAt` | `string` | ✅ | | Format: `datetime` |
+| `pinnedBy` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ❌ | | |
+| `message` | [`#messageView`](#messageview) | ❌ | | |
+
+---
+
## Lexicon Source
```json
@@ -81,6 +102,37 @@ description: Reference for the place.stream.chat.defs lexicon
}
}
}
+ },
+ "pinnedRecordView": {
+ "type": "object",
+ "description": "View of a pinned chat record with hydrated message data.",
+ "required": ["uri", "cid", "record", "indexedAt"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "record": {
+ "type": "ref",
+ "ref": "place.stream.chat.pinnedRecord"
+ },
+ "indexedAt": {
+ "type": "string",
+ "format": "datetime"
+ },
+ "pinnedBy": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "message": {
+ "type": "ref",
+ "ref": "#messageView"
+ }
+ }
}
}
}
diff --git a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-pinnedrecord.md b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-pinnedrecord.md
new file mode 100644
index 00000000..8935e957
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-pinnedrecord.md
@@ -0,0 +1,65 @@
+---
+title: place.stream.chat.pinnedRecord
+description: Reference for the place.stream.chat.pinnedRecord lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Record pinning a chat message for prominent display.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| --------------- | -------- | ----- | --------------------------------------------------------------------------------- | ------------------ |
+| `pinnedMessage` | `string` | ✅ | AT-URI of the pinned chat message. | Format: `at-uri` |
+| `createdAt` | `string` | ✅ | When this pin was created. | Format: `datetime` |
+| `expiresAt` | `string` | ❌ | Optional expiration time. If set, the pin is considered inactive after this time. | Format: `datetime` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.chat.pinnedRecord",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record pinning a chat message for prominent display.",
+ "record": {
+ "type": "object",
+ "required": ["pinnedMessage", "createdAt"],
+ "properties": {
+ "pinnedMessage": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the pinned chat message."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "When this pin was created."
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Optional expiration time. If set, the pin is considered inactive after this time."
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createpin.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createpin.md
new file mode 100644
index 00000000..5c1ce46e
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createpin.md
@@ -0,0 +1,123 @@
+---
+title: place.stream.moderation.createPin
+description: Reference for the place.stream.moderation.createPin lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ------------ | -------- | ----- | -------------------------------------- | ------------------ |
+| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` |
+| `messageUri` | `string` | ✅ | The AT-URI of the chat message to pin. | Format: `at-uri` |
+| `expiresAt` | `string` | ❌ | Optional expiration time for this pin. | Format: `datetime` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ----- | -------- | ----- | ---------------------------------------- | ---------------- |
+| `uri` | `string` | ✅ | The AT-URI of the created pinned record. | Format: `at-uri` |
+| `cid` | `string` | ✅ | The CID of the created pinned record. | Format: `cid` |
+
+**Possible Errors:**
+
+- `Unauthorized`: The request lacks valid authentication credentials.
+- `Forbidden`: The caller does not have permission to pin messages for this streamer.
+- `SessionNotFound`: The streamer's OAuth session could not be found or is invalid.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.createPin",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "messageUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "messageUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the chat message to pin."
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Optional expiration time for this pin."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the created pinned record."
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "The CID of the created pinned record."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to pin messages for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletepin.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletepin.md
new file mode 100644
index 00000000..49c4b83c
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletepin.md
@@ -0,0 +1,101 @@
+---
+title: place.stream.moderation.deletePin
+description: Reference for the place.stream.moderation.deletePin lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ---------- | -------- | ----- | ------------------------------------------ | ---------------- |
+| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` |
+| `pinUri` | `string` | ✅ | The AT-URI of the pinned record to delete. | Format: `at-uri` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+_(No properties defined)_
+**Possible Errors:**
+
+- `Unauthorized`: The request lacks valid authentication credentials.
+- `Forbidden`: The caller does not have permission to unpin messages for this streamer.
+- `SessionNotFound`: The streamer's OAuth session could not be found or is invalid.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.deletePin",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "pinUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "pinUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the pinned record to delete."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to unpin messages for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
index 986eafb7..c45f3a9b 100644
--- a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
@@ -52,7 +52,7 @@ Record granting moderation permissions to a user for this streamer's content.
"type": "array",
"items": {
"type": "string",
- "enum": ["ban", "hide", "livestream.manage"]
+ "enum": ["ban", "hide", "livestream.manage", "message.pin"]
},
"description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata."
},
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index dbcdff4a..f9b0ae1c 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -979,6 +979,96 @@
}
}
},
+ "/xrpc/place.stream.moderation.createPin": {
+ "post": {
+ "summary": "Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin.",
+ "operationId": "place.stream.moderation.createPin",
+ "tags": ["place.stream.moderation"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "The AT-URI of the created pinned record.",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "description": "The CID of the created pinned record.",
+ "format": "cid"
+ }
+ },
+ "required": ["uri", "cid"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "Unauthorized"
+ },
+ {
+ "const": "Forbidden"
+ },
+ {
+ "const": "SessionNotFound"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "description": "The DID of the streamer.",
+ "format": "did"
+ },
+ "messageUri": {
+ "type": "string",
+ "description": "The AT-URI of the chat message to pin.",
+ "format": "uri"
+ },
+ "expiresAt": {
+ "type": "string",
+ "description": "Optional expiration time for this pin.",
+ "format": "date-time"
+ }
+ },
+ "required": ["streamer", "messageUri"]
+ }
+ }
+ }
+ }
+ }
+ },
"/xrpc/place.stream.moderation.deleteBlock": {
"post": {
"summary": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.",
@@ -1125,6 +1215,79 @@
}
}
},
+ "/xrpc/place.stream.moderation.deletePin": {
+ "post": {
+ "summary": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.",
+ "operationId": "place.stream.moderation.deletePin",
+ "tags": ["place.stream.moderation"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "Unauthorized"
+ },
+ {
+ "const": "Forbidden"
+ },
+ {
+ "const": "SessionNotFound"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "description": "The DID of the streamer.",
+ "format": "did"
+ },
+ "pinUri": {
+ "type": "string",
+ "description": "The AT-URI of the pinned record to delete.",
+ "format": "uri"
+ }
+ },
+ "required": ["streamer", "pinUri"]
+ }
+ }
+ }
+ }
+ }
+ },
"/xrpc/place.stream.moderation.updateLivestream": {
"post": {
"summary": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.",
diff --git a/js/streamplace/src/useful-types.ts b/js/streamplace/src/useful-types.ts
index 2533a434..8ddf81c4 100644
--- a/js/streamplace/src/useful-types.ts
+++ b/js/streamplace/src/useful-types.ts
@@ -1,6 +1,7 @@
import {
PlaceStreamChatDefs,
PlaceStreamChatMessage,
+ PlaceStreamChatPinnedRecord,
PlaceStreamLivestream,
} from "./lexicons";
@@ -13,3 +14,8 @@ export interface ChatMessageViewHydrated
extends PlaceStreamChatDefs.MessageView {
record: PlaceStreamChatMessage.Record;
}
+
+export interface PinnedRecordViewHydrated
+ extends PlaceStreamChatDefs.PinnedRecordView {
+ record: PlaceStreamChatPinnedRecord.Record;
+}
diff --git a/lexicons/place/stream/chat/defs.json b/lexicons/place/stream/chat/defs.json
index da097e62..aa1dcd75 100644
--- a/lexicons/place/stream/chat/defs.json
+++ b/lexicons/place/stream/chat/defs.json
@@ -36,6 +36,25 @@
}
}
}
+ },
+ "pinnedRecordView": {
+ "type": "object",
+ "description": "View of a pinned chat record with hydrated message data.",
+ "required": ["uri", "cid", "record", "indexedAt"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" },
+ "record": { "type": "ref", "ref": "place.stream.chat.pinnedRecord" },
+ "indexedAt": { "type": "string", "format": "datetime" },
+ "pinnedBy": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "message": {
+ "type": "ref",
+ "ref": "#messageView"
+ }
+ }
}
}
}
diff --git a/lexicons/place/stream/chat/pinnedRecord.json b/lexicons/place/stream/chat/pinnedRecord.json
new file mode 100644
index 00000000..f43ac525
--- /dev/null
+++ b/lexicons/place/stream/chat/pinnedRecord.json
@@ -0,0 +1,32 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.chat.pinnedRecord",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record pinning a chat message for prominent display.",
+ "record": {
+ "type": "object",
+ "required": ["pinnedMessage", "createdAt"],
+ "properties": {
+ "pinnedMessage": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the pinned chat message."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "When this pin was created."
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Optional expiration time. If set, the pin is considered inactive after this time."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/moderation/createPin.json b/lexicons/place/stream/moderation/createPin.json
new file mode 100644
index 00000000..b7be8c50
--- /dev/null
+++ b/lexicons/place/stream/moderation/createPin.json
@@ -0,0 +1,67 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.createPin",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. Creates a place.stream.chat.pinnedRecord in the streamer's repo, replacing any existing pin.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "messageUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "messageUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the chat message to pin."
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Optional expiration time for this pin."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the created pinned record."
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "The CID of the created pinned record."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to pin messages for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
diff --git a/lexicons/place/stream/moderation/deletePin.json b/lexicons/place/stream/moderation/deletePin.json
new file mode 100644
index 00000000..50537fd0
--- /dev/null
+++ b/lexicons/place/stream/moderation/deletePin.json
@@ -0,0 +1,50 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.deletePin",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "pinUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "pinUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the pinned record to delete."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to unpin messages for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
diff --git a/lexicons/place/stream/moderation/permission.json b/lexicons/place/stream/moderation/permission.json
index ee579693..68a3d3ce 100644
--- a/lexicons/place/stream/moderation/permission.json
+++ b/lexicons/place/stream/moderation/permission.json
@@ -19,7 +19,7 @@
"type": "array",
"items": {
"type": "string",
- "enum": ["ban", "hide", "livestream.manage"]
+ "enum": ["ban", "hide", "livestream.manage", "message.pin"]
},
"description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata."
},
diff --git a/pkg/atproto/firehose.go b/pkg/atproto/firehose.go
index 8e7ca0be..66b71a27 100644
--- a/pkg/atproto/firehose.go
+++ b/pkg/atproto/firehose.go
@@ -340,6 +340,20 @@ func (atsync *ATProtoSynchronizer) handleCommitEventOps(ctx context.Context, evt
}
}
+ if collection.String() == constants.PLACE_STREAM_CHAT_PINNED_RECORD {
+ log.Debug(ctx, "deleting pinned record", "userDID", evt.Repo, "rkey", rkey.String())
+ err := atsync.Model.DeletePinnedRecord(ctx, rkey.String())
+ if err != nil {
+ log.Error(ctx, "failed to delete pinned record", "err", err)
+ }
+ deletedPin := map[string]any{
+ "$type": constants.PLACE_STREAM_CHAT_PINNED_RECORD,
+ "rkey": rkey.String(),
+ "deleted": true,
+ }
+ go atsync.Bus.Publish(evt.Repo, deletedPin)
+ }
+
default:
log.Error(ctx, "unexpected record op kind")
}
diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go
index 13610a55..a739a695 100644
--- a/pkg/atproto/sync.go
+++ b/pkg/atproto/sync.go
@@ -217,6 +217,51 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD
}
go atsync.Bus.Publish(userDID, streamplaceGate)
+ case *streamplace.ChatPinnedRecord:
+ repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID)
+ if err != nil {
+ return fmt.Errorf("failed to sync bluesky repo: %w", err)
+ }
+ if r == nil {
+ return nil
+ }
+ log.Debug(ctx, "creating pinned record", "userDID", userDID, "pinnedMessage", rec.PinnedMessage)
+ // Delete existing pinned records for this streamer (single-pin semantics)
+ err = atsync.Model.DeleteAllPinnedRecords(ctx, userDID)
+ if err != nil {
+ log.Error(ctx, "failed to delete existing pinned records", "err", err)
+ }
+ // Parse optional expiresAt
+ var expiresAt *time.Time
+ if rec.ExpiresAt != nil {
+ t, err := time.Parse(time.RFC3339, *rec.ExpiresAt)
+ if err == nil {
+ expiresAt = &t
+ }
+ }
+ pin := &model.PinnedRecord{
+ RKey: rkey.String(),
+ RepoDID: userDID,
+ PinnedMessage: rec.PinnedMessage,
+ CID: cid,
+ CreatedAt: now,
+ Repo: repo,
+ ExpiresAt: expiresAt,
+ }
+ err = atsync.Model.CreatePinnedRecord(ctx, pin)
+ if err != nil {
+ return fmt.Errorf("failed to create pinned record: %w", err)
+ }
+ pin, err = atsync.Model.GetPinnedRecord(ctx, rkey.String())
+ if err != nil {
+ return fmt.Errorf("failed to get pinned record after we just saved it: %w", err)
+ }
+ pinnedView, err := pin.ToStreamplacePinnedRecord()
+ if err != nil {
+ return fmt.Errorf("failed to convert pinned record: %w", err)
+ }
+ go atsync.Bus.Publish(userDID, pinnedView)
+
case *streamplace.ChatProfile:
repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID)
if err != nil {
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 56cb1511..4bebb298 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -13,6 +13,7 @@ var APP_BSKY_FEED_POST = "app.bsky.feed.post" //
var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all
var APP_BSKY_ACTOR_PROFILE = "app.bsky.actor.profile" //nolint:all
var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all
+var PLACE_STREAM_CHAT_PINNED_RECORD = "place.stream.chat.pinnedRecord" //nolint:all
var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all
var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all
diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go
index 6d8fbbf8..30efab4e 100644
--- a/pkg/gen/gen.go
+++ b/pkg/gen/gen.go
@@ -26,6 +26,7 @@ func main() {
streamplace.ChatMessage_ReplyRef{},
streamplace.ServerSettings{},
streamplace.ChatGate{},
+ streamplace.ChatPinnedRecord{},
streamplace.MultistreamTarget{},
streamplace.BroadcastOrigin{},
streamplace.BroadcastSyndication{},
diff --git a/pkg/model/model.go b/pkg/model/model.go
index 5fd9e944..5571182f 100644
--- a/pkg/model/model.go
+++ b/pkg/model/model.go
@@ -85,6 +85,12 @@ type Model interface {
GetGate(ctx context.Context, rkey string) (*Gate, error)
GetUserGates(ctx context.Context, userDID string) ([]*Gate, error)
+ CreatePinnedRecord(ctx context.Context, pin *PinnedRecord) error
+ DeletePinnedRecord(ctx context.Context, rkey string) error
+ DeleteAllPinnedRecords(ctx context.Context, streamerDID string) error
+ GetPinnedRecord(ctx context.Context, rkey string) (*PinnedRecord, error)
+ GetActivePinnedRecord(ctx context.Context, streamerDID string) (*PinnedRecord, error)
+
CreateChatProfile(ctx context.Context, profile *ChatProfile) error
GetChatProfile(ctx context.Context, repoDID string) (*ChatProfile, error)
@@ -179,6 +185,7 @@ func MakeDB(dbURL string) (Model, error) {
ChatMessage{},
ChatProfile{},
Gate{},
+ PinnedRecord{},
ServerSettings{},
Labeler{},
Label{},
diff --git a/pkg/model/pinned_record.go b/pkg/model/pinned_record.go
new file mode 100644
index 00000000..90add2f7
--- /dev/null
+++ b/pkg/model/pinned_record.go
@@ -0,0 +1,73 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "gorm.io/gorm"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+type PinnedRecord struct {
+ RKey string `gorm:"primaryKey;column:rkey"`
+ CID string `gorm:"column:cid"`
+ RepoDID string `json:"repoDID" gorm:"column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ PinnedMessage string `gorm:"column:pinned_message" json:"pinnedMessage"`
+ ExpiresAt *time.Time `gorm:"column:expires_at" json:"expiresAt"`
+ CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
+}
+
+func (p *PinnedRecord) ToStreamplacePinnedRecord() (*streamplace.ChatPinnedRecord, error) {
+ rec := &streamplace.ChatPinnedRecord{
+ LexiconTypeID: "place.stream.chat.pinnedRecord",
+ PinnedMessage: p.PinnedMessage,
+ CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
+ }
+ if p.ExpiresAt != nil {
+ s := p.ExpiresAt.UTC().Format(time.RFC3339)
+ rec.ExpiresAt = &s
+ }
+ return rec, nil
+}
+
+func (m *DBModel) CreatePinnedRecord(ctx context.Context, pin *PinnedRecord) error {
+ return m.DB.Create(pin).Error
+}
+
+func (m *DBModel) GetPinnedRecord(ctx context.Context, rkey string) (*PinnedRecord, error) {
+ var pin PinnedRecord
+ err := m.DB.Preload("Repo").Where("rkey = ?", rkey).First(&pin).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &pin, nil
+}
+
+func (m *DBModel) DeletePinnedRecord(ctx context.Context, rkey string) error {
+ return m.DB.Where("rkey = ?", rkey).Delete(&PinnedRecord{}).Error
+}
+
+func (m *DBModel) DeleteAllPinnedRecords(ctx context.Context, streamerDID string) error {
+ return m.DB.Where("repo_did = ?", streamerDID).Delete(&PinnedRecord{}).Error
+}
+
+func (m *DBModel) GetActivePinnedRecord(ctx context.Context, streamerDID string) (*PinnedRecord, error) {
+ var pin PinnedRecord
+ now := time.Now()
+ err := m.DB.Preload("Repo").
+ Where("repo_did = ? AND (expires_at IS NULL OR expires_at > ?)", streamerDID, now).
+ Order("created_at DESC").
+ First(&pin).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &pin, nil
+}
diff --git a/pkg/moderation/permissions.go b/pkg/moderation/permissions.go
index 81b0a9d1..92f2b8e7 100644
--- a/pkg/moderation/permissions.go
+++ b/pkg/moderation/permissions.go
@@ -17,6 +17,7 @@ const (
PermissionBan = "ban"
PermissionHide = "hide"
PermissionLivestreamManage = "livestream.manage"
+ PermissionMessagePin = "message.pin"
)
// ActionPermissions maps moderation actions to required permissions
@@ -26,6 +27,8 @@ var ActionPermissions = map[string]string{
"createGate": PermissionHide,
"deleteGate": PermissionHide,
"updateLivestream": PermissionLivestreamManage,
+ "createPin": PermissionMessagePin,
+ "deletePin": PermissionMessagePin,
}
// PermissionChecker validates moderation permissions
diff --git a/pkg/spxrpc/place_stream_moderation.go b/pkg/spxrpc/place_stream_moderation.go
index c6bb21a8..a70ff81d 100644
--- a/pkg/spxrpc/place_stream_moderation.go
+++ b/pkg/spxrpc/place_stream_moderation.go
@@ -360,3 +360,127 @@ func (s *Server) logAudit(ctx context.Context, streamerDID, moderatorDID, action
return s.statefulDB.CreateAuditLog(ctx, auditLog)
}
+
+func (s *Server) handlePlaceStreamModerationCreatePin(ctx context.Context, input *streamplace.ModerationCreatePin_Input) (*streamplace.ModerationCreatePin_Output, error) {
+ // Validate input
+ if err := validateDID(input.Streamer); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err))
+ }
+ if err := validateATURI(input.MessageUri); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid messageUri: %v", err))
+ }
+
+ // Get delegated moderation context (validates OAuth, permission, and returns client)
+ modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "createPin")
+ if err != nil {
+ return nil, err
+ }
+
+ // Delete existing pinned records for this streamer (single-pin semantics)
+ var listOutput comatproto.RepoListRecords_Output
+ err = modCtx.StreamerClient.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.listRecords",
+ map[string]any{
+ "repo": input.Streamer,
+ "collection": constants.PLACE_STREAM_CHAT_PINNED_RECORD,
+ }, nil, &listOutput)
+ if err != nil {
+ log.Error(ctx, "failed to list existing pinned records", "err", err)
+ } else {
+ for _, rec := range listOutput.Records {
+ rkey, delErr := extractRKey(rec.Uri)
+ if delErr != nil {
+ continue
+ }
+ deleteInput := comatproto.RepoDeleteRecord_Input{
+ Collection: constants.PLACE_STREAM_CHAT_PINNED_RECORD,
+ Rkey: rkey,
+ Repo: input.Streamer,
+ }
+ deleteOutput := comatproto.RepoDeleteRecord_Output{}
+ if err := modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput); err != nil {
+ log.Error(ctx, "failed to delete existing pinned record", "rkey", rkey, "err", err)
+ }
+ }
+ }
+
+ // Create the pinned record
+ pinnedRecord := &streamplace.ChatPinnedRecord{
+ LexiconTypeID: "place.stream.chat.pinnedRecord",
+ PinnedMessage: input.MessageUri,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ ExpiresAt: input.ExpiresAt,
+ }
+
+ createInput := comatproto.RepoCreateRecord_Input{
+ Collection: constants.PLACE_STREAM_CHAT_PINNED_RECORD,
+ Record: &lexutil.LexiconTypeDecoder{Val: pinnedRecord},
+ Repo: input.Streamer,
+ }
+ createOutput := comatproto.RepoCreateRecord_Output{}
+
+ err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, createInput, &createOutput)
+ if err != nil {
+ log.Error(ctx, "failed to create pinned record", "err", err)
+ if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createPin", input.MessageUri, "", "", false, err.Error()); auditErr != nil {
+ log.Error(ctx, "failed to create audit log", "error", auditErr)
+ }
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create pinned record: %v", err))
+ }
+
+ // Log successful audit entry
+ if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createPin", input.MessageUri, "", createOutput.Uri, true, ""); err != nil {
+ log.Error(ctx, "failed to create audit log", "error", err)
+ }
+
+ return &streamplace.ModerationCreatePin_Output{
+ Uri: createOutput.Uri,
+ Cid: createOutput.Cid,
+ }, nil
+}
+
+func (s *Server) handlePlaceStreamModerationDeletePin(ctx context.Context, input *streamplace.ModerationDeletePin_Input) (*streamplace.ModerationDeletePin_Output, error) {
+ // Validate input
+ if err := validateDID(input.Streamer); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err))
+ }
+ if err := validateATURI(input.PinUri); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid pinUri: %v", err))
+ }
+
+ // Get delegated moderation context (validates OAuth, permission, and returns client)
+ modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "deletePin")
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse pinUri to extract rkey
+ rkey, err := extractRKey(input.PinUri)
+ if err != nil {
+ log.Error(ctx, "failed to extract rkey from pinUri", "uri", input.PinUri, "err", err)
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid pinUri format")
+ }
+
+ // Delete pinned record from streamer's repo
+ deleteInput := comatproto.RepoDeleteRecord_Input{
+ Collection: constants.PLACE_STREAM_CHAT_PINNED_RECORD,
+ Rkey: rkey,
+ Repo: input.Streamer,
+ }
+ deleteOutput := comatproto.RepoDeleteRecord_Output{}
+
+ err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput)
+ if err != nil {
+ log.Error(ctx, "failed to delete pinned record", "err", err)
+ if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deletePin", input.PinUri, "", "", false, err.Error()); auditErr != nil {
+ log.Error(ctx, "failed to create audit log", "error", auditErr)
+ }
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete pinned record: %v", err))
+ }
+
+ // Log successful audit entry
+ if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deletePin", input.PinUri, "", "", true, ""); err != nil {
+ log.Error(ctx, "failed to create audit log", "error", err)
+ }
+
+ return &streamplace.ModerationDeletePin_Output{}, nil
+}
diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go
index 7367ebc7..346c0a55 100644
--- a/pkg/spxrpc/stubs.go
+++ b/pkg/spxrpc/stubs.go
@@ -296,8 +296,10 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error {
e.POST("/xrpc/place.stream.live.stopLivestream", s.HandlePlaceStreamLiveStopLivestream)
e.POST("/xrpc/place.stream.moderation.createBlock", s.HandlePlaceStreamModerationCreateBlock)
e.POST("/xrpc/place.stream.moderation.createGate", s.HandlePlaceStreamModerationCreateGate)
+ e.POST("/xrpc/place.stream.moderation.createPin", s.HandlePlaceStreamModerationCreatePin)
e.POST("/xrpc/place.stream.moderation.deleteBlock", s.HandlePlaceStreamModerationDeleteBlock)
e.POST("/xrpc/place.stream.moderation.deleteGate", s.HandlePlaceStreamModerationDeleteGate)
+ e.POST("/xrpc/place.stream.moderation.deletePin", s.HandlePlaceStreamModerationDeletePin)
e.POST("/xrpc/place.stream.moderation.updateLivestream", s.HandlePlaceStreamModerationUpdateLivestream)
e.POST("/xrpc/place.stream.multistream.createTarget", s.HandlePlaceStreamMultistreamCreateTarget)
e.POST("/xrpc/place.stream.multistream.deleteTarget", s.HandlePlaceStreamMultistreamDeleteTarget)
@@ -627,6 +629,24 @@ func (s *Server) HandlePlaceStreamModerationCreateGate(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamModerationCreatePin(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationCreatePin")
+ defer span.End()
+
+ var body placestream.ModerationCreatePin_Input
+ if err := c.Bind(&body); err != nil {
+ return err
+ }
+ var out *placestream.ModerationCreatePin_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamModerationCreatePin(ctx context.Context,body *placestream.ModerationCreatePin_Input) (*placestream.ModerationCreatePin_Output, error)
+ out, handleErr = s.handlePlaceStreamModerationCreatePin(ctx, &body)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamModerationDeleteBlock(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeleteBlock")
defer span.End()
@@ -663,6 +683,24 @@ func (s *Server) HandlePlaceStreamModerationDeleteGate(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamModerationDeletePin(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeletePin")
+ defer span.End()
+
+ var body placestream.ModerationDeletePin_Input
+ if err := c.Bind(&body); err != nil {
+ return err
+ }
+ var out *placestream.ModerationDeletePin_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamModerationDeletePin(ctx context.Context,body *placestream.ModerationDeletePin_Input) (*placestream.ModerationDeletePin_Output, error)
+ out, handleErr = s.handlePlaceStreamModerationDeletePin(ctx, &body)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamModerationUpdateLivestream(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationUpdateLivestream")
defer span.End()
diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go
index 0e88d5fb..197bd3b9 100644
--- a/pkg/streamplace/cbor_gen.go
+++ b/pkg/streamplace/cbor_gen.go
@@ -3634,6 +3634,228 @@ func (t *ChatGate) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
+func (t *ChatPinnedRecord) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+ fieldCount := 4
+
+ if t.ExpiresAt == nil {
+ fieldCount--
+ }
+
+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+ return err
+ }
+
+ // t.LexiconTypeID (string) (string)
+ if len("$type") > 1000000 {
+ return xerrors.Errorf("Value in field \"$type\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("$type")); err != nil {
+ return err
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.chat.pinnedRecord"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.chat.pinnedRecord")); err != nil {
+ return err
+ }
+
+ // t.CreatedAt (string) (string)
+ if len("createdAt") > 1000000 {
+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("createdAt")); err != nil {
+ return err
+ }
+
+ if len(t.CreatedAt) > 1000000 {
+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+ return err
+ }
+
+ // t.ExpiresAt (string) (string)
+ if t.ExpiresAt != nil {
+
+ if len("expiresAt") > 1000000 {
+ return xerrors.Errorf("Value in field \"expiresAt\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("expiresAt"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("expiresAt")); err != nil {
+ return err
+ }
+
+ if t.ExpiresAt == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if len(*t.ExpiresAt) > 1000000 {
+ return xerrors.Errorf("Value in field t.ExpiresAt was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ExpiresAt))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(*t.ExpiresAt)); err != nil {
+ return err
+ }
+ }
+ }
+
+ // t.PinnedMessage (string) (string)
+ if len("pinnedMessage") > 1000000 {
+ return xerrors.Errorf("Value in field \"pinnedMessage\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedMessage"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("pinnedMessage")); err != nil {
+ return err
+ }
+
+ if len(t.PinnedMessage) > 1000000 {
+ return xerrors.Errorf("Value in field t.PinnedMessage was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.PinnedMessage))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.PinnedMessage)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *ChatPinnedRecord) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = ChatPinnedRecord{}
+
+ cr := cbg.NewCborReader(r)
+
+ maj, extra, err := cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err == io.EOF {
+ err = io.ErrUnexpectedEOF
+ }
+ }()
+
+ if maj != cbg.MajMap {
+ return fmt.Errorf("cbor input should be of type map")
+ }
+
+ if extra > cbg.MaxLength {
+ return fmt.Errorf("ChatPinnedRecord: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 13)
+ for i := uint64(0); i < n; i++ {
+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ continue
+ }
+
+ switch string(nameBuf[:nameLen]) {
+ // t.LexiconTypeID (string) (string)
+ case "$type":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.LexiconTypeID = string(sval)
+ }
+ // t.CreatedAt (string) (string)
+ case "createdAt":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CreatedAt = string(sval)
+ }
+ // t.ExpiresAt (string) (string)
+ case "expiresAt":
+
+ {
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.ExpiresAt = (*string)(&sval)
+ }
+ }
+ // t.PinnedMessage (string) (string)
+ case "pinnedMessage":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.PinnedMessage = string(sval)
+ }
+
+ default:
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
func (t *MultistreamTarget) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
diff --git a/pkg/streamplace/chatdefs.go b/pkg/streamplace/chatdefs.go
index 6670cff7..0f2aedac 100644
--- a/pkg/streamplace/chatdefs.go
+++ b/pkg/streamplace/chatdefs.go
@@ -54,3 +54,15 @@ func (t *ChatDefs_MessageView_ReplyTo) UnmarshalJSON(b []byte) error {
return nil
}
}
+
+// ChatDefs_PinnedRecordView is a "pinnedRecordView" in the place.stream.chat.defs schema.
+//
+// View of a pinned chat record with hydrated message data.
+type ChatDefs_PinnedRecordView struct {
+ Cid string `json:"cid" cborgen:"cid"`
+ IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+ Message *ChatDefs_MessageView `json:"message,omitempty" cborgen:"message,omitempty"`
+ PinnedBy *appbsky.ActorDefs_ProfileViewBasic `json:"pinnedBy,omitempty" cborgen:"pinnedBy,omitempty"`
+ Record *ChatPinnedRecord `json:"record" cborgen:"record"`
+ Uri string `json:"uri" cborgen:"uri"`
+}
diff --git a/pkg/streamplace/chatpinnedRecord.go b/pkg/streamplace/chatpinnedRecord.go
new file mode 100644
index 00000000..b61cddfd
--- /dev/null
+++ b/pkg/streamplace/chatpinnedRecord.go
@@ -0,0 +1,23 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.chat.pinnedRecord
+
+package streamplace
+
+import (
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ lexutil.RegisterType("place.stream.chat.pinnedRecord", &ChatPinnedRecord{})
+}
+
+type ChatPinnedRecord struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.pinnedRecord"`
+ // createdAt: When this pin was created.
+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+ // expiresAt: Optional expiration time. If set, the pin is considered inactive after this time.
+ ExpiresAt *string `json:"expiresAt,omitempty" cborgen:"expiresAt,omitempty"`
+ // pinnedMessage: AT-URI of the pinned chat message.
+ PinnedMessage string `json:"pinnedMessage" cborgen:"pinnedMessage"`
+}
diff --git a/pkg/streamplace/moderationcreatePin.go b/pkg/streamplace/moderationcreatePin.go
new file mode 100644
index 00000000..15d742d9
--- /dev/null
+++ b/pkg/streamplace/moderationcreatePin.go
@@ -0,0 +1,39 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.moderation.createPin
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// ModerationCreatePin_Input is the input argument to a place.stream.moderation.createPin call.
+type ModerationCreatePin_Input struct {
+ // expiresAt: Optional expiration time for this pin.
+ ExpiresAt *string `json:"expiresAt,omitempty" cborgen:"expiresAt,omitempty"`
+ // messageUri: The AT-URI of the chat message to pin.
+ MessageUri string `json:"messageUri" cborgen:"messageUri"`
+ // streamer: The DID of the streamer.
+ Streamer string `json:"streamer" cborgen:"streamer"`
+}
+
+// ModerationCreatePin_Output is the output of a place.stream.moderation.createPin call.
+type ModerationCreatePin_Output struct {
+ // cid: The CID of the created pinned record.
+ Cid string `json:"cid" cborgen:"cid"`
+ // uri: The AT-URI of the created pinned record.
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+// ModerationCreatePin calls the XRPC method "place.stream.moderation.createPin".
+func ModerationCreatePin(ctx context.Context, c lexutil.LexClient, input *ModerationCreatePin_Input) (*ModerationCreatePin_Output, error) {
+ var out ModerationCreatePin_Output
+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.createPin", nil, input, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/moderationdeletePin.go b/pkg/streamplace/moderationdeletePin.go
new file mode 100644
index 00000000..28654d62
--- /dev/null
+++ b/pkg/streamplace/moderationdeletePin.go
@@ -0,0 +1,33 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.moderation.deletePin
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// ModerationDeletePin_Input is the input argument to a place.stream.moderation.deletePin call.
+type ModerationDeletePin_Input struct {
+ // pinUri: The AT-URI of the pinned record to delete.
+ PinUri string `json:"pinUri" cborgen:"pinUri"`
+ // streamer: The DID of the streamer.
+ Streamer string `json:"streamer" cborgen:"streamer"`
+}
+
+// ModerationDeletePin_Output is the output of a place.stream.moderation.deletePin call.
+type ModerationDeletePin_Output struct {
+}
+
+// ModerationDeletePin calls the XRPC method "place.stream.moderation.deletePin".
+func ModerationDeletePin(ctx context.Context, c lexutil.LexClient, input *ModerationDeletePin_Input) (*ModerationDeletePin_Output, error) {
+ var out ModerationDeletePin_Output
+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.deletePin", nil, input, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}