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