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