diff --git a/excalidraw-app/components/AtprotoCreateAccountDialog.tsx b/excalidraw-app/components/AtprotoCreateAccountDialog.tsx new file mode 100644 index 00000000..5b5c5fe6 --- /dev/null +++ b/excalidraw-app/components/AtprotoCreateAccountDialog.tsx @@ -0,0 +1,93 @@ +import { Dialog } from "@excalidraw/excalidraw/components/Dialog"; +import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton"; +import { useState } from "react"; + +import { createAccount } from "../data/atproto/client"; + +/** + * Deliberate speed bump in front of account creation: an Atmosphere identity is + * shared across every atproto app, so most people asking for a new account + * already have one and would only fragment their data by making a second. + */ +export const AtprotoCreateAccountDialog = ({ + onCancel, +}: { + onCancel: () => void; +}) => { + const [isRedirecting, setIsRedirecting] = useState(false); + const [error, setError] = useState(null); + + const onConfirm = async () => { + if (isRedirecting) { + return; + } + setIsRedirecting(true); + setError(null); + try { + // redirects away on success; local drawing is preserved via LocalData + await createAccount(); + // reaching this point means the user navigated back / aborted + setIsRedirecting(false); + } catch (error: any) { + console.error(error); + setError(error.message); + setIsRedirecting(false); + } + }; + + return ( + +
+
+

+ An Atmosphere account isn't specific to Lexidraw. It's the same + account you use for{" "} + + Bluesky + + ,{" "} + + Tangled + + ,{" "} + + stream.place + + ,{" "} + + leaflet.pub + + , and{" "} + + more + + . If any of those are yours, go back and sign in with that handle + instead. +

+

+ Still need one? We'll send you to selfhosted.social to + sign up, then bring you back here. +

+
+ {error &&

{error}

} +
+ + +
+
+
+ ); +}; diff --git a/excalidraw-app/components/AtprotoSignInDialog.scss b/excalidraw-app/components/AtprotoSignInDialog.scss new file mode 100644 index 00000000..eed7f621 --- /dev/null +++ b/excalidraw-app/components/AtprotoSignInDialog.scss @@ -0,0 +1,61 @@ +.excalidraw { + .AtprotoSignInDialog { + display: flex; + flex-direction: column; + gap: 1rem; + + &__intro { + margin: 0; + } + + &__error { + margin: 0; + color: var(--color-danger); + } + + &__create { + display: flex; + flex-direction: column; + gap: 0.75rem; + + padding-top: 1rem; + border-top: 1px solid var(--dialog-border-color); + font-size: 0.875rem; + line-height: 150%; + + p { + margin: 0; + } + } + } + + .AtprotoCreateAccountDialog { + display: flex; + flex-direction: column; + gap: 1.5rem; + + &__body { + display: flex; + flex-direction: column; + gap: 0.75rem; + + font-size: 0.875rem; + line-height: 150%; + + p { + margin: 0; + } + } + + &__error { + margin: 0; + color: var(--color-danger); + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + } + } +} diff --git a/excalidraw-app/components/AtprotoSignInDialog.tsx b/excalidraw-app/components/AtprotoSignInDialog.tsx index 29549cf5..818b6e73 100644 --- a/excalidraw-app/components/AtprotoSignInDialog.tsx +++ b/excalidraw-app/components/AtprotoSignInDialog.tsx @@ -8,6 +8,10 @@ import { useState } from "react"; import { atom, useAtom } from "../app-jotai"; import { signIn } from "../data/atproto/client"; +import { AtprotoCreateAccountDialog } from "./AtprotoCreateAccountDialog"; + +import "./AtprotoSignInDialog.scss"; + export const signInDialogOpenAtom = atom(false); export const AtprotoSignInDialog = () => { @@ -15,6 +19,7 @@ export const AtprotoSignInDialog = () => { const [handle, setHandle] = useState(""); const [isRedirecting, setIsRedirecting] = useState(false); const [error, setError] = useState(null); + const [showCreateConfirm, setShowCreateConfirm] = useState(false); if (!isOpen) { return null; @@ -45,8 +50,8 @@ export const AtprotoSignInDialog = () => { onCloseRequest={() => setIsOpen(false)} title="Sign in" > -
-

+

+

Enter your Atmosphere handle (e.g. alice.bsky.social) to sign in with atproto. Your drawings are saved to your own account.

@@ -58,16 +63,28 @@ export const AtprotoSignInDialog = () => { onChange={setHandle} onKeyDown={(event) => event.key === KEYS.ENTER && onSubmit()} /> - {error && ( -

{error}

- )} + {error &&

{error}

} +
+

Don't have an Atmosphere account?

+ setShowCreateConfirm(true)} + /> +
+ {showCreateConfirm && ( + setShowCreateConfirm(false)} + /> + )} ); }; diff --git a/excalidraw-app/data/atproto/client.ts b/excalidraw-app/data/atproto/client.ts index ee477f10..9b71172a 100644 --- a/excalidraw-app/data/atproto/client.ts +++ b/excalidraw-app/data/atproto/client.ts @@ -7,6 +7,8 @@ import { extractAtprotoData, } from "@atproto/oauth-client-browser"; +import { SIGNUP_PDS } from "./constants"; + import type { OAuthSession } from "@atproto/oauth-client-browser"; // Both the scene record and its revision checkpoints need repo write scope. @@ -101,6 +103,19 @@ export const signIn = async (handle: string): Promise => { await client.signInRedirect(handle, { scope: OAUTH_SCOPE }); }; +/** + * Redirects to SIGNUP_PDS's account creation flow; never resolves on success. + * An input starting with https:// is resolved as a PDS URL rather than as a + * handle, and prompt "create" asks that server for its sign-up screen. + */ +export const createAccount = async (): Promise => { + const client = await getOAuthClient(); + await client.signInRedirect(SIGNUP_PDS, { + scope: OAUTH_SCOPE, + prompt: "create", + }); +}; + export const signOut = async (did: string): Promise => { const client = await getOAuthClient(); await client.revoke(did); diff --git a/excalidraw-app/data/atproto/constants.ts b/excalidraw-app/data/atproto/constants.ts index 6db6e953..caa2de06 100644 --- a/excalidraw-app/data/atproto/constants.ts +++ b/excalidraw-app/data/atproto/constants.ts @@ -8,3 +8,6 @@ export const SCENE_BLOB_WARN_BYTES = 10 * 1024 * 1024; // debounce interval for autosaving the active drawing to the PDS export const SAVE_TO_PDS_TIMEOUT = 15000; + +// PDS offered for in-app account creation; passed to OAuth as a service URL +export const SIGNUP_PDS = "https://selfhosted.social";