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