a tool for shared writing and social publishing
1import { ButtonPrimary } from "components/Buttons";
2import { Popover } from "components/Popover";
3import { MenuItem } from "components/Menu";
4import { Separator } from "components/Layout";
5import { useUIState } from "src/useUIState";
6import { useState } from "react";
7import { useSmoker, useToaster } from "components/Toast";
8import { BlockProps, BlockLayout } from "./Block";
9import { useEntity, useReplicache } from "src/replicache";
10import { useEntitySetContext } from "components/EntitySetProvider";
11import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
12import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription";
13import { focusPage } from "src/utils/focusPage";
14import { v7 } from "uuid";
15import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers";
16import { getBlocksWithType } from "src/hooks/queries/useBlocks";
17import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML";
18import { htmlToMarkdown } from "src/htmlMarkdownParsers";
19import {
20 addSubscription,
21 removeSubscription,
22 unsubscribe,
23 useSubscriptionStatus,
24} from "src/hooks/useSubscriptionStatus";
25import { usePageTitle } from "components/utils/UpdateLeafletTitle";
26import { ArrowDownTiny } from "components/Icons/ArrowDownTiny";
27import { InfoSmall } from "components/Icons/InfoSmall";
28
29export const MailboxBlock = (props: BlockProps) => {
30 let isSubscribed = useSubscriptionStatus(props.entityID);
31 let isSelected = useUIState((s) =>
32 s.selectedBlocks.find((b) => b.value === props.entityID),
33 );
34
35 let permission = useEntitySetContext().permissions.write;
36 let { rep } = useReplicache();
37 let smoke = useSmoker();
38 let draft = useEntity(props.entityID, "mailbox/draft");
39 let entity_set = useEntitySetContext();
40
41 let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count");
42 if (!permission)
43 return (
44 <MailboxReaderView entityID={props.entityID} parent={props.parent} />
45 );
46
47 return (
48 <div className={`mailboxContent relative w-full flex flex-col gap-1`}>
49 <BlockLayout
50 isSelected={!!isSelected}
51 hasBackground={"accent"}
52 className="flex gap-2 items-center justify-center"
53 >
54 <ButtonPrimary
55 onClick={async () => {
56 let entity;
57 if (draft) {
58 entity = draft.data.value;
59 } else {
60 entity = v7();
61 await rep?.mutate.createDraft({
62 mailboxEntity: props.entityID,
63 permission_set: entity_set.set,
64 newEntity: entity,
65 firstBlockEntity: v7(),
66 firstBlockFactID: v7(),
67 });
68 }
69 useUIState.getState().openPage(props.parent, entity);
70 if (rep) focusPage(entity, rep, "focusFirstBlock");
71 return;
72 }}
73 >
74 {draft ? "Edit Draft" : "Write a Post"}
75 </ButtonPrimary>
76 <MailboxInfo />
77 </BlockLayout>
78 <div className="flex gap-3 items-center justify-between">
79 {
80 <>
81 {!isSubscribed?.confirmed ? (
82 <SubscribePopover
83 entityID={props.entityID}
84 unconfirmed={!!isSubscribed && !isSubscribed.confirmed}
85 parent={props.parent}
86 />
87 ) : (
88 <button
89 className="text-tertiary hover:text-accent-contrast"
90 onClick={(e) => {
91 let rect = e.currentTarget.getBoundingClientRect();
92 unsubscribe(isSubscribed);
93 smoke({
94 text: "unsubscribed!",
95 position: { x: rect.left, y: rect.top - 8 },
96 });
97 }}
98 >
99 Unsubscribe
100 </button>
101 )}
102 <div className="flex gap-2 place-items-center">
103 <span className="text-tertiary">
104 {!subscriber_count ||
105 subscriber_count?.data.value === undefined ||
106 subscriber_count?.data.value === 0
107 ? "no"
108 : subscriber_count?.data.value}{" "}
109 reader
110 {subscriber_count?.data.value === 1 ? "" : "s"}
111 </span>
112 <Separator classname="h-5" />
113
114 <GoToArchive entityID={props.entityID} parent={props.parent} />
115 </div>
116 </>
117 }
118 </div>
119 </div>
120 );
121};
122
123const MailboxReaderView = (props: { entityID: string; parent: string }) => {
124 let isSubscribed = useSubscriptionStatus(props.entityID);
125 let isSelected = useUIState((s) =>
126 s.selectedBlocks.find((b) => b.value === props.entityID),
127 );
128 let archive = useEntity(props.entityID, "mailbox/archive");
129 let smoke = useSmoker();
130 let { rep } = useReplicache();
131 return (
132 <div className={`mailboxContent relative w-full flex flex-col gap-1 h-32`}>
133 <BlockLayout
134 isSelected={!!isSelected}
135 hasBackground={"accent"}
136 className="`h-full flex flex-col gap-2 items-center justify-center"
137 >
138 {!isSubscribed?.confirmed ? (
139 <>
140 <SubscribeForm
141 entityID={props.entityID}
142 role={"reader"}
143 parent={props.parent}
144 />
145 </>
146 ) : (
147 <div className="flex flex-col gap-2 items-center place-self-center">
148 <div className=" font-bold text-secondary ">
149 You're Subscribed!
150 </div>
151 <div className="flex flex-col gap-1 items-center place-self-center">
152 {archive ? (
153 <ButtonPrimary
154 onMouseDown={(e) => {
155 e.preventDefault();
156 if (rep) {
157 useUIState
158 .getState()
159 .openPage(props.parent, archive.data.value);
160 focusPage(archive.data.value, rep);
161 }
162 }}
163 >
164 See All Posts
165 </ButtonPrimary>
166 ) : (
167 <div className="text-tertiary">Nothing has been posted yet</div>
168 )}
169 <button
170 className="text-accent-contrast hover:underline text-sm"
171 onClick={(e) => {
172 let rect = e.currentTarget.getBoundingClientRect();
173 unsubscribe(isSubscribed);
174 smoke({
175 text: "unsubscribed!",
176 position: { x: rect.left, y: rect.top - 8 },
177 });
178 }}
179 >
180 unsubscribe
181 </button>
182 </div>
183 </div>
184 )}
185 </BlockLayout>
186 </div>
187 );
188};
189
190const MailboxInfo = (props: { subscriber?: boolean }) => {
191 return (
192 <Popover
193 className="max-w-xs"
194 trigger={<InfoSmall className="shrink-0 text-accent-contrast" />}
195 >
196 <div className="text-sm text-secondary flex flex-col gap-2">
197 {props.subscriber ? (
198 <>
199 <p className="font-bold">
200 Get a notification whenever the creator posts to this mailbox!
201 </p>
202 <p>
203 Your contact info will be kept private, and you can unsubscribe
204 anytime.
205 </p>
206 </>
207 ) : (
208 <>
209 <p className="font-bold">
210 When you post to this mailbox, subscribers will be notified!
211 </p>
212 <p>Reader contact info is kept private.</p>
213 <p>You can have one draft post at a time.</p>
214 </>
215 )}
216 </div>
217 </Popover>
218 );
219};
220
221const SubscribePopover = (props: {
222 entityID: string;
223 parent: string;
224 unconfirmed: boolean;
225}) => {
226 return (
227 <Popover
228 className="max-w-sm"
229 trigger={
230 <div className="font-bold text-accent-contrast">
231 {props.unconfirmed ? "Confirm" : "Subscribe"}
232 </div>
233 }
234 >
235 <div className="text-secondary flex flex-col gap-2 py-1">
236 <SubscribeForm
237 compact
238 entityID={props.entityID}
239 role="author"
240 parent={props.parent}
241 />
242 </div>
243 </Popover>
244 );
245};
246
247const SubscribeForm = (props: {
248 entityID: string;
249 parent: string;
250 role: "author" | "reader";
251 compact?: boolean;
252}) => {
253 let smoke = useSmoker();
254 let [channel, setChannel] = useState<"email" | "sms">("email");
255 let [email, setEmail] = useState("");
256 let [sms, setSMS] = useState("");
257
258 let subscription = useSubscriptionStatus(props.entityID);
259 let [code, setCode] = useState("");
260 let { permission_token } = useReplicache();
261 if (subscription && !subscription.confirmed) {
262 return (
263 <div className="flex flex-col gap-3 justify-center text-center ">
264 <div className="font-bold text-secondary ">
265 Enter the code we sent to{" "}
266 <code
267 className="italic"
268 style={{ fontFamily: "var(--font-quattro)" }}
269 >
270 {subscription.email}
271 </code>{" "}
272 here!
273 </div>
274 <div className="flex flex-col gap-1">
275 <form
276 onSubmit={async (e) => {
277 e.preventDefault();
278 let result = await confirmEmailSubscription(
279 subscription.id,
280 code,
281 );
282
283 let rect = document
284 .getElementById("confirm-code-button")
285 ?.getBoundingClientRect();
286
287 if (!result) {
288 smoke({
289 error: true,
290 text: "oops, incorrect code",
291 position: {
292 x: rect ? rect.left + 45 : 0,
293 y: rect ? rect.top + 15 : 0,
294 },
295 });
296 return;
297 }
298 addSubscription(result.subscription);
299 }}
300 className="mailboxConfirmCodeInput flex gap-2 items-center mx-auto"
301 >
302 <input
303 type="number"
304 value={code}
305 className="appearance-none focus:outline-hidden focus:border-border w-20 border border-border-light bg-bg-page rounded-md p-1"
306 onChange={(e) => setCode(e.currentTarget.value)}
307 />
308
309 <ButtonPrimary type="submit" id="confirm-code-button">
310 Confirm!
311 </ButtonPrimary>
312 </form>
313
314 <button
315 onMouseDown={() => {
316 removeSubscription(subscription);
317 setEmail("");
318 }}
319 className="text-accent-contrast hover:underline text-sm"
320 >
321 use another contact
322 </button>
323 </div>
324 </div>
325 );
326 }
327 return (
328 <>
329 <div className="flex flex-col gap-1">
330 <form
331 onSubmit={async (e) => {
332 e.preventDefault();
333 let subscriptionID = await subscribeToMailboxWithEmail(
334 props.entityID,
335 email,
336 permission_token,
337 );
338 if (subscriptionID) addSubscription(subscriptionID);
339 }}
340 className={`mailboxSubscribeForm flex sm:flex-row flex-col ${props.compact && "sm:flex-col sm:gap-2"} gap-2 sm:gap-3 items-center place-self-center mx-auto`}
341 >
342 <div className="mailboxChannelInput flex gap-2 border border-border-light bg-bg-page rounded-md py-1 px-2 grow max-w-72 ">
343 <input
344 value={email}
345 type="email"
346 onChange={(e) => setEmail(e.target.value)}
347 className="w-full appearance-none focus:outline-hidden bg-transparent"
348 placeholder="youremail@email.com"
349 />
350 </div>
351 <ButtonPrimary type="submit">Subscribe!</ButtonPrimary>
352 </form>
353 {props.role === "reader" && (
354 <GoToArchive entityID={props.entityID} parent={props.parent} small />
355 )}
356 </div>
357 </>
358 );
359};
360
361const GoToArchive = (props: {
362 entityID: string;
363 parent: string;
364 small?: boolean;
365}) => {
366 let archive = useEntity(props.entityID, "mailbox/archive");
367 let { rep } = useReplicache();
368
369 return archive ? (
370 <button
371 className={`text-tertiary hover:text-accent-contrast ${props.small && "text-sm"}`}
372 onMouseDown={(e) => {
373 e.preventDefault();
374 if (rep) {
375 useUIState.getState().openPage(props.parent, archive.data.value);
376 focusPage(archive.data.value, rep);
377 }
378 }}
379 >
380 past posts
381 </button>
382 ) : (
383 <div className={`text-tertiary text-center ${props.small && "text-sm"}`}>
384 no posts yet
385 </div>
386 );
387};