a tool for shared writing and social publishing
at update/thread-viewer 402 lines 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 = ( 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&apos;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};