a tool for shared writing and social publishing

return result type from atproto actions and display error if oauth error

+518 -116
+25 -6
actions/publishToPublication.ts
··· 2 2 3 3 import * as Y from "yjs"; 4 4 import * as base64 from "base64-js"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 import { getIdentityData } from "actions/getIdentityData"; 7 10 import { 8 11 AtpBaseClient, ··· 50 53 import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 51 54 import { v7 } from "uuid"; 52 55 56 + type PublishResult = 57 + | { success: true; rkey: string; record: PubLeafletDocument.Record } 58 + | { success: false; error: OAuthSessionError }; 59 + 53 60 export async function publishToPublication({ 54 61 root_entity, 55 62 publication_uri, ··· 68 75 tags?: string[]; 69 76 cover_image?: string | null; 70 77 entitiesToDelete?: string[]; 71 - }) { 72 - const oauthClient = await createOauthClient(); 78 + }): Promise<PublishResult> { 73 79 let identity = await getIdentityData(); 74 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 80 + if (!identity || !identity.atp_did) { 81 + return { 82 + success: false, 83 + error: { 84 + type: "oauth_session_expired", 85 + message: "Not authenticated", 86 + did: "", 87 + }, 88 + }; 89 + } 75 90 76 - let credentialSession = await oauthClient.restore(identity.atp_did); 91 + const sessionResult = await restoreOAuthSession(identity.atp_did); 92 + if (!sessionResult.ok) { 93 + return { success: false, error: sessionResult.error }; 94 + } 95 + let credentialSession = sessionResult.value; 77 96 let agent = new AtpBaseClient( 78 97 credentialSession.fetchHandler.bind(credentialSession), 79 98 ); ··· 237 256 await createMentionNotifications(result.uri, record, credentialSession.did!); 238 257 } 239 258 240 - return { rkey, record: JSON.parse(JSON.stringify(record)) }; 259 + return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; 241 260 } 242 261 243 262 async function processBlocksToPages(
+16 -3
app/[leaflet_id]/actions/PublishButton.tsx
··· 39 39 import { BlueskyLogin } from "app/login/LoginForm"; 40 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 41 import { AddTiny } from "components/Icons/AddTiny"; 42 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 42 43 43 44 export const PublishButton = (props: { entityID: string }) => { 44 45 let { data: pub } = useLeafletPublicationData(); ··· 102 103 onClick={async () => { 103 104 if (!pub) return; 104 105 setIsLoading(true); 105 - let doc = await publishToPublication({ 106 + let result = await publishToPublication({ 106 107 root_entity: rootEntity, 107 108 publication_uri: pub.publications?.uri, 108 109 leaflet_id: permission_token.id, ··· 114 115 setIsLoading(false); 115 116 mutate(); 116 117 118 + if (!result.success) { 119 + toaster({ 120 + content: isOAuthSessionError(result.error) ? ( 121 + <OAuthErrorMessage error={result.error} /> 122 + ) : ( 123 + "Failed to publish" 124 + ), 125 + type: "error", 126 + }); 127 + return; 128 + } 129 + 117 130 // Generate URL based on whether it's in a publication or standalone 118 131 let docUrl = pub.publications 119 - ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` 120 - : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; 132 + ? `${getPublicationURL(pub.publications)}/${result.rkey}` 133 + : `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`; 121 134 122 135 toaster({ 123 136 content: (
+48 -22
app/[leaflet_id]/publish/PublishPost.tsx
··· 22 22 import { TagSelector } from "../../../components/Tags"; 23 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 24 import { PubIcon } from "components/ActionBar/Publications"; 25 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 25 26 26 27 type Props = { 27 28 title: string; ··· 65 66 let [charCount, setCharCount] = useState(0); 66 67 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 67 68 let [isLoading, setIsLoading] = useState(false); 69 + let [oauthError, setOauthError] = useState< 70 + import("src/atproto-oauth").OAuthSessionError | null 71 + >(null); 68 72 let params = useParams(); 69 73 let { rep } = useReplicache(); 70 74 ··· 101 105 async function submit() { 102 106 if (isLoading) return; 103 107 setIsLoading(true); 108 + setOauthError(null); 104 109 await rep?.push(); 105 - let doc = await publishToPublication({ 110 + let result = await publishToPublication({ 106 111 root_entity: props.root_entity, 107 112 publication_uri: props.publication_uri, 108 113 leaflet_id: props.leaflet_id, ··· 112 117 cover_image: replicacheCoverImage, 113 118 entitiesToDelete: props.entitiesToDelete, 114 119 }); 115 - if (!doc) return; 120 + 121 + if (!result.success) { 122 + setIsLoading(false); 123 + if (isOAuthSessionError(result.error)) { 124 + setOauthError(result.error); 125 + } 126 + return; 127 + } 116 128 117 129 // Generate post URL based on whether it's in a publication or standalone 118 130 let post_url = props.record?.base_path 119 - ? `https://${props.record.base_path}/${doc.rkey}` 120 - : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; 131 + ? `https://${props.record.base_path}/${result.rkey}` 132 + : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; 121 133 122 134 let [text, facets] = editorStateRef.current 123 135 ? editorStateToFacetedText(editorStateRef.current) 124 136 : []; 125 - if (shareOption === "bluesky") 126 - await publishPostToBsky({ 137 + if (shareOption === "bluesky") { 138 + let bskyResult = await publishPostToBsky({ 127 139 facets: facets || [], 128 140 text: text || "", 129 141 title: props.title, 130 142 url: post_url, 131 143 description: props.description, 132 - document_record: doc.record, 133 - rkey: doc.rkey, 144 + document_record: result.record, 145 + rkey: result.rkey, 134 146 }); 147 + if (!bskyResult.success && isOAuthSessionError(bskyResult.error)) { 148 + setIsLoading(false); 149 + setOauthError(bskyResult.error); 150 + return; 151 + } 152 + } 135 153 setIsLoading(false); 136 154 props.setPublishState({ state: "success", post_url }); 137 155 } ··· 168 186 </div> 169 187 <hr className="border-border mb-2" /> 170 188 171 - <div className="flex justify-between"> 172 - <Link 173 - className="hover:no-underline! font-bold" 174 - href={`/${params.leaflet_id}`} 175 - > 176 - Back 177 - </Link> 178 - <ButtonPrimary 179 - type="submit" 180 - className="place-self-end h-[30px]" 181 - disabled={charCount > 300} 182 - > 183 - {isLoading ? <DotLoader /> : "Publish this Post!"} 184 - </ButtonPrimary> 189 + <div className="flex flex-col gap-2"> 190 + <div className="flex justify-between"> 191 + <Link 192 + className="hover:no-underline! font-bold" 193 + href={`/${params.leaflet_id}`} 194 + > 195 + Back 196 + </Link> 197 + <ButtonPrimary 198 + type="submit" 199 + className="place-self-end h-[30px]" 200 + disabled={charCount > 300} 201 + > 202 + {isLoading ? <DotLoader /> : "Publish this Post!"} 203 + </ButtonPrimary> 204 + </div> 205 + {oauthError && ( 206 + <OAuthErrorMessage 207 + error={oauthError} 208 + className="text-right text-sm text-accent-contrast" 209 + /> 210 + )} 185 211 </div> 186 212 </div> 187 213 </form>
+25 -6
app/[leaflet_id]/publish/publishBskyPost.ts
··· 9 9 import { TID } from "@atproto/common"; 10 10 import { getIdentityData } from "actions/getIdentityData"; 11 11 import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 12 - import { createOauthClient } from "src/atproto-oauth"; 12 + import { 13 + restoreOAuthSession, 14 + OAuthSessionError, 15 + } from "src/atproto-oauth"; 13 16 import { supabaseServerClient } from "supabase/serverClient"; 14 17 import { Json } from "supabase/database.types"; 15 18 import { ··· 18 21 } from "src/utils/getMicroLinkOgImage"; 19 22 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 20 23 24 + type PublishBskyResult = 25 + | { success: true } 26 + | { success: false; error: OAuthSessionError }; 27 + 21 28 export async function publishPostToBsky(args: { 22 29 text: string; 23 30 url: string; ··· 26 33 document_record: PubLeafletDocument.Record; 27 34 rkey: string; 28 35 facets: AppBskyRichtextFacet.Main[]; 29 - }) { 30 - const oauthClient = await createOauthClient(); 36 + }): Promise<PublishBskyResult> { 31 37 let identity = await getIdentityData(); 32 - if (!identity || !identity.atp_did) return null; 38 + if (!identity || !identity.atp_did) { 39 + return { 40 + success: false, 41 + error: { 42 + type: "oauth_session_expired", 43 + message: "Not authenticated", 44 + did: "", 45 + }, 46 + }; 47 + } 33 48 34 - let credentialSession = await oauthClient.restore(identity.atp_did); 49 + const sessionResult = await restoreOAuthSession(identity.atp_did); 50 + if (!sessionResult.ok) { 51 + return { success: false, error: sessionResult.error }; 52 + } 53 + let credentialSession = sessionResult.value; 35 54 let agent = new AtpBaseClient( 36 55 credentialSession.fetchHandler.bind(credentialSession), 37 56 ); ··· 111 130 data: record as Json, 112 131 }) 113 132 .eq("uri", result.uri); 114 - return true; 133 + return { success: true }; 115 134 }
+1 -1
app/api/oauth/[route]/route.ts
··· 121 121 else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); 122 122 if (action?.action === "subscribe") { 123 123 let result = await subscribeToPublication(action.publication); 124 - if (result.hasFeed === false) 124 + if (result.success && result.hasFeed === false) 125 125 url.searchParams.set("showSubscribeSuccess", "true"); 126 126 } 127 127
+19 -2
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 26 27 27 28 export const SubscribeWithBluesky = (props: { 28 29 pubName: string; ··· 133 134 }) => { 134 135 let { identity } = useIdentityData(); 135 136 let toaster = useToaster(); 137 + let [oauthError, setOauthError] = useState< 138 + import("src/atproto-oauth").OAuthSessionError | null 139 + >(null); 136 140 let [, subscribe, subscribePending] = useActionState(async () => { 141 + setOauthError(null); 137 142 let result = await subscribeToPublication( 138 143 props.pub_uri, 139 144 window.location.href + "?refreshAuth", 140 145 ); 146 + if (!result.success) { 147 + if (isOAuthSessionError(result.error)) { 148 + setOauthError(result.error); 149 + } 150 + return; 151 + } 141 152 if (result.hasFeed === false) { 142 153 props.setSuccessModalOpen(true); 143 154 } ··· 172 183 } 173 184 174 185 return ( 175 - <> 186 + <div className="flex flex-col gap-2 place-self-center"> 176 187 <form 177 188 action={subscribe} 178 189 className="place-self-center flex flex-row gap-1" ··· 187 198 )} 188 199 </ButtonPrimary> 189 200 </form> 190 - </> 201 + {oauthError && ( 202 + <OAuthErrorMessage 203 + error={oauthError} 204 + className="text-center text-sm text-accent-1" 205 + /> 206 + )} 207 + </div> 191 208 ); 192 209 }; 193 210
+21 -5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 38 38 import { CloseTiny } from "components/Icons/CloseTiny"; 39 39 import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 40 40 import { betterIsUrl } from "src/utils/isURL"; 41 + import { useToaster } from "components/Toast"; 42 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 41 43 import { Mention, MentionAutocomplete } from "components/Mention"; 42 44 import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 43 45 ··· 95 97 } = useInteractionState(props.doc_uri); 96 98 let [loading, setLoading] = useState(false); 97 99 let view = useRef<null | EditorView>(null); 100 + let toaster = useToaster(); 98 101 99 102 // Mention autocomplete state 100 103 const [mentionOpen, setMentionOpen] = useState(false); ··· 161 164 setLoading(true); 162 165 let currentState = view.current.state; 163 166 let [plaintext, facets] = docToFacetedText(currentState.doc); 164 - let comment = await publishComment({ 167 + let result = await publishComment({ 165 168 pageId: props.pageId, 166 169 document: props.doc_uri, 167 170 comment: { ··· 178 181 }, 179 182 }); 180 183 184 + if (!result.success) { 185 + setLoading(false); 186 + toaster({ 187 + content: isOAuthSessionError(result.error) ? ( 188 + <OAuthErrorMessage error={result.error} /> 189 + ) : ( 190 + "Failed to post comment" 191 + ), 192 + type: "error", 193 + }); 194 + return; 195 + } 196 + 181 197 let tr = currentState.tr; 182 198 tr = tr.replaceWith( 183 199 0, ··· 194 210 localComments: [ 195 211 ...s.localComments, 196 212 { 197 - record: comment.record, 198 - uri: comment.uri, 213 + record: result.record, 214 + uri: result.uri, 199 215 bsky_profiles: { 200 - record: comment.profile as Json, 201 - did: new AtUri(comment.uri).host, 216 + record: result.profile as Json, 217 + did: new AtUri(result.uri).host, 202 218 }, 203 219 }, 204 220 ],
+25 -5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 3 3 import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 5 import { PubLeafletRichtextFacet } from "lexicons/api"; 6 - import { createOauthClient } from "src/atproto-oauth"; 6 + import { 7 + restoreOAuthSession, 8 + OAuthSessionError, 9 + } from "src/atproto-oauth"; 7 10 import { TID } from "@atproto/common"; 8 11 import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; 9 12 import { supabaseServerClient } from "supabase/serverClient"; ··· 15 18 } from "src/notifications"; 16 19 import { v7 } from "uuid"; 17 20 21 + type PublishCommentResult = 22 + | { success: true; record: Json; profile: any; uri: string } 23 + | { success: false; error: OAuthSessionError }; 24 + 18 25 export async function publishComment(args: { 19 26 document: string; 20 27 pageId?: string; ··· 24 31 replyTo?: string; 25 32 attachment: PubLeafletComment.Record["attachment"]; 26 33 }; 27 - }) { 28 - const oauthClient = await createOauthClient(); 34 + }): Promise<PublishCommentResult> { 29 35 let identity = await getIdentityData(); 30 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 36 + if (!identity || !identity.atp_did) { 37 + return { 38 + success: false, 39 + error: { 40 + type: "oauth_session_expired", 41 + message: "Not authenticated", 42 + did: "", 43 + }, 44 + }; 45 + } 31 46 32 - let credentialSession = await oauthClient.restore(identity.atp_did); 47 + const sessionResult = await restoreOAuthSession(identity.atp_did); 48 + if (!sessionResult.ok) { 49 + return { success: false, error: sessionResult.error }; 50 + } 51 + let credentialSession = sessionResult.value; 33 52 let agent = new AtpBaseClient( 34 53 credentialSession.fetchHandler.bind(credentialSession), 35 54 ); ··· 108 127 } 109 128 110 129 return { 130 + success: true, 111 131 record: data?.[0].record as Json, 112 132 profile: lexToJson(profile.value), 113 133 uri: uri.toString(),
+12 -4
app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts
··· 1 1 "use server"; 2 2 3 - import { createOauthClient } from "src/atproto-oauth"; 3 + import { 4 + restoreOAuthSession, 5 + OAuthSessionError, 6 + } from "src/atproto-oauth"; 4 7 import { getIdentityData } from "actions/getIdentityData"; 5 8 import { AtpBaseClient, AtUri } from "@atproto/api"; 6 9 import { PubLeafletPollVote } from "lexicons/api"; ··· 12 15 pollUri: string, 13 16 pollCid: string, 14 17 selectedOption: string, 15 - ): Promise<{ success: boolean; error?: string }> { 18 + ): Promise< 19 + { success: true } | { success: false; error: string | OAuthSessionError } 20 + > { 16 21 try { 17 22 const identity = await getIdentityData(); 18 23 ··· 20 25 return { success: false, error: "Not authenticated" }; 21 26 } 22 27 23 - const oauthClient = await createOauthClient(); 24 - const session = await oauthClient.restore(identity.atp_did); 28 + const sessionResult = await restoreOAuthSession(identity.atp_did); 29 + if (!sessionResult.ok) { 30 + return { success: false, error: sessionResult.error }; 31 + } 32 + const session = sessionResult.value; 25 33 let agent = new AtpBaseClient(session.fetchHandler.bind(session)); 26 34 27 35 const voteRecord: PubLeafletPollVote.Record = {
+50 -13
app/lish/[did]/[publication]/dashboard/deletePost.ts
··· 2 2 3 3 import { AtpBaseClient } from "lexicons/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 import { AtUri } from "@atproto/syntax"; 7 10 import { supabaseServerClient } from "supabase/serverClient"; 8 11 import { revalidatePath } from "next/cache"; 9 12 10 - export async function deletePost(document_uri: string) { 13 + export async function deletePost( 14 + document_uri: string 15 + ): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { 11 16 let identity = await getIdentityData(); 12 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 17 + if (!identity || !identity.atp_did) { 18 + return { 19 + success: false, 20 + error: { 21 + type: "oauth_session_expired", 22 + message: "Not authenticated", 23 + did: "", 24 + }, 25 + }; 26 + } 13 27 14 - const oauthClient = await createOauthClient(); 15 - let credentialSession = await oauthClient.restore(identity.atp_did); 28 + const sessionResult = await restoreOAuthSession(identity.atp_did); 29 + if (!sessionResult.ok) { 30 + return { success: false, error: sessionResult.error }; 31 + } 32 + let credentialSession = sessionResult.value; 16 33 let agent = new AtpBaseClient( 17 34 credentialSession.fetchHandler.bind(credentialSession), 18 35 ); 19 36 let uri = new AtUri(document_uri); 20 - if (uri.host !== identity.atp_did) return; 37 + if (uri.host !== identity.atp_did) { 38 + return { success: true }; 39 + } 21 40 22 41 await Promise.all([ 23 42 agent.pub.leaflet.document.delete({ ··· 31 50 .eq("doc", document_uri), 32 51 ]); 33 52 34 - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 53 + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 54 + return { success: true }; 35 55 } 36 56 37 - export async function unpublishPost(document_uri: string) { 57 + export async function unpublishPost( 58 + document_uri: string 59 + ): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { 38 60 let identity = await getIdentityData(); 39 - if (!identity || !identity.atp_did) throw new Error("No Identity"); 61 + if (!identity || !identity.atp_did) { 62 + return { 63 + success: false, 64 + error: { 65 + type: "oauth_session_expired", 66 + message: "Not authenticated", 67 + did: "", 68 + }, 69 + }; 70 + } 40 71 41 - const oauthClient = await createOauthClient(); 42 - let credentialSession = await oauthClient.restore(identity.atp_did); 72 + const sessionResult = await restoreOAuthSession(identity.atp_did); 73 + if (!sessionResult.ok) { 74 + return { success: false, error: sessionResult.error }; 75 + } 76 + let credentialSession = sessionResult.value; 43 77 let agent = new AtpBaseClient( 44 78 credentialSession.fetchHandler.bind(credentialSession), 45 79 ); 46 80 let uri = new AtUri(document_uri); 47 - if (uri.host !== identity.atp_did) return; 81 + if (uri.host !== identity.atp_did) { 82 + return { success: true }; 83 + } 48 84 49 85 await Promise.all([ 50 86 agent.pub.leaflet.document.delete({ ··· 53 89 }), 54 90 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 55 91 ]); 56 - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 92 + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 93 + return { success: true }; 57 94 }
+22 -6
app/lish/addFeed.tsx
··· 2 2 3 3 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 - import { createOauthClient } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 const leafletFeedURI = 7 10 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 8 11 9 - export async function addFeed() { 10 - const oauthClient = await createOauthClient(); 12 + export async function addFeed(): Promise< 13 + { success: true } | { success: false; error: OAuthSessionError } 14 + > { 11 15 let identity = await getIdentityData(); 12 16 if (!identity || !identity.atp_did) { 13 - throw new Error("Invalid identity data"); 17 + return { 18 + success: false, 19 + error: { 20 + type: "oauth_session_expired", 21 + message: "Not authenticated", 22 + did: "", 23 + }, 24 + }; 14 25 } 15 26 16 - let credentialSession = await oauthClient.restore(identity.atp_did); 27 + const sessionResult = await restoreOAuthSession(identity.atp_did); 28 + if (!sessionResult.ok) { 29 + return { success: false, error: sessionResult.error }; 30 + } 31 + let credentialSession = sessionResult.value; 17 32 let bsky = new BskyAgent(credentialSession); 18 33 let prefs = await bsky.app.bsky.actor.getPreferences(); 19 34 let savedFeeds = prefs.data.preferences.find( ··· 23 38 let hasFeed = !!savedFeeds.items.find( 24 39 (feed) => feed.value === leafletFeedURI, 25 40 ); 26 - if (hasFeed) return; 41 + if (hasFeed) return { success: true }; 27 42 28 43 await bsky.addSavedFeeds([ 29 44 { ··· 32 47 type: "feed", 33 48 }, 34 49 ]); 50 + return { success: true }; 35 51 }
+34 -12
app/lish/createPub/CreatePubForm.tsx
··· 13 13 import { string } from "zod"; 14 14 import { DotLoader } from "components/utils/DotLoader"; 15 15 import { Checkbox } from "components/Checkbox"; 16 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 16 17 17 18 type DomainState = 18 19 | { status: "empty" } ··· 32 33 let [domainState, setDomainState] = useState<DomainState>({ 33 34 status: "empty", 34 35 }); 36 + let [oauthError, setOauthError] = useState< 37 + import("src/atproto-oauth").OAuthSessionError | null 38 + >(null); 35 39 let fileInputRef = useRef<HTMLInputElement>(null); 36 40 37 41 let router = useRouter(); ··· 43 47 e.preventDefault(); 44 48 if (!subdomainValidator.safeParse(domainValue).success) return; 45 49 setFormState("loading"); 46 - let data = await createPublication({ 50 + setOauthError(null); 51 + let result = await createPublication({ 47 52 name: nameValue, 48 53 description: descriptionValue, 49 54 iconFile: logoFile, 50 55 subdomain: domainValue, 51 56 preferences: { showInDiscover, showComments: true }, 52 57 }); 58 + 59 + if (!result.success) { 60 + setFormState("normal"); 61 + if (result.error && isOAuthSessionError(result.error)) { 62 + setOauthError(result.error); 63 + } 64 + return; 65 + } 66 + 53 67 // Show a spinner while this is happening! Maybe a progress bar? 54 68 setTimeout(() => { 55 69 setFormState("normal"); 56 - if (data?.publication) 57 - router.push(`${getBasePublicationURL(data.publication)}/dashboard`); 70 + if (result.publication) 71 + router.push(`${getBasePublicationURL(result.publication)}/dashboard`); 58 72 }, 500); 59 73 }} 60 74 > ··· 139 153 </Checkbox> 140 154 <hr className="border-border-light" /> 141 155 142 - <div className="flex w-full justify-end"> 143 - <ButtonPrimary 144 - type="submit" 145 - disabled={ 146 - !nameValue || !domainValue || domainState.status !== "valid" 147 - } 148 - > 149 - {formState === "loading" ? <DotLoader /> : "Create Publication!"} 150 - </ButtonPrimary> 156 + <div className="flex flex-col gap-2"> 157 + <div className="flex w-full justify-end"> 158 + <ButtonPrimary 159 + type="submit" 160 + disabled={ 161 + !nameValue || !domainValue || domainState.status !== "valid" 162 + } 163 + > 164 + {formState === "loading" ? <DotLoader /> : "Create Publication!"} 165 + </ButtonPrimary> 166 + </div> 167 + {oauthError && ( 168 + <OAuthErrorMessage 169 + error={oauthError} 170 + className="text-right text-sm text-accent-1" 171 + /> 172 + )} 151 173 </div> 152 174 </form> 153 175 );
+24 -5
app/lish/createPub/createPublication.ts
··· 1 1 "use server"; 2 2 import { TID } from "@atproto/common"; 3 3 import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 4 - import { createOauthClient } from "src/atproto-oauth"; 4 + import { 5 + restoreOAuthSession, 6 + OAuthSessionError, 7 + } from "src/atproto-oauth"; 5 8 import { getIdentityData } from "actions/getIdentityData"; 6 9 import { supabaseServerClient } from "supabase/serverClient"; 7 10 import { Un$Typed } from "@atproto/api"; ··· 18 21 .min(3) 19 22 .max(63) 20 23 .regex(/^[a-z0-9-]+$/); 24 + type CreatePublicationResult = 25 + | { success: true; publication: any } 26 + | { success: false; error?: OAuthSessionError }; 27 + 21 28 export async function createPublication({ 22 29 name, 23 30 description, ··· 30 37 iconFile: File | null; 31 38 subdomain: string; 32 39 preferences: Omit<PubLeafletPublication.Preferences, "$type">; 33 - }) { 40 + }): Promise<CreatePublicationResult> { 34 41 let isSubdomainValid = subdomainValidator.safeParse(subdomain); 35 42 if (!isSubdomainValid.success) { 36 43 return { success: false }; 37 44 } 38 - const oauthClient = await createOauthClient(); 39 45 let identity = await getIdentityData(); 40 - if (!identity || !identity.atp_did) return; 46 + if (!identity || !identity.atp_did) { 47 + return { 48 + success: false, 49 + error: { 50 + type: "oauth_session_expired", 51 + message: "Not authenticated", 52 + did: "", 53 + }, 54 + }; 55 + } 41 56 42 57 let domain = `${subdomain}.leaflet.pub`; 43 58 44 - let credentialSession = await oauthClient.restore(identity.atp_did); 59 + const sessionResult = await restoreOAuthSession(identity.atp_did); 60 + if (!sessionResult.ok) { 61 + return { success: false, error: sessionResult.error }; 62 + } 63 + let credentialSession = sessionResult.value; 45 64 let agent = new AtpBaseClient( 46 65 credentialSession.fetchHandler.bind(credentialSession), 47 66 );
+65 -16
app/lish/createPub/updatePublication.ts
··· 5 5 PubLeafletPublication, 6 6 PubLeafletThemeColor, 7 7 } from "lexicons/api"; 8 - import { createOauthClient } from "src/atproto-oauth"; 8 + import { 9 + restoreOAuthSession, 10 + OAuthSessionError, 11 + } from "src/atproto-oauth"; 9 12 import { getIdentityData } from "actions/getIdentityData"; 10 13 import { supabaseServerClient } from "supabase/serverClient"; 11 14 import { Json } from "supabase/database.types"; 12 15 import { AtUri } from "@atproto/syntax"; 13 16 import { $Typed } from "@atproto/api"; 14 17 18 + type UpdatePublicationResult = 19 + | { success: true; publication: any } 20 + | { success: false; error?: OAuthSessionError }; 21 + 15 22 export async function updatePublication({ 16 23 uri, 17 24 name, ··· 24 31 description: string; 25 32 iconFile: File | null; 26 33 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 27 - }) { 28 - const oauthClient = await createOauthClient(); 34 + }): Promise<UpdatePublicationResult> { 29 35 let identity = await getIdentityData(); 30 - if (!identity || !identity.atp_did) return; 36 + if (!identity || !identity.atp_did) { 37 + return { 38 + success: false, 39 + error: { 40 + type: "oauth_session_expired", 41 + message: "Not authenticated", 42 + did: "", 43 + }, 44 + }; 45 + } 31 46 32 - let credentialSession = await oauthClient.restore(identity.atp_did); 47 + const sessionResult = await restoreOAuthSession(identity.atp_did); 48 + if (!sessionResult.ok) { 49 + return { success: false, error: sessionResult.error }; 50 + } 51 + let credentialSession = sessionResult.value; 33 52 let agent = new AtpBaseClient( 34 53 credentialSession.fetchHandler.bind(credentialSession), 35 54 ); ··· 38 57 .select("*") 39 58 .eq("uri", uri) 40 59 .single(); 41 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 60 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 61 + return { success: false }; 62 + } 42 63 let aturi = new AtUri(existingPub.uri); 43 64 44 65 let record: PubLeafletPublication.Record = { ··· 94 115 }: { 95 116 uri: string; 96 117 base_path: string; 97 - }) { 98 - const oauthClient = await createOauthClient(); 118 + }): Promise<UpdatePublicationResult> { 99 119 let identity = await getIdentityData(); 100 - if (!identity || !identity.atp_did) return; 120 + if (!identity || !identity.atp_did) { 121 + return { 122 + success: false, 123 + error: { 124 + type: "oauth_session_expired", 125 + message: "Not authenticated", 126 + did: "", 127 + }, 128 + }; 129 + } 101 130 102 - let credentialSession = await oauthClient.restore(identity.atp_did); 131 + const sessionResult = await restoreOAuthSession(identity.atp_did); 132 + if (!sessionResult.ok) { 133 + return { success: false, error: sessionResult.error }; 134 + } 135 + let credentialSession = sessionResult.value; 103 136 let agent = new AtpBaseClient( 104 137 credentialSession.fetchHandler.bind(credentialSession), 105 138 ); ··· 108 141 .select("*") 109 142 .eq("uri", uri) 110 143 .single(); 111 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 144 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 145 + return { success: false }; 146 + } 112 147 let aturi = new AtUri(existingPub.uri); 113 148 114 149 let record: PubLeafletPublication.Record = { ··· 155 190 accentBackground: Color; 156 191 accentText: Color; 157 192 }; 158 - }) { 159 - const oauthClient = await createOauthClient(); 193 + }): Promise<UpdatePublicationResult> { 160 194 let identity = await getIdentityData(); 161 - if (!identity || !identity.atp_did) return; 195 + if (!identity || !identity.atp_did) { 196 + return { 197 + success: false, 198 + error: { 199 + type: "oauth_session_expired", 200 + message: "Not authenticated", 201 + did: "", 202 + }, 203 + }; 204 + } 162 205 163 - let credentialSession = await oauthClient.restore(identity.atp_did); 206 + const sessionResult = await restoreOAuthSession(identity.atp_did); 207 + if (!sessionResult.ok) { 208 + return { success: false, error: sessionResult.error }; 209 + } 210 + let credentialSession = sessionResult.value; 164 211 let agent = new AtpBaseClient( 165 212 credentialSession.fetchHandler.bind(credentialSession), 166 213 ); ··· 169 216 .select("*") 170 217 .eq("uri", uri) 171 218 .single(); 172 - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 219 + if (!existingPub || existingPub.identity_did !== identity.atp_did) { 220 + return { success: false }; 221 + } 173 222 let aturi = new AtUri(existingPub.uri); 174 223 175 224 let oldRecord = existingPub.record as PubLeafletPublication.Record;
+40 -9
app/lish/subscribeToPublication.ts
··· 3 3 import { AtpBaseClient } from "lexicons/api"; 4 4 import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 5 5 import { getIdentityData } from "actions/getIdentityData"; 6 - import { createOauthClient } from "src/atproto-oauth"; 6 + import { 7 + restoreOAuthSession, 8 + OAuthSessionError, 9 + } from "src/atproto-oauth"; 7 10 import { TID } from "@atproto/common"; 8 11 import { supabaseServerClient } from "supabase/serverClient"; 9 12 import { revalidatePath } from "next/cache"; ··· 21 24 let leafletFeedURI = 22 25 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 23 26 let idResolver = new IdResolver(); 27 + 28 + type SubscribeResult = 29 + | { success: true; hasFeed: boolean } 30 + | { success: false; error: OAuthSessionError }; 31 + 24 32 export async function subscribeToPublication( 25 33 publication: string, 26 34 redirectRoute?: string, 27 - ) { 28 - const oauthClient = await createOauthClient(); 35 + ): Promise<SubscribeResult | never> { 29 36 let identity = await getIdentityData(); 30 37 if (!identity || !identity.atp_did) { 31 38 return redirect( ··· 33 40 ); 34 41 } 35 42 36 - let credentialSession = await oauthClient.restore(identity.atp_did); 43 + const sessionResult = await restoreOAuthSession(identity.atp_did); 44 + if (!sessionResult.ok) { 45 + return { success: false, error: sessionResult.error }; 46 + } 47 + let credentialSession = sessionResult.value; 37 48 let agent = new AtpBaseClient( 38 49 credentialSession.fetchHandler.bind(credentialSession), 39 50 ); ··· 90 101 ) as AppBskyActorDefs.SavedFeedsPrefV2; 91 102 revalidatePath("/lish/[did]/[publication]", "layout"); 92 103 return { 104 + success: true, 93 105 hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), 94 106 }; 95 107 } 96 108 97 - export async function unsubscribeToPublication(publication: string) { 98 - const oauthClient = await createOauthClient(); 109 + type UnsubscribeResult = 110 + | { success: true } 111 + | { success: false; error: OAuthSessionError }; 112 + 113 + export async function unsubscribeToPublication( 114 + publication: string 115 + ): Promise<UnsubscribeResult> { 99 116 let identity = await getIdentityData(); 100 - if (!identity || !identity.atp_did) return; 117 + if (!identity || !identity.atp_did) { 118 + return { 119 + success: false, 120 + error: { 121 + type: "oauth_session_expired", 122 + message: "Not authenticated", 123 + did: "", 124 + }, 125 + }; 126 + } 101 127 102 - let credentialSession = await oauthClient.restore(identity.atp_did); 128 + const sessionResult = await restoreOAuthSession(identity.atp_did); 129 + if (!sessionResult.ok) { 130 + return { success: false, error: sessionResult.error }; 131 + } 132 + let credentialSession = sessionResult.value; 103 133 let agent = new AtpBaseClient( 104 134 credentialSession.fetchHandler.bind(credentialSession), 105 135 ); ··· 109 139 .eq("identity", identity.atp_did) 110 140 .eq("publication", publication) 111 141 .single(); 112 - if (!existingSubscription) return; 142 + if (!existingSubscription) return { success: true }; 113 143 await agent.pub.leaflet.graph.subscription.delete({ 114 144 repo: credentialSession.did!, 115 145 rkey: new AtUri(existingSubscription.uri).rkey, ··· 120 150 .eq("identity", identity.atp_did) 121 151 .eq("publication", publication); 122 152 revalidatePath("/lish/[did]/[publication]", "layout"); 153 + return { success: true }; 123 154 }
+35
components/OAuthError.tsx
··· 1 + "use client"; 2 + 3 + import { OAuthSessionError } from "src/atproto-oauth"; 4 + import { usePathname } from "next/navigation"; 5 + 6 + export function OAuthErrorMessage({ 7 + error, 8 + className, 9 + }: { 10 + error: OAuthSessionError; 11 + className?: string; 12 + }) { 13 + const pathname = usePathname(); 14 + const signInUrl = `/api/oauth/login?redirect_url=${encodeURIComponent(pathname)}${error.did ? `&handle=${encodeURIComponent(error.did)}` : ""}`; 15 + 16 + return ( 17 + <div className={className}> 18 + <span>Your session has expired or is invalid. </span> 19 + <a href={signInUrl} className="underline font-bold whitespace-nowrap"> 20 + Sign in again 21 + </a> 22 + </div> 23 + ); 24 + } 25 + 26 + export function isOAuthSessionError( 27 + error: unknown, 28 + ): error is OAuthSessionError { 29 + return ( 30 + typeof error === "object" && 31 + error !== null && 32 + "type" in error && 33 + (error as OAuthSessionError).type === "oauth_session_expired" 34 + ); 35 + }
+21 -1
components/ThemeManager/PubThemeSetter.tsx
··· 17 17 import { Separator } from "components/Layout"; 18 18 import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 19 19 import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; 20 + import { useToaster } from "components/Toast"; 21 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 20 22 21 23 export type ImageState = { 22 24 src: string; ··· 57 59 58 60 let pubBGImage = image?.src || null; 59 61 let leafletBGRepeat = image?.repeat || null; 62 + let toaster = useToaster(); 60 63 61 64 return ( 62 65 <BaseThemeProvider local {...localPubTheme}> ··· 80 83 accentText: ColorToRGB(localPubTheme.accent2), 81 84 }, 82 85 }); 86 + 87 + if (!result.success) { 88 + props.setLoading(false); 89 + if (result.error && isOAuthSessionError(result.error)) { 90 + toaster({ 91 + content: <OAuthErrorMessage error={result.error} />, 92 + type: "error", 93 + }); 94 + } else { 95 + toaster({ 96 + content: "Failed to update theme", 97 + type: "error", 98 + }); 99 + } 100 + return; 101 + } 102 + 83 103 mutate((pub) => { 84 - if (result?.publication && pub?.publication) 104 + if (result.publication && pub?.publication) 85 105 return { 86 106 ...pub, 87 107 publication: { ...pub.publication, ...result.publication },
+27
src/atproto-oauth.ts
··· 3 3 NodeSavedSession, 4 4 NodeSavedState, 5 5 RuntimeLock, 6 + OAuthSession, 6 7 } from "@atproto/oauth-client-node"; 7 8 import { JoseKey } from "@atproto/jwk-jose"; 8 9 import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata"; ··· 10 11 11 12 import Client from "ioredis"; 12 13 import Redlock from "redlock"; 14 + import { Result, Ok, Err } from "./result"; 13 15 export async function createOauthClient() { 14 16 let keyset = 15 17 process.env.NODE_ENV === "production" ··· 90 92 .eq("key", key); 91 93 }, 92 94 }; 95 + 96 + export type OAuthSessionError = { 97 + type: "oauth_session_expired"; 98 + message: string; 99 + did: string; 100 + }; 101 + 102 + export async function restoreOAuthSession( 103 + did: string 104 + ): Promise<Result<OAuthSession, OAuthSessionError>> { 105 + try { 106 + const oauthClient = await createOauthClient(); 107 + const session = await oauthClient.restore(did); 108 + return Ok(session); 109 + } catch (error) { 110 + return Err({ 111 + type: "oauth_session_expired", 112 + message: 113 + error instanceof Error 114 + ? error.message 115 + : "OAuth session expired or invalid", 116 + did, 117 + }); 118 + } 119 + }
+8
src/result.ts
··· 1 + // Result type - a discriminated union for handling success/error cases 2 + export type Result<T, E> = 3 + | { ok: true; value: T } 4 + | { ok: false; error: E }; 5 + 6 + // Constructors 7 + export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value }); 8 + export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });