a tool for shared writing and social publishing
1import Link from "next/link";
2import { useLeafletPublicationData } from "components/PageSWRDataProvider";
3import { useRef, useState } from "react";
4import { useReplicache } from "src/replicache";
5import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
6import { Separator } from "components/Layout";
7import { AtUri } from "@atproto/syntax";
8import {
9 getBasePublicationURL,
10 getPublicationURL,
11} from "app/lish/createPub/getPublicationURL";
12import { useSubscribe } from "src/replicache/useSubscribe";
13import { useEntitySetContext } from "components/EntitySetProvider";
14import { timeAgo } from "src/utils/timeAgo";
15import { CommentTiny } from "components/Icons/CommentTiny";
16import { QuoteTiny } from "components/Icons/QuoteTiny";
17import { TagTiny } from "components/Icons/TagTiny";
18import { Popover } from "components/Popover";
19import { TagSelector } from "components/Tags";
20import { useIdentityData } from "components/IdentityProvider";
21import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader";
22import { Backdater } from "./Backdater";
23import { RecommendTinyEmpty } from "components/Icons/RecommendTiny";
24import { mergePreferences } from "src/utils/mergePreferences";
25
26export const PublicationMetadata = (props: { noInteractions?: boolean }) => {
27 let { rep } = useReplicache();
28 let {
29 data: pub,
30 normalizedDocument,
31 normalizedPublication,
32 } = useLeafletPublicationData();
33 let { identity } = useIdentityData();
34 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title"));
35 let description = useSubscribe(rep, (tx) =>
36 tx.get<string>("publication_description"),
37 );
38 let postPreferences = useSubscribe(rep, (tx) =>
39 tx.get<{
40 showComments?: boolean;
41 showMentions?: boolean;
42 showRecommends?: boolean;
43 } | null>("post_preferences"),
44 );
45 let merged = mergePreferences(
46 postPreferences || undefined,
47 normalizedPublication?.preferences,
48 );
49 let publishedAt = normalizedDocument?.publishedAt;
50
51 if (!pub) return null;
52
53 if (typeof title !== "string") {
54 title = pub?.title || "";
55 }
56 if (typeof description !== "string") {
57 description = pub?.description || "";
58 }
59 let tags = true;
60
61 return (
62 <PostHeaderLayout
63 pubLink={
64 <div className="flex gap-2 items-center">
65 {pub.publications && (
66 <Link
67 href={
68 identity?.atp_did === pub.publications?.identity_did
69 ? `${getBasePublicationURL(pub.publications)}/dashboard`
70 : getPublicationURL(pub.publications)
71 }
72 className="leafletMetadata text-accent-contrast font-bold hover:no-underline"
73 >
74 {pub.publications?.name}
75 </Link>
76 )}
77 <div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md ">
78 DRAFT
79 </div>
80 </div>
81 }
82 postTitle={
83 <TextField
84 className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent"
85 value={title}
86 onChange={async (newTitle) => {
87 await rep?.mutate.updatePublicationDraft({
88 title: newTitle,
89 description,
90 });
91 }}
92 placeholder="Untitled"
93 />
94 }
95 postDescription={
96 <TextField
97 placeholder="add an optional description..."
98 className="pt-1 italic text-secondary outline-hidden bg-transparent"
99 value={description}
100 onChange={async (newDescription) => {
101 await rep?.mutate.updatePublicationDraft({
102 title,
103 description: newDescription,
104 });
105 }}
106 />
107 }
108 postInfo={
109 <>
110 {pub.doc ? (
111 <div className="flex gap-2 items-center">
112 <p className="text-sm text-tertiary">
113 Published{" "}
114 {publishedAt && (
115 <Backdater publishedAt={publishedAt} docURI={pub.doc} />
116 )}
117 </p>
118
119 <Link
120 target="_blank"
121 className="text-sm"
122 href={
123 pub.publications
124 ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`
125 : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}`
126 }
127 >
128 View
129 </Link>
130 </div>
131 ) : (
132 <p>Draft</p>
133 )}
134 {!props.noInteractions && (
135 <div className="flex gap-2 text-border items-center">
136 {merged.showRecommends !== false && (
137 <div className="flex gap-1 items-center">
138 <RecommendTinyEmpty />—
139 </div>
140 )}
141
142 {merged.showMentions !== false && (
143 <div className="flex gap-1 items-center">
144 <QuoteTiny />—
145 </div>
146 )}
147 {merged.showComments !== false && (
148 <div className="flex gap-1 items-center">
149 <CommentTiny />—
150 </div>
151 )}
152 {tags && (
153 <>
154 {merged.showRecommends !== false ||
155 merged.showMentions !== false ||
156 merged.showComments !== false ? (
157 <Separator classname="h-4!" />
158 ) : null}
159 <AddTags />
160 </>
161 )}
162 </div>
163 )}
164 </>
165 }
166 />
167 );
168};
169
170export const TextField = ({
171 value,
172 onChange,
173 className,
174 placeholder,
175}: {
176 value: string;
177 onChange: (v: string) => Promise<void>;
178 className: string;
179 placeholder: string;
180}) => {
181 let { undoManager } = useReplicache();
182 let actionTimeout = useRef<number | null>(null);
183 let { permissions } = useEntitySetContext();
184 let previousSelection = useRef<null | { start: number; end: number }>(null);
185 let ref = useRef<HTMLTextAreaElement | null>(null);
186 return (
187 <AsyncValueAutosizeTextarea
188 ref={ref}
189 disabled={!permissions.write}
190 onSelect={(e) => {
191 let start = e.currentTarget.selectionStart,
192 end = e.currentTarget.selectionEnd;
193 previousSelection.current = { start, end };
194 }}
195 className={className}
196 value={value}
197 onBlur={async () => {
198 if (actionTimeout.current) {
199 undoManager.endGroup();
200 window.clearTimeout(actionTimeout.current);
201 actionTimeout.current = null;
202 }
203 }}
204 onChange={async (e) => {
205 let newValue = e.currentTarget.value;
206 let oldValue = value;
207 let start = e.currentTarget.selectionStart,
208 end = e.currentTarget.selectionEnd;
209 await onChange(e.currentTarget.value);
210
211 if (actionTimeout.current) {
212 window.clearTimeout(actionTimeout.current);
213 } else {
214 undoManager.startGroup();
215 }
216
217 actionTimeout.current = window.setTimeout(() => {
218 undoManager.endGroup();
219 actionTimeout.current = null;
220 }, 200);
221 let previousStart = previousSelection.current?.start || null,
222 previousEnd = previousSelection.current?.end || null;
223 undoManager.add({
224 redo: async () => {
225 await onChange(newValue);
226 ref.current?.setSelectionRange(start, end);
227 ref.current?.focus();
228 },
229 undo: async () => {
230 await onChange(oldValue);
231 ref.current?.setSelectionRange(previousStart, previousEnd);
232 ref.current?.focus();
233 },
234 });
235 }}
236 placeholder={placeholder}
237 />
238 );
239};
240
241export const PublicationMetadataPreview = () => {
242 let { data: pub, normalizedDocument } = useLeafletPublicationData();
243 let publishedAt = normalizedDocument?.publishedAt;
244
245 if (!pub) return null;
246
247 return (
248 <PostHeaderLayout
249 pubLink={
250 <div className="text-accent-contrast font-bold hover:no-underline">
251 {pub.publications?.name}
252 </div>
253 }
254 postTitle={pub.title}
255 postDescription={pub.description}
256 postInfo={
257 pub.doc ? (
258 <p>Published {publishedAt && timeAgo(publishedAt)}</p>
259 ) : (
260 <p>Draft</p>
261 )
262 }
263 />
264 );
265};
266
267export const AddTags = () => {
268 let { data: pub, normalizedDocument } = useLeafletPublicationData();
269 let { rep } = useReplicache();
270
271 // Get tags from Replicache local state or published document
272 let replicacheTags = useSubscribe(rep, (tx) =>
273 tx.get<string[]>("publication_tags"),
274 );
275
276 // Determine which tags to use - prioritize Replicache state
277 let tags: string[] = [];
278 if (Array.isArray(replicacheTags)) {
279 tags = replicacheTags;
280 } else if (
281 normalizedDocument?.tags &&
282 Array.isArray(normalizedDocument.tags)
283 ) {
284 tags = normalizedDocument.tags as string[];
285 }
286
287 // Update tags in replicache local state
288 const handleTagsChange = async (newTags: string[]) => {
289 // Store tags in replicache for next publish/update
290 await rep?.mutate.updatePublicationDraft({
291 tags: newTags,
292 });
293 };
294
295 return (
296 <Popover
297 className="p-2! w-full min-w-xs"
298 trigger={
299 <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary">
300 <TagTiny />{" "}
301 {tags.length > 0
302 ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}`
303 : "Add Tags"}
304 </div>
305 }
306 >
307 <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} />
308 </Popover>
309 );
310};