diff --git a/actions/addLinkCard.ts b/actions/addLinkCard.ts index 07c84732..24e130d5 100644 --- a/actions/addLinkCard.ts +++ b/actions/addLinkCard.ts @@ -8,7 +8,6 @@ let supabase = createClient( ); export async function addLinkCard(args: { link: string }) { - console.log("addLinkCard"); let result = await get_url_preview_data(args.link); return result; } diff --git a/actions/createNewDoc.ts b/actions/createNewDoc.ts index c66b8e49..2fbb6395 100644 --- a/actions/createNewDoc.ts +++ b/actions/createNewDoc.ts @@ -14,7 +14,6 @@ import { v7 } from "uuid"; import { sql } from "drizzle-orm"; export async function createNewDoc() { - console.log("Create new doc"); const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); const db = drizzle(client); let { permissionToken } = await db.transaction(async (tx) => { diff --git a/actions/deleteDoc.ts b/actions/deleteDoc.ts index ef7e4225..28929496 100644 --- a/actions/deleteDoc.ts +++ b/actions/deleteDoc.ts @@ -15,7 +15,6 @@ import { PermissionToken } from "src/replicache"; import { revalidatePath } from "next/cache"; export async function deleteDoc(permission_token: PermissionToken) { - console.log("Delete doc"); const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); const db = drizzle(client); await db.transaction(async (tx) => { diff --git a/actions/subscriptions/confirmEmailSubscription.ts b/actions/subscriptions/confirmEmailSubscription.ts new file mode 100644 index 00000000..476b4428 --- /dev/null +++ b/actions/subscriptions/confirmEmailSubscription.ts @@ -0,0 +1,87 @@ +"use server"; + +import { createClient } from "@supabase/supabase-js"; +import { createIdentity } from "actions/createIdentity"; +import { and, eq, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { + email_subscriptions_to_entity, + facts, + permission_tokens, +} from "drizzle/schema"; +import postgres from "postgres"; +import { Fact, PermissionToken } from "src/replicache"; +import { serverMutationContext } from "src/replicache/serverMutationContext"; +import { Database } from "supabase/database.types"; +import { v7 } from "uuid"; + +export async function confirmEmailSubscription( + subscriptionID: string, + code: string, +) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + let subscription = await db.transaction(async (tx) => { + let [{ email_subscriptions_to_entity: sub, permission_tokens: token }] = + await db + .select() + .from(email_subscriptions_to_entity) + .innerJoin( + permission_tokens, + eq(permission_tokens.id, email_subscriptions_to_entity.token), + ) + .where(and(eq(email_subscriptions_to_entity.id, subscriptionID))); + if (sub.confirmed) return { subscription: sub, token }; + if (code !== sub.confirmation_code) return null; + let [fact] = (await db + .select() + .from(facts) + .where( + and( + eq(facts.entity, sub.entity), + + eq(facts.attribute, "mailbox/subscriber-count"), + ), + )) as Fact<"mailbox/subscriber-count">[]; + if (!fact) { + await db.insert(facts).values({ + id: v7(), + entity: sub.entity, + data: sql`${{ type: "number", value: 1 }}::jsonb`, + attribute: "mailbox/subscriber-count", + }); + } else { + await db + .update(facts) + .set({ + data: sql`${{ type: "number", value: fact.data.value + 1 }}::jsonb`, + }) + .where(eq(facts.id, fact.id)); + } + let [subscription] = await db + .update(email_subscriptions_to_entity) + .set({ + confirmed: true, + }) + .where(eq(email_subscriptions_to_entity.id, sub.id)) + .returning(); + + return { subscription, token }; + }); + + let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + ); + let channel = supabase.channel( + `rootEntity:${subscription?.token.root_entity}`, + ); + await channel.send({ + type: "broadcast", + event: "poke", + payload: { message: "poke" }, + }); + supabase.removeChannel(channel); + client.end(); + return subscription; +} diff --git a/actions/subscriptions/deleteSubscription.ts b/actions/subscriptions/deleteSubscription.ts new file mode 100644 index 00000000..44cc8d46 --- /dev/null +++ b/actions/subscriptions/deleteSubscription.ts @@ -0,0 +1,51 @@ +"use server"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { email_subscriptions_to_entity, facts } from "drizzle/schema"; +import postgres from "postgres"; +import { eq, and, sql } from "drizzle-orm"; +import { Fact } from "src/replicache"; +import { v7 } from "uuid"; + +export async function deleteSubscription(subscriptionID: string) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + try { + await db.transaction(async (db) => { + let [subscription] = await db + .select() + .from(email_subscriptions_to_entity) + .where(eq(email_subscriptions_to_entity.id, subscriptionID)); + if (!subscription) return; + let [fact] = (await db + .select() + .from(facts) + .where( + and( + eq(facts.entity, subscription.entity), + + eq(facts.attribute, "mailbox/subscriber-count"), + ), + )) as Fact<"mailbox/subscriber-count">[]; + if (fact) { + await db + .update(facts) + .set({ + data: sql`${{ type: "number", value: fact.data.value - 1 }}::jsonb`, + }) + .where(eq(facts.id, fact.id)); + } + await db + .delete(email_subscriptions_to_entity) + .where(eq(email_subscriptions_to_entity.id, subscriptionID)); + }); + + client.end(); + return { success: true }; + } catch (error) { + console.error("Error unsubscribing:", error); + client.end(); + return { success: false, error: "Failed to unsubscribe" }; + } +} diff --git a/actions/subscriptions/sendPostToSubscribers.ts b/actions/subscriptions/sendPostToSubscribers.ts new file mode 100644 index 00000000..f835a902 --- /dev/null +++ b/actions/subscriptions/sendPostToSubscribers.ts @@ -0,0 +1,100 @@ +"use server"; + +import { getCurrentDeploymentDomain } from "src/utils/getCurrentDeploymentDomain"; +import { createServerClient } from "@supabase/ssr"; +import { and, eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { email_subscriptions_to_entity, entities } from "drizzle/schema"; +import postgres from "postgres"; +import { PermissionToken } from "src/replicache"; +import { Database } from "supabase/database.types"; + +let supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + { cookies: {} } +); +export async function sendPostToSubscribers({ + title, + permission_token, + mailboxEntity, + messageEntity, + contents, +}: { + title: string; + permission_token: PermissionToken; + mailboxEntity: string; + messageEntity: string; + contents: { + html: string; + markdown: string; + }; +}) { + let token_rights = await supabase + .from("permission_tokens") + .select("*, permission_token_rights(*)") + .eq("id", permission_token.id) + .single(); + let rootEntity = token_rights.data?.root_entity; + if (!rootEntity || !token_rights.data) return { title: "Doc not found" }; + let { data } = await supabase.rpc("get_facts", { + root: rootEntity, + }); + + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + let subscribers = await db + .select() + .from(email_subscriptions_to_entity) + .innerJoin(entities, eq(email_subscriptions_to_entity.entity, entities.id)) + .where(eq(email_subscriptions_to_entity.entity, mailboxEntity)); + let entity_set = subscribers[0]?.entities.set; + if ( + !token_rights.data.permission_token_rights.find( + (r) => r.entity_set === entity_set + ) + ) { + return; + } + let domain = getCurrentDeploymentDomain(); + let res = await fetch("https://api.postmarkapp.com/email/batch", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, + }, + body: JSON.stringify( + subscribers.map((sub) => ({ + Headers: [ + { + Name: "List-Unsubscribe-Post", + Value: "List-Unsubscribe=One-Click", + }, + { + Name: "List-Unsubscribe", + Value: `<${domain}/mail/unsubscribe?sub_id=${sub.email_subscriptions_to_entity.id}>`, + }, + ], + MessageStream: "broadcast", + From: "Leaflet Mailbox ", + Subject: `New Mail in: ${title}`, + To: sub.email_subscriptions_to_entity.email, + HtmlBody: ` + You've got new mail from + ${title}! + +
+ ${contents.html} +
+ Manage your subscription at + + ${title} + + `, + TextBody: contents.markdown, + })) + ), + }); + client.end(); + return; +} diff --git a/actions/subscriptions/subscribeToMailboxWithEmail.ts b/actions/subscriptions/subscribeToMailboxWithEmail.ts new file mode 100644 index 00000000..2122a454 --- /dev/null +++ b/actions/subscriptions/subscribeToMailboxWithEmail.ts @@ -0,0 +1,107 @@ +"use server"; + +import * as base64 from "base64-js"; +import { createServerClient } from "@supabase/ssr"; +import { and, eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { email_subscriptions_to_entity } from "drizzle/schema"; +import postgres from "postgres"; +import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; +import { Fact, PermissionToken } from "src/replicache"; +import { Attributes } from "src/replicache/attributes"; +import { Database } from "supabase/database.types"; +import * as Y from "yjs"; +import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; + +let supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + { cookies: {} }, +); +const generateCode = () => { + // Generate a random 6 digit code + let digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + const randomDigit = () => digits[Math.floor(Math.random() * digits.length)]; + return [ + randomDigit(), + randomDigit(), + randomDigit(), + randomDigit(), + randomDigit(), + randomDigit(), + ].join(""); +}; + +export async function subscribeToMailboxWithEmail( + entity: string, + email: string, + token: PermissionToken, +) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + let newCode = generateCode(); + let subscription = await db.transaction(async (tx) => { + let existingEmail = await db + .select() + .from(email_subscriptions_to_entity) + .where( + and( + eq(email_subscriptions_to_entity.entity, entity), + eq(email_subscriptions_to_entity.email, email), + ), + ); + if (existingEmail[0]) return existingEmail[0]; + if (existingEmail.length === 0) { + let newSubscription = await tx + .insert(email_subscriptions_to_entity) + .values({ + token: token.id, + entity, + email, + confirmation_code: newCode, + }) + .returning(); + return newSubscription[0]; + } + }); + if (!subscription) return; + + let res = await fetch("https://api.postmarkapp.com/email", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, + }, + body: JSON.stringify({ + From: "Leaflet Subscriptions ", + Subject: `Your confirmation code is ${subscription.confirmation_code}`, + To: email, + TextBody: `Paste this code to confirm your subscription to a mailbox in ${await getPageTitle(token.root_entity)}: + +${subscription.confirmation_code} + `, + }), + }); + client.end(); + return subscription; +} + +async function getPageTitle(root_entity: string) { + let { data } = await supabase.rpc("get_facts", { + root: root_entity, + }); + let initialFacts = (data as unknown as Fact[]) || []; + let blocks = getBlocksWithTypeLocal(initialFacts, root_entity); + let title = blocks.filter( + (f) => f.type === "text" || f.type === "heading", + )[0]; + let text = initialFacts.find( + (f) => f.entity === title.value && f.attribute === "block/text", + ) as Fact<"block/text"> | undefined; + if (!text) return "Untitled Doc"; + let doc = new Y.Doc(); + const update = base64.toByteArray(text.data.value); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + return YJSFragmentToString(nodes[0]) || "Untitled Leaflet"; +} diff --git a/app/emails/unsubscribe/route.ts b/app/emails/unsubscribe/route.ts new file mode 100644 index 00000000..dc01c7bd --- /dev/null +++ b/app/emails/unsubscribe/route.ts @@ -0,0 +1,22 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { email_subscriptions_to_entity } from "drizzle/schema"; +import postgres from "postgres"; +import { eq } from "drizzle-orm"; + +export async function POST(request: NextRequest) { + let sub_id = request.nextUrl.searchParams.get("sub_id"); + if (!sub_id) return new Response(null, { status: 404 }); + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + try { + await db + .delete(email_subscriptions_to_entity) + .where(eq(email_subscriptions_to_entity.id, sub_id)); + } catch (error) { + console.log(error); + } + client.end(); + return new Response(null, { status: 200 }); +} diff --git a/app/globals.css b/app/globals.css index a54dfa66..8f6d5db4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -122,6 +122,15 @@ display: none; } +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +input[type="number"] { + -moz-appearance: textfield; +} + .highlight { @apply px-[1px]; @apply py-[1px]; diff --git a/app/home/DocOptions.tsx b/app/home/DocOptions.tsx index fb3af355..32b358c8 100644 --- a/app/home/DocOptions.tsx +++ b/app/home/DocOptions.tsx @@ -1,11 +1,6 @@ "use client"; -import { PopoverArrow } from "components/Icons"; import { DeleteSmall, MoreOptionsTiny } from "components/Icons"; -import * as Popover from "@radix-ui/react-popover"; import { Menu, MenuItem } from "components/Layout"; -import { theme } from "tailwind.config"; -import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; -import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; export const DocOptions = (props: { doc_id: string; @@ -13,36 +8,22 @@ export const DocOptions = (props: { }) => { return ( <> -
- - - - - - - - - - { - props.setState("deleting"); - }} - > - - Delete Doc - - - - - - - - - -
+ + + + } + > + { + props.setState("deleting"); + }} + > + + Delete Doc + + ); }; diff --git a/app/home/DocPreview.tsx b/app/home/DocPreview.tsx index 6f5f2d25..eef16556 100644 --- a/app/home/DocPreview.tsx +++ b/app/home/DocPreview.tsx @@ -27,7 +27,7 @@ export const DocPreview = (props: { href={"/" + props.token.id} className={`no-underline hover:no-underline text-primary h-full`} > -
+
)} {state === "normal" && ( - +
+ +
)}
diff --git a/components/Blocks/BlockOptions.tsx b/components/Blocks/BlockOptions.tsx index c2fa6801..5e56d3ce 100644 --- a/components/Blocks/BlockOptions.tsx +++ b/components/Blocks/BlockOptions.tsx @@ -11,6 +11,7 @@ import { Header3Small, LinkSmall, LinkTextToolbarSmall, + MailboxSmall, ParagraphSmall, } from "components/Icons"; import { generateKeyBetween } from "fractional-indexing"; @@ -26,8 +27,8 @@ import { ToolbarButton } from "components/Toolbar"; import * as Tooltip from "@radix-ui/react-tooltip"; import { TextBlockTypeButton, - TextBlockTypeButtons, -} from "components/Toolbar/TextBlockTypeButtons"; + TextBlockTypeToolbar, +} from "components/Toolbar/TextBlockTypeToolbar"; import { isUrl } from "src/utils/isURL"; import { useSmoker, useToaster } from "components/Toast"; @@ -109,15 +110,6 @@ export function BlockOptions(props: Props) {
- { - setblockMenuState("link"); - }} - > - - + { + setblockMenuState("link"); + }} + > + + + + { + let entity; + if (!props.entityID) { + entity = v7(); + await rep?.mutate.addBlock({ + parent: props.parent, + factID: v7(), + permission_set: entity_set.set, + type: "mailbox", + position: generateKeyBetween( + props.position, + props.nextPosition, + ), + newEntityID: entity, + }); + } else { + entity = props.entityID; + await rep?.mutate.assertFact({ + entity, + attribute: "block/type", + data: { type: "block-type-union", value: "mailbox" }, + }); + } + }} + > + + - setblockMenuState("default")} /> diff --git a/components/Blocks/CardBlock.tsx b/components/Blocks/CardBlock.tsx index 0447c661..7201d569 100644 --- a/components/Blocks/CardBlock.tsx +++ b/components/Blocks/CardBlock.tsx @@ -11,10 +11,11 @@ import { useEntity, useReplicache } from "src/replicache"; import { useUIState } from "src/useUIState"; import { RenderedTextBlock } from "components/Blocks/TextBlock"; import { useDocMetadata } from "src/hooks/queries/useDocMetadata"; -import { CloseTiny } from "components/Icons"; +import { CloseTiny, TrashSmall } from "components/Icons"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useEntitySetContext } from "components/EntitySetProvider"; import { useBlocks } from "src/hooks/queries/useBlocks"; +import { AreYouSure } from "./DeleteBlock"; export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { let { rep } = useReplicache(); @@ -31,18 +32,12 @@ export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { let isOpen = useUIState((s) => s.openCards).includes(cardEntity); let [areYouSure, setAreYouSure] = useState(false); - useEffect(() => { if (!isSelected) { setAreYouSure(false); } }, [isSelected]); - useEffect(() => { - if (isSelected) { - } - }, [isSelected]); - useEffect(() => { if (!isSelected) return; let listener = (e: KeyboardEvent) => { @@ -98,33 +93,11 @@ export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { } }} > - {/* if the block is not focused, set are you sure to false*/} - {areYouSure && isSelected ? ( -
-
Delete this Page?
-
- - -
-
+ {areYouSure ? ( + setAreYouSure(false)} + entityID={props.entityID} + /> ) : ( <>
)} -
)} @@ -186,7 +158,7 @@ export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { setAreYouSure(true); }} > - + )} diff --git a/components/Blocks/DeleteBlock.tsx b/components/Blocks/DeleteBlock.tsx new file mode 100644 index 00000000..2a89d9c6 --- /dev/null +++ b/components/Blocks/DeleteBlock.tsx @@ -0,0 +1,51 @@ +import { useEffect } from "react"; +import { useEntity, useReplicache } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { focusBlock } from "."; + +export const AreYouSure = (props: { + entityID: string; + onClick?: () => void; + closeAreYouSure: () => void; +}) => { + let { rep } = useReplicache(); + let card = useEntity(props.entityID, "block/card"); + let cardID = card ? card.data.value : props.entityID; + let type = useEntity(props.entityID, "block/type")?.data.value; + + return ( +
+
+ Delete this{" "} + {type === "card" ? Page : Mailbox and Posts}?{" "} +
+
+ + +
+
+ ); +}; diff --git a/components/Blocks/ExternalLinkBlock.tsx b/components/Blocks/ExternalLinkBlock.tsx index f816122e..4e640b5b 100644 --- a/components/Blocks/ExternalLinkBlock.tsx +++ b/components/Blocks/ExternalLinkBlock.tsx @@ -1,5 +1,5 @@ import { useEntity, useReplicache } from "src/replicache"; -import { CloseTiny } from "components/Icons"; +import { CloseTiny, TrashSmall } from "components/Icons"; import { useEntitySetContext } from "components/EntitySetProvider"; import { useUIState } from "src/useUIState"; @@ -68,7 +68,7 @@ export const ExternalLinkBlock = (props: { entityID: string }) => { }); }} > - + )} diff --git a/components/Blocks/MailboxBlock.tsx b/components/Blocks/MailboxBlock.tsx new file mode 100644 index 00000000..2e087be9 --- /dev/null +++ b/components/Blocks/MailboxBlock.tsx @@ -0,0 +1,587 @@ +import { ButtonPrimary } from "components/Buttons"; +import { ArrowDownTiny, InfoSmall } from "components/Icons"; +import { Popover } from "components/Popover"; +import { Menu, MenuItem, Separator } from "components/Layout"; +import { useUIState } from "src/useUIState"; +import { useEffect, useState } from "react"; +import { useSmoker, useToaster } from "components/Toast"; +import { BlockProps } from "."; +import { useEntity, useReplicache } from "src/replicache"; +import { AreYouSure } from "./DeleteBlock"; +import { focusBlock } from "."; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; +import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; +import { focusCard } from "components/Cards"; +import { v7 } from "uuid"; +import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; +import { getBlocksWithType } from "src/hooks/queries/useBlocks"; +import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML"; +import { htmlToMarkdown } from "src/htmlMarkdownParsers"; +import { + addSubscription, + removeSubscription, + unsubscribe, + useSubscriptionStatus, +} from "src/hooks/useSubscriptionStatus"; +import { scanIndex } from "src/replicache/utils"; +import { usePageTitle } from "components/utils/UpdatePageTitle"; + +export const MailboxBlock = (props: BlockProps) => { + let isSubscribed = useSubscriptionStatus(props.entityID); + let [areYouSure, setAreYouSure] = useState(false); + let isSelected = useUIState((s) => + s.selectedBlock.find((b) => b.value === props.entityID), + ); + + let card = useEntity(props.entityID, "block/card"); + let cardEntity = card ? card.data.value : props.entityID; + let permission = useEntitySetContext().permissions.write; + + let { rep } = useReplicache(); + + let smoke = useSmoker(); + + useEffect(() => { + if (!isSelected) { + setAreYouSure(false); + } + }, [isSelected]); + let draft = useEntity(props.entityID, "mailbox/draft"); + let entity_set = useEntitySetContext(); + let archive = useEntity(props.entityID, "mailbox/archive"); + + useEffect(() => { + if (!isSelected) return; + let listener = (e: KeyboardEvent) => { + let el = e.target as HTMLElement; + if ( + el.tagName === "INPUT" || + el.tagName === "textarea" || + el.contentEditable === "true" + ) + return; + if (e.key === "Backspace" && permission) { + if (e.defaultPrevented) return; + if (areYouSure === false) { + setAreYouSure(true); + } else { + e.preventDefault(); + + rep && + rep.mutate.removeBlock({ + blockEntity: props.entityID, + }); + + props.previousBlock && + focusBlock(props.previousBlock, { type: "end" }); + + draft && useUIState.getState().closeCard(draft.data.value); + archive && useUIState.getState().closeCard(archive.data.value); + } + } + }; + window.addEventListener("keydown", listener); + return () => window.removeEventListener("keydown", listener); + }, [ + draft, + archive, + areYouSure, + cardEntity, + isSelected, + permission, + props.entityID, + props.previousBlock, + rep, + ]); + + let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); + if (!permission) + return ( + + ); + + return ( +
+
+ {!areYouSure ? ( +
+ { + let entity; + if (draft) { + entity = draft.data.value; + } else { + entity = v7(); + await rep?.mutate.createDraft({ + mailboxEntity: props.entityID, + permission_set: entity_set.set, + newEntity: entity, + firstBlockEntity: v7(), + firstBlockFactID: v7(), + }); + } + useUIState.getState().openCard(props.parent, entity); + if (rep) focusCard(entity, rep, "focusFirstBlock"); + return; + }} + > + {draft ? "Edit Draft" : "Write a Post"} + + +
+ ) : ( + setAreYouSure(false)} + onClick={() => { + draft && useUIState.getState().closeCard(draft.data.value); + archive && useUIState.getState().closeCard(archive.data.value); + }} + /> + )} +
+
+ { + <> + {!isSubscribed?.confirmed ? ( + + ) : ( + + )} +
+ + {!subscriber_count || + subscriber_count?.data.value === undefined || + subscriber_count?.data.value === 0 + ? "no" + : subscriber_count?.data.value}{" "} + reader + {subscriber_count?.data.value === 1 ? "" : "s"} + + + + +
+ + } +
+
+ ); +}; + +const MailboxReaderView = (props: { entityID: string; parent: string }) => { + let isSubscribed = useSubscriptionStatus(props.entityID); + let isSelected = useUIState((s) => + s.selectedBlock.find((b) => b.value === props.entityID), + ); + let archive = useEntity(props.entityID, "mailbox/archive"); + let smoke = useSmoker(); + let { rep } = useReplicache(); + return ( +
+
+
+ {!isSubscribed ? ( + <> + + + ) : ( +
+
+ You're Subscribed! +
+
+ {archive ? ( + { + e.preventDefault(); + if (rep) { + useUIState + .getState() + .openCard(props.parent, archive.data.value); + focusCard(archive.data.value, rep); + } + }} + > + See All Posts + + ) : ( +
+ Nothing has been posted yet +
+ )} + +
+
+ )} +
+
+
+ ); +}; + +const MailboxInfo = (props: { subscriber?: boolean }) => { + return ( + } + content={ +
+ {props.subscriber ? ( + <> +

+ Get a notification whenever the creator posts to this mailbox! +

+

+ Your contact info will be kept private, and you can unsubscribe + anytime. +

+ + ) : ( + <> +

+ When you post to this mailbox, subscribers will be notified! +

+

Reader contact info is kept private.

+

You can have one draft post at a time.

+ + )} +
+ } + /> + ); +}; + +const SubscribePopover = (props: { + entityID: string; + parent: string; + unconfirmed: boolean; +}) => { + return ( + + {props.unconfirmed ? "Confirm" : "Subscribe"} +
+ } + content={ +
+ +
+ } + /> + ); +}; + +const SubscribeForm = (props: { + entityID: string; + parent: string; + role: "author" | "reader"; + compact?: boolean; +}) => { + let smoke = useSmoker(); + let [channel, setChannel] = useState<"email" | "sms">("email"); + let [email, setEmail] = useState(""); + let [sms, setSMS] = useState(""); + + let subscription = useSubscriptionStatus(props.entityID); + let [code, setCode] = useState(""); + let { permission_token } = useReplicache(); + if (subscription && !subscription.confirmed) { + return ( +
+
+ Enter the code we sent to{" "} + + {subscription.email} + {" "} + here! +
+
+
{ + e.preventDefault(); + let result = await confirmEmailSubscription( + subscription.id, + code, + ); + + let rect = document + .getElementById("confirm-code-button") + ?.getBoundingClientRect(); + + if (!result) { + smoke({ + error: true, + text: "oops, incorrect code", + position: { + x: rect ? rect.left + 45 : 0, + y: rect ? rect.top + 15 : 0, + }, + }); + return; + } + addSubscription(result.subscription); + }} + className="mailboxConfirmCodeInput flex gap-2 items-center mx-auto" + > + setCode(e.currentTarget.value)} + /> + + + Confirm! + +
+ + +
+
+ ); + } + return ( + <> +
+
{ + e.preventDefault(); + let subscriptionID = await subscribeToMailboxWithEmail( + props.entityID, + email, + permission_token, + ); + if (subscriptionID) addSubscription(subscriptionID); + }} + 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`} + > +
+ { + setChannel(channel); + }} + /> + + {channel === "email" ? ( + setEmail(e.target.value)} + className="w-full appearance-none focus:outline-none bg-transparent" + placeholder="youremail@email.com" + /> + ) : ( + setSMS(e.target.value)} + className="w-full appearance-none focus:outline-none bg-transparent" + placeholder="123-456-7890" + /> + )} +
+ Subscribe! +
+ {props.role === "reader" && ( + + )} +
+ + ); +}; + +const ChannelSelector = (props: { + channel: "email" | "sms"; + setChannel: (channel: "email" | "sms") => void; +}) => { + return ( + + {props.channel === "email" ? "Email" : "SMS"}{" "} + +
+ } + > + { + props.setChannel("email"); + }} + > + Email + + { + props.setChannel("sms"); + }} + > + SMS + + + ); +}; +export const DraftPostOptions = (props: { mailboxEntity: string }) => { + let toaster = useToaster(); + let draft = useEntity(props.mailboxEntity, "mailbox/draft"); + let { rep, permission_token } = useReplicache(); + let entity_set = useEntitySetContext(); + let archive = useEntity(props.mailboxEntity, "mailbox/archive"); + let pagetitle = usePageTitle(permission_token.root_entity); + let subscriber_count = useEntity( + props.mailboxEntity, + "mailbox/subscriber-count", + ); + if (!draft) return null; + + // once the send button is clicked, close the card and show a toast. + return ( +
+
+ Draft +
+ +
+ ); +}; + +const GoToArchive = (props: { + entityID: string; + parent: string; + small?: boolean; +}) => { + let archive = useEntity(props.entityID, "mailbox/archive"); + let { rep } = useReplicache(); + + return archive ? ( + + ) : ( +
+ no posts yet +
+ ); +}; diff --git a/components/Blocks/TextBlock/keymap.ts b/components/Blocks/TextBlock/keymap.ts index ab27de59..c081dba2 100644 --- a/components/Blocks/TextBlock/keymap.ts +++ b/components/Blocks/TextBlock/keymap.ts @@ -326,7 +326,6 @@ const enter = propsRef.current.nextBlock?.listData && propsRef.current.nextBlock.listData.depth > propsRef.current.listData.depth; - console.log(propsRef); position = generateKeyBetween( hasChild ? null : propsRef.current.position, propsRef.current.nextPosition, diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index c0c1a521..572a3969 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -11,6 +11,8 @@ import { useUIState } from "src/useUIState"; import { CardBlock } from "./CardBlock"; import { ExternalLinkBlock } from "./ExternalLinkBlock"; import { BlockOptions } from "./BlockOptions"; +import { MailboxBlock } from "./MailboxBlock"; + import { useBlocks } from "src/hooks/queries/useBlocks"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { useEntitySetContext } from "components/EntitySetProvider"; @@ -296,7 +298,7 @@ function Block(props: BlockProps) { if (e.key === "Backspace") { if (!entity_set.permissions.write) return; if (textBlocks[props.type]) return; - if (props.type === "card") return; + if (props.type === "card" || props.type === "mailbox") return; e.preventDefault(); r.mutate.removeBlock({ blockEntity: props.entityID }); useUIState.getState().closeCard(props.entityID); @@ -406,6 +408,10 @@ export const BaseBlock = (props: BlockProps & { preview?: boolean }) => { ) : props.type === "link" ? ( + ) : props.type === "mailbox" ? ( +
+ +
) : null} ); diff --git a/components/Buttons.tsx b/components/Buttons.tsx index 08224c26..8d9eb2ee 100644 --- a/components/Buttons.tsx +++ b/components/Buttons.tsx @@ -9,7 +9,7 @@ export function ButtonPrimary( return ( + ); }; diff --git a/components/MobileFooter.tsx b/components/MobileFooter.tsx index da49ae0c..d6c863d9 100644 --- a/components/MobileFooter.tsx +++ b/components/MobileFooter.tsx @@ -2,7 +2,7 @@ import { useUIState } from "src/useUIState"; import { Media } from "./Media"; import { ThemePopover } from "./ThemeManager/ThemeSetter"; -import { TextToolbar } from "components/Toolbar"; +import { Toolbar } from "components/Toolbar"; import { ShareOptions } from "./ShareOptions"; import { HomeButton } from "./HomeButton"; @@ -18,7 +18,7 @@ export function MobileFooter(props: { entityID: string }) { if (e.currentTarget === e.target) e.preventDefault(); }} > - diff --git a/components/Popover.tsx b/components/Popover.tsx new file mode 100644 index 00000000..b9b1b1ba --- /dev/null +++ b/components/Popover.tsx @@ -0,0 +1,36 @@ +import * as RadixPopover from "@radix-ui/react-popover"; +import { PopoverArrow } from "./Icons"; +import { theme } from "tailwind.config"; + +export const Popover = (props: { + trigger: React.ReactNode; + content: React.ReactNode; + align?: "start" | "end" | "center"; + background?: string; + border?: string; + className?: string; +}) => { + return ( + + {props.trigger} + + + {props.content} + + + + + + + ); +}; diff --git a/components/SelectionManager.tsx b/components/SelectionManager.tsx index d8fa5196..6f903ab7 100644 --- a/components/SelectionManager.tsx +++ b/components/SelectionManager.tsx @@ -86,7 +86,6 @@ export function SelectionManager() { let [sortedBlocks, siblings] = await getSortedSelection(); for (let block of sortedBlocks) { if (!block.listData) { - console.log("yo?"); await rep?.mutate.assertFact({ entity: block.value, attribute: "block/is-list", diff --git a/components/ShareOptions/index.tsx b/components/ShareOptions/index.tsx index ed45afa7..69f44e5d 100644 --- a/components/ShareOptions/index.tsx +++ b/components/ShareOptions/index.tsx @@ -1,12 +1,10 @@ import { useReplicache } from "src/replicache"; -import { PopoverArrow, ShareSmall } from "components/Icons"; +import { ShareSmall } from "components/Icons"; import { useEffect, useState } from "react"; import { getShareLink } from "./getShareLink"; import { useEntitySetContext } from "components/EntitySetProvider"; import { useSmoker } from "components/Toast"; -import * as Popover from "@radix-ui/react-popover"; import { Menu, MenuItem } from "components/Layout"; -import { theme } from "tailwind.config"; import { HoverButton } from "components/Buttons"; export function ShareOptions(props: { rootEntity: string }) { @@ -37,74 +35,74 @@ export function ShareOptions(props: { rootEntity: string }) { return null; return ( - - + label="Share" background="bg-accent-1" text="text-accent-2" /> - - - - - { - if (link) { - navigator.clipboard.writeText( - `${location.protocol}//${location.host}/${link}`, - ); - smoker({ - position: { x: e.clientX, y: e.clientY }, - text: "Publish link copied!", - }); - } - }} - > -
-
- Publish -
-
- Share a read only version of this doc -
-
-
- { - if (link) { - navigator.clipboard.writeText(`${window.location.href}`); - smoker({ - position: { x: e.clientX, y: e.clientY }, - text: "Collab link copied!", - }); - } - }} - > -
-
- Collaborate -
-
- Invite people to work together -
-
-
-
- - - -
-
- + } + > + { + event.preventDefault(); + let rect = document + .getElementById("get-publish-link") + ?.getBoundingClientRect(); + if (link) { + navigator.clipboard.writeText( + `${location.protocol}//${location.host}/${link}`, + ); + smoker({ + position: { + x: rect ? rect.left + 80 : 0, + y: rect ? rect.top + 26 : 0, + }, + text: "Publish link copied!", + }); + } + }} + > +
+
+ Publish +
+
+ Share a read only version of this doc +
+
+
+ { + event.preventDefault(); + let rect = document + .getElementById("get-collab-link") + ?.getBoundingClientRect(); + if (link) { + navigator.clipboard.writeText(`${window.location.href}`); + smoker({ + position: { + x: rect ? rect.left + 80 : 0, + y: rect ? rect.top + 26 : 0, + }, + text: "Collab link copied!", + }); + } + }} + > +
+
+ Collaborate +
+
+ Invite people to work together +
+
+
{" "} +
); } diff --git a/components/Toast.tsx b/components/Toast.tsx index 7368190f..1f7924b4 100644 --- a/components/Toast.tsx +++ b/components/Toast.tsx @@ -59,7 +59,7 @@ export const PopUpProvider: React.FC> = ( () => { setToastState(null); }, - toast?.duration ? toast.duration : 3000, + toast?.duration ? toast.duration : 6000, ); }, [setToastState], @@ -84,9 +84,9 @@ const Toast = (props: { setToast: (t: Toast | null) => void; }) => { let transitions = useTransition(props.toast ? [props.toast] : [], { - from: { top: -30 }, + from: { top: -40 }, enter: { top: 8 }, - leave: { top: -30 }, + leave: { top: -40 }, config: { mass: 8, friction: 150, @@ -101,12 +101,12 @@ const Toast = (props: { className={`toastAnimationWrapper fixed bottom-0 right-0 left-0 z-50 h-fit`} >
{item.content}
diff --git a/components/Toolbar/HighlightButton.tsx b/components/Toolbar/HighlightToolbar.tsx similarity index 69% rename from components/Toolbar/HighlightButton.tsx rename to components/Toolbar/HighlightToolbar.tsx index e5632435..22c29440 100644 --- a/components/Toolbar/HighlightButton.tsx +++ b/components/Toolbar/HighlightToolbar.tsx @@ -1,25 +1,14 @@ -import { setEditorState, useEditorStates } from "src/state/useEditorState"; +import { useEditorStates } from "src/state/useEditorState"; import { useUIState } from "src/useUIState"; import { schema } from "components/Blocks/TextBlock/schema"; import { TextSelection } from "prosemirror-state"; -import { toggleMarkInFocusedBlock } from "./TextDecorationButton"; +import { + TextDecorationButton, + toggleMarkInFocusedBlock, +} from "./TextDecorationButton"; import * as Popover from "@radix-ui/react-popover"; import * as Tooltip from "@radix-ui/react-tooltip"; - import { theme } from "../../tailwind.config"; - -import { - ColorPicker as SpectrumColorPicker, - parseColor, - Color, - ColorArea, - ColorThumb, - ColorSlider, - Input, - ColorField, - SliderTrack, - ColorSwatch, -} from "react-aria-components"; import { ColorPicker, pickers, @@ -27,13 +16,123 @@ import { setColorAttribute, } from "components/ThemeManager/ThemeSetter"; import { useEntity, useReplicache } from "src/replicache"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; import { useParams } from "next/navigation"; import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; -import { PaintSmall, PopoverArrow } from "components/Icons"; +import { + ArrowRightTiny, + HighlightSmall, + PaintSmall, + PopoverArrow, +} from "components/Icons"; +import { Separator, ShortcutKey } from "components/Layout"; +import { isMac } from "@react-aria/utils"; import { ToolbarButton } from "."; +export const HighlightButton = (props: { + lastUsedHighlight: string; + setToolbarState: (s: "highlight") => void; +}) => { + return ( +
+ +
+ Highlight +
+
+ {isMac() ? ( + <> + ⌘ +{" "} + Ctrl +{" "} + H + + ) : ( + <> + Ctrl +{" "} + Meta +{" "} + H + + )} +
+
+ } + attrs={{ color: props.lastUsedHighlight }} + mark={schema.marks.highlight} + icon={ + + } + /> + + { + props.setToolbarState("highlight"); + }} + className="-ml-1" + > + + +
+ ); +}; + +export const HighlightToolbar = (props: { + onClose: () => void; + lastUsedHighlight: "1" | "2" | "3"; + setLastUsedHighlight: (color: "1" | "2" | "3") => void; +}) => { + let focusedBlock = useUIState((s) => s.focusedBlock); + let focusedEditor = useEditorStates((s) => + focusedBlock ? s.editorStates[focusedBlock.entityID] : null, + ); + let [initialRender, setInitialRender] = useState(true); + useEffect(() => { + setInitialRender(false); + }, []); + + useEffect(() => { + // we're not returning initialRender in the dependancy array on purpose! although admittedly, can't remember why not... + if (initialRender) return; + if (focusedEditor) props.onClose(); + }, [focusedEditor, props]); + + return ( +
+
+ + + + + + +
+
+ ); +}; + export const HighlightColorButton = (props: { color: "1" | "2" | "3"; lastUsedHighlight: "1" | "2" | "3"; diff --git a/components/Toolbar/LinkButton.tsx b/components/Toolbar/InlineLinkToolbar.tsx similarity index 98% rename from components/Toolbar/LinkButton.tsx rename to components/Toolbar/InlineLinkToolbar.tsx index c37fcb5a..fa1be833 100644 --- a/components/Toolbar/LinkButton.tsx +++ b/components/Toolbar/InlineLinkToolbar.tsx @@ -45,7 +45,7 @@ export function LinkButton(props: { setToolbarState: (s: "link") => void }) { ); } -export function LinkEditor(props: { onClose: () => void }) { +export function InlineLinkToolbar(props: { onClose: () => void }) { let focusedBlock = useUIState((s) => s.focusedBlock); let focusedEditor = useEditorStates((s) => focusedBlock ? s.editorStates[focusedBlock.entityID] : null, diff --git a/components/Toolbar/ListButton.tsx b/components/Toolbar/ListToolbar.tsx similarity index 98% rename from components/Toolbar/ListButton.tsx rename to components/Toolbar/ListToolbar.tsx index 6a22fcbe..004a44f4 100644 --- a/components/Toolbar/ListButton.tsx +++ b/components/Toolbar/ListToolbar.tsx @@ -38,7 +38,8 @@ export const ListButton = (props: { setToolbarState: (s: "list") => void }) => { } - onClick={() => { + onClick={(e) => { + e.preventDefault(); if (!focusedBlock) return; if (!isList?.data.value) { rep?.mutate.assertFact({ diff --git a/components/Toolbar/TextBlockTypeButtons.tsx b/components/Toolbar/TextBlockTypeToolbar.tsx similarity index 98% rename from components/Toolbar/TextBlockTypeButtons.tsx rename to components/Toolbar/TextBlockTypeToolbar.tsx index 1f8a25b9..e7d102e1 100644 --- a/components/Toolbar/TextBlockTypeButtons.tsx +++ b/components/Toolbar/TextBlockTypeToolbar.tsx @@ -4,7 +4,7 @@ import { Header3Small, ParagraphSmall, } from "components/Icons"; -import { Separator, ShortcutKey } from "components/Layout"; +import { ShortcutKey } from "components/Layout"; import { ToolbarButton } from "components/Toolbar"; import { TextSelection } from "prosemirror-state"; import { useCallback } from "react"; @@ -12,7 +12,7 @@ import { useEntity, useReplicache } from "src/replicache"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { useUIState } from "src/useUIState"; -export const TextBlockTypeButtons = (props: { +export const TextBlockTypeToolbar = (props: { onClose: () => void; className?: string; }) => { diff --git a/components/Toolbar/TextToolbar.tsx b/components/Toolbar/TextToolbar.tsx new file mode 100644 index 00000000..ea36b700 --- /dev/null +++ b/components/Toolbar/TextToolbar.tsx @@ -0,0 +1,81 @@ +import { isMac } from "@react-aria/utils"; +import { BoldSmall, ItalicSmall, StrikethroughSmall } from "components/Icons"; +import { Separator, ShortcutKey } from "components/Layout"; +import { metaKey } from "src/utils/metaKey"; +import { LinkButton } from "./InlineLinkToolbar"; +import { ListButton } from "./ListToolbar"; +import { TextBlockTypeButton } from "./TextBlockTypeToolbar"; +import { TextDecorationButton } from "./TextDecorationButton"; +import { HighlightButton } from "./HighlightToolbar"; +import { ToolbarTypes } from "."; +import { schema } from "components/Blocks/TextBlock/schema"; + +export const TextToolbar = (props: { + lastUsedHighlight: string; + setToolbarState: (s: ToolbarTypes) => void; +}) => { + return ( + <> + +
Bold
+
+ {metaKey()} +{" "} + B +
+ + } + mark={schema.marks.strong} + icon={} + /> + +
Italic
+
+ {metaKey()} +{" "} + I +
+ + mark={schema.marks.em} + icon={} + /> + +
+ Strikethrough +
+
+ {isMac() ? ( + <> + ⌘ +{" "} + Ctrl +{" "} + X + + ) : ( + <> + Ctrl +{" "} + Meta +{" "} + X + + )} +
+ + } + mark={schema.marks.strikethrough} + icon={} + /> + + + + + + + + + ); +}; diff --git a/components/Toolbar/index.tsx b/components/Toolbar/index.tsx index d52c448f..d005eb44 100644 --- a/components/Toolbar/index.tsx +++ b/components/Toolbar/index.tsx @@ -5,9 +5,6 @@ import { BoldSmall, CloseTiny, ItalicSmall, - ListUnorderedSmall, - ListIndentDecreaseSmall, - ListIndentIncreaseSmall, StrikethroughSmall, HighlightSmall, PopoverArrow, @@ -18,33 +15,35 @@ import { TextDecorationButton } from "./TextDecorationButton"; import { keepFocus, TextBlockTypeButton, - TextBlockTypeButtons, -} from "./TextBlockTypeButtons"; -import { LinkButton, LinkEditor } from "./LinkButton"; -import { - HighlightColorButton, - HighlightColorSettings, -} from "./HighlightButton"; + TextBlockTypeToolbar, +} from "./TextBlockTypeToolbar"; +import { LinkButton, InlineLinkToolbar } from "./InlineLinkToolbar"; import { theme } from "../../tailwind.config"; import { useEditorStates } from "src/state/useEditorState"; import { useUIState } from "src/useUIState"; -import { useEntity, useReplicache } from "src/replicache"; +import { useReplicache } from "src/replicache"; import * as Tooltip from "@radix-ui/react-tooltip"; import { Separator, ShortcutKey } from "components/Layout"; import { metaKey } from "src/utils/metaKey"; import { isMac } from "@react-aria/utils"; import { addShortcut } from "src/shortcuts"; -import { useBlocks } from "src/hooks/queries/useBlocks"; -import { indent, outdent } from "src/utils/list-operations"; -import { ListButton, ListToolbar } from "./ListButton"; +import { ListButton, ListToolbar } from "./ListToolbar"; +import { HighlightToolbar } from "./HighlightToolbar"; +import { TextToolbar } from "./TextToolbar"; + +export type ToolbarTypes = + | "default" + | "highlight" + | "link" + | "header" + | "list" + | "linkBlock"; -export const TextToolbar = (props: { cardID: string; blockID: string }) => { +export const Toolbar = (props: { cardID: string; blockID: string }) => { let { rep } = useReplicache(); let focusedBlock = useUIState((s) => s.focusedBlock); - let [toolbarState, setToolbarState] = useState< - "default" | "highlight" | "link" | "header" | "list" | "linkBlock" - >("default"); + let [toolbarState, setToolbarState] = useState("default"); let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); let setLastUsedHighlight = (color: "1" | "2" | "3") => @@ -73,113 +72,12 @@ export const TextToolbar = (props: { cardID: string; blockID: string }) => {
{toolbarState === "default" ? ( - <> - -
Bold
-
- {metaKey()} +{" "} - B -
-
- } - mark={schema.marks.strong} - icon={} - /> - -
Italic
-
- {metaKey()} +{" "} - I -
-
- mark={schema.marks.em} - icon={} - /> - -
- Strikethrough -
-
- {isMac() ? ( - <> - ⌘ +{" "} - Ctrl +{" "} - X - - ) : ( - <> - Ctrl +{" "} - Meta +{" "} - X - - )} -
- - } - mark={schema.marks.strikethrough} - icon={} - /> -
- -
- Highlight -
-
- {isMac() ? ( - <> - ⌘ +{" "} - Ctrl +{" "} - H - - ) : ( - <> - Ctrl +{" "} - Meta +{" "} - H - - )} -
-
- } - attrs={{ color: lastUsedHighlight }} - mark={schema.marks.highlight} - icon={ - - } - /> - - { - setToolbarState("highlight"); - }} - className="-ml-1" - > - - - - - - - - - - + { + setToolbarState(s); + }} + /> ) : toolbarState === "highlight" ? ( setToolbarState("default")} @@ -191,14 +89,14 @@ export const TextToolbar = (props: { cardID: string; blockID: string }) => { ) : toolbarState === "list" ? ( setToolbarState("default")} /> ) : toolbarState === "link" ? ( - { activeEditor?.view?.focus(); setToolbarState("default"); }} /> ) : toolbarState === "header" ? ( - setToolbarState("default")} /> + setToolbarState("default")} /> ) : null}