a tool for shared writing and social publishing
1import { useEntitySetContext } from "components/EntitySetProvider";
2import { useEffect, useState } from "react";
3import { useEntity } from "src/replicache";
4import { useUIState } from "src/useUIState";
5import { BlockProps } from "../Block";
6import { elementId } from "src/utils/elementId";
7import { focusBlock } from "src/utils/focusBlock";
8import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
9import { BlueskyEmbed, PostNotAvailable } from "./BlueskyEmbed";
10import { BlueskyPostEmpty } from "./BlueskyEmpty";
11import { BlueskyRichText } from "./BlueskyRichText";
12import { Separator } from "components/Layout";
13import { useInitialPageLoad } from "components/InitialPageLoadProvider";
14import { BlueskyTiny } from "components/Icons/BlueskyTiny";
15import { CommentTiny } from "components/Icons/CommentTiny";
16import { useLocalizedDate } from "src/hooks/useLocalizedDate";
17
18export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => {
19 let { permissions } = useEntitySetContext();
20 let isSelected = useUIState((s) =>
21 s.selectedBlocks.find((b) => b.value === props.entityID),
22 );
23 let post = useEntity(props.entityID, "block/bluesky-post")?.data.value;
24
25 useEffect(() => {
26 if (props.preview) return;
27 let input = document.getElementById(elementId.block(props.entityID).input);
28 if (isSelected) {
29 input?.focus();
30 } else input?.blur();
31 }, [isSelected, props.entityID, props.preview]);
32
33 switch (true) {
34 case !post:
35 if (!permissions.write) return null;
36 return (
37 <label
38 id={props.preview ? undefined : elementId.block(props.entityID).input}
39 className={`
40 w-full h-[104px] p-2
41 text-tertiary hover:text-accent-contrast hover:cursor-pointer
42 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
43 ${isSelected ? "border-2 border-tertiary" : "border border-border"}
44 ${props.pageType === "canvas" && "bg-bg-page"}`}
45 onMouseDown={() => {
46 focusBlock(
47 { type: props.type, value: props.entityID, parent: props.parent },
48 { type: "start" },
49 );
50 }}
51 >
52 <BlueskyPostEmpty {...props} />
53 </label>
54 );
55
56 case AppBskyFeedDefs.isBlockedPost(post) ||
57 AppBskyFeedDefs.isBlockedAuthor(post) ||
58 AppBskyFeedDefs.isNotFoundPost(post):
59 return (
60 <div
61 className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`}
62 >
63 <PostNotAvailable />
64 </div>
65 );
66
67 case AppBskyFeedDefs.isThreadViewPost(post):
68 let record = post.post
69 .record as AppBskyFeedDefs.FeedViewPost["post"]["record"];
70 let facets = record.facets;
71
72 // silliness to get the text and timestamp from the record with proper types
73 let text: string | null = null;
74 let timestamp: string | undefined = undefined;
75 if (AppBskyFeedPost.isRecord(record)) {
76 text = (record as AppBskyFeedPost.Record).text;
77 timestamp = (record as AppBskyFeedPost.Record).createdAt;
78 }
79
80 //getting the url to the post
81 let postId = post.post.uri.split("/")[4];
82 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`;
83
84 return (
85 <div
86 className={`
87 flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
88 ${isSelected ? "block-border-selected " : "block-border"}
89 `}
90 >
91 {post.post.author && record && (
92 <>
93 <div className="bskyAuthor w-full flex items-center gap-2">
94 {post.post.author?.avatar ? (
95 <img
96 src={post.post.author?.avatar}
97 alt={`${post.post.author?.displayName}'s avatar`}
98 className="shrink-0 w-8 h-8 rounded-full border border-border-light"
99 />
100 ) : (
101 <div className="shrink-0 w-8 h-8 rounded-full border border-border-light bg-border"></div>
102 )}
103 <div className="grow flex flex-col gap-0.5 leading-tight">
104 <div className=" font-bold text-secondary">
105 {post.post.author?.displayName}
106 </div>
107 <a
108 className="text-xs text-tertiary hover:underline"
109 target="_blank"
110 href={`https://bsky.app/profile/${post.post.author?.handle}`}
111 >
112 @{post.post.author?.handle}
113 </a>
114 </div>
115 </div>
116
117 <div className="flex flex-col gap-2 ">
118 <div>
119 <pre className="whitespace-pre-wrap">
120 {BlueskyRichText({
121 record: record as AppBskyFeedPost.Record | null,
122 })}
123 </pre>
124 </div>
125 {post.post.embed && (
126 <BlueskyEmbed embed={post.post.embed} postUrl={url} />
127 )}
128 </div>
129 </>
130 )}
131 <div className="w-full flex gap-2 items-center justify-between">
132 {timestamp && <PostDate timestamp={timestamp} />}
133 <div className="flex gap-2 items-center">
134 {post.post.replyCount && post.post.replyCount > 0 && (
135 <>
136 <a
137 className="flex items-center gap-1 hover:no-underline"
138 target="_blank"
139 href={url}
140 >
141 {post.post.replyCount}
142 <CommentTiny />
143 </a>
144 <Separator classname="h-4" />
145 </>
146 )}
147
148 <a className="" target="_blank" href={url}>
149 <BlueskyTiny />
150 </a>
151 </div>
152 </div>
153 </div>
154 );
155 }
156};
157
158function PostDate(props: { timestamp: string }) {
159 const formattedDate = useLocalizedDate(props.timestamp, {
160 month: "short",
161 day: "numeric",
162 year: "numeric",
163 hour: "numeric",
164 minute: "numeric",
165 hour12: true,
166 });
167 return <div className="text-xs text-tertiary">{formattedDate}</div>;
168}