a tool for shared writing and social publishing
at main 13 kB view raw
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&apos;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};