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 { 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 <div
60 className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`}
61 >
62 <PostNotAvailable />
63 </div>
64 );
65
66 case AppBskyFeedDefs.isThreadViewPost(post):
67 let record = post.post
68 .record as AppBskyFeedDefs.FeedViewPost["post"]["record"];
69 let facets = record.facets;
70
71 // silliness to get the text and timestamp from the record with proper types
72 let text: string | null = null;
73 let timestamp: string | undefined = undefined;
74 if (AppBskyFeedPost.isRecord(record)) {
75 text = (record as AppBskyFeedPost.Record).text;
76 timestamp = (record as AppBskyFeedPost.Record).createdAt;
77 }
78
79 //getting the url to the post
80 let postId = post.post.uri.split("/")[4];
81 let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`;
82
83 return (
84 <div
85 className={`
86 flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
87 ${isSelected ? "block-border-selected " : "block-border"}
88 `}
89 >
90 {post.post.author && record && (
91 <>
92 <div className="bskyAuthor w-full flex items-center gap-2">
93 {post.post.author?.avatar ? (
94 <img
95 src={post.post.author?.avatar}
96 alt={`${post.post.author?.displayName}'s avatar`}
97 className="shrink-0 w-8 h-8 rounded-full border border-border-light"
98 />
99 ) : (
100 <div className="shrink-0 w-8 h-8 rounded-full border border-border-light bg-border"></div>
101 )}
102 <div className="grow flex flex-col gap-0.5 leading-tight">
103 <div className=" font-bold text-secondary">
104 {post.post.author?.displayName}
105 </div>
106 <a
107 className="text-xs text-tertiary hover:underline"
108 target="_blank"
109 href={`https://bsky.app/profile/${post.post.author?.handle}`}
110 >
111 @{post.post.author?.handle}
112 </a>
113 </div>
114 </div>
115
116 <div className="flex flex-col gap-2 ">
117 <div>
118 <pre className="whitespace-pre-wrap">
119 {BlueskyRichText({
120 record: record as AppBskyFeedPost.Record | null,
121 })}
122 </pre>
123 </div>
124 {post.post.embed && (
125 <BlueskyEmbed embed={post.post.embed} postUrl={url} />
126 )}
127 </div>
128 </>
129 )}
130 <div className="w-full flex gap-2 items-center justify-between">
131 {timestamp && <PostDate timestamp={timestamp} />}
132 <div className="flex gap-2 items-center">
133 {post.post.replyCount && post.post.replyCount > 0 && (
134 <>
135 <a
136 className="flex items-center gap-1 hover:no-underline"
137 target="_blank"
138 href={url}
139 >
140 {post.post.replyCount}
141 <CommentTiny />
142 </a>
143 <Separator classname="h-4" />
144 </>
145 )}
146
147 <a className="" target="_blank" href={url}>
148 <BlueskyTiny />
149 </a>
150 </div>
151 </div>
152 </div>
153 );
154 }
155};
156
157function PostDate(props: { timestamp: string }) {
158 const formattedDate = useLocalizedDate(props.timestamp, {
159 month: "short",
160 day: "numeric",
161 year: "numeric",
162 hour: "numeric",
163 minute: "numeric",
164 hour12: true,
165 });
166 return <div className="text-xs text-tertiary">{formattedDate}</div>;
167}