a tool for shared writing and social publishing

oops forgot the new files

+240
+134
app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient, PubLeafletInteractionsRecommend } from "lexicons/api"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6 + import { TID } from "@atproto/common"; 7 + import { AtUri, Un$Typed } from "@atproto/api"; 8 + import { supabaseServerClient } from "supabase/serverClient"; 9 + import { Json } from "supabase/database.types"; 10 + 11 + type RecommendResult = 12 + | { success: true; uri: string } 13 + | { 14 + success: false; 15 + error: OAuthSessionError | { type: string; message: string }; 16 + }; 17 + 18 + export async function recommendAction(args: { 19 + document: string; 20 + }): Promise<RecommendResult> { 21 + console.log("recommend action..."); 22 + let identity = await getIdentityData(); 23 + if (!identity || !identity.atp_did) { 24 + console.log("recommended"); 25 + 26 + return { 27 + success: false, 28 + error: { 29 + type: "oauth_session_expired", 30 + message: "Not authenticated", 31 + }, 32 + }; 33 + } 34 + 35 + const sessionResult = await restoreOAuthSession(identity.atp_did); 36 + if (!sessionResult.ok) { 37 + return { success: false, error: sessionResult.error }; 38 + } 39 + let credentialSession = sessionResult.value; 40 + let agent = new AtpBaseClient( 41 + credentialSession.fetchHandler.bind(credentialSession), 42 + ); 43 + 44 + let record: Un$Typed<PubLeafletInteractionsRecommend.Record> = { 45 + subject: args.document, 46 + createdAt: new Date().toISOString(), 47 + }; 48 + 49 + let rkey = TID.nextStr(); 50 + let uri = AtUri.make( 51 + credentialSession.did!, 52 + "pub.leaflet.interactions.recommend", 53 + rkey, 54 + ); 55 + 56 + await agent.pub.leaflet.interactions.recommend.create( 57 + { rkey, repo: credentialSession.did! }, 58 + record, 59 + ); 60 + 61 + await supabaseServerClient.from("recommends_on_documents").upsert({ 62 + uri: uri.toString(), 63 + document: args.document, 64 + recommender_did: credentialSession.did!, 65 + record: { 66 + $type: "pub.leaflet.interactions.recommend", 67 + ...record, 68 + } as unknown as Json, 69 + }); 70 + 71 + return { 72 + success: true, 73 + uri: uri.toString(), 74 + }; 75 + } 76 + 77 + export async function unrecommendAction(args: { 78 + document: string; 79 + }): Promise<RecommendResult> { 80 + let identity = await getIdentityData(); 81 + if (!identity || !identity.atp_did) { 82 + return { 83 + success: false, 84 + error: { 85 + type: "oauth_session_expired", 86 + message: "Not authenticated", 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 + ); 99 + 100 + // Find the existing recommend record 101 + const { data: existingRecommend } = await supabaseServerClient 102 + .from("recommends_on_documents") 103 + .select("uri") 104 + .eq("document", args.document) 105 + .eq("recommender_did", credentialSession.did!) 106 + .single(); 107 + 108 + if (!existingRecommend) { 109 + return { 110 + success: false, 111 + error: { 112 + type: "not_found", 113 + message: "Recommend not found", 114 + }, 115 + }; 116 + } 117 + 118 + let uri = new AtUri(existingRecommend.uri); 119 + 120 + await agent.pub.leaflet.interactions.recommend.delete({ 121 + rkey: uri.rkey, 122 + repo: credentialSession.did!, 123 + }); 124 + 125 + await supabaseServerClient 126 + .from("recommends_on_documents") 127 + .delete() 128 + .eq("uri", existingRecommend.uri); 129 + 130 + return { 131 + success: true, 132 + uri: existingRecommend.uri, 133 + }; 134 + }
+37
components/Icons/RecommendTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const RecommendTinyFilled = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M13.8218 8.85542C13.9838 8.63176 14.2964 8.58118 14.5201 8.74312C14.7433 8.90516 14.7932 9.21786 14.6314 9.44136C12.9671 11.7399 10.7811 13.1142 9.07472 14.0947C8.83547 14.2321 8.52981 14.1491 8.3921 13.9101C8.25463 13.6707 8.33728 13.365 8.57667 13.2275C10.2589 12.2608 12.2881 10.9736 13.8218 8.85542ZM9.09327 2.90525C10.0113 2.2003 11.4161 2.21431 12.2886 2.61521C13.0365 2.95905 13.6929 3.5946 14.0044 4.62106C14.2614 5.46809 14.2169 6.28576 14.0044 7.17867C13.4531 9.49467 10.1475 11.7776 8.22413 12.8828C8.15152 12.9245 8.05431 12.9453 7.97315 12.9453C7.89219 12.9453 7.80343 12.9243 7.73096 12.8828C5.80749 11.7776 2.50174 9.49385 1.95065 7.1777C1.7383 6.28491 1.69376 5.46798 1.95065 4.62106C2.26221 3.59471 2.91764 2.95906 3.66551 2.61521C4.53812 2.21415 5.94374 2.19992 6.86181 2.90525C7.4145 3.32999 7.72613 3.72603 7.97315 4.14939C8.22018 3.72604 8.5406 3.32998 9.09327 2.90525ZM4.55418 3.84079C4.44015 3.58958 4.14441 3.47805 3.89305 3.59177C2.93793 4.0246 2.4787 5.35564 2.85105 6.64059C2.9282 6.90532 3.20525 7.05713 3.47019 6.98043C3.73523 6.9035 3.88869 6.62638 3.81199 6.36129C3.52801 5.38087 3.94973 4.66317 4.30516 4.50192C4.55654 4.38789 4.6681 4.09224 4.55418 3.84079Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + }; 20 + 21 + export const RecommendTinyEmpty = (props: Props) => { 22 + return ( 23 + <svg 24 + width="16" 25 + height="16" 26 + viewBox="0 0 16 16" 27 + fill="none" 28 + xmlns="http://www.w3.org/2000/svg" 29 + {...props} 30 + > 31 + <path 32 + d="M13.8215 8.85505C13.9834 8.63149 14.2961 8.58084 14.5197 8.74275C14.7432 8.90468 14.7928 9.21739 14.631 9.44099C12.9668 11.7395 10.7808 13.1138 9.0744 14.0943C8.83501 14.2318 8.52937 14.1491 8.39178 13.9097C8.25431 13.6703 8.33696 13.3647 8.57635 13.2271C10.2586 12.2605 12.2878 10.9733 13.8215 8.85505ZM4.12127 2.44392C5.05035 2.20462 6.17272 2.3143 7.04412 3.04744C7.33889 3.29547 7.62399 3.64884 7.85369 3.96833C7.89451 4.02512 7.93345 4.08237 7.97186 4.13826C8.22436 3.76381 8.53885 3.3457 8.86248 3.06501C9.80388 2.24888 11.1891 2.16939 12.1564 2.56501C12.9693 2.89763 13.663 3.49593 14.0002 4.60701C14.267 5.48669 14.2598 6.26139 14.0461 7.15974C13.7527 8.39225 12.7396 9.53682 11.6691 10.4703C10.5802 11.4198 9.3429 12.2265 8.47772 12.7681C8.47247 12.7714 8.46646 12.7748 8.46111 12.7779C8.43136 12.795 8.369 12.8315 8.30096 12.8619C8.2405 12.8889 8.11991 12.937 7.97576 12.9371C7.82229 12.9372 7.7007 12.8832 7.63885 12.8521C7.6045 12.8349 7.57372 12.8176 7.55291 12.8052C7.52605 12.7893 7.52018 12.7855 7.50701 12.7779C7.50235 12.7752 7.49792 12.7719 7.49334 12.7691C6.59506 12.2129 5.35778 11.3987 4.27654 10.4439C3.21273 9.50447 2.21958 8.35999 1.92693 7.13044C1.71321 6.23218 1.70502 5.4352 1.97186 4.55525C2.31285 3.43128 3.22341 2.67532 4.12127 2.44392ZM6.40057 3.81306C5.82954 3.33259 5.06002 3.23404 4.37029 3.41169C3.79433 3.56026 3.16381 4.07131 2.92889 4.84529C2.72085 5.53135 2.72051 6.14631 2.89959 6.899C3.11654 7.81042 3.90364 8.77988 4.93865 9.69392C5.94258 10.5805 7.10507 11.3507 7.98358 11.8961C8.83657 11.3611 9.99989 10.5988 11.0119 9.71638C12.0571 8.8049 12.8571 7.83679 13.0734 6.9283C13.2523 6.17606 13.2512 5.58411 13.0431 4.89802C12.8044 4.11102 12.3496 3.72381 11.7775 3.48982C11.1013 3.21328 10.1298 3.29025 9.51776 3.82087C9.10331 4.18037 8.63998 4.9218 8.40545 5.3238C8.3158 5.4772 8.1515 5.57185 7.97381 5.57185C7.79617 5.57171 7.63172 5.47723 7.54217 5.3238C7.43363 5.13777 7.25216 4.84479 7.04119 4.55134C6.82572 4.25167 6.5988 3.97993 6.40057 3.81306Z" 33 + fill="currentColor" 34 + /> 35 + </svg> 36 + ); 37 + };
+69
components/RecommendButton.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { RecommendTinyEmpty, RecommendTinyFilled } from "./Icons/RecommendTiny"; 5 + import { 6 + recommendAction, 7 + unrecommendAction, 8 + } from "app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction"; 9 + 10 + export function RecommendButton(props: { 11 + documentUri: string; 12 + recommendsCount: number; 13 + hasRecommended: boolean; 14 + className?: string; 15 + showCount?: boolean; 16 + }) { 17 + const [hasRecommended, setHasRecommended] = useState(props.hasRecommended); 18 + const [count, setCount] = useState(props.recommendsCount); 19 + const [isPending, setIsPending] = useState(false); 20 + 21 + const handleClick = async () => { 22 + if (isPending) return; 23 + 24 + const currentlyRecommended = hasRecommended; 25 + setIsPending(true); 26 + setHasRecommended(!currentlyRecommended); 27 + setCount((c) => (currentlyRecommended ? c - 1 : c + 1)); 28 + 29 + try { 30 + if (currentlyRecommended) { 31 + await unrecommendAction({ document: props.documentUri }); 32 + } else { 33 + await recommendAction({ document: props.documentUri }); 34 + } 35 + } catch (error) { 36 + // Revert on error 37 + setHasRecommended(currentlyRecommended); 38 + setCount((c) => (currentlyRecommended ? c + 1 : c - 1)); 39 + } finally { 40 + setIsPending(false); 41 + } 42 + }; 43 + 44 + const showCount = props.showCount !== false; 45 + 46 + return ( 47 + <button 48 + onClick={(e) => { 49 + e.preventDefault(); 50 + e.stopPropagation(); 51 + handleClick(); 52 + }} 53 + disabled={isPending} 54 + className={`recommendButton flex gap-1 items-center hover:text-accent-contrast ${props.className || ""}`} 55 + aria-label={hasRecommended ? "Remove recommend" : "Recommend"} 56 + > 57 + {hasRecommended ? ( 58 + <RecommendTinyFilled className="text-accent-contrast" /> 59 + ) : ( 60 + <RecommendTinyEmpty /> 61 + )} 62 + {showCount && count > 0 && ( 63 + <span className={`${hasRecommended && "text-accent-contrast"}`}> 64 + {count} 65 + </span> 66 + )} 67 + </button> 68 + ); 69 + }