Live video on the AT Protocol

Merge pull request #393 from streamplace/natb/hide-msg

chat: add 'hide message' feature

authored by

natalie and committed by
GitHub
3104dc0a c25f7e8a

+679 -67
+130 -4
js/components/src/components/chat/chat.tsx
··· 1 - import { Reply, ShieldEllipsis } from "lucide-react-native"; 2 - import { ComponentProps, memo, useRef } from "react"; 1 + import { Ellipsis, Reply, ShieldEllipsis } from "lucide-react-native"; 2 + import { ComponentProps, memo, useEffect, useRef, useState } from "react"; 3 3 import { FlatList, Platform, Pressable } from "react-native"; 4 4 import Swipeable, { 5 5 SwipeableMethods, ··· 16 16 useSetReplyToMessage, 17 17 View, 18 18 } from "../../"; 19 - import { flex, py, w } from "../../lib/theme/atoms"; 19 + import { bg, flex, px, py, w } from "../../lib/theme/atoms"; 20 20 import { RenderChatMessage } from "./chat-message"; 21 21 import { ModView } from "./mod-view"; 22 22 ··· 55 55 return `${item.uri}`; 56 56 }; 57 57 58 + // Actions bar for larger screens 59 + const ActionsBar = memo( 60 + ({ 61 + item, 62 + visible, 63 + hoverTimeoutRef, 64 + }: { 65 + item: ChatMessageViewHydrated; 66 + visible: boolean; 67 + hoverTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>; 68 + }) => { 69 + const setReply = useSetReplyToMessage(); 70 + const setModMsg = usePlayerStore((state) => state.setModMessage); 71 + 72 + if (!visible) return null; 73 + 74 + return ( 75 + <View 76 + style={[ 77 + { 78 + position: "absolute", 79 + top: -14, 80 + right: 8, 81 + flexDirection: "row", 82 + backgroundColor: "rgba(180,180,180, 0.5)", 83 + borderRadius: 6, 84 + borderWidth: 1, 85 + padding: 1, 86 + gap: 4, 87 + zIndex: 10, 88 + }, 89 + ]} 90 + > 91 + <Pressable 92 + onPress={() => setReply(item)} 93 + style={[ 94 + { 95 + padding: 6, 96 + borderRadius: 4, 97 + backgroundColor: "rgba(255, 255, 255, 0.1)", 98 + }, 99 + ]} 100 + onHoverIn={() => { 101 + // Keep the actions bar visible when hovering over it 102 + if (hoverTimeoutRef.current) { 103 + clearTimeout(hoverTimeoutRef.current); 104 + hoverTimeoutRef.current = null; 105 + } 106 + }} 107 + > 108 + <Reply color="white" size={16} /> 109 + </Pressable> 110 + <Pressable 111 + onPress={() => setModMsg(item)} 112 + style={[ 113 + { 114 + padding: 6, 115 + borderRadius: 4, 116 + backgroundColor: "rgba(255, 255, 255, 0.1)", 117 + }, 118 + ]} 119 + onHoverIn={() => { 120 + // Keep the actions bar visible when hovering over it 121 + if (hoverTimeoutRef.current) { 122 + clearTimeout(hoverTimeoutRef.current); 123 + hoverTimeoutRef.current = null; 124 + } 125 + }} 126 + > 127 + <Ellipsis color="white" size={16} /> 128 + </Pressable> 129 + </View> 130 + ); 131 + }, 132 + ); 133 + 58 134 const ChatLine = memo(({ item }: { item: ChatMessageViewHydrated }) => { 59 135 const setReply = useSetReplyToMessage(); 60 136 const setModMsg = usePlayerStore((state) => state.setModMessage); 61 137 const swipeableRef = useRef<SwipeableMethods | null>(null); 138 + const [isHovered, setIsHovered] = useState(false); 139 + const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); 140 + 141 + const handleHoverIn = () => { 142 + if (hoverTimeoutRef.current) { 143 + clearTimeout(hoverTimeoutRef.current); 144 + hoverTimeoutRef.current = null; 145 + } 146 + setIsHovered(true); 147 + }; 148 + 149 + const handleHoverOut = () => { 150 + hoverTimeoutRef.current = setTimeout(() => { 151 + setIsHovered(false); 152 + }, 50); 153 + }; 154 + 155 + useEffect(() => { 156 + return () => { 157 + if (hoverTimeoutRef.current) { 158 + clearTimeout(hoverTimeoutRef.current); 159 + } 160 + }; 161 + }, []); 162 + 163 + if (Platform.OS === "web") { 164 + return ( 165 + <View 166 + style={[ 167 + py[1], 168 + px[2], 169 + { position: "relative", borderRadius: 8 }, 170 + isHovered && bg.gray[950], 171 + ]} 172 + onPointerEnter={handleHoverIn} 173 + onPointerLeave={handleHoverOut} 174 + > 175 + <Pressable> 176 + <RenderChatMessage item={item} /> 177 + </Pressable> 178 + <ActionsBar 179 + item={item} 180 + visible={isHovered} 181 + hoverTimeoutRef={hoverTimeoutRef} 182 + /> 183 + </View> 184 + ); 185 + } 186 + 62 187 return ( 63 - <Pressable onLongPress={() => setModMsg(item)}> 188 + <Pressable> 64 189 <Swipeable 65 190 containerStyle={[py[1]]} 66 191 friction={2} 67 192 enableTrackpadTwoFingerGesture 68 193 rightThreshold={40} 194 + leftThreshold={40} 69 195 renderRightActions={Platform.OS === "android" ? undefined : RightAction} 70 196 renderLeftActions={Platform.OS === "android" ? undefined : LeftAction} 71 197 overshootFriction={9}
+90 -39
js/components/src/components/chat/mod-view.tsx
··· 1 1 import { TriggerRef } from "@rn-primitives/dropdown-menu"; 2 - import { forwardRef, useEffect, useRef } from "react"; 3 - import { gap, mr } from "../../lib/theme/atoms"; 2 + import { forwardRef, useEffect, useRef, useState } from "react"; 3 + import { gap, mr, w } from "../../lib/theme/atoms"; 4 4 import { usePlayerStore } from "../../player-store"; 5 - import { useCreateBlockRecord } from "../../streamplace-store/block"; 5 + import { 6 + useCreateBlockRecord, 7 + useCreateHideChatRecord, 8 + } from "../../streamplace-store/block"; 6 9 import { usePDSAgent } from "../../streamplace-store/xrpc"; 7 10 11 + import { Linking } from "react-native"; 12 + import { useStreamplaceStore } from "../../streamplace-store"; 8 13 import { 14 + atoms, 9 15 DropdownMenu, 10 16 DropdownMenuGroup, 11 17 DropdownMenuItem, ··· 15 21 Text, 16 22 View, 17 23 } from "../ui"; 18 - import { RenderChatMessage } from "./chat-message"; 24 + 25 + const BSKY_FRONTEND_DOMAIN = "bsky.app"; 19 26 20 27 type ModViewProps = { 21 28 onClose?: () => void; ··· 33 40 const message = usePlayerStore((state) => state.modMessage); 34 41 35 42 let agent = usePDSAgent(); 36 - let createBlockRecord = useCreateBlockRecord(); 43 + let [messageRemoved, setMessageRemoved] = useState(false); 44 + let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); 45 + let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); 46 + 47 + // get the channel did 48 + const channelId = usePlayerStore((state) => state.src); 49 + // get the logged in user's identity 50 + const handle = useStreamplaceStore((state) => state.handle); 37 51 38 52 if (!agent?.did) { 39 53 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> ··· 44 58 useEffect(() => { 45 59 if (message) { 46 60 console.log("opening mod view"); 61 + setMessageRemoved(false); 47 62 triggerRef.current?.open(); 48 63 } else { 49 64 console.log("closing mod view"); ··· 52 67 }, [message]); 53 68 54 69 return ( 55 - <DropdownMenu> 70 + <DropdownMenu 71 + style={[layout.flex.row, layout.flex.alignCenter, gap.all[2], w[80]]} 72 + > 56 73 <DropdownMenuTrigger ref={triggerRef}> 57 74 {/* Hidden trigger */} 58 75 <View /> ··· 62 79 <> 63 80 <DropdownMenuGroup> 64 81 <DropdownMenuItem> 65 - <View style={[layout.flex.column, mr[5], { gap: 6 }]}> 66 - <RenderChatMessage item={message} /> 82 + <View 83 + style={[ 84 + layout.flex.column, 85 + mr[5], 86 + { gap: 6, maxWidth: "100%" }, 87 + ]} 88 + > 89 + <Text 90 + style={{ 91 + fontVariant: ["tabular-nums"], 92 + color: atoms.colors.gray[300], 93 + }} 94 + > 95 + {new Date(message.record.createdAt).toLocaleTimeString([], { 96 + hour: "2-digit", 97 + minute: "2-digit", 98 + hour12: false, 99 + })}{" "} 100 + @{message.author.handle}: {message.record.text} 101 + </Text> 67 102 </View> 68 103 </DropdownMenuItem> 69 104 </DropdownMenuGroup> 70 105 71 - <DropdownMenuGroup title={`Moderation actions`}> 72 - {/* <DropdownMenuItem 73 - onPress={ 74 - onDeleteMessage 75 - ? () => onDeleteMessage(modMessage) 76 - : undefined 77 - } 106 + {/* TODO: Checking for non-owner moderators */} 107 + {channelId === handle && ( 108 + <DropdownMenuGroup title={`Moderation actions`}> 109 + <DropdownMenuItem 110 + disabled={isHideLoading || messageRemoved} 111 + onPress={() => { 112 + if (isHideLoading || messageRemoved) return; 113 + createHideChat(message.uri) 114 + .then((r) => setMessageRemoved(true)) 115 + .catch((e) => console.error(e)); 116 + }} 78 117 > 79 - <Text customColor={colors.ios.systemTeal}> 80 - Delete message 118 + <Text 119 + color={ 120 + isHideLoading || messageRemoved ? "muted" : "destructive" 121 + } 122 + > 123 + {isHideLoading 124 + ? "Removing..." 125 + : messageRemoved 126 + ? "Message removed" 127 + : "Remove this message"} 81 128 </Text> 82 129 </DropdownMenuItem> 83 - <DropdownMenuSeparator /> 84 130 <DropdownMenuItem 85 - onPress={ 86 - onBanUser 87 - ? () => onBanUser(modMessage.author.handle) 88 - : undefined 89 - } 131 + disabled={message.author.did === agent?.did || isBlockLoading} 132 + onPress={() => { 133 + createBlock(message.author.did) 134 + .then((r) => console.log(r)) 135 + .catch((e) => console.error(e)); 136 + }} 90 137 > 91 - <Text color="destructive"> 92 - Ban user @{modMessage.author.handle} 93 - </Text> 94 - </DropdownMenuItem> */} 138 + {message.author.did === agent?.did ? ( 139 + <Text color="muted"> 140 + Block yourself (you can't block yourself) 141 + </Text> 142 + ) : ( 143 + <Text color="destructive"> 144 + {isBlockLoading 145 + ? "Blocking..." 146 + : `Block user @${message.author.handle} from this channel`} 147 + </Text> 148 + )} 149 + </DropdownMenuItem> 150 + </DropdownMenuGroup> 151 + )} 152 + 153 + <DropdownMenuGroup title={`User actions`}> 95 154 <DropdownMenuItem 96 - disabled={message.author.did === agent?.did} 97 155 onPress={() => { 98 - console.log("Creating block record"); 99 - createBlockRecord(message.author.did) 100 - .then((r) => console.log(r)) 101 - .catch((e) => console.error(e)); 156 + Linking.openURL( 157 + `https://${BSKY_FRONTEND_DOMAIN}/profile/${channelId}`, 158 + ); 102 159 }} 103 160 > 104 - <Text color="destructive"> 105 - {message.author.did === agent?.did ? ( 106 - <>Block yourself (you can't block yourself)</> 107 - ) : ( 108 - <>Block user @{message.author.handle} from this channel</> 109 - )} 110 - </Text> 161 + <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 111 162 </DropdownMenuItem> 112 163 </DropdownMenuGroup> 113 164 </>
+8
js/components/src/components/ui/dropdown.tsx
··· 73 73 index={open ? 3 : -1} 74 74 snapPoints={snapPoints} 75 75 enablePanDownToClose 76 + enableDynamicSizing 77 + enableContentPanningGesture={false} 78 + backdropComponent={({ style }) => ( 79 + <Pressable 80 + style={[style, StyleSheet.absoluteFill]} 81 + onPress={() => onOpenChange?.(false)} 82 + /> 83 + )} 76 84 onClose={() => onOpenChange?.(false)} 77 85 style={[overlayStyle]} 78 86 backgroundStyle={[bg.black, a.radius.all.md, a.shadows.md, p[1]]}
+52 -2
js/components/src/livestream-store/chat.tsx
··· 23 23 ); 24 24 }; 25 25 26 + export const usePendingHides = () => 27 + useLivestreamStore((state) => state.pendingHides); 28 + 29 + export const useAddPendingHide = () => { 30 + const store = getStoreFromContext(); 31 + return useCallback( 32 + (messageUri: string) => { 33 + const state = store.getState(); 34 + if (!state.pendingHides.includes(messageUri)) { 35 + const newPendingHides = [...state.pendingHides, messageUri]; 36 + const newState = reduceChat(state, [], [], [messageUri]); 37 + store.setState({ 38 + ...newState, 39 + pendingHides: newPendingHides, 40 + }); 41 + } 42 + }, 43 + [store], 44 + ); 45 + }; 46 + 26 47 export type NewChatMessage = { 27 48 text: string; 28 49 reply?: { ··· 87 108 chatProfile: chatProfile || undefined, 88 109 }; 89 110 90 - state = reduceChat(state, [localChat], []); 111 + state = reduceChat(state, [localChat], [], []); 91 112 store.setState(state); 92 113 93 114 await pdsAgent.com.atproto.repo.createRecord({ ··· 138 159 state: LivestreamState, 139 160 newMessages: ChatMessageViewHydrated[], 140 161 blocks: PlaceStreamDefs.BlockView[], 162 + hideUris: string[] = [], 141 163 ): LivestreamState => { 142 - if (newMessages.length === 0 && blocks.length === 0) { 164 + if ( 165 + newMessages.length === 0 && 166 + blocks.length === 0 && 167 + hideUris.length === 0 168 + ) { 143 169 return state; 144 170 } 145 171 ··· 160 186 } 161 187 } 162 188 189 + if (hideUris.length > 0) { 190 + for (const [key, message] of Object.entries(newChatIndex)) { 191 + if (hideUris.includes(message.uri)) { 192 + delete newChatIndex[key]; 193 + removedKeys.add(key); 194 + hasChanges = true; 195 + } 196 + } 197 + } 198 + 163 199 const messagesToAdd: { key: string; message: ChatMessageViewHydrated }[] = []; 164 200 165 201 for (const message of newMessages) { 202 + // don't worry about messages that will be hidden 203 + if (state.pendingHides.includes(message.uri)) { 204 + continue; 205 + } 206 + 166 207 const date = new Date(message.record.createdAt); 167 208 const key = `${date.getTime()}-${message.uri}`; 168 209 ··· 251 292 removedKeys, 252 293 ); 253 294 295 + // Clean up pendingHides - remove URIs that we've now processed 296 + let newPendingHides = state.pendingHides; 297 + if (hideUris.length > 0) { 298 + newPendingHides = state.pendingHides.filter( 299 + (uri) => !hideUris.includes(uri), 300 + ); 301 + } 302 + 254 303 return { 255 304 ...state, 256 305 chatIndex: newChatIndex, 257 306 chat: newChatList, 307 + pendingHides: newPendingHides, 258 308 }; 259 309 }; 260 310
+1
js/components/src/livestream-store/livestream-state.tsx
··· 13 13 authors: { [key: string]: ChatMessageViewHydrated["chatProfile"] }; 14 14 livestream: LivestreamViewHydrated | null; 15 15 viewers: number | null; 16 + pendingHides: string[]; 16 17 segment: PlaceStreamSegment.Record | null; 17 18 renditions: PlaceStreamDefs.Rendition[]; 18 19 replyToMessage: ChatMessageViewHydrated | null;
+1
js/components/src/livestream-store/livestream-store.tsx
··· 13 13 chat: [], 14 14 livestream: null, 15 15 viewers: null, 16 + pendingHides: [], 16 17 segment: null, 17 18 renditions: [], 18 19 replyToMessage: null,
+17 -3
js/components/src/livestream-store/websocket-consumer.tsx
··· 3 3 ChatMessageViewHydrated, 4 4 LivestreamViewHydrated, 5 5 PlaceStreamChatDefs, 6 + PlaceStreamChatGate, 6 7 PlaceStreamChatMessage, 7 8 PlaceStreamDefs, 8 9 PlaceStreamLivestream, ··· 37 38 chatProfile: (message as any).chatProfile, 38 39 replyTo: (message as any).replyTo, 39 40 }; 40 - state = reduceChat(state, [hydrated], []); 41 + state = reduceChat(state, [hydrated], [], []); 41 42 } else if (PlaceStreamSegment.isRecord(message)) { 42 43 state = { 43 44 ...state, ··· 45 46 }; 46 47 } else if (PlaceStreamDefs.isBlockView(message)) { 47 48 const block = message as PlaceStreamDefs.BlockView; 48 - state = reduceChat(state, [], [block]); 49 + state = reduceChat(state, [], [block], []); 49 50 } else if (PlaceStreamDefs.isRenditions(message)) { 50 51 state = { 51 52 ...state, ··· 56 57 ...state, 57 58 profile: message, 58 59 }; 60 + } else if (PlaceStreamChatGate.isRecord(message)) { 61 + const hideRecord = message as PlaceStreamChatGate.Record; 62 + const hiddenMessageUri = hideRecord.hiddenMessage; 63 + const newPendingHides = [...state.pendingHides]; 64 + if (!newPendingHides.includes(hiddenMessageUri)) { 65 + newPendingHides.push(hiddenMessageUri); 66 + } 67 + 68 + state = { 69 + ...state, 70 + pendingHides: newPendingHides, 71 + }; 72 + state = reduceChat(state, [], [], [hiddenMessageUri]); 59 73 } 60 74 } 61 - return reduceChat(state, [], []); 75 + return reduceChat(state, [], [], []); 62 76 };
+54 -12
js/components/src/streamplace-store/block.tsx
··· 1 1 import { AppBskyGraphBlock } from "@atproto/api"; 2 + import { useState } from "react"; 2 3 import { usePDSAgent } from "./xrpc"; 3 4 4 5 export function useCreateBlockRecord() { 5 6 let agent = usePDSAgent(); 7 + const [isLoading, setIsLoading] = useState(false); 6 8 7 - return async (subjectDID: string) => { 9 + const createBlock = async (subjectDID: string) => { 8 10 if (!agent) { 9 11 throw new Error("No PDS agent found"); 10 12 } ··· 13 15 throw new Error("No user DID found, assuming not logged in"); 14 16 } 15 17 16 - const record: AppBskyGraphBlock.Record = { 17 - $type: "app.bsky.graph.block", 18 - subject: subjectDID, 19 - createdAt: new Date().toISOString(), 20 - }; 21 - return await agent.com.atproto.repo.createRecord({ 22 - repo: agent.did, 23 - collection: "app.bsky.graph.block", 24 - record, 25 - }); 18 + setIsLoading(true); 19 + try { 20 + const record: AppBskyGraphBlock.Record = { 21 + $type: "app.bsky.graph.block", 22 + subject: subjectDID, 23 + createdAt: new Date().toISOString(), 24 + }; 25 + const result = await agent.com.atproto.repo.createRecord({ 26 + repo: agent.did, 27 + collection: "app.bsky.graph.block", 28 + record, 29 + }); 30 + return result; 31 + } finally { 32 + setIsLoading(false); 33 + } 34 + }; 26 35 27 - return record; 36 + return { createBlock, isLoading }; 37 + } 38 + 39 + export function useCreateHideChatRecord() { 40 + let agent = usePDSAgent(); 41 + const [isLoading, setIsLoading] = useState(false); 42 + 43 + const createHideChat = async (chatMessageUri: string) => { 44 + if (!agent) { 45 + throw new Error("No PDS agent found"); 46 + } 47 + 48 + if (!agent.did) { 49 + throw new Error("No user DID found, assuming not logged in"); 50 + } 51 + 52 + setIsLoading(true); 53 + try { 54 + const record = { 55 + $type: "place.stream.chat.gate", 56 + hiddenMessage: chatMessageUri, 57 + }; 58 + 59 + const result = await agent.com.atproto.repo.createRecord({ 60 + repo: agent.did, 61 + collection: "place.stream.chat.gate", 62 + record, 63 + }); 64 + return result; 65 + } finally { 66 + setIsLoading(false); 67 + } 28 68 }; 69 + 70 + return { createHideChat, isLoading }; 29 71 }
+53
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-gate.md
··· 1 + --- 2 + title: place.stream.chat.gate 3 + description: Reference for the place.stream.chat.gate lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + Record defining a single gated chat message. 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | --------------- | -------- | ----- | ------------------------------- | ---------------- | 24 + | `hiddenMessage` | `string` | ✅ | URI of the hidden chat message. | Format: `at-uri` | 25 + 26 + --- 27 + 28 + ## Lexicon Source 29 + 30 + ```json 31 + { 32 + "lexicon": 1, 33 + "id": "place.stream.chat.gate", 34 + "defs": { 35 + "main": { 36 + "type": "record", 37 + "key": "tid", 38 + "description": "Record defining a single gated chat message.", 39 + "record": { 40 + "type": "object", 41 + "required": ["hiddenMessage"], 42 + "properties": { 43 + "hiddenMessage": { 44 + "type": "string", 45 + "format": "at-uri", 46 + "description": "URI of the hidden chat message." 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + ```
+22
lexicons/place/stream/chat/gate.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.chat.gate", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record defining a single gated chat message.", 9 + "record": { 10 + "type": "object", 11 + "required": ["hiddenMessage"], 12 + "properties": { 13 + "hiddenMessage": { 14 + "type": "string", 15 + "format": "at-uri", 16 + "description": "URI of the hidden chat message." 17 + } 18 + } 19 + } 20 + } 21 + } 22 + }
+4 -7
pkg/atproto/firehose.go
··· 26 26 "stream.place/streamplace/pkg/model" 27 27 notificationpkg "stream.place/streamplace/pkg/notifications" 28 28 29 + "slices" 30 + 29 31 "github.com/gorilla/websocket" 30 32 ) 31 33 ··· 149 151 constants.APP_BSKY_FEED_POST, 150 152 constants.APP_BSKY_GRAPH_BLOCK, 151 153 constants.PLACE_STREAM_SERVER_SETTINGS, 154 + constants.PLACE_STREAM_CHAT_GATE, 152 155 } 153 156 154 157 func (atsync *ATProtoSynchronizer) handleCommitEventOps(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit) { ··· 176 179 ctx = log.WithLogValues(ctx, "eventKind", op.Action, "collection", collection.String(), "rkey", rkey.String()) 177 180 178 181 if len(CollectionFilter) > 0 { 179 - keep := false 180 - for _, c := range CollectionFilter { 181 - if collection.String() == c { 182 - keep = true 183 - break 184 - } 185 - } 182 + keep := slices.Contains(CollectionFilter, collection.String()) 186 183 if !keep { 187 184 continue 188 185 }
+32
pkg/atproto/sync.go
··· 148 148 } 149 149 } 150 150 151 + case *streamplace.ChatGate: 152 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 153 + if err != nil { 154 + return fmt.Errorf("failed to sync bluesky repo: %w", err) 155 + } 156 + if r == nil { 157 + // someone we don't know about 158 + return nil 159 + } 160 + log.Debug(ctx, "creating gate", "userDID", userDID, "hiddenMessage", rec.HiddenMessage) 161 + gate := &model.Gate{ 162 + RKey: rkey.String(), 163 + RepoDID: userDID, 164 + HiddenMessage: rec.HiddenMessage, 165 + CID: cid, 166 + CreatedAt: now, 167 + Repo: repo, 168 + } 169 + err = atsync.Model.CreateGate(ctx, gate) 170 + if err != nil { 171 + return fmt.Errorf("failed to create gate: %w", err) 172 + } 173 + gate, err = atsync.Model.GetGate(ctx, rkey.String()) 174 + if err != nil { 175 + return fmt.Errorf("failed to get gate after we just saved it?!: %w", err) 176 + } 177 + streamplaceGate, err := gate.ToStreamplaceGate() 178 + if err != nil { 179 + return fmt.Errorf("failed to convert gate to streamplace gate: %w", err) 180 + } 181 + go atsync.Bus.Publish(userDID, streamplaceGate) 182 + 151 183 case *streamplace.ChatProfile: 152 184 repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 153 185 if err != nil {
+1
pkg/constants/constants.go
··· 9 9 var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all 10 10 var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all 11 11 var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all 12 + var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all 12 13 13 14 const DID_KEY_PREFIX = "did:key" //nolint:all 14 15 const ADDRESS_KEY_PREFIX = "0x" //nolint:all
+1
pkg/gen/gen.go
··· 24 24 streamplace.ChatProfile_Color{}, 25 25 streamplace.ChatMessage_ReplyRef{}, 26 26 streamplace.ServerSettings{}, 27 + streamplace.ChatGate{}, 27 28 ); err != nil { 28 29 panic(err) 29 30 }
+3
pkg/model/chat_message.go
··· 115 115 // Exclude messages from users blocked by the streamer 116 116 Joins("LEFT JOIN blocks ON blocks.repo_did = chat_messages.streamer_repo_did AND blocks.subject_did = chat_messages.repo_did"). 117 117 Where("blocks.rkey IS NULL"). // Only include messages where no block exists 118 + // Exclude gated messages 119 + Joins("LEFT JOIN gates ON gates.repo_did = chat_messages.streamer_repo_did AND gates.hidden_message = chat_messages.uri"). 120 + Where("gates.hidden_message IS NULL"). // Only include messages where no gate exists 118 121 Limit(100). 119 122 Order("chat_messages.created_at DESC"). 120 123 Find(&dbmessages).Error
+55
pkg/model/gate.go
··· 1 + package model 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "time" 7 + 8 + "gorm.io/gorm" 9 + "stream.place/streamplace/pkg/streamplace" 10 + ) 11 + 12 + type Gate struct { 13 + RKey string `gorm:"primaryKey;column:rkey"` 14 + CID string `gorm:"column:cid"` 15 + RepoDID string `json:"repoDID" gorm:"column:repo_did"` 16 + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 17 + HiddenMessage string `gorm:"column:hidden_message" json:"hiddenMessage"` 18 + CreatedAt time.Time `gorm:"column:created_at"` 19 + } 20 + 21 + func (g *Gate) ToStreamplaceGate() (*streamplace.ChatGate, error) { 22 + return &streamplace.ChatGate{ 23 + LexiconTypeID: "place.stream.chat.gate", 24 + HiddenMessage: g.HiddenMessage, 25 + }, nil 26 + } 27 + 28 + func (m *DBModel) CreateGate(ctx context.Context, gate *Gate) error { 29 + return m.DB.Create(gate).Error 30 + } 31 + 32 + func (m *DBModel) GetGate(ctx context.Context, rkey string) (*Gate, error) { 33 + var gate Gate 34 + err := m.DB.Preload("Repo").Where("rkey = ?", rkey).First(&gate).Error 35 + if errors.Is(err, gorm.ErrRecordNotFound) { 36 + return nil, nil 37 + } 38 + if err != nil { 39 + return nil, err 40 + } 41 + return &gate, nil 42 + } 43 + 44 + func (m *DBModel) DeleteGate(ctx context.Context, rkey string) error { 45 + return m.DB.Where("rkey = ?", rkey).Delete(&Gate{}).Error 46 + } 47 + 48 + func (m *DBModel) GetUserGates(ctx context.Context, userDID string) ([]*Gate, error) { 49 + var gates []*Gate 50 + err := m.DB.Where("repo_did = ?", userDID).Find(&gates).Error 51 + if err != nil { 52 + return nil, err 53 + } 54 + return gates, nil 55 + }
+6
pkg/model/model.go
··· 85 85 MostRecentChatMessages(repoDID string) ([]*streamplace.ChatDefs_MessageView, error) 86 86 GetChatMessage(cid string) (*ChatMessage, error) 87 87 88 + CreateGate(ctx context.Context, gate *Gate) error 89 + DeleteGate(ctx context.Context, rkey string) error 90 + GetGate(ctx context.Context, rkey string) (*Gate, error) 91 + GetUserGates(ctx context.Context, userDID string) ([]*Gate, error) 92 + 88 93 CreateChatProfile(ctx context.Context, profile *ChatProfile) error 89 94 GetChatProfile(ctx context.Context, repoDID string) (*ChatProfile, error) 90 95 ··· 160 165 Block{}, 161 166 ChatMessage{}, 162 167 ChatProfile{}, 168 + Gate{}, 163 169 oatproxy.OAuthSession{}, 164 170 ServerSettings{}, 165 171 XrpcStreamEvent{},
+130
pkg/streamplace/cbor_gen.go
··· 2769 2769 2770 2770 return nil 2771 2771 } 2772 + func (t *ChatGate) MarshalCBOR(w io.Writer) error { 2773 + if t == nil { 2774 + _, err := w.Write(cbg.CborNull) 2775 + return err 2776 + } 2777 + 2778 + cw := cbg.NewCborWriter(w) 2779 + 2780 + if _, err := cw.Write([]byte{162}); err != nil { 2781 + return err 2782 + } 2783 + 2784 + // t.LexiconTypeID (string) (string) 2785 + if len("$type") > 1000000 { 2786 + return xerrors.Errorf("Value in field \"$type\" was too long") 2787 + } 2788 + 2789 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2790 + return err 2791 + } 2792 + if _, err := cw.WriteString(string("$type")); err != nil { 2793 + return err 2794 + } 2795 + 2796 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.chat.gate"))); err != nil { 2797 + return err 2798 + } 2799 + if _, err := cw.WriteString(string("place.stream.chat.gate")); err != nil { 2800 + return err 2801 + } 2802 + 2803 + // t.HiddenMessage (string) (string) 2804 + if len("hiddenMessage") > 1000000 { 2805 + return xerrors.Errorf("Value in field \"hiddenMessage\" was too long") 2806 + } 2807 + 2808 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("hiddenMessage"))); err != nil { 2809 + return err 2810 + } 2811 + if _, err := cw.WriteString(string("hiddenMessage")); err != nil { 2812 + return err 2813 + } 2814 + 2815 + if len(t.HiddenMessage) > 1000000 { 2816 + return xerrors.Errorf("Value in field t.HiddenMessage was too long") 2817 + } 2818 + 2819 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.HiddenMessage))); err != nil { 2820 + return err 2821 + } 2822 + if _, err := cw.WriteString(string(t.HiddenMessage)); err != nil { 2823 + return err 2824 + } 2825 + return nil 2826 + } 2827 + 2828 + func (t *ChatGate) UnmarshalCBOR(r io.Reader) (err error) { 2829 + *t = ChatGate{} 2830 + 2831 + cr := cbg.NewCborReader(r) 2832 + 2833 + maj, extra, err := cr.ReadHeader() 2834 + if err != nil { 2835 + return err 2836 + } 2837 + defer func() { 2838 + if err == io.EOF { 2839 + err = io.ErrUnexpectedEOF 2840 + } 2841 + }() 2842 + 2843 + if maj != cbg.MajMap { 2844 + return fmt.Errorf("cbor input should be of type map") 2845 + } 2846 + 2847 + if extra > cbg.MaxLength { 2848 + return fmt.Errorf("ChatGate: map struct too large (%d)", extra) 2849 + } 2850 + 2851 + n := extra 2852 + 2853 + nameBuf := make([]byte, 13) 2854 + for i := uint64(0); i < n; i++ { 2855 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2856 + if err != nil { 2857 + return err 2858 + } 2859 + 2860 + if !ok { 2861 + // Field doesn't exist on this type, so ignore it 2862 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2863 + return err 2864 + } 2865 + continue 2866 + } 2867 + 2868 + switch string(nameBuf[:nameLen]) { 2869 + // t.LexiconTypeID (string) (string) 2870 + case "$type": 2871 + 2872 + { 2873 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2874 + if err != nil { 2875 + return err 2876 + } 2877 + 2878 + t.LexiconTypeID = string(sval) 2879 + } 2880 + // t.HiddenMessage (string) (string) 2881 + case "hiddenMessage": 2882 + 2883 + { 2884 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2885 + if err != nil { 2886 + return err 2887 + } 2888 + 2889 + t.HiddenMessage = string(sval) 2890 + } 2891 + 2892 + default: 2893 + // Field doesn't exist on this type, so ignore it 2894 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2895 + return err 2896 + } 2897 + } 2898 + } 2899 + 2900 + return nil 2901 + }
+19
pkg/streamplace/chatgate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package streamplace 4 + 5 + // schema: place.stream.chat.gate 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + util.RegisterType("place.stream.chat.gate", &ChatGate{}) 13 + } // 14 + // RECORDTYPE: ChatGate 15 + type ChatGate struct { 16 + LexiconTypeID string `json:"$type,const=place.stream.chat.gate" cborgen:"$type,const=place.stream.chat.gate"` 17 + // hiddenMessage: URI of the hidden chat message. 18 + HiddenMessage string `json:"hiddenMessage" cborgen:"hiddenMessage"` 19 + }