Read-it-later social network

update queries with authed viewer subscription

+106 -69
+16 -34
src/lib/components/PublicationCard.svelte
··· 2 2 import { getContext } from "svelte"; 3 3 import { createQuery } from "@tanstack/svelte-query"; 4 4 import type { QuicksliceClient } from "quickslice-client-js"; 5 - import { parseAtUri, resolveHandle, type MiniDoc, type PublicationNode } from "$lib/utils"; 5 + import { parseAtUri, resolveHandle, type MiniDoc, type PublicationNode, type SubscriptionNode } from "$lib/utils"; 6 6 7 7 const user = getContext("user") as MiniDoc; 8 8 const atclient = getContext("atclient") as QuicksliceClient; 9 9 10 - let { publication, showEmpty = false }: { publication: PublicationNode, showEmpty?: boolean } = $props(); 10 + let { publication, showEmpty = false }: { 11 + publication: PublicationNode & { viewerSiteStandardGraphSubscriptionViaPublication?: SubscriptionNode | null }, showEmpty?: boolean 12 + } = $props(); 13 + 14 + const { rkey: pubRkey } = parseAtUri(publication.uri); 11 15 12 16 let disableSubscribeButton = $state(false); 13 17 let isSubscribeButtonHovered = $state(false); ··· 44 48 }, 45 49 })); 46 50 47 - const subscriptionQuery = createQuery(() => ({ 48 - queryKey: ["isSubscribed", publication.uri, user && user.did], 49 - queryFn: async () => { 50 - if (!user.did) { 51 - return { records: [] } 52 - } 53 - const constellationUrl = new URL("https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks"); 54 - constellationUrl.searchParams.set("subject", publication.uri); 55 - constellationUrl.searchParams.set("source", "site.standard.graph.subscription:publication"); 56 - constellationUrl.searchParams.set("did", user.did); 57 - const response = await fetch(constellationUrl, { 58 - headers: { 59 - "Accept": "application/json" 60 - } 61 - }); 62 - 63 - 64 - const json = await response.json() as { records: { did: string, collection: string, rkey: string }[] }; 65 - return json; 66 - }, 67 - select: (data) => data.records[0] && data.records[0].rkey 68 - })); 69 - 70 51 let documents = $derived(countQuery.data?.documents || 0); 71 52 let subscribers = $derived(countQuery.data?.subscribers || 0); 72 - let subscriptionRkey = $derived(subscriptionQuery.data); 73 - let blobSyncUrl = $derived((`${miniDocQuery.data?.pds}/xrpc/com.atproto.sync.getBlob?did=${publication.did}&cid=${publication.value.icon?.ref.$link}`)); 74 - const theme = publication.value.basicTheme || { 53 + let subscriptionRkey = $derived(parseAtUri(publication.viewerSiteStandardGraphSubscriptionViaPublication?.uri || "").rkey); 54 + let blobSyncUrl = $derived(`${miniDocQuery.data?.pds}/xrpc/com.atproto.sync.getBlob?did=${publication.did}&cid=${publication.icon?.ref}`); 55 + const theme = publication.basicTheme || { 75 56 $type: "site.standard.theme.basic", 76 57 background: { 77 58 $type: "site.standard.theme.color#rgb", ··· 167 148 `} 168 149 > 169 150 <div class="flex flex-1 flex-col items-center justify-center gap-3 p-8"> 170 - {#if publication.value.icon} 151 + {#if publication.icon} 171 152 <img 172 153 src={blobSyncUrl.toString()} 173 - alt={publication.value.name} 154 + alt={publication.name} 174 155 class="size-24 rounded-xl hover:-rotate-15 transition-transform duration-150" 175 156 /> 176 157 {/if} 177 158 <h3 class="text-xl font-semibold text-center text-balance"> 178 - {publication.value.name} 159 + {publication.name} 179 160 </h3> 180 161 <a 181 162 href={`https://bsky.app/profile/${publication.actorHandle}`} ··· 187 168 by @{publication.actorHandle} 188 169 </a> 189 170 <p class="text-xs text-center max-w-md leading-relaxed font-neco"> 190 - {publication.value.description} 171 + {publication.description} 191 172 </p> 192 173 </div> 193 174 194 175 <div class="flex w-full lg:w-32 border-t lg:flex-col lg:border-t-0 lg:border-l bg-muted/50"> 195 - <div 176 + <a 177 + href={`/${miniDocQuery.data?.handle}/${pubRkey}`} 196 178 class="group flex flex-1 flex-col items-center justify-center gap-1 border-r lg:border-r-0 lg:border-b border-border p-4 hover:cursor-pointer" 197 179 style={` 198 180 background-color: rgb(${theme.accent.r},${theme.accent.g},${theme.accent.b}); ··· 206 188 Documents 207 189 <span class="group-hover:rotate-45 transition-transform duration-150">↗</span> 208 190 </span> 209 - </div> 191 + </a> 210 192 <button 211 193 onclick={toggleSubscribe} 212 194 disabled={disableSubscribeButton}
+7 -3
src/lib/utils.ts
··· 39 39 40 40 export type ATBlob = { 41 41 $type: string; 42 - ref: { $link: string; }; 42 + ref: string; 43 43 mimeType: string; 44 44 size: number; 45 45 } ··· 51 51 r: number; 52 52 } 53 53 54 - export type PublicationNode = Node & { value: { 54 + export type PublicationNode = Node & { 55 55 url: string; 56 56 name: string; 57 57 description: string; ··· 67 67 accent: StandardSiteThemeColorRGB; 68 68 accentForeground: StandardSiteThemeColorRGB; 69 69 }; 70 - }} 70 + } 71 + 72 + export type SubscriptionNode = Node & { 73 + publication: string; 74 + } 71 75 72 76 export type DocumentNode = Node & { value: { 73 77 title: string;
+5
src/routes/+error.svelte
··· 1 + <script lang="ts"> 2 + import { page } from "$app/state"; 3 + </script> 4 + 5 + <p>{page.error.message}</p>
+3 -12
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 import { page } from '$app/state'; 4 - import { onMount, setContext } from 'svelte'; 4 + import { setContext } from 'svelte'; 5 5 import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query"; 6 6 import { SvelteQueryDevtools } from "@tanstack/svelte-query-devtools"; 7 - import { goto } from '$app/navigation'; 8 7 9 8 let { data, children } = $props(); 10 9 const { atclient, user } = data; ··· 32 31 } 33 32 } 34 33 }); 35 - 36 - /** 37 - onMount(() => { 38 - if (user) { 39 - goto("/home"); 40 - } 41 - }); 42 - **/ 43 34 </script> 44 35 45 36 <QueryClientProvider client={queryClient}> ··· 50 41 51 42 <div class="flex gap-4 items-center flex-wrap"> 52 43 <nav class="flex gap-4 flex-wrap items-center px-3 py-1.5"> 53 - <a href="/explore" class="hover:text-shadow-sm" title="explore" aria-label="explore">🛰️ explore</a> 44 + <a href="/explore" class="hover:text-shadow-sm" title="explore" aria-label="explore">🛰️ Explore</a> 54 45 {#if user} 55 - <a href="/home" class="hover:text-shadow-sm" title="explore" aria-label="explore">🏠 {user.handle}</a> 46 + <a href="/home" class="hover:text-shadow-sm" title="explore" aria-label="explore">🏠 Home</a> 56 47 {/if} 57 48 </nav> 58 49 {#if user}
+1 -1
src/routes/+layout.ts
··· 13 13 14 14 if (url.searchParams.has("code")) { 15 15 await atclient.handleRedirectCallback(); 16 - redirect(302, "/"); 16 + redirect(302, "/home"); 17 17 } 18 18 19 19 const isAuthed = await atclient.isAuthenticated();
+24 -2
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { getContext } from "svelte"; 2 3 import LeafletIcon from "$lib/components/LeafletIcon.svelte"; 3 4 import OffprintIcon from "$lib/components/OffprintIcon.svelte"; 4 5 import PcktIcon from "$lib/components/PcktIcon.svelte"; 5 - </script> 6 + import type { QuicksliceClient } from "quickslice-client-js"; 6 7 8 + const user = getContext("user"); 9 + const atclient = getContext("atclient") as QuicksliceClient; 10 + let handleInput = $state(""); 7 11 12 + async function login() { 13 + if (handleInput) { 14 + await atclient.loginWithRedirect({ handle: handleInput }); 15 + } 16 + } 17 + </script> 8 18 9 19 <section class="flex flex-col gap-4 my-8"> 10 20 <h2 class="text-amber-400 text-2xl font-bold font-neco">Talk about what everyone's reading today</h2> ··· 39 49 <h2 class="text-center text-amber-400 text-3xl font-bold font-neco">Find your next read on potatonet</h2> 40 50 <div class="flex gap-4"> 41 51 <a href="/explore" class="bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2">🛰️ Explore</a> 42 - <button class="bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2">Login</button> 52 + {#if user} 53 + <a href="/home" class="bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2">🏠 Home</a> 54 + {:else} 55 + <input 56 + type="text" 57 + bind:value={handleInput} 58 + placeholder="Handle (eg: zeu.dev)" 59 + class="border border-black border-dashed text-sm px-3 py-2 hover:shadow-lg focus:shadow-lg" 60 + /> 61 + <button onclick={login} class="bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 62 + Login 63 + </button> 64 + {/if} 43 65 </div> 44 66 <pre class="text-xs tracking-widest"> 45 67
-7
src/routes/[handle]/[pubRkey]/+page.svelte
··· 1 - <script lang="ts"> 2 - import { page } from "$app/state"; 3 - 4 - const { handle, pubRkey } = page.params; 5 - </script> 6 - 7 - <p>{handle} {pubRkey}</p>
+31 -5
src/routes/explore/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { Debounced } from "runed"; 3 - import type { PublicationNode } from '$lib/utils'; 3 + import type { PublicationNode, SubscriptionNode } from '$lib/utils'; 4 4 import { createInfiniteQuery } from '@tanstack/svelte-query'; 5 5 import PublicationCard from '$lib/components/PublicationCard.svelte'; 6 6 ··· 23 23 actorHandle: { contains: "${debouncedSearchTerm.current}" } 24 24 }] 25 25 }`}) { 26 - edges {} 26 + edges { 27 + node { 28 + viewerSiteStandardGraphSubscriptionViaPublication {} 29 + uri 30 + indexedAt 31 + cid 32 + did 33 + url 34 + name 35 + description 36 + icon {} 37 + actorHandle 38 + preferences { 39 + showInDiscover 40 + } 41 + basicTheme {} 42 + } 43 + } 27 44 pageInfo { 28 45 hasNextPage 29 46 endCursor ··· 31 48 } 32 49 } 33 50 `; 34 - const data = await atclient.publicQuery(query); 51 + let data; 52 + if (user) { 53 + data = await atclient.query(query); 54 + } 55 + else { 56 + data = await atclient.publicQuery(query); 57 + } 35 58 return data as { 36 59 siteStandardPublication: { 37 - edges: { node: PublicationNode, cursor: string }[], 60 + edges: { 61 + node: PublicationNode & { viewerSiteStandardGraphSubscriptionViaPublication: SubscriptionNode | null}, 62 + cursor: string 63 + }[], 38 64 pageInfo: { 39 65 hasNextPage: boolean; 40 66 endCursor: string; ··· 98 124 {#if currentPage?.length === 0} 99 125 There are no publications based onb the current filters 100 126 {/if} 101 - {#each currentPage as publication (publication.uri)} 127 + {#each currentPage as publication, i (i)} 102 128 <PublicationCard {publication} /> 103 129 {/each} 104 130 {/if}
+19 -5
src/routes/home/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { Debounced } from "runed"; 3 2 import { goto } from "$app/navigation"; 4 3 import { getContext, onMount } from "svelte"; 5 4 import type { MiniDoc, PublicationNode } from "$lib/utils"; ··· 28 27 }) { 29 28 edges { 30 29 node { 31 - publicationResolved {} 30 + uri 31 + publicationResolved { 32 + uri 33 + indexedAt 34 + cid 35 + did 36 + url 37 + name 38 + description 39 + icon {} 40 + actorHandle 41 + preferences { 42 + showInDiscover 43 + } 44 + basicTheme {} 45 + } 32 46 } 33 47 } 34 48 pageInfo { ··· 39 53 } 40 54 `; 41 55 42 - const data = await atclient.publicQuery(query); 56 + const data = await atclient.query(query); 43 57 return data as { 44 58 siteStandardGraphSubscription: { 45 - edges: { node: { publicationResolved: PublicationNode }}[], 59 + edges: { node: { uri: string, publicationResolved: PublicationNode }}[], 46 60 pageInfo: { 47 61 hasNextPage: boolean; 48 62 endCursor: string; ··· 54 68 getNextPageParam: (lastPage) => lastPage.siteStandardGraphSubscription.pageInfo.endCursor, 55 69 select: (data) => { 56 70 const items = data.pages.map((page) => page.siteStandardGraphSubscription.edges).flat(); 57 - const nodes = items.map((i) => i.node.publicationResolved); 71 + const nodes = items.map((i) => { return { ...(i.node.publicationResolved), viewerSiteStandardGraphSubscriptionViaPublication: { uri: i.node.uri }} }); 58 72 return nodes; 59 73 } 60 74 }));