Mirror — see github.com/blacksky-algorithms/blacksky.community
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #75 from blacksky-algorithms/feature/assembly-embed

Interactive Assembly conversation embeds with verified voting

authored by

ruuuuu.de and committed by
GitHub
bfe09598 d8f3e2ad

+919 -15
+661
src/components/Post/Embed/ExternalEmbed/AssemblyEmbed.tsx
··· 1 + import React, {useCallback, useEffect, useState} from 'react' 2 + import {Image, Linking, Pressable, StyleSheet, View} from 'react-native' 3 + import {type AppBskyEmbedExternal} from '@atproto/api' 4 + 5 + import {type EmbedPlayerParams} from '#/lib/strings/embed-player' 6 + import {useAgent, useSession} from '#/state/session' 7 + import {Logo as BlackskyLogo} from '#/view/icons/Logo' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {Text} from '#/components/Typography' 10 + 11 + const ASSEMBLY_API = 'https://assembly.blacksky.community/api/v3' 12 + 13 + interface Statement { 14 + tid: number 15 + txt: string 16 + remaining?: number 17 + author_name?: string 18 + author_avatar?: string 19 + author_is_blacksky_member?: boolean 20 + author_is_funder?: boolean 21 + author_is_team?: boolean 22 + author_is_oss_supporter?: boolean 23 + at_uri?: string 24 + at_cid?: string 25 + } 26 + 27 + interface ConversationMeta { 28 + conversation_id: string 29 + topic: string 30 + description?: string 31 + is_active: boolean 32 + auth_needed_to_vote: boolean 33 + at_uri?: string 34 + at_cid?: string 35 + } 36 + 37 + interface EmbedConversationResponse { 38 + conversation: ConversationMeta 39 + nextComment: Statement | null 40 + } 41 + 42 + interface ParticipationInitResponse { 43 + auth?: {token: string} 44 + nextComment?: Statement 45 + } 46 + 47 + interface VoteResponse { 48 + auth?: {token: string} 49 + nextComment?: Statement 50 + } 51 + 52 + function extractConversationId(uri: string): string { 53 + try { 54 + const url = new URL(uri) 55 + return url.pathname.slice(1) 56 + } catch { 57 + return '' 58 + } 59 + } 60 + 61 + export function AssemblyEmbed({ 62 + link, 63 + }: { 64 + link: AppBskyEmbedExternal.ViewExternal 65 + params: EmbedPlayerParams 66 + }) { 67 + const t = useTheme() 68 + const agent = useAgent() 69 + const {currentAccount} = useSession() 70 + const conversationId = extractConversationId(link.uri) 71 + 72 + const [data, setData] = useState<EmbedConversationResponse | null>(null) 73 + const [statement, setStatement] = useState<Statement | null>(null) 74 + const [voting, setVoting] = useState(false) 75 + const [error, setError] = useState<string | null>(null) 76 + const [allVoted, setAllVoted] = useState(false) 77 + const [notFound, setNotFound] = useState(false) 78 + 79 + const isAuthenticated = !!currentAccount?.did 80 + 81 + useEffect(() => { 82 + if (!conversationId) return 83 + 84 + const init = async () => { 85 + // 1. Fetch conversation data (public endpoint, CORS-safe) 86 + try { 87 + const convResp = await fetch( 88 + `${ASSEMBLY_API}/embed/conversation?conversation_id=${conversationId}`, 89 + ) 90 + if (!convResp.ok) { 91 + if (convResp.status === 400 || convResp.status === 404) { 92 + setNotFound(true) 93 + } 94 + return 95 + } 96 + const convData = (await convResp.json()) as EmbedConversationResponse 97 + setData(convData) 98 + setStatement(convData.nextComment) 99 + if (!convData.nextComment) setAllVoted(true) 100 + if (!convData.conversation.is_active) return 101 + } catch { 102 + setError('Failed to load conversation') 103 + return 104 + } 105 + 106 + // 2. If authenticated, try to get a participation JWT (separate try/catch — non-fatal) 107 + if (isAuthenticated && currentAccount?.did) { 108 + try { 109 + const xidParams = new URLSearchParams({ 110 + conversation_id: conversationId, 111 + includePCA: 'false', 112 + xid: currentAccount.did, 113 + }) 114 + 115 + const initResp = await fetch( 116 + `${ASSEMBLY_API}/participationInit?${xidParams.toString()}`, 117 + ) 118 + 119 + if (initResp.ok) { 120 + const initData = 121 + (await initResp.json()) as ParticipationInitResponse 122 + 123 + if (initData.auth?.token) { 124 + // JWT available but not needed — votes are verified via repo records 125 + } 126 + 127 + // Use personalized next comment if available (excludes already-voted) 128 + if (initData.nextComment) { 129 + setStatement(initData.nextComment) 130 + } 131 + } 132 + } catch { 133 + // participationInit failed (likely CORS) — continue with public data 134 + } 135 + } 136 + } 137 + 138 + void init() 139 + }, [conversationId, isAuthenticated, currentAccount?.did]) 140 + 141 + const handleVote = useCallback( 142 + async (value: -1 | 0 | 1) => { 143 + if (!statement || voting || !agent.session) return 144 + setVoting(true) 145 + setError(null) 146 + 147 + try { 148 + // 1. Create signed vote record in user's repo. 149 + // This IS the authentication — only the DID owner can create 150 + // records in their repo. The PDS validates the signing key. 151 + const createResult = await agent.com.atproto.repo.createRecord({ 152 + repo: agent.assertDid, 153 + collection: 'community.blacksky.assembly.vote', 154 + record: { 155 + $type: 'community.blacksky.assembly.vote', 156 + subject: { 157 + uri: statement.at_uri || '', 158 + cid: statement.at_cid || '', 159 + }, 160 + value, 161 + createdAt: new Date().toISOString(), 162 + }, 163 + }) 164 + 165 + // 2. Submit the AT URI to assembly's verified vote endpoint. 166 + // The server fetches the record from the user's PDS to verify 167 + // the DID owner actually created it — no impersonation possible. 168 + const voteResp = await fetch(`${ASSEMBLY_API}/embed/vote`, { 169 + method: 'POST', 170 + headers: {'Content-Type': 'application/json'}, 171 + body: JSON.stringify({ 172 + conversation_id: conversationId, 173 + tid: statement.tid, 174 + vote: value, 175 + vote_at_uri: createResult.data.uri, 176 + }), 177 + }) 178 + 179 + if (!voteResp.ok) { 180 + throw new Error('Vote submission failed') 181 + } 182 + 183 + const voteResult = (await voteResp.json()) as VoteResponse 184 + 185 + if (voteResult.nextComment) { 186 + setStatement(voteResult.nextComment) 187 + } else { 188 + setAllVoted(true) 189 + setStatement(null) 190 + } 191 + } catch { 192 + setError('Vote failed. Please try again.') 193 + } finally { 194 + setVoting(false) 195 + } 196 + }, 197 + [agent, statement, conversationId, voting], 198 + ) 199 + 200 + const onVote = useCallback( 201 + (value: -1 | 0 | 1) => { 202 + void handleVote(value) 203 + }, 204 + [handleVote], 205 + ) 206 + 207 + const openAssembly = useCallback(() => { 208 + void Linking.openURL( 209 + `https://assembly.blacksky.community/${conversationId}`, 210 + ) 211 + }, [conversationId]) 212 + 213 + if (notFound) { 214 + return ( 215 + <View 216 + style={[ 217 + styles.card, 218 + {backgroundColor: t.atoms.bg_contrast_25.backgroundColor}, 219 + ]}> 220 + <Text style={[a.text_sm, {color: t.atoms.text_contrast_medium.color}]}> 221 + This conversation is no longer available. 222 + </Text> 223 + </View> 224 + ) 225 + } 226 + 227 + if (error && !data) { 228 + return ( 229 + <View 230 + style={[ 231 + styles.card, 232 + {backgroundColor: t.atoms.bg_contrast_25.backgroundColor}, 233 + ]}> 234 + <Text style={[a.text_sm, {color: t.atoms.text_contrast_medium.color}]}> 235 + {error} 236 + </Text> 237 + </View> 238 + ) 239 + } 240 + 241 + if (!data) { 242 + return ( 243 + <View 244 + style={[ 245 + styles.card, 246 + {backgroundColor: t.atoms.bg_contrast_25.backgroundColor}, 247 + ]}> 248 + <View style={styles.logoContainer}> 249 + <BlackskyLogo width={20} fill={t.atoms.text.color} /> 250 + <Text style={{fontSize: 11, fontWeight: '600', color: '#8B8BFF'}}> 251 + People's Assembly 252 + </Text> 253 + </View> 254 + <Text 255 + style={[ 256 + a.text_sm, 257 + {color: t.atoms.text_contrast_medium.color, marginTop: 8}, 258 + ]}> 259 + Loading... 260 + </Text> 261 + </View> 262 + ) 263 + } 264 + 265 + if (!data.conversation.is_active) { 266 + return ( 267 + <View 268 + style={[ 269 + styles.card, 270 + {backgroundColor: t.atoms.bg_contrast_25.backgroundColor}, 271 + ]}> 272 + <AssemblyHeader 273 + topic={data.conversation.topic} 274 + description={data.conversation.description} 275 + /> 276 + <Text 277 + style={[ 278 + a.text_sm, 279 + {color: t.atoms.text_contrast_medium.color, marginTop: 8}, 280 + ]}> 281 + This conversation is closed. 282 + </Text> 283 + <AssemblyFooter onPress={openAssembly} /> 284 + </View> 285 + ) 286 + } 287 + 288 + if (data.conversation.auth_needed_to_vote && !isAuthenticated) { 289 + return ( 290 + <View 291 + style={[ 292 + styles.card, 293 + {backgroundColor: t.atoms.bg_contrast_25.backgroundColor}, 294 + ]}> 295 + <AssemblyHeader 296 + topic={data.conversation.topic} 297 + description={data.conversation.description} 298 + /> 299 + {statement ? ( 300 + <View style={styles.statementCard}> 301 + <Text style={[a.text_md, a.font_bold, {lineHeight: 22}]}> 302 + {statement.txt} 303 + </Text> 304 + </View> 305 + ) : null} 306 + <Pressable 307 + style={styles.signInButton} 308 + onPress={openAssembly} 309 + accessibilityRole="link" 310 + accessibilityLabel="Sign in to vote" 311 + accessibilityHint="Opens the assembly page to sign in and vote"> 312 + <Text style={[a.text_sm, a.font_semibold, {color: '#fff'}]}> 313 + Sign in to vote 314 + </Text> 315 + </Pressable> 316 + <AssemblyFooter onPress={openAssembly} /> 317 + </View> 318 + ) 319 + } 320 + 321 + return ( 322 + <View 323 + style={[ 324 + styles.card, 325 + {backgroundColor: t.atoms.bg_contrast_25.backgroundColor}, 326 + ]}> 327 + <AssemblyHeader 328 + topic={data.conversation.topic} 329 + description={data.conversation.description} 330 + /> 331 + 332 + {allVoted ? ( 333 + <View style={{marginTop: 12}}> 334 + <Text 335 + style={[a.text_sm, {color: t.atoms.text_contrast_medium.color}]}> 336 + You've voted on all statements. 337 + </Text> 338 + </View> 339 + ) : statement ? ( 340 + <> 341 + <View style={styles.statementCardStack}> 342 + <View style={styles.statementCard}> 343 + <View style={styles.statementAuthorRow}> 344 + {statement.author_avatar ? ( 345 + <Image 346 + source={{uri: statement.author_avatar}} 347 + style={styles.authorAvatar} 348 + accessibilityIgnoresInvertColors 349 + /> 350 + ) : ( 351 + <View 352 + style={[styles.authorAvatar, {backgroundColor: '#ddd'}]} 353 + /> 354 + )} 355 + <View style={{flex: 1}}> 356 + <Text style={[a.text_xs, {color: '#666'}]}> 357 + {statement.author_name || 'Anonymous'} wrote: 358 + </Text> 359 + {(statement.author_is_team || 360 + statement.author_is_blacksky_member || 361 + statement.author_is_funder || 362 + statement.author_is_oss_supporter) && ( 363 + <View style={styles.badgeRow}> 364 + {statement.author_is_team && ( 365 + <View style={[styles.badge, {backgroundColor: '#000'}]}> 366 + <Text style={[styles.badgeText, {color: '#fff'}]}> 367 + Admin 368 + </Text> 369 + </View> 370 + )} 371 + {statement.author_is_blacksky_member && ( 372 + <View 373 + style={[styles.badge, {backgroundColor: '#8B8BFF'}]}> 374 + <Text style={[styles.badgeText, {color: '#fff'}]}> 375 + Member 376 + </Text> 377 + </View> 378 + )} 379 + {statement.author_is_funder && ( 380 + <View 381 + style={[styles.badge, {backgroundColor: '#D2FC51'}]}> 382 + <Text style={[styles.badgeText, {color: '#000'}]}> 383 + Funder 384 + </Text> 385 + </View> 386 + )} 387 + {statement.author_is_oss_supporter && ( 388 + <View 389 + style={[styles.badge, {backgroundColor: '#FF6B35'}]}> 390 + <Text style={[styles.badgeText, {color: '#fff'}]}> 391 + OSS 392 + </Text> 393 + </View> 394 + )} 395 + </View> 396 + )} 397 + </View> 398 + </View> 399 + <Text 400 + style={[ 401 + a.text_md, 402 + a.font_bold, 403 + {lineHeight: 22, color: '#000'}, 404 + ]}> 405 + {statement.txt} 406 + </Text> 407 + {statement.remaining != null && statement.remaining > 0 && ( 408 + <Text 409 + style={[ 410 + a.text_xs, 411 + { 412 + color: '#999', 413 + marginTop: 6, 414 + textAlign: 'right', 415 + }, 416 + ]}> 417 + {statement.remaining > 100 ? '100+' : statement.remaining}{' '} 418 + remaining 419 + </Text> 420 + )} 421 + </View> 422 + {(statement.remaining ?? 0) > 1 && ( 423 + <View style={styles.stackLayer1} /> 424 + )} 425 + {(statement.remaining ?? 0) > 2 && ( 426 + <View style={styles.stackLayer2} /> 427 + )} 428 + </View> 429 + 430 + <View style={styles.voteButtons}> 431 + <Pressable 432 + style={({hovered}: {hovered?: boolean}) => [ 433 + styles.voteButton, 434 + { 435 + borderColor: '#61C554', 436 + backgroundColor: hovered 437 + ? 'rgba(97, 197, 84, 0.15)' 438 + : t.atoms.bg_contrast_25.backgroundColor, 439 + }, 440 + ]} 441 + onPress={() => onVote(-1)} 442 + disabled={voting} 443 + accessibilityRole="button" 444 + accessibilityLabel="Agree" 445 + accessibilityHint="Vote agree on this statement"> 446 + <Text style={[a.text_sm, a.font_semibold, {color: '#61C554'}]}> 447 + {voting ? '...' : 'Agree'} 448 + </Text> 449 + </Pressable> 450 + <Pressable 451 + style={({hovered}: {hovered?: boolean}) => [ 452 + styles.voteButton, 453 + { 454 + borderColor: '#F40B42', 455 + backgroundColor: hovered 456 + ? 'rgba(244, 11, 66, 0.12)' 457 + : t.atoms.bg_contrast_25.backgroundColor, 458 + }, 459 + ]} 460 + onPress={() => onVote(1)} 461 + disabled={voting} 462 + accessibilityRole="button" 463 + accessibilityLabel="Disagree" 464 + accessibilityHint="Vote disagree on this statement"> 465 + <Text style={[a.text_sm, a.font_semibold, {color: '#F40B42'}]}> 466 + {voting ? '...' : 'Disagree'} 467 + </Text> 468 + </Pressable> 469 + <Pressable 470 + style={({hovered}: {hovered?: boolean}) => [ 471 + styles.voteButton, 472 + { 473 + borderColor: t.atoms.border_contrast_low.borderColor, 474 + backgroundColor: hovered 475 + ? t.atoms.bg_contrast_50.backgroundColor 476 + : t.atoms.bg_contrast_25.backgroundColor, 477 + }, 478 + ]} 479 + onPress={() => onVote(0)} 480 + disabled={voting} 481 + accessibilityRole="button" 482 + accessibilityLabel="Pass" 483 + accessibilityHint="Pass on this statement"> 484 + <Text 485 + style={[ 486 + a.text_sm, 487 + a.font_semibold, 488 + {color: t.atoms.text_contrast_medium.color}, 489 + ]}> 490 + {voting ? '...' : 'Pass / Unsure'} 491 + </Text> 492 + </Pressable> 493 + </View> 494 + 495 + {error ? ( 496 + <Text style={[a.text_xs, {color: '#F40B42', marginTop: 6}]}> 497 + {error} 498 + </Text> 499 + ) : null} 500 + </> 501 + ) : null} 502 + 503 + <AssemblyFooter onPress={openAssembly} /> 504 + </View> 505 + ) 506 + } 507 + 508 + function AssemblyHeader({ 509 + topic, 510 + description, 511 + }: { 512 + topic: string 513 + description?: string 514 + }) { 515 + const t = useTheme() 516 + return ( 517 + <View style={styles.header}> 518 + <View style={styles.logoContainer}> 519 + <BlackskyLogo width={20} fill={t.atoms.text.color} /> 520 + <Text 521 + style={{fontSize: 11, fontWeight: '600', color: t.atoms.text.color}}> 522 + People's Assembly 523 + </Text> 524 + </View> 525 + <Text 526 + style={[{fontSize: 15, fontWeight: '700', marginTop: 6}]} 527 + numberOfLines={2}> 528 + {topic} 529 + </Text> 530 + {description ? ( 531 + <Text 532 + style={[ 533 + a.text_xs, 534 + {color: t.atoms.text_contrast_medium.color, marginTop: 4}, 535 + ]} 536 + numberOfLines={2}> 537 + {description} 538 + </Text> 539 + ) : null} 540 + </View> 541 + ) 542 + } 543 + 544 + function AssemblyFooter({onPress}: {onPress: () => void}) { 545 + return ( 546 + <View style={styles.footer}> 547 + <Pressable 548 + onPress={onPress} 549 + accessibilityRole="link" 550 + accessibilityLabel="Submit a statement" 551 + accessibilityHint="Opens the assembly conversation page"> 552 + <Text style={{fontSize: 12, color: '#8B8BFF'}}> 553 + Submit a statement → 554 + </Text> 555 + </Pressable> 556 + </View> 557 + ) 558 + } 559 + 560 + const styles = StyleSheet.create({ 561 + card: { 562 + borderRadius: 8, 563 + padding: 14, 564 + marginTop: 8, 565 + borderWidth: 1, 566 + borderColor: 'rgba(0,0,0,0.1)', 567 + overflow: 'hidden', 568 + }, 569 + header: { 570 + marginBottom: 10, 571 + }, 572 + logoContainer: { 573 + flexDirection: 'row', 574 + alignItems: 'center', 575 + gap: 6, 576 + }, 577 + statementCardStack: { 578 + marginTop: 8, 579 + marginBottom: 6, 580 + }, 581 + statementCard: { 582 + padding: 12, 583 + borderRadius: 5, 584 + borderWidth: 1, 585 + borderLeftWidth: 3, 586 + borderColor: 'lightgray', 587 + backgroundColor: '#fff', 588 + position: 'relative', 589 + zIndex: 3, 590 + }, 591 + stackLayer1: { 592 + height: 16, 593 + marginTop: -12, 594 + marginHorizontal: '0.5%', 595 + borderWidth: 1, 596 + borderTopWidth: 0, 597 + borderColor: 'lightgray', 598 + borderRadius: 5, 599 + backgroundColor: '#fff', 600 + zIndex: 2, 601 + }, 602 + stackLayer2: { 603 + height: 16, 604 + marginTop: -12, 605 + marginHorizontal: '1%', 606 + borderWidth: 1, 607 + borderTopWidth: 0, 608 + borderColor: '#eee', 609 + borderRadius: 5, 610 + backgroundColor: '#fafafa', 611 + zIndex: 1, 612 + }, 613 + voteButtons: { 614 + flexDirection: 'row', 615 + gap: 8, 616 + marginTop: 10, 617 + }, 618 + voteButton: { 619 + flex: 1, 620 + paddingVertical: 10, 621 + borderRadius: 8, 622 + borderWidth: 2, 623 + alignItems: 'center', 624 + }, 625 + statementAuthorRow: { 626 + flexDirection: 'row', 627 + alignItems: 'center', 628 + gap: 8, 629 + marginBottom: 8, 630 + }, 631 + authorAvatar: { 632 + width: 22, 633 + height: 22, 634 + borderRadius: 11, 635 + }, 636 + badgeRow: { 637 + flexDirection: 'row', 638 + gap: 3, 639 + marginTop: 2, 640 + }, 641 + badge: { 642 + paddingHorizontal: 4, 643 + paddingVertical: 1, 644 + borderRadius: 3, 645 + }, 646 + badgeText: { 647 + fontSize: 9, 648 + fontWeight: '600', 649 + }, 650 + signInButton: { 651 + backgroundColor: '#8B8BFF', 652 + paddingVertical: 12, 653 + borderRadius: 8, 654 + alignItems: 'center', 655 + marginTop: 12, 656 + }, 657 + footer: { 658 + marginTop: 10, 659 + alignItems: 'flex-end', 660 + }, 661 + })
+9
src/components/Post/Embed/ExternalEmbed/index.tsx
··· 17 17 import {Link} from '#/components/Link' 18 18 import {Text} from '#/components/Typography' 19 19 import {IS_NATIVE} from '#/env' 20 + import {AssemblyEmbed} from './AssemblyEmbed' 20 21 import {ExternalGif} from './ExternalGif' 21 22 import {ExternalPlayer} from './ExternalPlayer' 22 23 import {GifEmbed} from './Gif' ··· 58 59 shareUrl(link.uri) 59 60 } 60 61 }, [link.uri, playHaptic]) 62 + 63 + if (embedPlayerParams?.type === 'assembly_conversation') { 64 + return ( 65 + <View style={style}> 66 + <AssemblyEmbed link={link} params={embedPlayerParams} /> 67 + </View> 68 + ) 69 + } 61 70 62 71 if (embedPlayerParams?.source === 'tenor') { 63 72 const parsedAlt = parseAltFromGIFDescription(link.description)
+9 -1
src/components/TrendingTopics.tsx
··· 173 173 return React.useMemo(() => { 174 174 const {topic: displayName, link} = raw 175 175 176 - if (link.startsWith('/search')) { 176 + if (link.startsWith('/topic/')) { 177 + return { 178 + type: 'topic', 179 + label: _(msg`Browse posts about ${displayName}`), 180 + displayName, 181 + uri: undefined, 182 + url: link, 183 + } 184 + } else if (link.startsWith('/search')) { 177 185 return { 178 186 type: 'topic', 179 187 label: _(msg`Browse posts about ${displayName}`),
+20 -2
src/lib/strings/embed-player.ts
··· 10 10 ? 'http://localhost:8100' 11 11 : 'https://blacksky.community' 12 12 : __DEV__ && !process.env.JEST_WORKER_ID 13 - ? 'http://localhost:8100' 14 - : 'https://blacksky.community' 13 + ? 'http://localhost:8100' 14 + : 'https://blacksky.community' 15 15 16 16 export const embedPlayerSources = [ 17 17 'youtube', ··· 24 24 'giphy', 25 25 'tenor', 26 26 'flickr', 27 + 'assembly', 27 28 ] as const 28 29 29 30 export type EmbedPlayerSource = (typeof embedPlayerSources)[number] ··· 44 45 | 'giphy_gif' 45 46 | 'tenor_gif' 46 47 | 'flickr_album' 48 + | 'assembly_conversation' 47 49 48 50 export const externalEmbedLabels: Record<EmbedPlayerSource, string> = { 49 51 youtube: 'YouTube', ··· 56 58 appleMusic: 'Apple Music', 57 59 soundcloud: 'SoundCloud', 58 60 flickr: 'Flickr', 61 + assembly: "Blacksky People's Assembly", 59 62 } 60 63 61 64 export interface EmbedPlayerParams { ··· 459 462 return undefined 460 463 } 461 464 } 465 + 466 + // Assembly conversations 467 + if (urlp.hostname === 'assembly.blacksky.community') { 468 + const match = urlp.pathname.match(/^\/([0-9A-Za-z]{5,})$/) 469 + if (match) { 470 + return { 471 + type: 'assembly_conversation' as EmbedPlayerType, 472 + source: 'assembly' as EmbedPlayerSource, 473 + playerUri: `https://assembly.blacksky.community/${match[1]}`, 474 + hideDetails: false, 475 + } 476 + } 477 + } 462 478 } 463 479 464 480 export function getPlayerAspect({ ··· 498 514 return {height: 165} 499 515 case 'apple_music_song': 500 516 return {height: 150} 517 + case 'assembly_conversation': 518 + return {height: 320} 501 519 default: 502 520 return {aspectRatio: 16 / 9} 503 521 }
+8
src/screens/Search/modules/ExploreTrendingTopics.tsx
··· 221 221 return _(msg`Video Games`) 222 222 case 'pop-culture': 223 223 return _(msg`Entertainment`) 224 + case 'entertainment': 225 + return _(msg`Entertainment`) 226 + case 'culture': 227 + return _(msg`Culture`) 228 + case 'music': 229 + return _(msg`Music`) 230 + case 'community': 231 + return _(msg`Community`) 224 232 case 'news': 225 233 return _(msg`News`) 226 234 case 'other':
+204 -9
src/screens/Topic.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {useFocusEffect} from '@react-navigation/native' 7 7 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 8 + import { 9 + type InfiniteData, 10 + type QueryClient, 11 + useInfiniteQuery, 12 + } from '@tanstack/react-query' 8 13 9 14 import {HITSLOP_10} from '#/lib/constants' 10 15 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' ··· 14 19 import {cleanError} from '#/lib/strings/errors' 15 20 import {enforceLen} from '#/lib/strings/helpers' 16 21 import {useSearchPostsQuery} from '#/state/queries/search-posts' 22 + import {useAgent} from '#/state/session' 17 23 import {useSetMinimalShellMode} from '#/state/shell' 18 24 import {Pager} from '#/view/com/pager/Pager' 19 25 import {TabBar} from '#/view/com/pager/TabBar' ··· 25 31 import * as Layout from '#/components/Layout' 26 32 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 27 33 34 + const TOPICS_API = 'https://api.blacksky.community' 35 + const PAGE_SIZE = 25 36 + 28 37 const renderItem = ({item}: ListRenderItemInfo<AppBskyFeedDefs.PostView>) => { 29 38 return <Post post={item} /> 30 39 } ··· 36 45 export default function TopicScreen({ 37 46 route, 38 47 }: NativeStackScreenProps<CommonNavigatorParams, 'Topic'>) { 39 - const {topic} = route.params 48 + const {topic: topicParam} = route.params 40 49 const {_} = useLingui() 41 50 51 + const isTopicId = /^\d+$/.test(topicParam) 52 + 53 + const [topicName, setTopicName] = React.useState( 54 + isTopicId ? '' : decodeURIComponent(topicParam), 55 + ) 56 + 42 57 const headerTitle = React.useMemo(() => { 43 - return enforceLen(decodeURIComponent(topic), 24, true, 'middle') 44 - }, [topic]) 58 + return topicName ? enforceLen(topicName, 30, true, 'middle') : _(msg`Topic`) 59 + }, [topicName, _]) 45 60 46 61 const onShare = React.useCallback(() => { 47 62 const url = new URL('https://blacksky.community') 48 - url.pathname = `/topic/${topic}` 63 + url.pathname = `/topic/${topicParam}` 49 64 shareUrl(url.toString()) 50 - }, [topic]) 65 + }, [topicParam]) 51 66 52 - const [activeTab, setActiveTab] = React.useState(0) 53 67 const setMinimalShellMode = useSetMinimalShellMode() 54 68 55 69 useFocusEffect( ··· 58 72 }, [setMinimalShellMode]), 59 73 ) 60 74 75 + if (isTopicId) { 76 + return ( 77 + <Layout.Screen> 78 + <Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}> 79 + <Layout.Header.Outer> 80 + <Layout.Header.BackButton /> 81 + <Layout.Header.Content> 82 + <Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText> 83 + </Layout.Header.Content> 84 + <Layout.Header.Slot> 85 + <Button 86 + label={_(msg`Share`)} 87 + size="small" 88 + variant="ghost" 89 + color="primary" 90 + shape="round" 91 + onPress={onShare} 92 + hitSlop={HITSLOP_10} 93 + style={[{right: -3}]}> 94 + <ButtonIcon icon={Share} size="md" /> 95 + </Button> 96 + </Layout.Header.Slot> 97 + </Layout.Header.Outer> 98 + </Layout.Center> 99 + <CuratedTopicFeed topicId={topicParam} onTopicName={setTopicName} /> 100 + </Layout.Screen> 101 + ) 102 + } 103 + 104 + // Legacy: search-based topic view (non-numeric topic param) 105 + return <LegacyTopicScreen topicParam={topicParam} headerTitle={headerTitle} onShare={onShare} /> 106 + } 107 + 108 + function CuratedTopicFeed({ 109 + topicId, 110 + onTopicName, 111 + }: { 112 + topicId: string 113 + onTopicName: (name: string) => void 114 + }) { 115 + const {_} = useLingui() 116 + const initialNumToRender = useInitialNumToRender() 117 + const [isPTR, setIsPTR] = React.useState(false) 118 + const trackPostView = usePostViewTracking('Topic') 119 + const agent = useAgent() 120 + 121 + const { 122 + data, 123 + isLoading, 124 + isFetched, 125 + isError, 126 + error, 127 + refetch, 128 + fetchNextPage, 129 + hasNextPage, 130 + isFetchingNextPage, 131 + } = useInfiniteQuery({ 132 + queryKey: ['topic-feed', topicId], 133 + initialPageParam: undefined as string | undefined, 134 + queryFn: async ({pageParam}) => { 135 + const params = new URLSearchParams({topicId, limit: String(PAGE_SIZE)}) 136 + if (pageParam) params.set('cursor', pageParam) 137 + 138 + const res = await fetch( 139 + `${TOPICS_API}/xrpc/app.bsky.unspecced.getTopicFeed?${params}`, 140 + ) 141 + if (!res.ok) throw new Error(`getTopicFeed failed: ${res.status}`) 142 + const json = (await res.json()) as { 143 + posts: string[] 144 + topic: {name: string} 145 + cursor: string | null 146 + } 147 + 148 + if (json.topic?.name) { 149 + onTopicName(json.topic.name) 150 + } 151 + 152 + if (!json.posts?.length) { 153 + return {posts: [] as AppBskyFeedDefs.PostView[], cursor: null} 154 + } 155 + 156 + // Hydrate post URIs through the appview (max 25 per call) 157 + const hydrated = await agent.getPosts({uris: json.posts.slice(0, 25)}) 158 + return { 159 + posts: hydrated.data.posts, 160 + cursor: json.cursor, 161 + } 162 + }, 163 + getNextPageParam: lastPage => lastPage?.cursor ?? undefined, 164 + }) 165 + 166 + const posts = React.useMemo(() => { 167 + return data?.pages.flatMap(page => page.posts) ?? [] 168 + }, [data]) 169 + 170 + const onRefresh = React.useCallback(async () => { 171 + setIsPTR(true) 172 + await refetch() 173 + setIsPTR(false) 174 + }, [refetch]) 175 + 176 + const onEndReached = React.useCallback(() => { 177 + if (isFetchingNextPage || !hasNextPage || error) return 178 + fetchNextPage() 179 + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 180 + 181 + return ( 182 + <> 183 + {posts.length < 1 ? ( 184 + <ListMaybePlaceholder 185 + isLoading={isLoading || !isFetched} 186 + isError={isError} 187 + onRetry={refetch} 188 + emptyType="results" 189 + emptyMessage={_(msg`We couldn't find any results for that topic.`)} 190 + /> 191 + ) : ( 192 + <List 193 + data={posts} 194 + renderItem={renderItem} 195 + keyExtractor={keyExtractor} 196 + refreshing={isPTR} 197 + onRefresh={onRefresh} 198 + onEndReached={onEndReached} 199 + onEndReachedThreshold={4} 200 + onItemSeen={trackPostView} 201 + // @ts-ignore web only -prf 202 + desktopFixedHeight 203 + ListFooterComponent={ 204 + <ListFooter 205 + isFetchingNextPage={isFetchingNextPage} 206 + error={cleanError(error)} 207 + onRetry={fetchNextPage} 208 + /> 209 + } 210 + initialNumToRender={initialNumToRender} 211 + windowSize={11} 212 + /> 213 + )} 214 + </> 215 + ) 216 + } 217 + 218 + export function* findAllPostsInTopicQueryData( 219 + queryClient: QueryClient, 220 + uri: string, 221 + ): Generator<AppBskyFeedDefs.PostView, undefined> { 222 + type PageData = {posts: AppBskyFeedDefs.PostView[]; cursor: string | null} 223 + const queryDatas = queryClient.getQueriesData<InfiniteData<PageData>>({ 224 + queryKey: ['topic-feed'], 225 + }) 226 + for (const [_queryKey, queryData] of queryDatas) { 227 + if (!queryData?.pages) continue 228 + for (const page of queryData.pages) { 229 + for (const post of page.posts) { 230 + if (post.uri === uri) { 231 + yield post 232 + } 233 + } 234 + } 235 + } 236 + } 237 + 238 + // Legacy search-based topic view for non-numeric topic params 239 + function LegacyTopicScreen({ 240 + topicParam, 241 + headerTitle, 242 + onShare, 243 + }: { 244 + topicParam: string 245 + headerTitle: string 246 + onShare: () => void 247 + }) { 248 + const {_} = useLingui() 249 + const [activeTab, setActiveTab] = React.useState(0) 250 + const setMinimalShellMode = useSetMinimalShellMode() 251 + 61 252 const onPageSelected = React.useCallback( 62 253 (index: number) => { 63 254 setMinimalShellMode(false) ··· 71 262 { 72 263 title: _(msg`Top`), 73 264 component: ( 74 - <TopicScreenTab topic={topic} sort="top" active={activeTab === 0} /> 265 + <TopicScreenTab 266 + topic={topicParam} 267 + sort="top" 268 + active={activeTab === 0} 269 + /> 75 270 ), 76 271 }, 77 272 { 78 273 title: _(msg`Latest`), 79 274 component: ( 80 275 <TopicScreenTab 81 - topic={topic} 276 + topic={topicParam} 82 277 sort="latest" 83 278 active={activeTab === 1} 84 279 /> 85 280 ), 86 281 }, 87 282 ] 88 - }, [_, topic, activeTab]) 283 + }, [_, topicParam, activeTab]) 89 284 90 285 return ( 91 286 <Layout.Screen>
+4
src/state/cache/post-shadow.ts
··· 15 15 import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' 16 16 import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' 17 17 import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' 18 + import {findAllPostsInTopicQueryData} from '#/screens/Topic' 18 19 import {castAsShadow, type Shadow} from './types' 19 20 export type {Shadow} from './types' 20 21 ··· 192 193 yield post 193 194 } 194 195 for (let post of findAllPostsInBookmarksQueryData(queryClient, uri)) { 196 + yield post 197 + } 198 + for (let post of findAllPostsInTopicQueryData(queryClient, uri)) { 195 199 yield post 196 200 } 197 201 }
+1
src/state/persisted/schema.ts
··· 107 107 appleMusic: z.enum(externalEmbedOptions).optional(), 108 108 soundcloud: z.enum(externalEmbedOptions).optional(), 109 109 flickr: z.enum(externalEmbedOptions).optional(), 110 + assembly: z.enum(externalEmbedOptions).optional(), 110 111 }) 111 112 .optional(), 112 113 invites: z.object({
+1 -1
src/state/queries/trending/useGetTrendsQuery.ts
··· 14 14 15 15 export const createGetTrendsQueryKey = () => ['trends'] 16 16 17 - const PUBLIC_API = 'https://public.api.bsky.app' 17 + const PUBLIC_API = 'https://api.blacksky.community' 18 18 19 19 export function useGetTrendsQuery() { 20 20 const {data: preferences} = usePreferencesQuery()
+1 -1
src/state/queries/trending/useTrendingTopics.ts
··· 16 16 17 17 export const trendingTopicsQueryKey = ['trending-topics'] 18 18 19 - const PUBLIC_API = 'https://public.api.bsky.app' 19 + const PUBLIC_API = 'https://api.blacksky.community' 20 20 21 21 export function useTrendingTopics() { 22 22 const {data: preferences} = usePreferencesQuery()
+1 -1
src/view/shell/desktop/SidebarTrendingTopics.tsx
··· 17 17 import {Text} from '#/components/Typography' 18 18 import {useAnalytics} from '#/analytics' 19 19 20 - const TRENDING_LIMIT = 5 20 + const TRENDING_LIMIT = 7 21 21 22 22 export function SidebarTrendingTopics() { 23 23 const {enabled} = useTrendingConfig()