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