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