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