diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts
index a56b483d..191a6bc3 100644
--- a/actions/publishToPublication.ts
+++ b/actions/publishToPublication.ts
@@ -17,7 +17,7 @@ import {
import { Block } from "components/Blocks/Block";
import { TID } from "@atproto/common";
import { supabaseServerClient } from "supabase/serverClient";
-import { scanIndex, scanIndexLocal } from "src/replicache/utils";
+import { scanIndexLocal } from "src/replicache/utils";
import type { Fact } from "src/replicache";
import type { Attribute } from "src/replicache/attributes";
import {
@@ -30,17 +30,16 @@ import { AtUri } from "@atproto/syntax";
import { Json } from "supabase/database.types";
import { $Typed, UnicodeString } from "@atproto/api";
import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
+import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
export async function publishToPublication({
root_entity,
- blocks,
publication_uri,
leaflet_id,
title,
description,
}: {
root_entity: string;
- blocks: Block[];
publication_uri: string;
leaflet_id: string;
title?: string;
@@ -48,7 +47,7 @@ export async function publishToPublication({
}) {
const oauthClient = await createOauthClient();
let identity = await getIdentityData();
- if (!identity || !identity.atp_did) return null;
+ if (!identity || !identity.atp_did) throw new Error("No Identity");
let credentialSession = await oauthClient.restore(identity.atp_did);
let agent = new AtpBaseClient(
@@ -60,12 +59,15 @@ export async function publishToPublication({
.eq("publication", publication_uri)
.eq("leaflet", leaflet_id)
.single();
- if (!draft || identity.atp_did !== draft?.publications?.identity_did) return;
+ if (!draft || identity.atp_did !== draft?.publications?.identity_did)
+ throw new Error("No draft or not publisher");
let { data } = await supabaseServerClient.rpc("get_facts", {
root: root_entity,
});
+ let facts = (data as unknown as Fact[]) || [];
+ let blocks = getBlocksWithTypeLocal(facts, root_entity);
- let scan = scanIndexLocal((data as unknown as Fact[]) || []);
+ let scan = scanIndexLocal(facts);
let images = blocks
.filter((b) => b.type === "image")
.map((b) => scan.eav(b.value, "block/image")[0]);
@@ -130,7 +132,7 @@ export async function publishToPublication({
.eq("publication", publication_uri),
]);
- return { rkey };
+ return { rkey, record };
}
function blocksToRecord(
diff --git a/app/[leaflet_id]/Actions.tsx b/app/[leaflet_id]/Actions.tsx
index 578ff4de..06349af8 100644
--- a/app/[leaflet_id]/Actions.tsx
+++ b/app/[leaflet_id]/Actions.tsx
@@ -4,20 +4,20 @@ import {
getPublicationURL,
} from "app/lish/createPub/getPublicationURL";
import { ActionButton } from "components/ActionBar/ActionButton";
-import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
import { GoBackSmall } from "components/Icons/GoBackSmall";
import { PublishSmall } from "components/Icons/PublishSmall";
import { useIdentityData } from "components/IdentityProvider";
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
import { useToaster } from "components/Toast";
import { DotLoader } from "components/utils/DotLoader";
-import { publications } from "drizzle/schema";
import Link from "next/link";
-import { useParams } from "next/navigation";
+import { useParams, useRouter } from "next/navigation";
+import router from "next/router";
import { useState } from "react";
import { useBlocks } from "src/hooks/queries/useBlocks";
-import { useEntity, useReplicache } from "src/replicache";
+import { useReplicache, useEntity } from "src/replicache";
import { Json } from "supabase/database.types";
+
export const BackToPubButton = (props: {
publication: {
identity_did: string;
@@ -27,7 +27,6 @@ export const BackToPubButton = (props: {
uri: string;
};
}) => {
- let { identity } = useIdentityData();
return (
{
+ let { data: pub } = useLeafletPublicationData();
+ let params = useParams();
+ let router = useRouter();
+ if (!pub?.doc)
+ return (
+ }
+ label={"Publish!"}
+ onClick={() => {
+ router.push(`/${params.leaflet_id}/publish`);
+ }}
+ />
+ );
+
+ return ;
+};
+
+const UpdateButton = () => {
let [isLoading, setIsLoading] = useState(false);
let { data: pub, mutate } = useLeafletPublicationData();
- let identity = useIdentityData();
let { permission_token, rootEntity } = useReplicache();
- let rootPage = useEntity(rootEntity, "root/page")[0];
- let blocks = useBlocks(rootPage?.data.value);
let toaster = useToaster();
+
return (
}
- label={isLoading ? : pub?.doc ? "Update!" : "Publish!"}
+ label={isLoading ? : "Update!"}
onClick={async () => {
if (!pub || !pub.publications) return;
setIsLoading(true);
let doc = await publishToPublication({
root_entity: rootEntity,
- blocks,
publication_uri: pub.publications.uri,
leaflet_id: permission_token.id,
title: pub.title,
diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx
new file mode 100644
index 00000000..aedca9a0
--- /dev/null
+++ b/app/[leaflet_id]/publish/PublishPost.tsx
@@ -0,0 +1,196 @@
+"use client";
+import { publishToPublication } from "actions/publishToPublication";
+import { DotLoader } from "components/utils/DotLoader";
+import { useState } from "react";
+import { ButtonPrimary } from "components/Buttons";
+import { Radio } from "components/Checkbox";
+import { useParams } from "next/navigation";
+import Link from "next/link";
+import { AutosizeTextarea } from "components/utils/AutosizeTextarea";
+import { PubLeafletPublication } from "lexicons/api";
+import { publishPostToBsky } from "./publishBskyPost";
+import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
+import { AtUri } from "@atproto/syntax";
+
+type Props = {
+ title: string;
+ leaflet_id: string;
+ root_entity: string;
+ profile: ProfileViewDetailed;
+ description: string;
+ publication_uri: string;
+ record?: PubLeafletPublication.Record;
+};
+
+export function PublishPost(props: Props) {
+ let [publishState, setPublishState] = useState<
+ { state: "default" } | { state: "success"; post_url: string }
+ >({ state: "default" });
+ return (
+
+ {publishState.state === "default" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+const PublishPostForm = (
+ props: {
+ setPublishState: (s: { state: "success"; post_url: string }) => void;
+ } & Props,
+) => {
+ let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
+ let [postContent, setPostContent] = useState("");
+ let [isLoading, setIsLoading] = useState(false);
+ let params = useParams();
+
+ async function submit() {
+ setIsLoading(true);
+ let doc = await publishToPublication({
+ root_entity: props.root_entity,
+ publication_uri: props.publication_uri,
+ leaflet_id: props.leaflet_id,
+ title: props.title,
+ description: props.description,
+ });
+ if (!doc) return;
+
+ let post_url = `https://${props.record?.base_path}/${doc.rkey}`;
+ let publishedPost = await publishPostToBsky({
+ text: postContent,
+ title: props.title,
+ url: post_url,
+ description: props.description,
+ record: doc.record,
+ rkey: doc.rkey,
+ });
+ setIsLoading(false);
+ props.setPublishState({ state: "success", post_url });
+ }
+
+ return (
+
+ );
+};
+
+const PublishPostSuccess = (props: {
+ post_url: string;
+ publication_uri: string;
+ record: Props["record"];
+}) => {
+ let uri = new AtUri(props.publication_uri);
+ return (
+
+
Woo! You published your post!
+
+
+ );
+};
diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx
new file mode 100644
index 00000000..518c36fc
--- /dev/null
+++ b/app/[leaflet_id]/publish/page.tsx
@@ -0,0 +1,54 @@
+import { supabaseServerClient } from "supabase/serverClient";
+import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data";
+import { PublishPost } from "./PublishPost";
+import { PubLeafletPublication } from "lexicons/api";
+import { getIdentityData } from "actions/getIdentityData";
+
+import { AtpAgent } from "@atproto/api";
+
+export const preferredRegion = ["sfo1"];
+export const dynamic = "force-dynamic";
+export const fetchCache = "force-no-store";
+
+type Props = {
+ // this is now a token id not leaflet! Should probs rename
+ params: Promise<{ leaflet_id: string }>;
+};
+export default async function LeafletPage(props: Props) {
+ let leaflet_id = (await props.params).leaflet_id;
+ let { result: res } = await get_leaflet_data.handler(
+ { token_id: leaflet_id },
+ { supabase: supabaseServerClient },
+ );
+ let rootEntity = res.data?.root_entity;
+ if (
+ !rootEntity ||
+ !res.data ||
+ res.data.blocked_by_admin ||
+ !res.data.leaflets_in_publications[0]
+ )
+ return (
+
+ missin something
+
{JSON.stringify(res.data, undefined, 2)}
+
+ );
+
+ let identity = await getIdentityData();
+ if (!identity || !identity.atp_did) return null;
+ let pub = res.data.leaflets_in_publications[0];
+ let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
+
+ let profile = await agent.getProfile({ actor: identity.atp_did });
+ return (
+
+ );
+}
diff --git a/app/[leaflet_id]/publish/publishBskyPost.ts b/app/[leaflet_id]/publish/publishBskyPost.ts
new file mode 100644
index 00000000..d5fa49f0
--- /dev/null
+++ b/app/[leaflet_id]/publish/publishBskyPost.ts
@@ -0,0 +1,70 @@
+"use server";
+
+import { Agent as BskyAgent } from "@atproto/api";
+import { TID } from "@atproto/common";
+import { getIdentityData } from "actions/getIdentityData";
+import { AtpBaseClient, PubLeafletDocument } from "lexicons/api";
+import { createOauthClient } from "src/atproto-oauth";
+
+export async function publishPostToBsky(bskyPost: {
+ text: string;
+ url: string;
+ title: string;
+ description: string;
+ record: PubLeafletDocument.Record;
+ rkey: string;
+}) {
+ const oauthClient = await createOauthClient();
+ let identity = await getIdentityData();
+ if (!identity || !identity.atp_did) return null;
+
+ let credentialSession = await oauthClient.restore(identity.atp_did);
+ let agent = new AtpBaseClient(
+ credentialSession.fetchHandler.bind(credentialSession),
+ );
+ let newPostUrl = bskyPost.url;
+ let preview_image = await fetch(
+ `https://pro.microlink.io/?url=${newPostUrl}&screenshot=true&viewport.width=1400&viewport.height=733&meta=false&embed=screenshot.url&force=true`,
+ {
+ headers: {
+ "x-api-key": process.env.MICROLINK_API_KEY!,
+ },
+ },
+ );
+
+ let binary = await preview_image.blob();
+ let blob = await agent.com.atproto.repo.uploadBlob(binary, {
+ headers: { "Content-Type": binary.type },
+ });
+ let bsky = new BskyAgent(credentialSession);
+ let post = await bsky.app.bsky.feed.post.create(
+ {
+ repo: credentialSession.did!,
+ rkey: TID.nextStr(),
+ },
+ {
+ text: bskyPost.text,
+ createdAt: new Date().toISOString(),
+ embed: {
+ $type: "app.bsky.embed.external",
+ external: {
+ uri: bskyPost.url,
+ title: bskyPost.title,
+ description: bskyPost.description,
+ thumb: blob.data.blob,
+ },
+ },
+ },
+ );
+ let record = bskyPost.record;
+ record.postRef = post;
+
+ let { data: result } = await agent.com.atproto.repo.putRecord({
+ rkey: bskyPost.rkey,
+ repo: credentialSession.did!,
+ collection: bskyPost.record.$type,
+ record,
+ validate: false, //TODO publish the lexicon so we can validate!
+ });
+ return true;
+}
diff --git a/app/api/oauth/[route]/afterSignInActions.ts b/app/api/oauth/[route]/afterSignInActions.ts
new file mode 100644
index 00000000..ed3a6852
--- /dev/null
+++ b/app/api/oauth/[route]/afterSignInActions.ts
@@ -0,0 +1,19 @@
+export type ActionAfterSignIn = {
+ action: "subscribe";
+ publication: string;
+};
+
+export function encodeActionToSearchParam(actions: ActionAfterSignIn): string {
+ return encodeURIComponent(JSON.stringify(actions));
+}
+
+export function parseActionFromSearchParam(
+ param: string | null,
+): ActionAfterSignIn | null {
+ if (!param) return null;
+ try {
+ return JSON.parse(decodeURIComponent(param)) as ActionAfterSignIn;
+ } catch {
+ return null;
+ }
+}
diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts
index 0588d96b..047dcaf5 100644
--- a/app/api/oauth/[route]/route.ts
+++ b/app/api/oauth/[route]/route.ts
@@ -1,5 +1,5 @@
-import { OAuthClientMetadata } from "@atproto/oauth-client-node";
import { createIdentity } from "actions/createIdentity";
+import { subscribeToPublication } from "app/lish/subscribe";
import { drizzle } from "drizzle-orm/postgres-js";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
@@ -9,10 +9,17 @@ import { createOauthClient } from "src/atproto-oauth";
import { setAuthToken } from "src/auth";
import { supabaseServerClient } from "supabase/serverClient";
+import { URLSearchParams } from "url";
+import {
+ ActionAfterSignIn,
+ parseActionFromSearchParam,
+} from "./afterSignInActions";
type OauthRequestClientState = {
redirect: string | null;
+ action: ActionAfterSignIn | null;
};
+
export async function GET(
req: NextRequest,
props: { params: Promise<{ route: string; handle?: string }> },
@@ -29,7 +36,8 @@ export async function GET(
const handle = searchParams.get("handle") as string;
// Put originating page here!
let redirect = searchParams.get("redirect_url");
- let state: OauthRequestClientState = { redirect };
+ let action = parseActionFromSearchParam(searchParams.get("action"));
+ let state: OauthRequestClientState = { redirect, action };
// Revoke any pending authentication requests if the connection is closed (optional)
const ac = new AbortController();
@@ -69,7 +77,8 @@ export async function GET(
.from("identities")
.update({ atp_did: session.did })
.eq("id", data.data.identity);
- return redirect(redirectPath);
+
+ return handleAction(s.action, redirectPath);
}
const client = postgres(process.env.DB_URL as string, {
idle_timeout: 5,
@@ -93,12 +102,28 @@ export async function GET(
console.log("authorize() was called with state:", state);
console.log("User authenticated as:", session.did);
+ return handleAction(s.action, redirectPath);
} catch (e) {
redirect(redirectPath);
}
- return redirect(redirectPath);
}
default:
return NextResponse.json({ error: "Invalid route" }, { status: 404 });
}
}
+
+const handleAction = async (
+ action: ActionAfterSignIn | null,
+ redirectPath: string,
+) => {
+ let [base, pathparams] = redirectPath.split("?");
+ let searchParams = new URLSearchParams(pathparams);
+ if (action?.action === "subscribe") {
+ let result = await subscribeToPublication(action.publication);
+ console.log(result);
+ if (result.hasFeed === false)
+ searchParams.set("showSubscribeSuccess", "true");
+ }
+
+ return redirect(base + "?" + searchParams.toString());
+};
diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx
index 35c6bc88..728cb023 100644
--- a/app/lish/Subscribe.tsx
+++ b/app/lish/Subscribe.tsx
@@ -1,9 +1,8 @@
"use client";
import { ButtonPrimary } from "components/Buttons";
-import { useEffect, useState } from "react";
+import { useActionState, useState } from "react";
import { Input } from "components/Input";
import { useIdentityData } from "components/IdentityProvider";
-import { SecondaryAuthTokenContextImpl } from "twilio/lib/rest/accounts/v1/secondaryAuthToken";
import {
confirmEmailAuthToken,
requestAuthEmailToken,
@@ -11,6 +10,14 @@ import {
import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail";
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
import { ShareSmall } from "components/Icons/ShareSmall";
+import { Popover } from "components/Popover";
+import { BlueskyTiny } from "components/Icons/BlueskyTiny";
+import { useToaster } from "components/Toast";
+import * as Dialog from "@radix-ui/react-dialog";
+import { subscribeToPublication, unsubscribeToPublication } from "./subscribe";
+import { DotLoader } from "components/utils/DotLoader";
+import { addFeed } from "./addFeed";
+import { useSearchParams } from "next/navigation";
type State =
| { state: "email" }
@@ -166,3 +173,185 @@ const ConfirmCodeInput = (props: {
);
};
+
+export const SubscribeWithBluesky = (props: {
+ isPost?: boolean;
+ pubName: string;
+ pub_uri: string;
+ subscribers: { identity: string }[];
+}) => {
+ let { identity } = useIdentityData();
+ let searchParams = useSearchParams();
+ let [successModalOpen, setSuccessModalOpen] = useState(
+ !!searchParams.has("showSubscribeSuccess"),
+ );
+ let subscribed =
+ identity?.atp_did &&
+ props.subscribers.find((s) => s.identity === identity.atp_did);
+
+ if (successModalOpen)
+ return (
+
+ );
+ if (subscribed) {
+ return ;
+ }
+ return (
+
+ {props.isPost && (
+
+ Get updates from {props.pubName}!
+
+ )}
+
+
+ );
+};
+
+const ManageSubscription = (props: {
+ isPost?: boolean;
+ pubName: string;
+ pub_uri: string;
+ subscribers: { identity: string }[];
+}) => {
+ let toaster = useToaster();
+ let [hasFeed] = useState(false);
+ let [, unsubscribe, unsubscribePending] = useActionState(async () => {
+ await unsubscribeToPublication(props.pub_uri);
+ toaster({
+ content: "You unsubscribed.",
+ type: "success",
+ });
+ }, null);
+ return (
+
+
+ You're Subscribed{props.isPost ? ` to ${props.pubName}` : "!"}
+
+
Manage }
+ >
+
+ {!hasFeed && (
+ <>
+
+
+ >
+ )}
+
+
{" "}
+
+
+ );
+};
+
+let BlueskySubscribeButton = (props: {
+ pub_uri: string;
+ setSuccessModalOpen: (open: boolean) => void;
+}) => {
+ let [, subscribe, subscribePending] = useActionState(async () => {
+ let result = await subscribeToPublication(
+ props.pub_uri,
+ window.location.href + "?refreshAuth",
+ );
+ if (result.hasFeed === false) {
+ props.setSuccessModalOpen(true);
+ }
+ }, null);
+
+ return (
+ <>
+
+ >
+ );
+};
+
+const SubscribeSuccessModal = ({
+ open,
+ setOpen,
+}: {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+}) => {
+ let searchParams = useSearchParams();
+ return (
+
+
+
+
+
+
+ Subscribed!
+
+
+ You'll get updates about this publication via a Feed just for you.
+ {
+ let feedurl =
+ "https://bsky.app/profile/leaflet.pub/feed/subscribedPublications";
+ await addFeed();
+ window.open(feedurl, "_blank");
+ }}
+ >
+ Add Bluesky Feed
+
+
+
+
+
+
+
+ );
+};
diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx
index a59990ae..17aab9b8 100644
--- a/app/lish/[did]/[publication]/[rkey]/page.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/page.tsx
@@ -15,6 +15,7 @@ import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
import { TextBlock } from "./TextBlock";
import { ThemeProvider } from "components/ThemeManager/ThemeProvider";
import { BskyAgent } from "@atproto/api";
+import { SubscribeWithBluesky } from "app/lish/Subscribe";
export async function generateMetadata(props: {
params: Promise<{ publication: string; did: string; rkey: string }>;
@@ -52,7 +53,9 @@ export default async function Post(props: {
let [{ data: document }, { data: profile }] = await Promise.all([
supabaseServerClient
.from("documents")
- .select("*, documents_in_publications(publications(*))")
+ .select(
+ "*, documents_in_publications(publications(*, publication_subscriptions(*)))",
+ )
.eq(
"uri",
AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey),
@@ -122,6 +125,16 @@ export default async function Post(props: {
return ;
})}
+
+
diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx
index 112be608..841d5a84 100644
--- a/app/lish/[did]/[publication]/page.tsx
+++ b/app/lish/[did]/[publication]/page.tsx
@@ -2,17 +2,14 @@ import { supabaseServerClient } from "supabase/serverClient";
import { Metadata } from "next";
import { ThemeProvider } from "components/ThemeManager/ThemeProvider";
-import React from "react";
import { get_publication_data } from "app/api/rpc/[command]/get_publication_data";
import { AtUri } from "@atproto/syntax";
-import {
- AtpBaseClient,
- PubLeafletDocument,
- PubLeafletPublication,
-} from "lexicons/api";
+import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
import Link from "next/link";
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
import { BskyAgent } from "@atproto/api";
+import { SubscribeWithBluesky } from "app/lish/Subscribe";
+import React from "react";
export async function generateMetadata(props: {
params: Promise<{ publication: string; did: string }>;
@@ -49,6 +46,7 @@ export default async function Publication(props: {
.from("publications")
.select(
`*,
+ publication_subscriptions(*),
documents_in_publications(documents(*))
`,
)
@@ -97,6 +95,13 @@ export default async function Publication(props: {
)}
+
+
+
{publication.documents_in_publications
diff --git a/app/lish/[did]/[publication]/subscribeSuccess/page.tsx b/app/lish/[did]/[publication]/subscribeSuccess/page.tsx
new file mode 100644
index 00000000..d84f4c6d
--- /dev/null
+++ b/app/lish/[did]/[publication]/subscribeSuccess/page.tsx
@@ -0,0 +1,23 @@
+import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
+import { RSSSmall } from "components/Icons/RSSSmall";
+
+export default function SubscribeSuccess() {
+ return (
+
+
+
You've Subscribed!
+
+ Add this custom feed to your Bluesky to get the updates from this and
+ ALL leaflet publications you subscribe to!
+
+
+
+ Add Custom Feed
+
+
+
+
+ );
+}
diff --git a/app/lish/addFeed.tsx b/app/lish/addFeed.tsx
new file mode 100644
index 00000000..4609ba5f
--- /dev/null
+++ b/app/lish/addFeed.tsx
@@ -0,0 +1,25 @@
+"use server";
+
+import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
+import { getIdentityData } from "actions/getIdentityData";
+import { createOauthClient } from "src/atproto-oauth";
+const leafletFeedURI =
+ "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
+
+export async function addFeed() {
+ const oauthClient = await createOauthClient();
+ let identity = await getIdentityData();
+ if (!identity || !identity.atp_did) {
+ throw new Error("Invalid identity data");
+ }
+
+ let credentialSession = await oauthClient.restore(identity.atp_did);
+ let bsky = new BskyAgent(credentialSession);
+ await bsky.addSavedFeeds([
+ {
+ value: leafletFeedURI,
+ pinned: true,
+ type: "feed",
+ },
+ ]);
+}
diff --git a/app/lish/subscribe.ts b/app/lish/subscribe.ts
new file mode 100644
index 00000000..314daaa8
--- /dev/null
+++ b/app/lish/subscribe.ts
@@ -0,0 +1,84 @@
+"use server";
+
+import { AtpBaseClient } from "lexicons/api";
+import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
+import { getIdentityData } from "actions/getIdentityData";
+import { createOauthClient } from "src/atproto-oauth";
+import { TID } from "@atproto/common";
+import { supabaseServerClient } from "supabase/serverClient";
+import { revalidatePath } from "next/cache";
+import { AtUri } from "@atproto/syntax";
+import { redirect } from "next/navigation";
+import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions";
+
+let leafletFeedURI =
+ "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications";
+export async function subscribeToPublication(
+ publication: string,
+ redirectRoute?: string,
+) {
+ const oauthClient = await createOauthClient();
+ let identity = await getIdentityData();
+ if (!identity || !identity.atp_did) {
+ return redirect(
+ `/api/oauth/login?redirect_url=${redirectRoute}&action=${encodeActionToSearchParam({ action: "subscribe", publication })}`,
+ );
+ }
+
+ let credentialSession = await oauthClient.restore(identity.atp_did);
+ let agent = new AtpBaseClient(
+ credentialSession.fetchHandler.bind(credentialSession),
+ );
+ let record = await agent.pub.leaflet.graph.subscription.create(
+ { repo: credentialSession.did!, rkey: TID.nextStr() },
+ {
+ publication,
+ },
+ );
+ let { error } = await supabaseServerClient
+ .from("publication_subscriptions")
+ .insert({
+ uri: record.uri,
+ record,
+ publication,
+ identity: credentialSession.did!,
+ });
+ let bsky = new BskyAgent(credentialSession);
+ let prefs = await bsky.app.bsky.actor.getPreferences();
+ let savedFeeds = prefs.data.preferences.find(
+ (pref) => pref.$type === "app.bsky.actor.defs#savedFeedsPrefV2",
+ ) as AppBskyActorDefs.SavedFeedsPrefV2;
+ revalidatePath("/lish/[did]/[publication]", "layout");
+ return {
+ hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI),
+ };
+}
+
+export async function unsubscribeToPublication(publication: string) {
+ console.log("calling unsubscribe!");
+ const oauthClient = await createOauthClient();
+ let identity = await getIdentityData();
+ if (!identity || !identity.atp_did) return;
+
+ let credentialSession = await oauthClient.restore(identity.atp_did);
+ let agent = new AtpBaseClient(
+ credentialSession.fetchHandler.bind(credentialSession),
+ );
+ let { data: existingSubscription } = await supabaseServerClient
+ .from("publication_subscriptions")
+ .select("*")
+ .eq("identity", identity.atp_did)
+ .eq("publication", publication)
+ .single();
+ if (!existingSubscription) return;
+ await agent.pub.leaflet.graph.subscription.delete({
+ repo: credentialSession.did!,
+ rkey: new AtUri(existingSubscription.uri).rkey,
+ });
+ await supabaseServerClient
+ .from("publication_subscriptions")
+ .delete()
+ .eq("identity", identity.atp_did)
+ .eq("publication", publication);
+ revalidatePath("/lish/[did]/[publication]", "layout");
+}
diff --git a/appview/index.ts b/appview/index.ts
index 1869cf6c..df5a3d63 100644
--- a/appview/index.ts
+++ b/appview/index.ts
@@ -31,6 +31,7 @@ async function main() {
},
});
let firehose = new Firehose({
+ subscriptionReconnectDelay: 3000,
excludeAccount: true,
excludeIdentity: true,
runner,
diff --git a/components/Icons/RSSSmall.tsx b/components/Icons/RSSSmall.tsx
new file mode 100644
index 00000000..7298b0fa
--- /dev/null
+++ b/components/Icons/RSSSmall.tsx
@@ -0,0 +1,18 @@
+import { Props } from "./Props";
+
+export const RSSSmall = (props: Props) => {
+ return (
+
+ );
+};
diff --git a/components/Modal.tsx b/components/Modal.tsx
new file mode 100644
index 00000000..2a534a7f
--- /dev/null
+++ b/components/Modal.tsx
@@ -0,0 +1,50 @@
+import * as Dialog from "@radix-ui/react-dialog";
+import React from "react";
+
+export const Modal = ({
+ className,
+ open,
+ onOpenChange,
+ asChild,
+ trigger,
+ title,
+ children,
+}: {
+ className?: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ asChild?: boolean;
+ trigger: React.ReactNode;
+ title?: React.ReactNode;
+ children: React.ReactNode;
+}) => {
+ return (
+
+ {trigger}
+
+
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {children}
+
+
+
+
+
+ );
+};
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 01f8a5cd..cddbb80c 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -210,7 +210,7 @@ export const publication_subscriptions = pgTable("publication_subscriptions", {
identity: text("identity").notNull(),
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
record: jsonb("record").notNull(),
- uri: text("uri"),
+ uri: text("uri").notNull(),
},
(table) => {
return {
diff --git a/middleware.ts b/middleware.ts
index a20f7a45..fbf5b25c 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -42,7 +42,10 @@ export default async function middleware(req: NextRequest) {
let pub = routes?.publication_domains[0]?.publications;
if (pub) {
let cookie = req.cookies.get("external_auth_token");
- if (!cookie && !hostname.includes("leaflet.pub")) {
+ if (
+ (!cookie || req.nextUrl.searchParams.has("refreshAuth")) &&
+ !hostname.includes("leaflet.pub")
+ ) {
return initiateAuthCallback(req);
}
let aturi = new AtUri(pub?.uri);
@@ -74,8 +77,10 @@ type CROSS_SITE_AUTH_RESPONSE = {
ts: string;
};
async function initiateAuthCallback(req: NextRequest) {
+ let redirectUrl = new URL(req.url);
+ redirectUrl.searchParams.delete("refreshAuth");
let token: CROSS_SITE_AUTH_REQUEST = {
- redirect: req.url,
+ redirect: redirectUrl.toString(),
ts: new Date().toISOString(),
};
let payload = btoa(JSON.stringify(token));
diff --git a/package-lock.json b/package-lock.json
index 3cb9d5a2..f74b53fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,11 +16,13 @@
"@atproto/sync": "^0.1.23",
"@atproto/syntax": "^0.3.3",
"@atproto/xrpc": "^0.6.9",
+ "@atproto/xrpc-server": "^0.7.19",
"@hono/node-server": "^1.14.3",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/bundle-analyzer": "^15.3.2",
"@next/mdx": "15.3.2",
+ "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-slider": "^1.3.4",
@@ -536,9 +538,9 @@
}
},
"node_modules/@atproto/xrpc-server": {
- "version": "0.7.18",
- "resolved": "https://registry.npmjs.org/@atproto/xrpc-server/-/xrpc-server-0.7.18.tgz",
- "integrity": "sha512-kjlAsI+UNbbm6AK3Y5Hb4BJ7VQHNKiYYu2kX5vhZJZHO8qfO40GPYYb/2TknZV8IG6fDPBQhUpcDRolI86sgag==",
+ "version": "0.7.19",
+ "resolved": "https://registry.npmjs.org/@atproto/xrpc-server/-/xrpc-server-0.7.19.tgz",
+ "integrity": "sha512-YSCl/tU2NDykgDYslFSOYCr96esUgDwncFiADKL59/fyIFPLoT0qY8Uq/budpxUh0qPzjow4HHgVWESOaOpUmA==",
"license": "MIT",
"dependencies": {
"@atproto/common": "^0.4.11",
@@ -2955,6 +2957,153 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
+ "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.10",
+ "@radix-ui/react-focus-guards": "1.1.2",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
+ "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
diff --git a/package.json b/package.json
index e5906e00..9440a591 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"dev": "next dev --turbo",
"publish-lexicons": "tsx lexicons/publish.ts",
"generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta",
- "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;",
+ "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;",
"wrangler-dev": "wrangler dev",
"build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node",
"build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node",
@@ -26,11 +26,13 @@
"@atproto/sync": "^0.1.23",
"@atproto/syntax": "^0.3.3",
"@atproto/xrpc": "^0.6.9",
+ "@atproto/xrpc-server": "^0.7.19",
"@hono/node-server": "^1.14.3",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/bundle-analyzer": "^15.3.2",
"@next/mdx": "15.3.2",
+ "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-slider": "^1.3.4",
diff --git a/supabase/database.types.ts b/supabase/database.types.ts
index 6f7b3c44..51ed7e81 100644
--- a/supabase/database.types.ts
+++ b/supabase/database.types.ts
@@ -690,21 +690,21 @@ export type Database = {
identity: string
publication: string
record: Json
- uri: string | null
+ uri: string
}
Insert: {
created_at?: string
identity: string
publication: string
record: Json
- uri?: string | null
+ uri: string
}
Update: {
created_at?: string
identity?: string
publication?: string
record?: Json
- uri?: string | null
+ uri?: string
}
Relationships: [
{