diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index b2968f8a..17d62de2 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -16,7 +16,7 @@ export async function getIdentityData() { identities( *, bsky_profiles(*), - subscribers_to_publications(*), + publication_subscriptions(*), custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), permission_token_on_homepage( diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts index 3ea702d8..55b69460 100644 --- a/app/api/oauth/[route]/route.ts +++ b/app/api/oauth/[route]/route.ts @@ -121,7 +121,6 @@ const handleAction = async ( else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); if (action?.action === "subscribe") { let result = await subscribeToPublication(action.publication); - console.log(result); if (result.hasFeed === false) url.searchParams.set("showSubscribeSuccess", "true"); } diff --git a/app/discover/PubListing.tsx b/app/discover/PubListing.tsx index c8c9d741..582dc331 100644 --- a/app/discover/PubListing.tsx +++ b/app/discover/PubListing.tsx @@ -1,5 +1,8 @@ "use client"; import { AtUri } from "@atproto/syntax"; +import { PublicationSubscription } from "app/reader/getSubscriptions"; +import { PubIcon } from "components/ActionBar/Publications"; +import { Separator } from "components/Layout"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; @@ -7,14 +10,11 @@ import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { timeAgo } from "src/utils/timeAgo"; import { Json } from "supabase/database.types"; -export const PubListing = (props: { - record: Json; - uri: string; - documents_in_publications: { - indexed_at: string; - documents: { data: Json } | null; - }[]; -}) => { +export const PubListing = ( + props: PublicationSubscription & { + resizeHeight?: boolean; + }, +) => { let record = props.record as PubLeafletPublication.Record; let theme = usePubTheme(record); let backgroundImage = record?.theme?.backgroundImage?.image?.ref @@ -43,26 +43,28 @@ export const PubListing = (props: { hover:outline-accent-contrast hover:border-accent-contrast`} >
- {!record?.icon ? record.name.slice(0, 1).toLocaleUpperCase() : null} -
-
-

{record.name}

-

{record.description}

-
+
+ +
+ +

{record.name}

+ {record.description && ( +

+ {record.description} +

+ )} +
+
+ {props.authorProfile?.handle} +

- Updated {timeAgo(props.documents_in_publications[0].indexed_at)} + Updated{" "} + {timeAgo( + props.documents_in_publications?.[0]?.documents?.indexed_at || + "", + )}

diff --git a/app/discover/SortedPublicationList.tsx b/app/discover/SortedPublicationList.tsx index 71aaa6b5..9641f29f 100644 --- a/app/discover/SortedPublicationList.tsx +++ b/app/discover/SortedPublicationList.tsx @@ -39,7 +39,7 @@ export function SortedPublicationList(props: { ); return bDate.getTime() - aDate.getTime(); }) - .map((pub) => )} + .map((pub) => )}
); diff --git a/app/discover/page.tsx b/app/discover/page.tsx index 2e2a8cd8..d25bc66e 100644 --- a/app/discover/page.tsx +++ b/app/discover/page.tsx @@ -40,7 +40,7 @@ export default async function Discover(props: {
{

Discover

-

- Explore publications on Leaflet ✨ Or{" "} - make your own! -

diff --git a/app/home/HomeEmpty/HomeEmpty.tsx b/app/home/HomeEmpty/HomeEmpty.tsx index b442bc36..ecabbabc 100644 --- a/app/home/HomeEmpty/HomeEmpty.tsx +++ b/app/home/HomeEmpty/HomeEmpty.tsx @@ -66,16 +66,16 @@ export function HomeEmptyState() { export const PublicationBanner = (props: { small?: boolean }) => { return (
{props.small ? ( - + ) : (
)} -
+
Start a Publication {" "} @@ -88,16 +88,16 @@ export const PublicationBanner = (props: { small?: boolean }) => { export const DiscoverBanner = (props: { small?: boolean }) => { return (
{props.small ? ( - + ) : (
)} -
+
Explore Publications {" "} diff --git a/app/home/HomeLayout.tsx b/app/home/HomeLayout.tsx index 47d34a81..8cd426fa 100644 --- a/app/home/HomeLayout.tsx +++ b/app/home/HomeLayout.tsx @@ -95,7 +95,7 @@ export const HomeLayout = (props: { return ( } @@ -192,7 +192,6 @@ export function HomeLeafletList(props: { {leaflets.filter((l) => !!l.token.leaflets_in_publications).length === 0 && } -
); } diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx index 4684976b..2ddd8456 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -44,12 +44,14 @@ export function CommentBox(props: { autoFocus?: boolean; }) { let mountRef = useRef(null); - let { commentBox: { quote } } = useInteractionState(props.doc_uri); + let { + commentBox: { quote }, + } = useInteractionState(props.doc_uri); let [loading, setLoading] = useState(false); - + const handleSubmit = async () => { if (loading || !view.current) return; - + setLoading(true); let currentState = view.current.state; let [plaintext, facets] = docToFacetedText(currentState.doc); @@ -108,8 +110,14 @@ export function CommentBox(props: { "Mod-z": undo, "Mod-y": redo, "Shift-Mod-z": redo, - "Ctrl-Enter": () => { handleSubmit(); return true; }, - "Meta-Enter": () => { handleSubmit(); return true; }, + "Ctrl-Enter": () => { + handleSubmit(); + return true; + }, + "Meta-Enter": () => { + handleSubmit(); + return true; + }, }), keymap(baseKeymap), autolink({ @@ -158,8 +166,6 @@ export function CommentBox(props: { let xml = new DOMParser().parseFromString(html, "text/html"); text = xml.textContent || ""; } - console.log("URL: " + window.location.toString()); - console.log("TEXT: " + text, text?.includes(QUOTE_PARAM)); if ( text?.includes(QUOTE_PARAM) && text.includes(window.location.toString()) @@ -190,7 +196,6 @@ export function CommentBox(props: { } }, dispatchTransaction(tr) { - console.log("dispatching?"); let newState = this.state.apply(tr); setEditorState(newState); view.current?.updateState(newState); @@ -207,7 +212,7 @@ export function CommentBox(props: { view.current = null; }; }, []); - + return (
{quote && ( @@ -216,7 +221,9 @@ export function CommentBox(props: {
- + {loading ? : }
diff --git a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx index 7c8b0951..f3525ecc 100644 --- a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx +++ b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx @@ -20,7 +20,6 @@ export const useHighlight = (pos: number[]) => { let highlights = activeHighlight ? [activeHighlight] : []; let decodedQuote = quote ? decodeQuotePosition(quote as string) : null; if (decodedQuote) highlights.push(decodedQuote); - console.log(highlights); return highlights .map((quotePosition) => { if (!quotePosition) return null; diff --git a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx index 4a8c3bf1..a5524c70 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx @@ -39,7 +39,7 @@ export default function PublicationDashboard({ return ( ); } catch (e) { - console.log(e); return
{JSON.stringify(e, undefined, 2)}
; } } diff --git a/app/lish/[did]/[publication]/icon.ts b/app/lish/[did]/[publication]/icon.ts index 94867461..f62000bc 100644 --- a/app/lish/[did]/[publication]/icon.ts +++ b/app/lish/[did]/[publication]/icon.ts @@ -46,7 +46,6 @@ export default async function Icon({ let identity = await idResolver.did.resolve(did); let service = identity?.service?.find((f) => f.id === "#atproto_pds"); - console.log(identity); if (!service) return null; let cid = (record.icon.ref as unknown as { $link: string })["$link"]; const response = await fetch( @@ -56,7 +55,6 @@ export default async function Icon({ let resizedImage = await sharp(await blob.arrayBuffer()) .resize({ width: 32, height: 32 }) .toBuffer(); - console.log("fetched favicon!"); return new Response(new Uint8Array(resizedImage), { headers: { "Content-Type": "image/png", diff --git a/app/lish/subscribeToPublication.ts b/app/lish/subscribeToPublication.ts index e9a47141..db93ed76 100644 --- a/app/lish/subscribeToPublication.ts +++ b/app/lish/subscribeToPublication.ts @@ -74,7 +74,6 @@ export async function subscribeToPublication( } export async function unsubscribeToPublication(publication: string) { - console.log("calling unsubscribe!"); const oauthClient = await createOauthClient(); let identity = await getIdentityData(); if (!identity || !identity.atp_did) return; diff --git a/app/reader/ReaderContent.tsx b/app/reader/ReaderContent.tsx new file mode 100644 index 00000000..cfae2371 --- /dev/null +++ b/app/reader/ReaderContent.tsx @@ -0,0 +1,279 @@ +"use client"; +import { AtUri } from "@atproto/api"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { PubIcon } from "components/ActionBar/Publications"; +import { ButtonPrimary } from "components/Buttons"; +import { CommentTiny } from "components/Icons/CommentTiny"; +import { DiscoverSmall } from "components/Icons/DiscoverSmall"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { Separator } from "components/Layout"; +import { SpeedyLink } from "components/SpeedyLink"; +import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; +import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; +import { useSmoker } from "components/Toast"; +import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { Json } from "supabase/database.types"; +import type { Cursor, Post } from "./getReaderFeed"; +import useSWRInfinite from "swr/infinite"; +import { getReaderFeed } from "./getReaderFeed"; +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; + +export const ReaderContent = (props: { + root_entity: string; + posts: Post[]; + nextCursor: Cursor | null; +}) => { + const getKey = ( + pageIndex: number, + previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["reader-feed", null] as const; + + // Add the cursor to the key + return ["reader-feed", previousPageData?.nextCursor] as const; + }; + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, cursor]) => getReaderFeed(cursor), + { + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], + revalidateFirstPage: false, + }, + ); + + const loadMoreRef = useRef(null); + + // Set up intersection observer to load more when trigger element is visible + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + const hasMore = data && data[data.length - 1]?.nextCursor; + if (hasMore) { + setSize(size + 1); + } + } + }, + { threshold: 0.1 }, + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [data, size, setSize, isValidating]); + + const allPosts = data ? data.flatMap((page) => page.posts) : []; + + if (allPosts.length === 0 && !isValidating) return ; + + return ( +
+ {allPosts.map((p) => ( + + ))} + {/* Trigger element for loading more posts */} + + ); +}; + +const Post = (props: Post) => { + let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; + + let postRecord = props.documents.data as PubLeafletDocument.Record; + let postUri = new AtUri(props.documents.uri); + + let theme = usePubTheme(pubRecord); + let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref + ? blobRefToSrc( + pubRecord?.theme?.backgroundImage?.image?.ref, + new AtUri(props.publication.uri).host, + ) + : null; + + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; + + let showPageBackground = pubRecord.theme?.showPageBackground; + + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; + let comments = + pubRecord.preferences?.showComments === false + ? 0 + : props.documents.comments_on_documents?.[0]?.count || 0; + + return ( + + + + ); +}; + +const PubInfo = (props: { + href: string; + pubRecord: PubLeafletPublication.Record; + uri: string; +}) => { + return ( + + + {props.pubRecord.name} + + ); +}; + +const PostInfo = (props: { + author: string; + publishedAt: string | undefined; +}) => { + return ( +
+ {props.author} + {props.publishedAt && ( + <> + + {new Date(props.publishedAt).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + })}{" "} + + )} +
+ ); +}; + +const PostInterations = (props: { + quotesCount: number; + commentsCount: number; + postUrl: string; + showComments: boolean | undefined; +}) => { + let smoker = useSmoker(); + let interactionsAvailable = + props.quotesCount > 0 || + (props.showComments !== false && props.commentsCount > 0); + + return ( +
+ {props.quotesCount === 0 ? null : ( +
+ Post quotes + {props.quotesCount} +
+ )} + {props.showComments === false || props.commentsCount === 0 ? null : ( +
+ Post comments + {props.commentsCount} +
+ )} + {interactionsAvailable && } + +
+ ); +}; +const ReaderEmpty = () => { + return ( +
+ Nothing to read yet…
+ Subscribe to publications and find their posts here! + + Discover Publications + +
+ ); +}; diff --git a/app/reader/SubscriptionsContent.tsx b/app/reader/SubscriptionsContent.tsx new file mode 100644 index 00000000..512867de --- /dev/null +++ b/app/reader/SubscriptionsContent.tsx @@ -0,0 +1,102 @@ +"use client"; +import { PubListing } from "app/discover/PubListing"; +import { ButtonPrimary } from "components/Buttons"; +import { DiscoverSmall } from "components/Icons/DiscoverSmall"; +import { Json } from "supabase/database.types"; +import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; +import useSWRInfinite from "swr/infinite"; +import { useEffect, useRef } from "react"; +import { Cursor } from "./getReaderFeed"; + +export const SubscriptionsContent = (props: { + publications: PublicationSubscription[]; + nextCursor: Cursor | null; +}) => { + const getKey = ( + pageIndex: number, + previousPageData: { + subscriptions: PublicationSubscription[]; + nextCursor: Cursor | null; + } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["subscriptions", null] as const; + + // Add the cursor to the key + return ["subscriptions", previousPageData?.nextCursor] as const; + }; + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, cursor]) => getSubscriptions(cursor), + { + fallbackData: [ + { subscriptions: props.publications, nextCursor: props.nextCursor }, + ], + revalidateFirstPage: false, + }, + ); + + const loadMoreRef = useRef(null); + + // Set up intersection observer to load more when trigger element is visible + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + const hasMore = data && data[data.length - 1]?.nextCursor; + if (hasMore) { + setSize(size + 1); + } + } + }, + { threshold: 0.1 }, + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [data, size, setSize, isValidating]); + + const allPublications = data + ? data.flatMap((page) => page.subscriptions) + : []; + + if (allPublications.length === 0 && !isValidating) + return ; + + return ( +
+
+ {allPublications?.map((p, index) => )} +
+ {/* Trigger element for loading more subscriptions */} + + ); +}; + +const SubscriptionsEmpty = () => { + return ( +
+ You haven't subscribed to any publications yet! + + Discover Publications + +
+ ); +}; diff --git a/app/reader/getReaderFeed.ts b/app/reader/getReaderFeed.ts new file mode 100644 index 00000000..9cb7d2b0 --- /dev/null +++ b/app/reader/getReaderFeed.ts @@ -0,0 +1,106 @@ +"use server"; + +import { getIdentityData } from "actions/getIdentityData"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { supabaseServerClient } from "supabase/serverClient"; +import { IdResolver } from "@atproto/identity"; +import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; +import Client from "ioredis"; +import { AtUri } from "@atproto/api"; +import { Json } from "supabase/database.types"; +import { idResolver } from "./idResolver"; + +export type Cursor = { + timestamp: string; + uri: string; +}; + +export async function getReaderFeed( + cursor?: Cursor | null, +): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { + let auth_res = await getIdentityData(); + if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; + let query = supabaseServerClient + .from("documents") + .select( + `*, + comments_on_documents(count), + document_mentions_in_bsky(count), + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, + ) + .eq( + "documents_in_publications.publications.publication_subscriptions.identity", + auth_res.atp_did, + ) + .order("indexed_at", { ascending: false }) + .order("uri", { ascending: false }) + .limit(25); + if (cursor) { + query = query.or( + `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, + ); + } + let { data: feed, error } = await query; + + let posts = await Promise.all( + feed?.map(async (post) => { + let pub = post.documents_in_publications[0].publications!; + let uri = new AtUri(post.uri); + let handle = await idResolver.did.resolve(uri.host); + let p: Post = { + publication: { + href: getPublicationURL(pub), + pubRecord: pub?.record || null, + uri: pub?.uri || "", + }, + author: handle?.alsoKnownAs?.[0] + ? `@${handle.alsoKnownAs[0].slice(5)}` + : null, + documents: { + comments_on_documents: post.comments_on_documents, + document_mentions_in_bsky: post.document_mentions_in_bsky, + data: post.data, + uri: post.uri, + indexed_at: post.indexed_at, + }, + }; + return p; + }) || [], + ); + const nextCursor = + posts.length > 0 + ? { + timestamp: posts[posts.length - 1].documents.indexed_at, + uri: posts[posts.length - 1].documents.uri, + } + : null; + + return { + posts, + nextCursor, + }; +} + +export type Post = { + author: string | null; + publication: { + href: string; + pubRecord: Json; + uri: string; + }; + documents: { + data: Json; + uri: string; + indexed_at: string; + comments_on_documents: + | { + count: number; + }[] + | undefined; + document_mentions_in_bsky: + | { + count: number; + }[] + | undefined; + }; +}; diff --git a/app/reader/getSubscriptions.ts b/app/reader/getSubscriptions.ts new file mode 100644 index 00000000..98f7934f --- /dev/null +++ b/app/reader/getSubscriptions.ts @@ -0,0 +1,70 @@ +"use server"; + +import { AtpAgent } from "@atproto/api"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { getIdentityData } from "actions/getIdentityData"; +import { Json } from "supabase/database.types"; +import { supabaseServerClient } from "supabase/serverClient"; +import { idResolver } from "./idResolver"; +import { Cursor } from "./getReaderFeed"; + +export async function getSubscriptions(cursor?: Cursor | null): Promise<{ + nextCursor: null | Cursor; + subscriptions: PublicationSubscription[]; +}> { + let auth_res = await getIdentityData(); + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; + let query = supabaseServerClient + .from("publication_subscriptions") + .select(`*, publications(*, documents_in_publications(*, documents(*)))`) + .order(`created_at`, { ascending: false }) + .order(`uri`, { ascending: false }) + .order("indexed_at", { + ascending: false, + referencedTable: "publications.documents_in_publications", + }) + .limit(1, { referencedTable: "publications.documents_in_publications" }) + .limit(25) + .eq("identity", auth_res.atp_did); + + if (cursor) { + query = query.or( + `created_at.lt.${cursor.timestamp},and(created_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, + ); + } + let { data: pubs, error } = await query; + + const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( + pubs?.map(async (pub) => { + let id = await idResolver.did.resolve(pub.publications?.identity_did!); + return { + ...pub.publications!, + authorProfile: id?.alsoKnownAs?.[0] + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } + : undefined, + }; + }) || [], + ); + + const nextCursor = + pubs && pubs.length > 0 + ? { + timestamp: pubs[pubs.length - 1].created_at, + uri: pubs[pubs.length - 1].uri, + } + : null; + + return { + subscriptions: hydratedSubscriptions, + nextCursor, + }; +} + +export type PublicationSubscription = { + authorProfile?: { handle: string }; + record: Json; + uri: string; + documents_in_publications: { + documents: { data?: Json; indexed_at: string } | null; + }[]; +}; diff --git a/app/reader/idResolver.ts b/app/reader/idResolver.ts new file mode 100644 index 00000000..f4c1af72 --- /dev/null +++ b/app/reader/idResolver.ts @@ -0,0 +1,78 @@ +import { IdResolver } from "@atproto/identity"; +import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; +import Client from "ioredis"; +// Create Redis client for DID caching +let redisClient: Client | null = null; +if (process.env.REDIS_URL) { + redisClient = new Client(process.env.REDIS_URL); +} + +// Redis-based DID cache implementation +class RedisDidCache implements DidCache { + private staleTTL: number; + private maxTTL: number; + + constructor( + private client: Client, + staleTTL = 60 * 60, // 1 hour + maxTTL = 60 * 60 * 24, // 24 hours + ) { + this.staleTTL = staleTTL; + this.maxTTL = maxTTL; + } + + async cacheDid(did: string, doc: DidDocument): Promise { + const cacheVal = { + doc, + updatedAt: Date.now(), + }; + await this.client.setex( + `did:${did}`, + this.maxTTL, + JSON.stringify(cacheVal), + ); + } + + async checkCache(did: string): Promise { + const cached = await this.client.get(`did:${did}`); + if (!cached) return null; + + const { doc, updatedAt } = JSON.parse(cached); + const now = Date.now(); + const age = now - updatedAt; + + return { + did, + doc, + updatedAt, + stale: age > this.staleTTL * 1000, + expired: age > this.maxTTL * 1000, + }; + } + + async refreshCache( + did: string, + getDoc: () => Promise, + ): Promise { + const doc = await getDoc(); + if (doc) { + await this.cacheDid(did, doc); + } + } + + async clearEntry(did: string): Promise { + await this.client.del(`did:${did}`); + } + + async clear(): Promise { + const keys = await this.client.keys("did:*"); + if (keys.length > 0) { + await this.client.del(...keys); + } + } +} + +// Create IdResolver with Redis-based DID cache +export const idResolver = new IdResolver({ + didCache: redisClient ? new RedisDidCache(redisClient) : undefined, +}); diff --git a/app/reader/page.tsx b/app/reader/page.tsx new file mode 100644 index 00000000..a22d5ce4 --- /dev/null +++ b/app/reader/page.tsx @@ -0,0 +1,91 @@ +import { cookies } from "next/headers"; +import { Fact, ReplicacheProvider } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { + ThemeBackgroundProvider, + ThemeProvider, +} from "components/ThemeManager/ThemeProvider"; +import { EntitySetProvider } from "components/EntitySetProvider"; +import { getIdentityData } from "actions/getIdentityData"; +import { supabaseServerClient } from "supabase/serverClient"; + +import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { ReaderContent } from "./ReaderContent"; +import { SubscriptionsContent } from "./SubscriptionsContent"; +import { getReaderFeed } from "./getReaderFeed"; +import { getSubscriptions } from "./getSubscriptions"; + +export default async function Reader(props: {}) { + let cookieStore = await cookies(); + let auth_res = await getIdentityData(); + let identity: string | undefined; + let permission_token = auth_res?.home_leaflet; + if (!permission_token) + return ( + +

Sorry, we can't find this page!

+

+ This may be a glitch on our end. If the issue persists please{" "} + send us a note. +

+
+ ); + let [homeLeafletFacts] = await Promise.all([ + supabaseServerClient.rpc("get_facts", { + root: permission_token.root_entity, + }), + ]); + let initialFacts = + (homeLeafletFacts.data as unknown as Fact[]) || []; + let root_entity = permission_token.root_entity; + + if (!auth_res?.atp_did) return; + let posts = await getReaderFeed(); + let publications = await getSubscriptions(); + return ( + + + + + + ), + }, + Subscriptions: { + controls: null, + content: ( + + ), + }, + }} + /> + + + + + ); +} diff --git a/appview/bumpCursor.js b/appview/bumpCursor.js index a311d2ef..ba0ff490 100644 --- a/appview/bumpCursor.js +++ b/appview/bumpCursor.js @@ -7,8 +7,4 @@ if (Number.isNaN(cursor)) { } else { let newCursor = (cursor + 300 * 60 * 60 * 12).toString(); writeFileSync(cursorFile, (cursor + 300 * 60 * 60 * 12).toString()); - console.log(` - old cursor: ${cursor} - new cursor: ${newCursor} - `); } diff --git a/components/ActionBar/Navigation.tsx b/components/ActionBar/Navigation.tsx index b834c715..b6cf0de9 100644 --- a/components/ActionBar/Navigation.tsx +++ b/components/ActionBar/Navigation.tsx @@ -7,6 +7,10 @@ import { DiscoverSmall } from "components/Icons/DiscoverSmall"; import { PublicationButtons } from "./Publications"; import { Popover } from "components/Popover"; import { MenuSmall } from "components/Icons/MenuSmall"; +import { + ReaderReadSmall, + ReaderUnreadSmall, +} from "components/Icons/ReaderSmall"; export type navPages = "home" | "reader" | "pub" | "discover"; @@ -14,8 +18,6 @@ export const DesktopNavigation = (props: { currentPage: navPages; publication?: string; }) => { - let unreadNotifications = true; - return (
@@ -89,7 +91,12 @@ const NavigationOptions = (props: { return ( <> - + + +
@@ -109,21 +116,26 @@ const HomeButton = (props: { current?: boolean }) => { ); }; -const ReaderButton = (props: { current?: boolean }) => { - // let readerUnreads = true; - // let subs = false; +const ReaderButton = (props: { current?: boolean; subs: boolean }) => { + let readerUnreads = false; + + if (!props.subs) return; + return ( + + : } + label="Reader" + className={` + ${readerUnreads && "text-accent-contrast!"} + ${props.current && "border-accent-contrast!"} + `} + /> + + ); +}; - // if (subs) - // return ( - // : } - // label="Reader" - // className={` - // ${readerUnreads ? "text-accent-contrast! border-accent-contrast" : props.current ? "bg-border-light! border-border" : ""} - // `} - // /> - // ); +const DiscoverButton = (props: { current?: boolean }) => { return ( { // console.log("thisURI : " + d.uri); // console.log("currentURI : " + props.currentPubUri); - console.log(d.uri === props.currentPubUri); return ( { export const PubIcon = (props: { record: PubLeafletPublication.Record; uri: string; + small?: boolean; + large?: boolean; + className?: string; }) => { if (!props.record) return; + let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; + return props.record.icon ? (
) : ( -
-
+
+
{props.record?.name.slice(0, 1)}
diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx index 6cbfe1f5..132e72a7 100644 --- a/components/Blocks/TextBlock/RenderYJSFragment.tsx +++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx @@ -32,7 +32,7 @@ export function RenderYJSFragment({ node.toArray().map((node, index) => { if (node.constructor === XmlText) { let deltas = node.toDelta() as Delta[]; - if (deltas.length === 0) return
; + if (deltas.length === 0) return
; return ( {deltas.map((d, index) => { diff --git a/components/Icons/ExternalLinkTiny.tsx b/components/Icons/ExternalLinkTiny.tsx new file mode 100644 index 00000000..d08695d6 --- /dev/null +++ b/components/Icons/ExternalLinkTiny.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const ExternalLinkTiny = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx index 356af898..afeabfb1 100644 --- a/components/PageHeader.tsx +++ b/components/PageHeader.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; export const Header = (props: { children: React.ReactNode; - hasBackgroundImage: boolean; + cardBorderHidden: boolean; }) => { let [scrollPos, setScrollPos] = useState(0); @@ -22,9 +22,9 @@ export const Header = (props: { } }, []); - let headerBGColor = props.hasBackgroundImage - ? "var(--bg-page)" - : "var(--bg-leaflet)"; + let headerBGColor = props.cardBorderHidden + ? "var(--bg-leaflet)" + : "var(--bg-page)"; return (
{ export function DashboardLayout< T extends { - [name: string]: { content: React.ReactNode; controls: React.ReactNode }; + [name: string]: { + content: React.ReactNode; + controls: React.ReactNode; + }; }, >(props: { id: string; - hasBackgroundImage: boolean; + cardBorderHidden: boolean; tabs: T; defaultTab: keyof T; currentPage: navPages; publication?: string; actions: React.ReactNode; }) { - let [tab, setTab] = useState(props.defaultTab); + const searchParams = useSearchParams(); + const tabParam = searchParams.get("tab"); + + // Initialize tab from search param if valid, otherwise use default + const initialTab = tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; + let [tab, setTab] = useState(initialTab); + + // Custom setter that updates both state and URL + const setTabWithUrl = (newTab: keyof T) => { + setTab(newTab); + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", newTab as string); + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState(null, "", newUrl); + }; + let { content, controls } = props.tabs[tab]; + let { ref } = usePreserveScroll( + `dashboard-${props.id}-${tab as string}`, + ); let [headerState, setHeaderState] = useState<"default" | "controls">( "default", @@ -150,23 +175,26 @@ export function DashboardLayout<
{Object.keys(props.tabs).length <= 1 && !controls ? null : ( <> -
+
{headerState === "default" ? ( <> {Object.keys(props.tabs).length > 1 && (
- {Object.keys(props.tabs).map((t) => ( - setTab(t)} - /> - ))} + {Object.keys(props.tabs).map((t) => { + return ( + setTabWithUrl(t)} + /> + ); + })}
)} {props.publication && ( @@ -326,13 +354,19 @@ const DisplayToggle = (props: { ); }; -function Tab(props: { name: string; selected: boolean; onSelect: () => void }) { +function Tab(props: { + name: string; + selected: boolean; + onSelect: () => void; + href?: string; +}) { return (
props.onSelect()} > {props.name} + {props.href && }
); } diff --git a/components/ThemeManager/PubPickers/PubBackgroundPickers.tsx b/components/ThemeManager/PubPickers/PubBackgroundPickers.tsx index 99b5ec86..d3272d66 100644 --- a/components/ThemeManager/PubPickers/PubBackgroundPickers.tsx +++ b/components/ThemeManager/PubPickers/PubBackgroundPickers.tsx @@ -67,7 +67,6 @@ export const BackgroundPicker = (props: { if (file) { const reader = new FileReader(); reader.onload = (e) => { - console.log("loaded!", props.bgImage); props.setBgImage({ src: e.target?.result as string, file, diff --git a/components/ThemeManager/PubThemeSetter.tsx b/components/ThemeManager/PubThemeSetter.tsx index d6d22396..db4e1d67 100644 --- a/components/ThemeManager/PubThemeSetter.tsx +++ b/components/ThemeManager/PubThemeSetter.tsx @@ -62,7 +62,6 @@ export const PubThemeSetter = () => { onSubmit={async (e) => { e.preventDefault(); if (!pub) return; - console.log(image); setLoading(true); let result = await updatePublicationTheme({ uri: pub.uri, diff --git a/components/utils/AutosizeTextarea.tsx b/components/utils/AutosizeTextarea.tsx index be6fea06..19cbf395 100644 --- a/components/utils/AutosizeTextarea.tsx +++ b/components/utils/AutosizeTextarea.tsx @@ -19,7 +19,6 @@ export const AutosizeTextarea = forwardRef< let { noWrap, ...rest } = props; useImperativeHandle(ref, () => textarea.current as HTMLTextAreaElement); - console.log({ noWrap }); return (
{ let { data: publications } = await supabaseServerClient .from("publication_subscriptions") - .select(`publications(documents_in_publications(documents(*)))`) + .select(`publications(*, documents_in_publications(documents(*)))`) .eq("identity", auth); const allPosts = (publications || []) diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts index 16bb6d4a..b416e32b 100644 --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -177,6 +177,36 @@ export const PubLeafletBlocksImage: LexiconDoc = { }, }; +export const PubLeafletBlocksOrderedList: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.blocks.orderedList", + defs: { + main: { + type: "object", + required: ["children"], + properties: { + startIndex: { type: "integer" }, + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, + }, + }, + listItem: { + type: "object", + required: ["content"], + properties: { + content: { + type: "union", + refs: [ + PubLeafletBlocksText, + PubLeafletBlocksHeader, + PubLeafletBlocksImage, + ].map((l) => l.id), + }, + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, + }, + }, + }, +}; + export const PubLeafletBlocksUnorderedList: LexiconDoc = { lexicon: 1, id: "pub.leaflet.blocks.unorderedList", diff --git a/src/hooks/usePreserveScroll.ts b/src/hooks/usePreserveScroll.ts new file mode 100644 index 00000000..c64c83f0 --- /dev/null +++ b/src/hooks/usePreserveScroll.ts @@ -0,0 +1,22 @@ +import { useRef, useEffect } from "react"; + +let scrollPositions: { [key: string]: number } = {}; +export function usePreserveScroll(key: string | null) { + let ref = useRef(null); + useEffect(() => { + if (!ref.current || !key) return; + + window.requestAnimationFrame(() => { + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); + }); + + const listener = () => { + if (!ref.current?.scrollTop) return; + scrollPositions[key] = ref.current.scrollTop; + }; + + ref.current.addEventListener("scroll", listener); + return () => ref.current?.removeEventListener("scroll", listener); + }, [key, ref.current]); + return { ref }; +} diff --git a/src/utils/addLinkBlock.ts b/src/utils/addLinkBlock.ts index 40556dc0..e08a0f27 100644 --- a/src/utils/addLinkBlock.ts +++ b/src/utils/addLinkBlock.ts @@ -59,7 +59,6 @@ export async function addLinkBlock( } if (data.data.links?.player?.[0]) { - console.log(data.data.links.player); let embed = data.data.links?.player?.[0]; await rep.mutate.assertFact([ { diff --git a/src/utils/focusBlock.ts b/src/utils/focusBlock.ts index 99606df8..fcf23cc2 100644 --- a/src/utils/focusBlock.ts +++ b/src/utils/focusBlock.ts @@ -85,7 +85,6 @@ export function focusBlock( top: nextBlockViewClientRect.top + 12, left: Math.max(position.left, nextBlockViewClientRect.left), }); - console.log(pos); break; } case "bottom": { diff --git a/src/utils/getBlocksAsHTML.tsx b/src/utils/getBlocksAsHTML.tsx index 143c8053..235735ba 100644 --- a/src/utils/getBlocksAsHTML.tsx +++ b/src/utils/getBlocksAsHTML.tsx @@ -187,6 +187,5 @@ async function renderBlock(b: Block, tx: ReadTransaction) { let [alignment] = await scanIndex(tx).eav(b.value, "block/text-alignment"); let toHtml = BlockTypeToHTML[b.type]; let element = await toHtml(b, tx, alignment?.data.value); - console.log(element); return renderToStaticMarkup(element); }