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