This repository has no description
0

Configure Feed

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

Merge pull request #250 from cosmik-network/feat/card-bluesky-post

Feat/card bluesky post

+987 -133
+12
package-lock.json
··· 23523 23523 "react": ">= 16.8 || 18.0.0" 23524 23524 } 23525 23525 }, 23526 + "node_modules/react-error-boundary": { 23527 + "version": "6.0.0", 23528 + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", 23529 + "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", 23530 + "dependencies": { 23531 + "@babel/runtime": "^7.12.5" 23532 + }, 23533 + "peerDependencies": { 23534 + "react": ">=16.13.1" 23535 + } 23536 + }, 23526 23537 "node_modules/react-error-overlay": { 23527 23538 "version": "6.0.9", 23528 23539 "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz", ··· 27725 27736 "next": "15.4.1", 27726 27737 "react": "19.1.0", 27727 27738 "react-dom": "19.1.0", 27739 + "react-error-boundary": "^6.0.0", 27728 27740 "react-icons": "^5.5.0" 27729 27741 }, 27730 27742 "devDependencies": {
+31 -22
src/webapp/components/contentDisplay/richTextRenderer/RichTextRenderer.tsx
··· 4 4 import Link from 'next/link'; 5 5 import { Anchor, AnchorProps, Text, TextProps } from '@mantine/core'; 6 6 import { getDomain } from '@/lib/utils/link'; 7 - import { Fragment } from 'react'; 8 7 9 8 interface Props { 10 9 text: string; 11 - linkProps?: Partial<AnchorProps>; // for mentions & links 12 - textProps?: Partial<TextProps>; // for plain text 10 + linkProps?: Partial<AnchorProps>; 11 + textProps?: Partial<TextProps>; 13 12 } 14 13 15 14 export default function RichTextRenderer({ ··· 21 20 richText.detectFacetsWithoutResolution(); 22 21 23 22 return ( 24 - <Fragment> 23 + <Text span {...textProps}> 25 24 {Array.from(richText.segments()).map((segment, i) => { 25 + // Mentions 26 26 if (segment.isMention()) { 27 27 return ( 28 28 <Anchor 29 - component={Link} 29 + key={`mention-${i}`} 30 + href={`/profile/${segment.text.slice(1)}`} 30 31 c={linkProps.c || 'blue'} 31 32 fw={linkProps.fw || 500} 32 - href={`/profile/${segment.text.slice(1)}`} 33 - key={segment.mention?.did || `mention-${i}`} 34 33 onClick={(e) => e.stopPropagation()} 35 34 {...linkProps} 36 35 > ··· 39 38 ); 40 39 } 41 40 41 + // Links 42 42 if (segment.isLink() && segment.link?.uri) { 43 43 return ( 44 44 <Anchor 45 + key={`link-${i}`} 46 + href={segment.link.uri} 47 + c={linkProps.c || 'blue'} 48 + fw={linkProps.fw || 500} 49 + target="_blank" 50 + onClick={(e) => e.stopPropagation()} 51 + {...linkProps} 52 + > 53 + {getDomain(segment.link.uri)} 54 + </Anchor> 55 + ); 56 + } 57 + 58 + // Hashtags 59 + if (segment.isTag()) { 60 + const encodedTag = encodeURIComponent(segment.tag?.tag || ''); 61 + return ( 62 + <Anchor 63 + key={`tag-${i}`} 45 64 component={Link} 46 65 c={linkProps.c || 'blue'} 47 66 fw={linkProps.fw || 500} 48 - href={segment.link.uri} 67 + href={`https://bsky.app/hashtag/${encodedTag}`} 49 68 target="_blank" 50 - key={segment.link.uri || `link-${i}`} 51 69 onClick={(e) => e.stopPropagation()} 52 70 {...linkProps} 53 71 > 54 - {getDomain(segment.link.uri)} 72 + {segment.text} 55 73 </Anchor> 56 74 ); 57 75 } 58 76 59 - return ( 60 - <Text 61 - key={`text-${i}`} 62 - span 63 - c={textProps.c} 64 - fw={textProps.fw || 400} 65 - {...textProps} 66 - > 67 - {segment.text} 68 - </Text> 69 - ); 77 + // Plain text 78 + return <span key={`text-${i}`}>{segment.text}</span>; 70 79 })} 71 - </Fragment> 80 + </Text> 72 81 ); 73 82 }
+70
src/webapp/features/cards/components/urlCardContent/LinkCardContent.tsx
··· 1 + import { getDomain } from '@/lib/utils/link'; 2 + import { 3 + Anchor, 4 + Group, 5 + Stack, 6 + Text, 7 + Tooltip, 8 + AspectRatio, 9 + Image, 10 + } from '@mantine/core'; 11 + import { UrlCard } from '@semble/types'; 12 + import Link from 'next/link'; 13 + 14 + interface Props { 15 + cardContent: UrlCard['cardContent']; 16 + } 17 + 18 + export default function LinkCardContent(props: Props) { 19 + const domain = getDomain(props.cardContent.url); 20 + 21 + return ( 22 + <Group justify="space-between" align="start" gap={'lg'}> 23 + <Stack gap={0} flex={1}> 24 + <Tooltip label={props.cardContent.url}> 25 + <Anchor 26 + onClick={(e) => e.stopPropagation()} 27 + component={Link} 28 + href={props.cardContent.url} 29 + target="_blank" 30 + c={'gray'} 31 + lineClamp={1} 32 + w={'fit-content'} 33 + > 34 + {domain} 35 + </Anchor> 36 + </Tooltip> 37 + {props.cardContent.title && ( 38 + <Anchor 39 + onClick={(e) => e.stopPropagation()} 40 + component={Link} 41 + href={props.cardContent.url} 42 + target="_blank" 43 + c={'bright'} 44 + lineClamp={2} 45 + fw={500} 46 + w={'fit-content'} 47 + > 48 + {props.cardContent.title} 49 + </Anchor> 50 + )} 51 + {props.cardContent.description && ( 52 + <Text c={'gray'} fz={'sm'} mt={'xs'} lineClamp={3}> 53 + {props.cardContent.description} 54 + </Text> 55 + )} 56 + </Stack> 57 + {props.cardContent.thumbnailUrl && ( 58 + <AspectRatio ratio={1 / 1}> 59 + <Image 60 + src={props.cardContent.thumbnailUrl} 61 + alt={`${props.cardContent.url} social preview image`} 62 + radius={'md'} 63 + w={75} 64 + h={75} 65 + /> 66 + </AspectRatio> 67 + )} 68 + </Group> 69 + ); 70 + }
+28
src/webapp/features/cards/components/urlCardContent/SembleCollectionCardContent.tsx
··· 1 + import { Group, Stack, Text } from '@mantine/core'; 2 + import { UrlCard } from '@semble/types'; 3 + 4 + interface Props { 5 + cardContent: UrlCard['cardContent']; 6 + } 7 + 8 + export default function SembleCollectionCardContent(props: Props) { 9 + return ( 10 + <Group justify="space-between" align="start" gap={'lg'}> 11 + <Stack gap={0} flex={1}> 12 + <Text c={'grape'} fw={500}> 13 + Collection 14 + </Text> 15 + {props.cardContent.title && ( 16 + <Text c={'bright'} lineClamp={2} fw={500} w={'fit-content'}> 17 + {props.cardContent.title} 18 + </Text> 19 + )} 20 + {props.cardContent.description && ( 21 + <Text c={'gray'} fz={'sm'} mt={'xs'} lineClamp={3}> 22 + {props.cardContent.description} 23 + </Text> 24 + )} 25 + </Stack> 26 + </Group> 27 + ); 28 + }
+27 -80
src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx
··· 1 - import { getDomain, isCollectionPage } from '@/lib/utils/link'; 2 - import { 3 - Anchor, 4 - AspectRatio, 5 - Group, 6 - Stack, 7 - Text, 8 - Image, 9 - Tooltip, 10 - } from '@mantine/core'; 1 + import { detectUrlPlatform, SupportedPlatform } from '@/lib/utils/link'; 11 2 import { UrlCard } from '@semble/types'; 12 - import Link from 'next/link'; 3 + import SembleCollectionCardContent from './SembleCollectionCardContent'; 4 + import LinkCardContent from './LinkCardContent'; 5 + import BlueskyPost from '@/features/platforms/bluesky/components/blueskyPost/BlueskyPost'; 6 + import { getPostUriFromUrl } from '@/lib/utils/atproto'; 7 + import { Suspense } from 'react'; 8 + import { ErrorBoundary } from 'react-error-boundary'; 9 + import BlueskyPostSkeleton from '@/features/platforms/bluesky/components/blueskyPost/Skeleton.BlueskyPost'; 13 10 14 11 interface Props { 15 12 url: string; ··· 17 14 } 18 15 19 16 export default function UrlCardContent(props: Props) { 20 - const domain = getDomain(props.url); 17 + const platform = detectUrlPlatform(props.url); 18 + 19 + if (platform === SupportedPlatform.SEMBLE_COLLECTION) { 20 + return <SembleCollectionCardContent cardContent={props.cardContent} />; 21 + } 21 22 22 - // semble collection 23 - if (isCollectionPage(props.url)) { 23 + if (platform === SupportedPlatform.BLUESKY_POST) { 24 24 return ( 25 - <Group justify="space-between" align="start" gap={'lg'}> 26 - <Stack gap={0} flex={1}> 27 - <Text c={'grape'} fw={500}> 28 - Collection 29 - </Text> 30 - {props.cardContent.title && ( 31 - <Text c={'bright'} lineClamp={2} fw={500} w={'fit-content'}> 32 - {props.cardContent.title} 33 - </Text> 34 - )} 35 - {props.cardContent.description && ( 36 - <Text c={'gray'} fz={'sm'} mt={'xs'} lineClamp={3}> 37 - {props.cardContent.description} 38 - </Text> 39 - )} 40 - </Stack> 41 - </Group> 25 + <ErrorBoundary 26 + fallback={<LinkCardContent cardContent={props.cardContent} />} 27 + > 28 + <Suspense fallback={<BlueskyPostSkeleton />}> 29 + <BlueskyPost 30 + uri={getPostUriFromUrl(props.url)} 31 + fallbackCardContent={ 32 + <LinkCardContent cardContent={props.cardContent} /> 33 + } 34 + /> 35 + </Suspense> 36 + </ErrorBoundary> 42 37 ); 43 38 } 44 39 45 - return ( 46 - <Group justify="space-between" align="start" gap={'lg'}> 47 - <Stack gap={0} flex={1}> 48 - <Tooltip label={props.url}> 49 - <Anchor 50 - onClick={(e) => e.stopPropagation()} 51 - component={Link} 52 - href={props.url} 53 - target="_blank" 54 - c={'gray'} 55 - lineClamp={1} 56 - w={'fit-content'} 57 - > 58 - {domain} 59 - </Anchor> 60 - </Tooltip> 61 - {props.cardContent.title && ( 62 - <Anchor 63 - onClick={(e) => e.stopPropagation()} 64 - component={Link} 65 - href={props.url} 66 - target="_blank" 67 - c={'bright'} 68 - lineClamp={2} 69 - fw={500} 70 - w={'fit-content'} 71 - > 72 - {props.cardContent.title} 73 - </Anchor> 74 - )} 75 - {props.cardContent.description && ( 76 - <Text c={'gray'} fz={'sm'} mt={'xs'} lineClamp={3}> 77 - {props.cardContent.description} 78 - </Text> 79 - )} 80 - </Stack> 81 - {props.cardContent.thumbnailUrl && ( 82 - <AspectRatio ratio={1 / 1}> 83 - <Image 84 - src={props.cardContent.thumbnailUrl} 85 - alt={`${props.url} social preview image`} 86 - radius={'md'} 87 - w={75} 88 - h={75} 89 - /> 90 - </AspectRatio> 91 - )} 92 - </Group> 93 - ); 40 + return <LinkCardContent cardContent={props.cardContent} />; 94 41 }
+6 -2
src/webapp/features/cards/containers/cardsContainer/Error.CardsContainer.tsx
··· 1 - import { Alert } from '@mantine/core'; 1 + import { Alert, Container } from '@mantine/core'; 2 2 3 3 export default function CardsContainerError() { 4 - return <Alert color="red" title="Could not load cards" />; 4 + return ( 5 + <Container p="xs" size="xl"> 6 + <Alert color="red" title="Could not load cards" /> 7 + </Container> 8 + ); 5 9 }
+56
src/webapp/features/platforms/bluesky/components/blueskyPost/BlueskyPost.tsx
··· 1 + import { AppBskyFeedDefs, AppBskyFeedPost } from '@atproto/api'; 2 + import { ReactElement } from 'react'; 3 + import { Group, Stack, Text, Avatar, Box } from '@mantine/core'; 4 + import RichTextRenderer from '@/components/contentDisplay/richTextRenderer/RichTextRenderer'; 5 + import useGetBlueskyPost from '../../lib/queries/useGetBlueskyPost'; 6 + import PostEmbed from '../postEmbed/PostEmbed'; 7 + import { FaBluesky } from 'react-icons/fa6'; 8 + 9 + interface Props { 10 + uri: string; 11 + fallbackCardContent: ReactElement; 12 + } 13 + 14 + export default function BlueskyPost(props: Props) { 15 + const { data, error } = useGetBlueskyPost({ uri: props.uri }); 16 + 17 + if ( 18 + !data.thread || 19 + !AppBskyFeedDefs.isThreadViewPost(data.thread) || 20 + AppBskyFeedDefs.isNotFoundPost(data.thread.post) || 21 + AppBskyFeedDefs.isBlockedPost(data.thread.post) || 22 + error 23 + ) { 24 + return props.fallbackCardContent; 25 + } 26 + 27 + const post = data.thread.post; 28 + const record = post.record as AppBskyFeedPost.Record; 29 + 30 + return ( 31 + <Stack justify="space-between" gap="xs"> 32 + <Group gap="xs" justify="space-between" wrap="nowrap" w={'100%'}> 33 + <Group gap={'xs'} wrap="nowrap"> 34 + <Avatar 35 + src={post.author.avatar} 36 + alt={`${post.author.handle} avatar`} 37 + size={'sm'} 38 + radius="xl" 39 + /> 40 + 41 + <Text c="bright" lineClamp={1} fw={500}> 42 + {post.author.displayName || post.author.handle} 43 + </Text> 44 + </Group> 45 + 46 + <FaBluesky fill="#0085ff" size={18} /> 47 + </Group> 48 + <Stack gap={'xs'} w={'100%'}> 49 + <Box> 50 + <RichTextRenderer text={record.text} textProps={{ lineClamp: 3 }} /> 51 + </Box> 52 + {post.embed && <PostEmbed embed={post.embed} mode="card" />} 53 + </Stack> 54 + </Stack> 55 + ); 56 + }
+22
src/webapp/features/platforms/bluesky/components/blueskyPost/Skeleton.BlueskyPost.tsx
··· 1 + import { Stack, Group, Avatar, Skeleton } from '@mantine/core'; 2 + import { FaBluesky } from 'react-icons/fa6'; 3 + 4 + export default function BlueskyPostSkeleton() { 5 + return ( 6 + <Stack justify="space-between" gap="xs"> 7 + <Group gap="xs" justify="space-between" wrap="nowrap" w={'100%'}> 8 + <Group gap={'xs'} wrap="nowrap" w={'100%'}> 9 + <Avatar size={'sm'} radius="xl" /> 10 + <Skeleton w={'70%'} h={14} /> 11 + </Group> 12 + 13 + <FaBluesky size={18} fill="#0085ff" /> 14 + </Group> 15 + <Stack w={'100%'} gap={5}> 16 + <Skeleton w={'100%'} h={10} /> 17 + <Skeleton w={'100%'} h={10} /> 18 + <Skeleton w={'100%'} h={10} /> 19 + </Stack> 20 + </Stack> 21 + ); 22 + }
+77
src/webapp/features/platforms/bluesky/components/externalEmbed/ExternalEmbed.tsx
··· 1 + import { getDomain } from '@/lib/utils/link'; 2 + import { AppBskyEmbedExternal } from '@atproto/api'; 3 + import { 4 + AspectRatio, 5 + Card, 6 + CardSection, 7 + Group, 8 + Image, 9 + Stack, 10 + Text, 11 + } from '@mantine/core'; 12 + import type { EmbedMode } from '../../types'; 13 + 14 + interface Props { 15 + embed: AppBskyEmbedExternal.View; 16 + mode?: EmbedMode; 17 + } 18 + 19 + export default function ExternalEmbed(props: Props) { 20 + if (props.mode === 'card') { 21 + return ( 22 + <Card p={'xs'} withBorder> 23 + <Group gap={'xs'} wrap="nowrap"> 24 + {props.embed.external.thumb && ( 25 + <AspectRatio ratio={1 / 1}> 26 + <Image 27 + src={props.embed.external.thumb} 28 + alt={props.embed.external.description} 29 + h={50} 30 + w={50} 31 + radius={'sm'} 32 + /> 33 + </AspectRatio> 34 + )} 35 + 36 + <Stack gap={0}> 37 + <Text fz={'sm'} fw={500} c={'bright'} lineClamp={1}> 38 + {props.embed.external.title} 39 + </Text> 40 + <Text fz={'sm'} fw={500} c={'gray'} lineClamp={1}> 41 + {getDomain(props.embed.external.uri)} 42 + </Text> 43 + </Stack> 44 + </Group> 45 + </Card> 46 + ); 47 + } 48 + return ( 49 + <Card 50 + p={0} 51 + component="a" 52 + href={props.embed.external.uri} 53 + target="_blank" 54 + withBorder 55 + > 56 + <CardSection> 57 + {props.embed.external.thumb && ( 58 + <AspectRatio ratio={16 / 9}> 59 + <Image 60 + src={props.embed.external.thumb} 61 + alt={props.embed.external.description} 62 + /> 63 + </AspectRatio> 64 + )} 65 + </CardSection> 66 + 67 + <Stack gap={0} p={'xs'}> 68 + <Text fz={'sm'} fw={500} c={'bright'} lineClamp={1}> 69 + {props.embed.external.title} 70 + </Text> 71 + <Text fz={'sm'} fw={500} c={'gray'} lineClamp={1}> 72 + {getDomain(props.embed.external.uri)} 73 + </Text> 74 + </Stack> 75 + </Card> 76 + ); 77 + }
+57
src/webapp/features/platforms/bluesky/components/feedEmbed/FeedEmbed.tsx
··· 1 + import { AppBskyFeedDefs } from '@atproto/api'; 2 + import { Avatar, Card, Group, Stack, Text } from '@mantine/core'; 3 + import type { EmbedMode } from '../../types'; 4 + import { getFeedLink } from '../../lib/utils/link'; 5 + 6 + interface Props { 7 + feed: AppBskyFeedDefs.GeneratorView; 8 + mode?: EmbedMode; 9 + } 10 + 11 + export default function FeedEmbed(props: Props) { 12 + if (props.mode === 'card') { 13 + return ( 14 + <Card p={'xs'} withBorder> 15 + <Group gap={'xs'} wrap="nowrap"> 16 + {props.feed.avatar && ( 17 + <Avatar src={props.feed.avatar} alt={props.feed.displayName} /> 18 + )} 19 + 20 + <Stack gap={0}> 21 + <Text fz={'sm'} fw={500} c={'bright'} lineClamp={1}> 22 + {props.feed.displayName} 23 + </Text> 24 + <Text fz={'sm'} fw={500} c={'gray'} lineClamp={1} span> 25 + Feed by @{props.feed.creator.handle} 26 + </Text> 27 + </Stack> 28 + </Group> 29 + </Card> 30 + ); 31 + } 32 + 33 + return ( 34 + <Card 35 + p={'xs'} 36 + component="a" 37 + href={getFeedLink(props.feed)} 38 + target="_blank" 39 + withBorder 40 + > 41 + <Group gap={'xs'} wrap="nowrap"> 42 + {props.feed.avatar && ( 43 + <Avatar src={props.feed.avatar} alt={props.feed.displayName} /> 44 + )} 45 + 46 + <Stack gap={0}> 47 + <Text fz={'sm'} fw={500} c={'bright'} lineClamp={1}> 48 + {props.feed.displayName} 49 + </Text> 50 + <Text fz={'sm'} fw={500} c={'gray'} lineClamp={1} span> 51 + Feed by @{props.feed.creator.handle} 52 + </Text> 53 + </Stack> 54 + </Group> 55 + </Card> 56 + ); 57 + }
+69
src/webapp/features/platforms/bluesky/components/imageEmbed/ImageEmbed.tsx
··· 1 + import { AppBskyEmbedImages } from '@atproto/api'; 2 + import { AspectRatio, SimpleGrid, Image, Anchor } from '@mantine/core'; 3 + import type { EmbedMode } from '../../types'; 4 + 5 + interface Props { 6 + images: AppBskyEmbedImages.ViewImage[]; 7 + mode?: EmbedMode; 8 + } 9 + 10 + export default function ImageEmbed(props: Props) { 11 + if (props.mode === 'card') { 12 + return ( 13 + <SimpleGrid cols={props.images.length > 1 ? 2 : 1} spacing="xs"> 14 + {props.images.map((img, i) => { 15 + const ratio = 16 + props.images.length === 1 17 + ? img?.aspectRatio 18 + ? img.aspectRatio.width / img.aspectRatio.height 19 + : 16 / 9 20 + : img?.aspectRatio 21 + ? img.aspectRatio.width / img.aspectRatio.height 22 + : 1 / 1; 23 + 24 + return ( 25 + <AspectRatio ratio={ratio} key={i}> 26 + <Image 27 + src={img.thumb} 28 + alt={img.alt} 29 + radius="sm" 30 + h={'100%'} 31 + w={'100%'} 32 + mah={props.images.length === 1 ? 120 : 150} 33 + /> 34 + </AspectRatio> 35 + ); 36 + })} 37 + </SimpleGrid> 38 + ); 39 + } 40 + 41 + return ( 42 + <SimpleGrid cols={props.images.length > 1 ? 2 : 1} spacing="xs"> 43 + {props.images.map((img, i) => { 44 + const ratio = 45 + props.images.length === 1 46 + ? img?.aspectRatio 47 + ? img.aspectRatio.width / img.aspectRatio.height 48 + : 16 / 9 49 + : img?.aspectRatio 50 + ? img.aspectRatio.width / img.aspectRatio.height 51 + : 1 / 1; 52 + 53 + return ( 54 + <AspectRatio ratio={ratio} key={i}> 55 + <Anchor href={img.fullsize} target="_blank"> 56 + <Image 57 + src={img.thumb} 58 + alt={img.alt} 59 + radius="sm" 60 + h={'100%'} 61 + w={'100%'} 62 + /> 63 + </Anchor> 64 + </AspectRatio> 65 + ); 66 + })} 67 + </SimpleGrid> 68 + ); 69 + }
+48
src/webapp/features/platforms/bluesky/components/listEmbed/ListEmbed.tsx
··· 1 + import { AppBskyGraphDefs } from '@atproto/api'; 2 + import { BsFillPeopleFill } from 'react-icons/bs'; 3 + import { Card, Group, Text } from '@mantine/core'; 4 + import type { EmbedMode } from '../../types'; 5 + import { getListLink } from '../../lib/utils/link'; 6 + 7 + interface Props { 8 + list: AppBskyGraphDefs.ListView; 9 + mode?: EmbedMode; 10 + } 11 + 12 + export default function ListEmbed(props: Props) { 13 + if (props.mode === 'card') { 14 + return ( 15 + <Card p={'xs'} withBorder> 16 + <Group gap={'xs'}> 17 + <BsFillPeopleFill /> 18 + <Text fz={'sm'} fw={500} c={'bright'} lineClamp={1}> 19 + {props.list.name} 20 + </Text> 21 + </Group> 22 + <Text fz={'sm'} fw={500} c={'gray'} lineClamp={1} span> 23 + @{props.list.creator.handle} 24 + </Text> 25 + </Card> 26 + ); 27 + } 28 + 29 + return ( 30 + <Card 31 + p={'xs'} 32 + component="a" 33 + href={getListLink(props.list)} 34 + target="_blank" 35 + withBorder 36 + > 37 + <Group gap={'xs'}> 38 + <BsFillPeopleFill /> 39 + <Text fz={'sm'} fw={500} c={'bright'} lineClamp={1}> 40 + {props.list.name} 41 + </Text> 42 + </Group> 43 + <Text fz={'sm'} fw={500} c={'gray'} lineClamp={1} span> 44 + @{props.list.creator.handle} 45 + </Text> 46 + </Card> 47 + ); 48 + }
+67
src/webapp/features/platforms/bluesky/components/postEmbed/PostEmbed.tsx
··· 1 + import { 2 + AppBskyEmbedExternal, 3 + AppBskyEmbedImages, 4 + AppBskyEmbedRecord, 5 + AppBskyEmbedRecordWithMedia, 6 + AppBskyEmbedVideo, 7 + AppBskyFeedDefs, 8 + AppBskyGraphDefs, 9 + } from '@atproto/api'; 10 + 11 + import ImageEmbed from '../imageEmbed/ImageEmbed'; 12 + import ExternalEmbed from '../externalEmbed/ExternalEmbed'; 13 + import VideoEmbed from '../videoEmbed/VideoEmbed'; 14 + import ListEmbed from '../listEmbed/ListEmbed'; 15 + import StarterPackEmbed from '../starterPackEmbed/StarterPackEmbed'; 16 + import FeedEmbed from '../feedEmbed/FeedEmbed'; 17 + import RecordEmbed from '../recordEmbed/RecordEmbed'; 18 + import type { EmbedMode } from '../../types'; 19 + 20 + interface Props { 21 + embed: AppBskyFeedDefs.PostView['embed']; 22 + mode?: EmbedMode; 23 + } 24 + 25 + export default function PostEmbed(props: Props) { 26 + switch (true) { 27 + case AppBskyEmbedImages.isView(props.embed): 28 + return <ImageEmbed images={props.embed.images} mode={props.mode} />; 29 + 30 + case AppBskyEmbedExternal.isView(props.embed): 31 + return <ExternalEmbed embed={props.embed} mode={props.mode} />; 32 + 33 + case AppBskyEmbedVideo.isView(props.embed): 34 + return <VideoEmbed embed={props.embed} />; 35 + 36 + // check for Record first before accessing embed.record 37 + case AppBskyEmbedRecord.isView(props.embed): { 38 + const record = props.embed.record; 39 + 40 + if (AppBskyGraphDefs.isStarterPackViewBasic(record)) { 41 + return <StarterPackEmbed embed={record} mode={props.mode} />; 42 + } 43 + 44 + if (AppBskyFeedDefs.isGeneratorView(record)) { 45 + return <FeedEmbed feed={record} mode={props.mode} />; 46 + } 47 + 48 + if (AppBskyGraphDefs.isListView(record)) { 49 + return <ListEmbed list={record} mode={props.mode} />; 50 + } 51 + 52 + return <RecordEmbed embed={record} mode={props.mode} />; 53 + } 54 + 55 + case AppBskyEmbedRecordWithMedia.isView(props.embed): 56 + return ( 57 + <RecordEmbed 58 + embed={props.embed.record.record} 59 + media={props.embed.media} 60 + mode={props.mode} 61 + /> 62 + ); 63 + 64 + default: 65 + return null; 66 + } 67 + }
+61
src/webapp/features/platforms/bluesky/components/recordEmbed/RecordEmbed.tsx
··· 1 + import RichTextRenderer from '@/components/contentDisplay/richTextRenderer/RichTextRenderer'; 2 + import { 3 + AppBskyEmbedRecord, 4 + AppBskyEmbedRecordWithMedia, 5 + AppBskyFeedPost, 6 + } from '@atproto/api'; 7 + import { Stack, Group, Avatar, Box, Text, Card } from '@mantine/core'; 8 + import PostEmbed from '../postEmbed/PostEmbed'; 9 + import type { EmbedMode } from '../../types'; 10 + 11 + interface Props { 12 + embed: AppBskyEmbedRecord.View['record']; 13 + media?: AppBskyEmbedRecordWithMedia.View['media']; 14 + mode?: EmbedMode; 15 + } 16 + 17 + export default function RecordEmbed(props: Props) { 18 + if ( 19 + AppBskyEmbedRecord.isViewBlocked(props.embed) || 20 + AppBskyEmbedRecord.isViewNotFound(props.embed) || 21 + !AppBskyEmbedRecord.isViewRecord(props.embed) 22 + ) { 23 + return null; 24 + } 25 + 26 + const post = props.embed; 27 + 28 + return ( 29 + <Stack gap={'xs'}> 30 + {props.media && <PostEmbed embed={props.media} mode={props.mode} />} 31 + <Card p={'sm'} flex={1} h={'100%'} withBorder> 32 + <Stack justify="space-between" align="start" gap="xs"> 33 + <Group gap="xs" wrap="nowrap"> 34 + <Avatar 35 + src={post.author.avatar} 36 + alt={`${post.author.handle} avatar`} 37 + radius="xl" 38 + size={'sm'} 39 + /> 40 + 41 + <Text c="bright" lineClamp={1} fw={500} w="fit-content"> 42 + {post.author.displayName || post.author.handle} 43 + </Text> 44 + </Group> 45 + <Stack gap={'xs'}> 46 + <Box> 47 + <RichTextRenderer 48 + text={(post.value as AppBskyFeedPost.Record).text} 49 + textProps={{ lineClamp: 1 }} 50 + /> 51 + </Box> 52 + {/* don't show quoted post's embed on card */} 53 + {post.embeds && props.mode !== 'card' && ( 54 + <PostEmbed embed={post.embeds[0]} mode={props.mode} /> 55 + )} 56 + </Stack> 57 + </Stack> 58 + </Card> 59 + </Stack> 60 + ); 61 + }
+72
src/webapp/features/platforms/bluesky/components/starterPackEmbed/StarterPackEmbed.tsx
··· 1 + import { AppBskyGraphDefs, AppBskyGraphStarterpack } from '@atproto/api'; 2 + import { 3 + AspectRatio, 4 + Box, 5 + Card, 6 + CardSection, 7 + Group, 8 + Image, 9 + Stack, 10 + Text, 11 + } from '@mantine/core'; 12 + import { getStarterPackImage, getStarterPackLink } from '../../lib/utils/link'; 13 + import type { EmbedMode } from '../../types'; 14 + 15 + interface Props { 16 + embed: AppBskyGraphDefs.StarterPackViewBasic; 17 + mode?: EmbedMode; 18 + } 19 + 20 + export default function StarterPackEmbed(props: Props) { 21 + if (!AppBskyGraphStarterpack.isRecord(props.embed.record)) { 22 + return null; 23 + } 24 + 25 + const image = getStarterPackImage(props.embed); 26 + 27 + if (props.mode === 'card') { 28 + return ( 29 + <Card p={'xs'} withBorder> 30 + <Group gap={'xs'} wrap="nowrap"> 31 + {image && ( 32 + <AspectRatio ratio={1 / 1}> 33 + <Image src={image} radius={'sm'} w={50} h={50} /> 34 + </AspectRatio> 35 + )} 36 + <Stack gap={0}> 37 + <Text fz={'sm'} fw={500} c={'bright'} lineClamp={1}> 38 + Starter pack 39 + </Text> 40 + <Text fz={'sm'} fw={500} c={'gray'} lineClamp={1} span> 41 + By @{props.embed.creator.handle} 42 + </Text> 43 + </Stack> 44 + </Group> 45 + </Card> 46 + ); 47 + } 48 + 49 + return ( 50 + <Card 51 + p={0} 52 + component="a" 53 + href={getStarterPackLink(props.embed)} 54 + target="_blank" 55 + withBorder 56 + > 57 + {image && ( 58 + <CardSection> 59 + <Image src={image} /> 60 + </CardSection> 61 + )} 62 + <Box p={'xs'}> 63 + <Text fz={'sm'} fw={500} c={'bright'} lineClamp={1}> 64 + Starter pack 65 + </Text> 66 + <Text fz={'sm'} fw={500} c={'gray'} lineClamp={1} span> 67 + By @{props.embed.creator.handle} 68 + </Text> 69 + </Box> 70 + </Card> 71 + ); 72 + }
+14
src/webapp/features/platforms/bluesky/components/videoEmbed/VideoEmbed.tsx
··· 1 + import { AppBskyEmbedVideo } from '@atproto/api'; 2 + import { AspectRatio, Image } from '@mantine/core'; 3 + 4 + interface Props { 5 + embed: AppBskyEmbedVideo.View; 6 + } 7 + 8 + export default function VideoEmbed(props: Props) { 9 + return ( 10 + <AspectRatio ratio={16 / 9}> 11 + <Image src={props.embed.thumbnail} alt={props.embed.alt} radius={'sm'} /> 12 + </AspectRatio> 13 + ); 14 + }
+116
src/webapp/features/platforms/bluesky/container/blueskySembleHeader/BlueskySembleHeader.tsx
··· 1 + import GuestSembleActions from '@/features/semble/components/sembleActions/GusetSembleActions'; 2 + import SembleActions from '@/features/semble/components/sembleActions/SembleActions'; 3 + import UrlAddedBySummary from '@/features/semble/components/urlAddedBySummary/UrlAddedBySummary'; 4 + import { getDomain } from '@/lib/utils/link'; 5 + import { 6 + Stack, 7 + Tooltip, 8 + Anchor, 9 + Text, 10 + Avatar, 11 + Group, 12 + Box, 13 + } from '@mantine/core'; 14 + import Link from 'next/link'; 15 + import { getBlueskyPost } from '../../lib/dal'; 16 + import { getPostUriFromUrl } from '@/lib/utils/atproto'; 17 + import { verifySessionOnServer } from '@/lib/auth/dal.server'; 18 + import { AppBskyFeedDefs, AppBskyFeedPost } from '@atproto/api'; 19 + import RichTextRenderer from '@/components/contentDisplay/richTextRenderer/RichTextRenderer'; 20 + import PostEmbed from '../../components/postEmbed/PostEmbed'; 21 + import { FaBluesky } from 'react-icons/fa6'; 22 + import SembleHeader from '@/features/semble/components/SembleHeader/SembleHeader'; 23 + import { getFormattedDate } from '@/lib/utils/time'; 24 + 25 + interface Props { 26 + url: string; 27 + } 28 + 29 + export default async function BlueskySembleHeader(props: Props) { 30 + const postUri = getPostUriFromUrl(props.url); 31 + const data = await getBlueskyPost(postUri); 32 + const session = await verifySessionOnServer(); 33 + 34 + if ( 35 + !data.thread || 36 + !AppBskyFeedDefs.isThreadViewPost(data.thread) || 37 + AppBskyFeedDefs.isNotFoundPost(data.thread.post) || 38 + AppBskyFeedDefs.isBlockedPost(data.thread.post) 39 + ) { 40 + // fallback 41 + return <SembleHeader url={props.url} />; 42 + } 43 + 44 + const post = data.thread.post; 45 + const record = post.record as AppBskyFeedPost.Record; 46 + 47 + return ( 48 + <Stack gap={'sm'} mx={'auto'} w={'100%'}> 49 + <Stack gap={'xs'}> 50 + <Text> 51 + <Text fw={700} c="tangerine" span> 52 + Semble 53 + </Text> 54 + <Text fw={700} c={'gray'} span> 55 + {` · `} 56 + </Text> 57 + <Tooltip label={props.url}> 58 + <Anchor 59 + component={Link} 60 + target="_blank" 61 + fw={700} 62 + c={'blue'} 63 + href={props.url} 64 + > 65 + {getDomain(props.url)} 66 + </Anchor> 67 + </Tooltip> 68 + </Text> 69 + 70 + {/* Post */} 71 + <Stack gap={'xs'}> 72 + <Group gap="xs" justify="space-between" wrap="nowrap"> 73 + <Group gap={'xs'} wrap="nowrap"> 74 + <Avatar 75 + src={post.author.avatar} 76 + alt={`${post.author.handle} social preview image`} 77 + radius="xl" 78 + /> 79 + <Stack gap={0} flex={1}> 80 + <Text c="bright" lineClamp={1} fw={500} w="fit-content"> 81 + {post.author.displayName || post.author.handle} 82 + </Text> 83 + <Text c="gray" lineClamp={1} w="fit-content"> 84 + @{post.author.handle} 85 + </Text> 86 + </Stack> 87 + </Group> 88 + <FaBluesky fill="#0085ff" size={18} /> 89 + </Group> 90 + <Stack gap={'xs'} w={'100%'}> 91 + <Box> 92 + <RichTextRenderer 93 + text={record.text} 94 + textProps={{ lineClamp: 3, c: 'bright' }} 95 + /> 96 + </Box> 97 + {post.embed && <PostEmbed embed={post.embed} />} 98 + </Stack> 99 + <Text c={'gray'} fz={'sm'} fw={500}> 100 + {getFormattedDate(post.indexedAt)} 101 + </Text> 102 + </Stack> 103 + </Stack> 104 + 105 + <Stack gap={'sm'} align="center"> 106 + {session ? ( 107 + <SembleActions url={props.url} /> 108 + ) : ( 109 + <GuestSembleActions url={props.url} /> 110 + )} 111 + </Stack> 112 + 113 + <UrlAddedBySummary url={props.url} /> 114 + </Stack> 115 + ); 116 + }
+54
src/webapp/features/platforms/bluesky/container/blueskySembleHeader/Skeleton.BlueskySembleHeader.tsx
··· 1 + import { Stack, Skeleton, Group, Box } from '@mantine/core'; 2 + import UrlAddedBySummarySkeleton from '@/features/semble/components/urlAddedBySummary/Skeleton.UrlAddedBySummary'; 3 + 4 + export default function BlueskySembleHeaderSkeleton() { 5 + return ( 6 + <Stack gap="md" mx="auto" w={'100%'}> 7 + {/* "Semble" */} 8 + <Skeleton w={120} h={16} /> 9 + 10 + {/* Post */} 11 + <Stack gap="xs" w={'100%'}> 12 + {/* Author row */} 13 + <Group gap="xs" justify="space-between" wrap="nowrap"> 14 + <Group gap="xs" wrap="nowrap"> 15 + <Skeleton circle h={40} w={40} /> 16 + 17 + <Stack gap={6} flex={1}> 18 + <Skeleton w={130} h={16} /> 19 + <Skeleton w={90} h={14} /> 20 + </Stack> 21 + </Group> 22 + 23 + {/* Bluesky icon placeholder */} 24 + <Skeleton h={18} w={18} radius="xl" /> 25 + </Group> 26 + 27 + {/* Post text + embed */} 28 + <Stack gap="xs" w="100%"> 29 + <Box> 30 + <Stack gap={6}> 31 + <Skeleton w="90%" h={14} /> 32 + <Skeleton w="85%" h={14} /> 33 + <Skeleton w="95%" h={14} /> 34 + </Stack> 35 + </Box> 36 + 37 + {/* Embed placeholder */} 38 + <Skeleton w="100%" h={180} radius="md" /> 39 + </Stack> 40 + </Stack> 41 + 42 + {/* Actions */} 43 + <Stack gap="sm" align="center"> 44 + <Group gap="xs"> 45 + <Skeleton w={44} h={44} circle /> 46 + <Skeleton w={131} h={44} radius="xl" /> 47 + </Group> 48 + </Stack> 49 + 50 + {/* URL added by summary */} 51 + <UrlAddedBySummarySkeleton /> 52 + </Stack> 53 + ); 54 + }
+31
src/webapp/features/platforms/bluesky/lib/utils/link.ts
··· 1 + import { AppBskyFeedDefs, AppBskyGraphDefs, AtUri } from '@atproto/api'; 2 + 3 + export const getStarterPackImage = ( 4 + starterPack: AppBskyGraphDefs.StarterPackViewBasic, 5 + ) => { 6 + const rkey = new AtUri(starterPack.uri).rkey; 7 + return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`; 8 + }; 9 + 10 + export const getStarterPackLink = ( 11 + starterPack: AppBskyGraphDefs.StarterPackViewBasic, 12 + ) => { 13 + const rkey = new AtUri(starterPack.uri).rkey; 14 + const handleOrDid = starterPack.creator.handle || starterPack.creator.did; 15 + 16 + return `https://bsky.app/starter-pack/${handleOrDid}/${rkey}`; 17 + }; 18 + 19 + export const getFeedLink = (feed: AppBskyFeedDefs.GeneratorView) => { 20 + const rkey = new AtUri(feed.uri).rkey; 21 + const handleOrDid = feed.creator.handle || feed.creator.did; 22 + 23 + return `https://bsky.app/profile/${handleOrDid}/feed/${rkey}`; 24 + }; 25 + 26 + export const getListLink = (list: AppBskyGraphDefs.ListView) => { 27 + const rkey = new AtUri(list.uri).rkey; 28 + const handleOrDid = list.creator.handle || list.creator.did; 29 + 30 + return `https://bsky.app/profile/${handleOrDid}/lists/${rkey}`; 31 + };
+1
src/webapp/features/platforms/bluesky/types/index.ts
··· 1 + export type EmbedMode = 'card' | 'thread';
+17 -4
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
··· 1 1 import SembleHeader from '../../components/SembleHeader/SembleHeader'; 2 - import { Container, Stack } from '@mantine/core'; 2 + import { Box, Container, Stack } from '@mantine/core'; 3 3 import { Suspense } from 'react'; 4 4 import SembleTabs from '../../components/sembleTabs/SembleTabs'; 5 5 import SembleHeaderSkeleton from '../../components/SembleHeader/Skeleton.SembleHeader'; 6 6 import SembleHeaderBackground from './SembleHeaderBackground'; 7 + import BlueskySembleHeader from '@/features/platforms/bluesky/container/blueskySembleHeader/BlueskySembleHeader'; 8 + import { detectUrlPlatform, SupportedPlatform } from '@/lib/utils/link'; 9 + import BlueskySembleHeaderSkeleton from '@/features/platforms/bluesky/container/blueskySembleHeader/Skeleton.BlueskySembleHeader'; 7 10 8 11 interface Props { 9 12 url: string; 10 13 } 11 14 12 15 export default function SembleContainer(props: Props) { 16 + const platform = detectUrlPlatform(props.url); 17 + 13 18 return ( 14 19 <Container p={0} fluid> 15 20 <SembleHeaderBackground /> 16 21 <Container px={'xs'} pb={'xs'} size={'xl'}> 17 22 <Stack gap={'xl'}> 18 - <Suspense fallback={<SembleHeaderSkeleton />}> 19 - <SembleHeader url={props.url} /> 20 - </Suspense> 23 + {platform === SupportedPlatform.BLUESKY_POST ? ( 24 + <Box maw={600} w={'100%'} mx={'auto'}> 25 + <Suspense fallback={<BlueskySembleHeaderSkeleton />}> 26 + <BlueskySembleHeader url={props.url} /> 27 + </Suspense> 28 + </Box> 29 + ) : ( 30 + <Suspense fallback={<SembleHeaderSkeleton />}> 31 + <SembleHeader url={props.url} /> 32 + </Suspense> 33 + )} 21 34 <SembleTabs url={props.url} /> 22 35 </Stack> 23 36 </Container>
-12
src/webapp/features/services/bluesky/components/blueskyPost/BlueskyPost.tsx
··· 1 - import useGetBlueskyPost from '../../lib/queries/useGetBlueskyPost'; 2 - 3 - interface Props { 4 - uri: string; 5 - } 6 - 7 - export default function BlueskyPost(props: Props) { 8 - const { data } = useGetBlueskyPost({ uri: props.uri }); 9 - console.log('data: ', data); 10 - 11 - return <>Bluesky post</>; 12 - }
src/webapp/features/services/bluesky/lib/blueskyKeys.ts src/webapp/features/platforms/bluesky/lib/blueskyKeys.ts
+10 -8
src/webapp/features/services/bluesky/lib/dal.ts src/webapp/features/platforms/bluesky/lib/dal.ts
··· 3 3 4 4 export const getBlueskyPost = cache(async (uri: string) => { 5 5 const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }); 6 - try { 7 - const post = await agent.getPostThread({ 8 - uri: uri, 9 - depth: 0, 10 - parentHeight: 0, 11 - }); 12 - return post.data; 13 - } catch (error) { 6 + 7 + const post = await agent.getPostThread({ 8 + uri: uri, 9 + depth: 0, 10 + parentHeight: 0, 11 + }); 12 + 13 + if (!post.success) { 14 14 throw new Error('Could not load bluesky post'); 15 15 } 16 + 17 + return post.data; 16 18 });
src/webapp/features/services/bluesky/lib/queries/useGetBlueskyPost.tsx src/webapp/features/platforms/bluesky/lib/queries/useGetBlueskyPost.tsx
+11
src/webapp/lib/utils/atproto.ts
··· 1 1 export const getRecordKey = (path: string) => path.split('/').pop() || ''; 2 + 3 + export const getPostUriFromUrl = (url: string) => { 4 + const match = url.match(/profile\/([^/]+)\/post\/([^/]+)/); 5 + 6 + if (match) { 7 + const [, handle, postId] = match; 8 + return `at://${handle}/app.bsky.feed.post/${postId}`; 9 + } 10 + 11 + return url; 12 + };
+9 -4
src/webapp/lib/utils/link.ts
··· 26 26 } 27 27 }; 28 28 29 - export enum SupportedService { 30 - BLUESKY = 'bluesky', 29 + export enum SupportedPlatform { 30 + BLUESKY_POST = 'bluesky post', 31 + SEMBLE_COLLECTION = 'semble collection', 31 32 } 32 33 33 - export const detectUrlService = (url: string): SupportedService | null => { 34 + export const detectUrlPlatform = (url: string): SupportedPlatform | null => { 35 + if (isCollectionPage(url)) { 36 + return SupportedPlatform.SEMBLE_COLLECTION; 37 + } 38 + 34 39 try { 35 40 const parsedUrl = new URL(url); 36 41 ··· 40 45 parsedUrl.hostname === 'bsky.app' && 41 46 parsedUrl.pathname.includes('/post/') 42 47 ) { 43 - return SupportedService.BLUESKY; 48 + return SupportedPlatform.BLUESKY_POST; 44 49 } 45 50 46 51 return null; // no supported service detected
+19
src/webapp/lib/utils/time.ts
··· 18 18 19 19 return `${count}${interval.label}`; 20 20 }; 21 + 22 + export const getFormattedDate = (date: string) => { 23 + const options: Intl.DateTimeFormatOptions = { 24 + month: 'short', 25 + day: 'numeric', 26 + year: 'numeric', 27 + hour: 'numeric', 28 + minute: 'numeric', 29 + hour12: true, 30 + }; 31 + 32 + const formattedDate: string = new Date(date).toLocaleString('en-US', options); 33 + 34 + // replace second comma with "at" 35 + // e.g. Sep 20, 2025, 7:50 PM -> Sep 20, 2025 at 7:50 PM 36 + const formattedWithAt: string = formattedDate.replace(/, (.*),/, ', $1 at '); 37 + 38 + return formattedWithAt; 39 + };
+2 -1
src/webapp/package.json
··· 26 26 "build-storybook": "storybook build" 27 27 }, 28 28 "dependencies": { 29 - "@semble/types": "*", 30 29 "@mantine/core": "^8.1.3", 31 30 "@mantine/dates": "^8.1.3", 32 31 "@mantine/dropzone": "^8.1.3", ··· 37 36 "@mdx-js/loader": "^3.1.1", 38 37 "@mdx-js/react": "^3.1.1", 39 38 "@next/mdx": "^15.5.4", 39 + "@semble/types": "*", 40 40 "@tanstack/react-query": "^5.85.5", 41 41 "@types/mdx": "^2.0.13", 42 42 "@vercel/analytics": "^1.5.0", ··· 45 45 "next": "15.4.1", 46 46 "react": "19.1.0", 47 47 "react-dom": "19.1.0", 48 + "react-error-boundary": "^6.0.0", 48 49 "react-icons": "^5.5.0" 49 50 }, 50 51 "devDependencies": {