Skip to content

Commit bfe0959

Browse files
authored
Merge pull request #75 from blacksky-algorithms/feature/assembly-embed
Interactive Assembly conversation embeds with verified voting
2 parents d8f3e2a + adc9d90 commit bfe0959

11 files changed

Lines changed: 919 additions & 15 deletions

File tree

src/components/Post/Embed/ExternalEmbed/AssemblyEmbed.tsx

Lines changed: 661 additions & 0 deletions
Large diffs are not rendered by default.

src/components/Post/Embed/ExternalEmbed/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
1717
import {Link} from '#/components/Link'
1818
import {Text} from '#/components/Typography'
1919
import {IS_NATIVE} from '#/env'
20+
import {AssemblyEmbed} from './AssemblyEmbed'
2021
import {ExternalGif} from './ExternalGif'
2122
import {ExternalPlayer} from './ExternalPlayer'
2223
import {GifEmbed} from './Gif'
@@ -59,6 +60,14 @@ export const ExternalEmbed = ({
5960
}
6061
}, [link.uri, playHaptic])
6162

63+
if (embedPlayerParams?.type === 'assembly_conversation') {
64+
return (
65+
<View style={style}>
66+
<AssemblyEmbed link={link} params={embedPlayerParams} />
67+
</View>
68+
)
69+
}
70+
6271
if (embedPlayerParams?.source === 'tenor') {
6372
const parsedAlt = parseAltFromGIFDescription(link.description)
6473
return (

src/components/TrendingTopics.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,15 @@ export function useTopic(raw: TrendingTopic): ParsedTrendingTopic {
173173
return React.useMemo(() => {
174174
const {topic: displayName, link} = raw
175175

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')) {
177185
return {
178186
type: 'topic',
179187
label: _(msg`Browse posts about ${displayName}`),

src/lib/strings/embed-player.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ const IFRAME_HOST = IS_WEB
1010
? 'http://localhost:8100'
1111
: 'https://blacksky.community'
1212
: __DEV__ && !process.env.JEST_WORKER_ID
13-
? 'http://localhost:8100'
14-
: 'https://blacksky.community'
13+
? 'http://localhost:8100'
14+
: 'https://blacksky.community'
1515

1616
export const embedPlayerSources = [
1717
'youtube',
@@ -24,6 +24,7 @@ export const embedPlayerSources = [
2424
'giphy',
2525
'tenor',
2626
'flickr',
27+
'assembly',
2728
] as const
2829

2930
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
@@ -44,6 +45,7 @@ export type EmbedPlayerType =
4445
| 'giphy_gif'
4546
| 'tenor_gif'
4647
| 'flickr_album'
48+
| 'assembly_conversation'
4749

4850
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
4951
youtube: 'YouTube',
@@ -56,6 +58,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
5658
appleMusic: 'Apple Music',
5759
soundcloud: 'SoundCloud',
5860
flickr: 'Flickr',
61+
assembly: "Blacksky People's Assembly",
5962
}
6063

6164
export interface EmbedPlayerParams {
@@ -459,6 +462,19 @@ export function parseEmbedPlayerFromUrl(
459462
return undefined
460463
}
461464
}
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+
}
462478
}
463479

464480
export function getPlayerAspect({
@@ -498,6 +514,8 @@ export function getPlayerAspect({
498514
return {height: 165}
499515
case 'apple_music_song':
500516
return {height: 150}
517+
case 'assembly_conversation':
518+
return {height: 320}
501519
default:
502520
return {aspectRatio: 16 / 9}
503521
}

src/screens/Search/modules/ExploreTrendingTopics.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,14 @@ function useCategoryDisplayName(
221221
return _(msg`Video Games`)
222222
case 'pop-culture':
223223
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`)
224232
case 'news':
225233
return _(msg`News`)
226234
case 'other':

src/screens/Topic.tsx

Lines changed: 204 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import {msg} from '@lingui/macro'
55
import {useLingui} from '@lingui/react'
66
import {useFocusEffect} from '@react-navigation/native'
77
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
8+
import {
9+
type InfiniteData,
10+
type QueryClient,
11+
useInfiniteQuery,
12+
} from '@tanstack/react-query'
813

914
import {HITSLOP_10} from '#/lib/constants'
1015
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
@@ -14,6 +19,7 @@ import {shareUrl} from '#/lib/sharing'
1419
import {cleanError} from '#/lib/strings/errors'
1520
import {enforceLen} from '#/lib/strings/helpers'
1621
import {useSearchPostsQuery} from '#/state/queries/search-posts'
22+
import {useAgent} from '#/state/session'
1723
import {useSetMinimalShellMode} from '#/state/shell'
1824
import {Pager} from '#/view/com/pager/Pager'
1925
import {TabBar} from '#/view/com/pager/TabBar'
@@ -25,6 +31,9 @@ import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/componen
2531
import * as Layout from '#/components/Layout'
2632
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
2733

34+
const TOPICS_API = 'https://api.blacksky.community'
35+
const PAGE_SIZE = 25
36+
2837
const renderItem = ({item}: ListRenderItemInfo<AppBskyFeedDefs.PostView>) => {
2938
return <Post post={item} />
3039
}
@@ -36,20 +45,25 @@ const keyExtractor = (item: AppBskyFeedDefs.PostView, index: number) => {
3645
export default function TopicScreen({
3746
route,
3847
}: NativeStackScreenProps<CommonNavigatorParams, 'Topic'>) {
39-
const {topic} = route.params
48+
const {topic: topicParam} = route.params
4049
const {_} = useLingui()
4150

51+
const isTopicId = /^\d+$/.test(topicParam)
52+
53+
const [topicName, setTopicName] = React.useState(
54+
isTopicId ? '' : decodeURIComponent(topicParam),
55+
)
56+
4257
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, _])
4560

4661
const onShare = React.useCallback(() => {
4762
const url = new URL('https://blacksky.community')
48-
url.pathname = `/topic/${topic}`
63+
url.pathname = `/topic/${topicParam}`
4964
shareUrl(url.toString())
50-
}, [topic])
65+
}, [topicParam])
5166

52-
const [activeTab, setActiveTab] = React.useState(0)
5367
const setMinimalShellMode = useSetMinimalShellMode()
5468

5569
useFocusEffect(
@@ -58,6 +72,183 @@ export default function TopicScreen({
5872
}, [setMinimalShellMode]),
5973
)
6074

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+
61252
const onPageSelected = React.useCallback(
62253
(index: number) => {
63254
setMinimalShellMode(false)
@@ -71,21 +262,25 @@ export default function TopicScreen({
71262
{
72263
title: _(msg`Top`),
73264
component: (
74-
<TopicScreenTab topic={topic} sort="top" active={activeTab === 0} />
265+
<TopicScreenTab
266+
topic={topicParam}
267+
sort="top"
268+
active={activeTab === 0}
269+
/>
75270
),
76271
},
77272
{
78273
title: _(msg`Latest`),
79274
component: (
80275
<TopicScreenTab
81-
topic={topic}
276+
topic={topicParam}
82277
sort="latest"
83278
active={activeTab === 1}
84279
/>
85280
),
86281
},
87282
]
88-
}, [_, topic, activeTab])
283+
}, [_, topicParam, activeTab])
89284

90285
return (
91286
<Layout.Screen>

0 commit comments

Comments
 (0)