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&apos;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};