diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index c9c407b5..7224413b 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -17,6 +17,7 @@ export async function uncachedGetIdentityData() { identities( *, bsky_profiles(*), + notifications(count), publication_subscriptions(*), custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*, @@ -33,6 +34,7 @@ export async function uncachedGetIdentityData() { ) )`, ) + .eq("identities.notifications.read", false) .eq("id", auth_token) .eq("confirmed", true) .single() diff --git a/app/(home-pages)/discover/SortedPublicationList.tsx b/app/(home-pages)/discover/SortedPublicationList.tsx index c980939f..d72a3e14 100644 --- a/app/(home-pages)/discover/SortedPublicationList.tsx +++ b/app/(home-pages)/discover/SortedPublicationList.tsx @@ -1,15 +1,71 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { theme } from "tailwind.config"; -import { PublicationsList } from "./page"; import { PubListing } from "./PubListing"; +import useSWRInfinite from "swr/infinite"; +import { getPublications, type Cursor, type Publication } from "./getPublications"; export function SortedPublicationList(props: { - publications: PublicationsList; + publications: Publication[]; order: string; + nextCursor: Cursor | null; }) { let [order, setOrder] = useState(props.order); + + const getKey = ( + pageIndex: number, + previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["discover-publications", order, null] as const; + + // Add the cursor to the key + return ["discover-publications", order, previousPageData?.nextCursor] as const; + }; + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, orderValue, cursor]) => { + const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated"; + return getPublications(orderParam, cursor); + }, + { + fallbackData: order === props.order + ? [{ publications: props.publications, nextCursor: props.nextCursor }] + : undefined, + 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.publications) : []; + return (
-
- {props.publications - ?.filter((pub) => pub.documents_in_publications.length > 0) - ?.sort((a, b) => { - if (order === "popular") { - return ( - b.publication_subscriptions[0].count - - a.publication_subscriptions[0].count - ); - } - const aDate = new Date( - a.documents_in_publications[0]?.indexed_at || 0, - ); - const bDate = new Date( - b.documents_in_publications[0]?.indexed_at || 0, - ); - return bDate.getTime() - aDate.getTime(); - }) - .map((pub) => )} +
+ {allPublications.map((pub) => ( + + ))} + {/* Trigger element for loading more publications */} +
); diff --git a/app/(home-pages)/discover/getPublications.ts b/app/(home-pages)/discover/getPublications.ts new file mode 100644 index 00000000..46843116 --- /dev/null +++ b/app/(home-pages)/discover/getPublications.ts @@ -0,0 +1,119 @@ +"use server"; + +import { supabaseServerClient } from "supabase/serverClient"; + +export type Cursor = { + indexed_at?: string; + count?: number; + uri: string; +}; + +export type Publication = Awaited< + ReturnType +>["publications"][number]; + +export async function getPublications( + order: "recentlyUpdated" | "popular" = "recentlyUpdated", + cursor?: Cursor | null, +): Promise<{ publications: any[]; nextCursor: Cursor | null }> { + const limit = 25; + + // Fetch all publications with their most recent document + let { data: publications, error } = await supabaseServerClient + .from("publications") + .select( + "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", + ) + .or( + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", + ) + .order("indexed_at", { + referencedTable: "documents_in_publications", + ascending: false, + }) + .limit(1, { referencedTable: "documents_in_publications" }); + + if (error) { + console.error("Error fetching publications:", error); + return { publications: [], nextCursor: null }; + } + + // Filter out publications without documents + const allPubs = (publications || []).filter( + (pub) => pub.documents_in_publications.length > 0, + ); + + // Sort on the server + allPubs.sort((a, b) => { + if (order === "popular") { + const aCount = a.publication_subscriptions[0]?.count || 0; + const bCount = b.publication_subscriptions[0]?.count || 0; + if (bCount !== aCount) { + return bCount - aCount; + } + // Secondary sort by uri for stability + return b.uri.localeCompare(a.uri); + } else { + // recentlyUpdated + const aDate = new Date( + a.documents_in_publications[0]?.indexed_at || 0, + ).getTime(); + const bDate = new Date( + b.documents_in_publications[0]?.indexed_at || 0, + ).getTime(); + if (bDate !== aDate) { + return bDate - aDate; + } + // Secondary sort by uri for stability + return b.uri.localeCompare(a.uri); + } + }); + + // Find cursor position and slice + let startIndex = 0; + if (cursor) { + startIndex = allPubs.findIndex((pub) => { + if (order === "popular") { + const pubCount = pub.publication_subscriptions[0]?.count || 0; + // Find first pub after cursor + return ( + pubCount < (cursor.count || 0) || + (pubCount === cursor.count && pub.uri < cursor.uri) + ); + } else { + const pubDate = pub.documents_in_publications[0]?.indexed_at || ""; + // Find first pub after cursor + return ( + pubDate < (cursor.indexed_at || "") || + (pubDate === cursor.indexed_at && pub.uri < cursor.uri) + ); + } + }); + // If not found, we're at the end + if (startIndex === -1) { + return { publications: [], nextCursor: null }; + } + } + + // Get the page + const page = allPubs.slice(startIndex, startIndex + limit); + + // Create next cursor + const nextCursor = + page.length === limit && startIndex + limit < allPubs.length + ? order === "recentlyUpdated" + ? { + indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, + uri: page[page.length - 1].uri, + } + : { + count: page[page.length - 1].publication_subscriptions[0]?.count || 0, + uri: page[page.length - 1].uri, + } + : null; + + return { + publications: page, + nextCursor, + }; +} diff --git a/app/(home-pages)/discover/page.tsx b/app/(home-pages)/discover/page.tsx index 7e14bf2a..4e01207c 100644 --- a/app/(home-pages)/discover/page.tsx +++ b/app/(home-pages)/discover/page.tsx @@ -1,26 +1,8 @@ -import { supabaseServerClient } from "supabase/serverClient"; import Link from "next/link"; import { SortedPublicationList } from "./SortedPublicationList"; import { Metadata } from "next"; import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; - -export type PublicationsList = Awaited>; -async function getPublications() { - let { data: publications, error } = await supabaseServerClient - .from("publications") - .select( - "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", - ) - .or( - "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", - ) - .order("indexed_at", { - referencedTable: "documents_in_publications", - ascending: false, - }) - .limit(1, { referencedTable: "documents_in_publications" }); - return publications; -} +import { getPublications } from "./getPublications"; export const metadata: Metadata = { title: "Leaflet Discover", @@ -50,7 +32,9 @@ export default async function Discover(props: { } const DiscoverContent = async (props: { order: string }) => { - let publications = await getPublications(); + const orderValue = + props.order === "popular" ? "popular" : "recentlyUpdated"; + let { publications, nextCursor } = await getPublications(orderValue); return (
@@ -61,7 +45,11 @@ const DiscoverContent = async (props: { order: string }) => { make your own!

- +
); }; diff --git a/app/(home-pages)/home/Actions/AccountSettings.tsx b/app/(home-pages)/home/Actions/AccountSettings.tsx index d6f30762..67df4f37 100644 --- a/app/(home-pages)/home/Actions/AccountSettings.tsx +++ b/app/(home-pages)/home/Actions/AccountSettings.tsx @@ -1,27 +1,134 @@ "use client"; import { ActionButton } from "components/ActionBar/ActionButton"; -import { Menu, MenuItem } from "components/Layout"; import { mutate } from "swr"; import { AccountSmall } from "components/Icons/AccountSmall"; import { LogoutSmall } from "components/Icons/LogoutSmall"; +import { Popover } from "components/Popover"; +import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; +import { SpeedyLink } from "components/SpeedyLink"; +import { GoBackSmall } from "components/Icons/GoBackSmall"; +import { useState } from "react"; +import { ThemeSetterContent } from "components/ThemeManager/ThemeSetter"; +import { useIsMobile } from "src/hooks/isMobile"; + +export const AccountSettings = (props: { entityID: string }) => { + let [state, setState] = useState<"menu" | "general" | "theme">("menu"); + let isMobile = useIsMobile(); -// it was going have a popover with a log out button -export const AccountSettings = () => { return ( - setState("menu")} + side={isMobile ? "top" : "right"} + align={isMobile ? "center" : "start"} + className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} trigger={ label="Settings" />} > - { + {state === "general" ? ( + setState("menu")} /> + ) : state === "theme" ? ( + setState("menu")} + /> + ) : ( + + )} + + ); +}; + +const SettingsMenu = (props: { + state: "menu" | "general" | "theme"; + setState: (s: typeof props.state) => void; +}) => { + let menuItemClassName = + "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; + + return ( +
+ + + +
+ ); +}; + +const GeneralSettings = (props: { backToMenu: () => void }) => { + return ( +
+ props.backToMenu()} + /> + +
+ +
+ ); +}; +const AccountThemeSettings = (props: { + entityID: string; + backToMenu: () => void; +}) => { + return ( +
+ props.backToMenu()} + /> + +
+ ); +}; +export const AccountSettingsHeader = (props: { + state: "menu" | "general" | "theme"; + backToMenuAction?: () => void; +}) => { + return ( +
+ {props.state === "menu" + ? "Settings" + : props.state === "general" + ? "General" + : props.state === "theme" + ? "Account Theme" + : ""} + {props.backToMenuAction && ( + + )} +
); }; diff --git a/app/(home-pages)/home/Actions/Actions.tsx b/app/(home-pages)/home/Actions/Actions.tsx index c52ece50..5f99d3f6 100644 --- a/app/(home-pages)/home/Actions/Actions.tsx +++ b/app/(home-pages)/home/Actions/Actions.tsx @@ -13,9 +13,11 @@ export const Actions = () => { return ( <> - {identity ? : } - {/**/} - + {identity ? ( + + ) : ( + + )} ); diff --git a/app/(home-pages)/home/Actions/CreateNewButton.tsx b/app/(home-pages)/home/Actions/CreateNewButton.tsx index 33ab9e3d..2052af7c 100644 --- a/app/(home-pages)/home/Actions/CreateNewButton.tsx +++ b/app/(home-pages)/home/Actions/CreateNewButton.tsx @@ -51,8 +51,8 @@ export const CreateNewLeafletButton = (props: {}) => { return ( { + let docRecord = props.commentData.documents + ?.data as PubLeafletDocument.Record; + let commentRecord = props.commentData.record as PubLeafletComment.Record; + let profileRecord = props.commentData.bsky_profiles + ?.record as AppBskyActorProfile.Record; + const displayName = + profileRecord.displayName || + props.commentData.bsky_profiles?.handle || + "Someone"; + const pubRecord = props.commentData.documents?.documents_in_publications[0] + ?.publications?.record as PubLeafletPublication.Record; + let rkey = new AtUri(props.commentData.documents?.uri!).rkey; + + return ( + } + actionText={<>{displayName} commented on your post} + content={ + + + + } + /> + ); +}; diff --git a/app/(home-pages)/notifications/FollowNotification.tsx b/app/(home-pages)/notifications/FollowNotification.tsx new file mode 100644 index 00000000..9b5e4dc2 --- /dev/null +++ b/app/(home-pages)/notifications/FollowNotification.tsx @@ -0,0 +1,19 @@ +import { Avatar } from "components/Avatar"; +import { Notification } from "./Notification"; + +export const DummyFollowNotification = (props: {}) => { + const identity = "celine"; + const pubName = "Pub Name Here"; + return ( + } + actionText={ + <> + {identity} followed {pubName}! + + } + /> + ); +}; diff --git a/app/(home-pages)/notifications/MentionNotification.tsx b/app/(home-pages)/notifications/MentionNotification.tsx new file mode 100644 index 00000000..58ee25ee --- /dev/null +++ b/app/(home-pages)/notifications/MentionNotification.tsx @@ -0,0 +1,53 @@ +import { MentionTiny } from "components/Icons/MentionTiny"; +import { ContentLayout, Notification } from "./Notification"; + +export const DummyPostMentionNotification = (props: {}) => { + return ( + } + actionText={<>celine mentioned your post} + content={ + + I'm just gonna put the description here. The surrounding context is + just sort of a pain to figure out +
+
Title of the Mentioned Post
+
+ And here is the description that follows it +
+
+
+ } + /> + ); +}; + +export const DummyUserMentionNotification = (props: { + cardBorderHidden: boolean; +}) => { + return ( + } + actionText={<>celine mentioned you} + content={ + +
+ ...llo this is the content of a post or whatever here it comes{" "} + @celine and here it + was! ooooh heck yeah the high is unre... +
+
+ } + /> + ); +}; diff --git a/app/(home-pages)/notifications/Notification.tsx b/app/(home-pages)/notifications/Notification.tsx new file mode 100644 index 00000000..2d5c2710 --- /dev/null +++ b/app/(home-pages)/notifications/Notification.tsx @@ -0,0 +1,116 @@ +"use client"; +import { Avatar } from "components/Avatar"; +import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; +import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; +import { timeAgo } from "src/utils/timeAgo"; +import { useReplicache, useEntity } from "src/replicache"; + +export const Notification = (props: { + icon: React.ReactNode; + actionText: React.ReactNode; + content?: React.ReactNode; + timestamp: string; + href: string; +}) => { + let { rootEntity } = useReplicache(); + let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data + .value; + + // If compact mode, always hide border + + return ( +
+ +
+
+
{props.icon}
+
+ {props.actionText} +
+
+
+ {timeAgo(props.timestamp)} +
+
+ {props.content && ( +
+
+ {props.content} +
+ )} +
+ ); +}; + +export const ContentLayout = (props: { + children: React.ReactNode; + postTitle: string; + pubRecord?: PubLeafletPublication.Record; +}) => { + let { rootEntity } = useReplicache(); + let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data + .value; + + return ( +
+ ); +}; + +type Facet = PubLeafletRichtextFacet.Main; +export const CommentInNotification = (props: { + avatar: string | undefined; + displayName: string; + plaintext: string; + facets?: Facet[]; + index: number[]; + className?: string; +}) => { + return ( +
+ +
+        
+      
+
+ ); +}; diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx new file mode 100644 index 00000000..f3ab4eff --- /dev/null +++ b/app/(home-pages)/notifications/NotificationList.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { HydratedNotification } from "src/notifications"; +import { CommentNotification } from "./CommentNotication"; +import { useEffect, createContext } from "react"; +import { markAsRead } from "./getNotifications"; +import { ReplyNotification } from "./ReplyNotification"; +import { useIdentityData } from "components/IdentityProvider"; + +export function NotificationList({ + notifications, + compact, +}: { + notifications: HydratedNotification[]; + compact?: boolean; +}) { + let { mutate } = useIdentityData(); + useEffect(() => { + setTimeout(async () => { + await markAsRead(); + mutate(); + }, 500); + }, []); + + if (notifications.length === 0) + return ( +
+ no notifications yet... +
+ ); + return ( +
+
+ {notifications.map((n) => { + if (n.type === "comment") { + if (n.parentData) return ; + return ; + } + })} +
+
+ ); +} diff --git a/app/(home-pages)/notifications/ReplyNotification.tsx b/app/(home-pages)/notifications/ReplyNotification.tsx new file mode 100644 index 00000000..10aa4703 --- /dev/null +++ b/app/(home-pages)/notifications/ReplyNotification.tsx @@ -0,0 +1,82 @@ +import { Avatar } from "components/Avatar"; +import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; +import { ReplyTiny } from "components/Icons/ReplyTiny"; +import { + CommentInNotification, + ContentLayout, + Notification, +} from "./Notification"; +import { HydratedCommentNotification } from "src/notifications"; +import { + PubLeafletComment, + PubLeafletDocument, + PubLeafletPublication, +} from "lexicons/api"; +import { AppBskyActorProfile, AtUri } from "@atproto/api"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; + +export const ReplyNotification = (props: HydratedCommentNotification) => { + let docRecord = props.commentData.documents + ?.data as PubLeafletDocument.Record; + let commentRecord = props.commentData.record as PubLeafletComment.Record; + let profileRecord = props.commentData.bsky_profiles + ?.record as AppBskyActorProfile.Record; + const displayName = + profileRecord.displayName || + props.commentData.bsky_profiles?.handle || + "Someone"; + + let parentRecord = props.parentData?.record as PubLeafletComment.Record; + let parentProfile = props.parentData?.bsky_profiles + ?.record as AppBskyActorProfile.Record; + const parentDisplayName = + parentProfile.displayName || + props.parentData?.bsky_profiles?.handle || + "Someone"; + + let rkey = new AtUri(props.commentData.documents?.uri!).rkey; + const pubRecord = props.commentData.documents?.documents_in_publications[0] + ?.publications?.record as PubLeafletPublication.Record; + + return ( + } + actionText={`${displayName} replied to your comment`} + content={ + + +
+ + + } + /> + ); +}; diff --git a/app/(home-pages)/notifications/getNotifications.ts b/app/(home-pages)/notifications/getNotifications.ts new file mode 100644 index 00000000..27d2fb48 --- /dev/null +++ b/app/(home-pages)/notifications/getNotifications.ts @@ -0,0 +1,28 @@ +"use server"; +import { getIdentityData } from "actions/getIdentityData"; +import { hydrateNotifications } from "src/notifications"; +import { supabaseServerClient } from "supabase/serverClient"; + +export async function getNotifications(limit?: number) { + let identity = await getIdentityData(); + if (!identity?.atp_did) return []; + let query = supabaseServerClient + .from("notifications") + .select("*") + .eq("recipient", identity.atp_did) + .order("created_at", { ascending: false }); + if (limit) query.limit(limit); + let { data } = await query; + let notifications = await hydrateNotifications(data || []); + return notifications; +} + +export async function markAsRead() { + let identity = await getIdentityData(); + if (!identity?.atp_did) return []; + await supabaseServerClient + .from("notifications") + .update({ read: true }) + .eq("recipient", identity.atp_did); + return; +} diff --git a/app/(home-pages)/notifications/page.tsx b/app/(home-pages)/notifications/page.tsx index 7e19c974..9be3f1d3 100644 --- a/app/(home-pages)/notifications/page.tsx +++ b/app/(home-pages)/notifications/page.tsx @@ -1,3 +1,36 @@ +import { getIdentityData } from "actions/getIdentityData"; +import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { redirect } from "next/navigation"; +import { hydrateNotifications } from "src/notifications"; +import { supabaseServerClient } from "supabase/serverClient"; +import { CommentNotification } from "./CommentNotication"; +import { NotificationList } from "./NotificationList"; + export default async function Notifications() { - return
Notifications
; + return ( + , + }, + }} + /> + ); } + +const NotificationContent = async () => { + let identity = await getIdentityData(); + if (!identity?.atp_did) return redirect("/home"); + let { data } = await supabaseServerClient + .from("notifications") + .select("*") + .eq("recipient", identity.atp_did); + let notifications = await hydrateNotifications(data || []); + return ; +}; diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index f662b9fd..570dfd4a 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -146,7 +146,7 @@ const PublishPostForm = (
diff --git a/app/about/page.tsx b/app/about/page.tsx deleted file mode 100644 index 42d35f9c..00000000 --- a/app/about/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { LegalContent } from "app/legal/content"; -import Link from "next/link"; - -export default function AboutPage() { - return ( -
-
-

- Leaflet.pub is a web app for instantly creating and collaborating on - documents.{" "} - - Click here - {" "} - to create one and get started! -

- -

- Leaflet is made by Learning Futures Inc. Previously we built{" "} - hyperlink.academy -

-

- You can find us on{" "} - Bluesky or email as - at contact@leaflet.pub -

-
- -
- ); -} diff --git a/app/globals.css b/app/globals.css index c544c238..03008310 100644 --- a/app/globals.css +++ b/app/globals.css @@ -370,6 +370,7 @@ pre.shiki { @apply pl-3; @apply ml-2; } + .transparent-container { @apply border; @apply border-border-light; @@ -399,6 +400,26 @@ pre.shiki { @apply rounded-md; } +.menuItem { + @apply text-secondary; + @apply hover:text-secondary; + @apply data-highlighted:bg-[var(--accent-light)]; + @apply data-highlighted:outline-none; + @apply hover:bg-[var(--accent-light)]; + text-align: left; + font-weight: 800; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + outline: none !important; + cursor: pointer; + background-color: transparent; + + :hover { + text-decoration: none !important; + background-color: rgb(var(--accent-light)); + } +} + .pwa-padding { padding-top: max(calc(env(safe-area-inset-top) - 8px)) !important; } diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index fbc85963..124af91f 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -8,7 +8,10 @@ import { TID } from "@atproto/common"; import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; -import { Notification } from "src/notifications"; +import { + Notification, + pingIdentityToUpdateNotification, +} from "src/notifications"; import { v7 } from "uuid"; export async function publishComment(args: { @@ -67,21 +70,24 @@ export async function publishComment(args: { } as unknown as Json, }) .select(); - let notifications: Notification[] = [ - { - id: v7(), - recipient: new AtUri(args.document).host, - data: { type: "comment", comment_uri: uri.toString() }, - }, - ]; - if (args.comment.replyTo) + let notifications: Notification[] = []; + let recipient = args.comment.replyTo + ? new AtUri(args.comment.replyTo).host + : new AtUri(args.document).host; + if (recipient !== credentialSession.did) { notifications.push({ id: v7(), - recipient: new AtUri(args.comment.replyTo).host, - data: { type: "comment", comment_uri: uri.toString() }, + recipient, + data: { + type: "comment", + comment_uri: uri.toString(), + parent_uri: args.comment.replyTo, + }, }); - // SOMEDAY: move this out the action with inngest or workflows - await supabaseServerClient.from("notifications").insert(notifications); + // SOMEDAY: move this out the action with inngest or workflows + await supabaseServerClient.from("notifications").insert(notifications); + await pingIdentityToUpdateNotification(recipient); + } return { record: data?.[0].record as Json, diff --git a/app/lish/[did]/[publication]/dashboard/Actions.tsx b/app/lish/[did]/[publication]/dashboard/Actions.tsx index 96e4452c..fbd807a0 100644 --- a/app/lish/[did]/[publication]/dashboard/Actions.tsx +++ b/app/lish/[did]/[publication]/dashboard/Actions.tsx @@ -1,21 +1,14 @@ "use client"; -import { Media } from "components/Media"; import { NewDraftActionButton } from "./NewDraftButton"; +import { PublicationSettingsButton } from "./PublicationSettings"; import { ActionButton } from "components/ActionBar/ActionButton"; -import { useRouter } from "next/navigation"; -import { Popover } from "components/Popover"; -import { SettingsSmall } from "components/Icons/SettingsSmall"; import { ShareSmall } from "components/Icons/ShareSmall"; import { Menu } from "components/Layout"; import { MenuItem } from "components/Layout"; -import { HomeSmall } from "components/Icons/HomeSmall"; -import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { usePublicationData } from "./PublicationSWRProvider"; import { useSmoker } from "components/Toast"; -import { PaintSmall } from "components/Icons/PaintSmall"; -import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; import { useIsMobile } from "src/hooks/isMobile"; import { SpeedyLink } from "components/SpeedyLink"; @@ -24,7 +17,6 @@ export const Actions = (props: { publication: string }) => { <> - ); @@ -85,42 +77,3 @@ function PublicationShareButton() {
); } - -function PublicationSettingsButton(props: { publication: string }) { - let isMobile = useIsMobile(); - return ( - - label="Settings" - /> - } - > - - - ); -} - -function PublicationThemeButton() { - let isMobile = useIsMobile(); - - return ( - label="Theme" /> - } - > - - - ); -} diff --git a/app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx b/app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx new file mode 100644 index 00000000..c9acf387 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { ActionButton } from "components/ActionBar/ActionButton"; +import { Popover } from "components/Popover"; +import { SettingsSmall } from "components/Icons/SettingsSmall"; +import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; +import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; +import { useIsMobile } from "src/hooks/isMobile"; +import { useState } from "react"; +import { GoBackSmall } from "components/Icons/GoBackSmall"; +import { theme } from "tailwind.config"; +import { ButtonPrimary } from "components/Buttons"; +import { DotLoader } from "components/utils/DotLoader"; +import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; + +export function PublicationSettingsButton(props: { publication: string }) { + let isMobile = useIsMobile(); + let [state, setState] = useState<"menu" | "general" | "theme">("menu"); + let [loading, setLoading] = useState(false); + + return ( + setState("menu")} + side={isMobile ? "top" : "right"} + align={isMobile ? "center" : "start"} + className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} + arrowFill={theme.colors["border-light"]} + trigger={ + + label="Settings" + /> + } + > + {state === "general" ? ( + setState("menu")} + loading={loading} + setLoadingAction={setLoading} + /> + ) : state === "theme" ? ( + setState("menu")} + loading={loading} + setLoading={setLoading} + /> + ) : ( + + )} + + ); +} + +const PubSettingsMenu = (props: { + state: "menu" | "general" | "theme"; + setState: (s: typeof props.state) => void; + loading: boolean; + setLoading: (l: boolean) => void; +}) => { + let menuItemClassName = + "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; + + return ( +
+ + + +
+ ); +}; + +export const PubSettingsHeader = (props: { + state: "menu" | "general" | "theme"; + backToMenuAction?: () => void; + loading: boolean; + setLoadingAction: (l: boolean) => void; +}) => { + return ( +
+ {props.state === "menu" + ? "Settings" + : props.state === "general" + ? "General" + : props.state === "theme" + ? "Publication Theme" + : ""} + {props.state !== "menu" && ( +
+ + + + {props.loading ? : "Update"} + +
+ )} +
+ ); +}; diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx index f2b06888..85354893 100644 --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -20,8 +20,13 @@ import { Verification } from "@vercel/sdk/esm/models/getprojectdomainop"; import Link from "next/link"; import { Checkbox } from "components/Checkbox"; import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; +import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings"; -export const EditPubForm = () => { +export const EditPubForm = (props: { + backToMenuAction: () => void; + loading: boolean; + setLoadingAction: (l: boolean) => void; +}) => { let { data } = usePublicationData(); let { publication: pubData } = data || {}; let record = pubData?.record as PubLeafletPublication.Record; @@ -57,11 +62,11 @@ export const EditPubForm = () => { return (
{ if (!pubData) return; e.preventDefault(); - setFormState("loading"); + props.setLoadingAction(true); + console.log("step 1:update"); let data = await updatePublication({ uri: pubData.uri, name: nameValue, @@ -73,110 +78,113 @@ export const EditPubForm = () => { }, }); toast({ type: "success", content: "Updated!" }); - setFormState("normal"); + props.setLoadingAction(false); mutate("publication-data"); }} > -
-

- Logo (optional) -

-
fileInputRef.current?.click()} - > - {iconPreview ? ( - Logo preview - ) : ( - - )} + +
+
+

+ Logo (optional) +

+
fileInputRef.current?.click()} + > + {iconPreview ? ( + Logo preview + ) : ( + + )} +
+ { + const file = e.target.files?.[0]; + if (file) { + setIconFile(file); + const reader = new FileReader(); + reader.onload = (e) => { + setIconPreview(e.target?.result as string); + }; + reader.readAsDataURL(file); + } + }} + />
- { - const file = e.target.files?.[0]; - if (file) { - setIconFile(file); - const reader = new FileReader(); - reader.onload = (e) => { - setIconPreview(e.target?.result as string); - }; - reader.readAsDataURL(file); - } - }} - /> -
- - - - -
- - setShowInDiscover(e.target.checked)} - > -
-

- Show In{" "} - - Discover - +

+ Publication Name

-

- This publication will appear on our public Discover page + { + setNameValue(e.currentTarget.value); + }} + /> + +

+ Description (optional)

-
-
+ { + setDescriptionValue(e.currentTarget.value); + }} + /> + - setShowComments(e.target.checked)} - > -
-

Show comments on posts

-
-
-
+ +
+ + setShowInDiscover(e.target.checked)} + > +
+

+ Show In{" "} + + Discover + +

+

+ This publication will appear on our public Discover page +

+
+
- - {formState === "loading" ? : "Update!"} - + setShowComments(e.target.checked)} + > +
+

Show comments on posts

+
+
+
); }; diff --git a/components/ActionBar/ActionButton.tsx b/components/ActionBar/ActionButton.tsx index 20be8f51..ed7bded2 100644 --- a/components/ActionBar/ActionButton.tsx +++ b/components/ActionBar/ActionButton.tsx @@ -8,7 +8,7 @@ import { PopoverOpenContext } from "components/Popover"; type ButtonProps = Omit; export const ActionButton = ( - props: ButtonProps & { + _props: ButtonProps & { id?: string; icon: React.ReactNode; label: React.ReactNode; @@ -17,9 +17,22 @@ export const ActionButton = ( nav?: boolean; className?: string; subtext?: string; + labelOnMobile?: boolean; + z?: boolean; }, ) => { - let { id, icon, label, primary, secondary, nav, ...buttonProps } = props; + let { + id, + icon, + label, + primary, + secondary, + nav, + labelOnMobile, + subtext, + className, + ...buttonProps + } = _props; let sidebar = useContext(SidebarContext); let inOpenPopover = useContext(PopoverOpenContext); useEffect(() => { @@ -30,6 +43,10 @@ export const ActionButton = ( }; } }, [sidebar, inOpenPopover]); + + let showLabelOnMobile = + labelOnMobile !== false && (primary || secondary || nav); + return ( - -
- {sample === "pub" ? ( - - ) : ( - - )} + + +
+
+ { + setTheme((t) => ({ ...t, bgPage: color })); + }} + setPrimary={(color) => { + setTheme((t) => ({ ...t, primary: color })); + }} + openPicker={openPicker} + setOpenPicker={(pickers) => setOpenPicker(pickers)} + hasPageBackground={showPageBackground} + /> + { + setTheme((t) => ({ ...t, accent1: color })); + }} + accent2={localPubTheme.accent2} + setAccent2={(color) => { + setTheme((t) => ({ ...t, accent2: color })); + }} + openPicker={openPicker} + setOpenPicker={(pickers) => setOpenPicker(pickers)} + /> +
+
+
+
+
Preview
+ {" "} + +
+ {sample === "pub" ? ( + + ) : ( + + )}
diff --git a/components/ThemeManager/ThemeSetter.tsx b/components/ThemeManager/ThemeSetter.tsx index 0911b3a6..9a90bfaa 100644 --- a/components/ThemeManager/ThemeSetter.tsx +++ b/components/ThemeManager/ThemeSetter.tsx @@ -82,97 +82,119 @@ export const ThemePopover = (props: { entityID: string; home?: boolean }) => { align={isMobile ? "center" : "start"} trigger={} label="Theme" />} > -
-
-
-
- setOpenPicker("null")} - setValue={set("theme/page-background")} - /> - -
- -
+ + + + ); +}; - -
-
+export const ThemeSetterContent = (props: { + entityID: string; + home?: boolean; +}) => { + let { rep } = useReplicache(); + let { data: pub } = useLeafletPublicationData(); -
{ - e.currentTarget === e.target && setOpenPicker("leaflet"); - }} - style={{ - backgroundImage: leafletBGImage - ? `url(${leafletBGImage.data.src})` - : undefined, - backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", - backgroundPosition: "center", - backgroundSize: !leafletBGRepeat - ? "cover" - : `calc(${leafletBGRepeat.data.value}px / 2 )`, - }} - className={`bg-bg-leaflet px-3 pt-4 pb-0 mb-2 flex flex-col gap-4 rounded-md border border-border`} - > - ( + props.home === true ? "leaflet" : "null", + ); + let set = useMemo(() => { + return setColorAttribute(rep, props.entityID); + }, [rep, props.entityID]); + + if (!permission) return null; + if (pub) return null; + return ( +
+
+
+
+ setOpenPicker(pickers)} + setOpenPicker={setOpenPicker} + closePicker={() => setOpenPicker("null")} + setValue={set("theme/page-background")} /> -
-
- setOpenPicker(pickers)} - /> - -
- - -
- - +
+
- {!props.home && } + +
- - +
+ +
{ + e.currentTarget === e.target && setOpenPicker("leaflet"); + }} + style={{ + backgroundImage: leafletBGImage + ? `url(${leafletBGImage.data.src})` + : undefined, + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", + backgroundPosition: "center", + backgroundSize: !leafletBGRepeat + ? "cover" + : `calc(${leafletBGRepeat.data.value}px / 2 )`, + }} + className={`bg-bg-leaflet px-3 pt-4 pb-0 mb-2 flex flex-col gap-4 rounded-md border border-border`} + > + setOpenPicker(pickers)} + /> +
+
+ setOpenPicker(pickers)} + /> + +
+ + +
+ + +
+ {!props.home && } +
); }; - function WatermarkSetter(props: { entityID: string }) { let { rep } = useReplicache(); let checked = useEntity(props.entityID, "theme/page-leaflet-watermark"); diff --git a/components/Toast.tsx b/components/Toast.tsx index 5435781c..d7fd453c 100644 --- a/components/Toast.tsx +++ b/components/Toast.tsx @@ -95,18 +95,14 @@ const Toast = (props: { from: { top: -40 }, enter: { top: 8 }, leave: { top: -40 }, - config: { - mass: 8, - friction: 150, - tension: 2000, - }, + config: {}, }); return transitions((style, item) => { return item ? (
; export type Notification = Omit, "data"> & { data: NotificationData; }; -// Notification data types (for writing to the notifications table) + export type NotificationData = - | { type: "comment"; comment_uri: string } + | { type: "comment"; comment_uri: string; parent_uri?: string } | { type: "subscribe"; subscription_uri: string }; -// Hydrated notification types -export type HydratedCommentNotification = { - id: string; - recipient: string; - created_at: string; - type: "comment"; - comment_uri: string; - commentData?: Tables<"comments_on_documents">; -}; - -export type HydratedSubscribeNotification = { - id: string; - recipient: string; - created_at: string; - type: "subscribe"; - subscription_uri: string; - subscriptionData?: Tables<"publication_subscriptions">; -}; - export type HydratedNotification = | HydratedCommentNotification | HydratedSubscribeNotification; +export async function hydrateNotifications( + notifications: NotificationRow[], +): Promise> { + // Call all hydrators in parallel + const [commentNotifications, subscribeNotifications] = await Promise.all([ + hydrateCommentNotifications(notifications), + hydrateSubscribeNotifications(notifications), + ]); + + // Combine all hydrated notifications + const allHydrated = [...commentNotifications, ...subscribeNotifications]; + + // Sort by created_at to maintain order + allHydrated.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + + return allHydrated; +} // Type guard to extract notification type type ExtractNotificationType = Extract< @@ -42,17 +43,11 @@ type ExtractNotificationType = Extract< { type: T } >; -// Hydrator function type -type NotificationHydrator = ( - notifications: NotificationRow[], -) => Promise>; +export type HydratedCommentNotification = Awaited< + ReturnType +>[0]; -/** - * Hydrates comment notifications - */ -async function hydrateCommentNotifications( - notifications: NotificationRow[], -): Promise { +async function hydrateCommentNotifications(notifications: NotificationRow[]) { const commentNotifications = notifications.filter( (n): n is NotificationRow & { data: ExtractNotificationType<"comment"> } => (n.data as NotificationData)?.type === "comment", @@ -63,10 +58,16 @@ async function hydrateCommentNotifications( } // Fetch comment data from the database - const commentUris = commentNotifications.map((n) => n.data.comment_uri); + const commentUris = commentNotifications.flatMap((n) => + n.data.parent_uri + ? [n.data.comment_uri, n.data.parent_uri] + : [n.data.comment_uri], + ); const { data: comments } = await supabaseServerClient .from("comments_on_documents") - .select("*") + .select( + "*,bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", + ) .in("uri", commentUris); return commentNotifications.map((notification) => ({ @@ -75,13 +76,23 @@ async function hydrateCommentNotifications( created_at: notification.created_at, type: "comment" as const, comment_uri: notification.data.comment_uri, - commentData: comments?.find((c) => c.uri === notification.data.comment_uri), + parentData: notification.data.parent_uri + ? comments?.find((c) => c.uri === notification.data.parent_uri)! + : undefined, + commentData: comments?.find( + (c) => c.uri === notification.data.comment_uri, + )!, })); } -/** - * Hydrates subscribe notifications - */ +export type HydratedSubscribeNotification = { + id: string; + recipient: string; + created_at: string; + type: "subscribe"; + subscription_uri: string; + subscriptionData?: Tables<"publication_subscriptions">; +}; async function hydrateSubscribeNotifications( notifications: NotificationRow[], ): Promise { @@ -117,26 +128,12 @@ async function hydrateSubscribeNotifications( })); } -/** - * Main hydration function that processes all notifications - */ -export async function hydrateNotifications( - notifications: NotificationRow[], -): Promise { - // Call all hydrators in parallel - const [commentNotifications, subscribeNotifications] = await Promise.all([ - hydrateCommentNotifications(notifications), - hydrateSubscribeNotifications(notifications), - ]); - - // Combine all hydrated notifications - const allHydrated = [...commentNotifications, ...subscribeNotifications]; - - // Sort by created_at to maintain order - allHydrated.sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); - - return allHydrated; +export async function pingIdentityToUpdateNotification(did: string) { + let channel = supabaseServerClient.channel(`identity.atp_did:${did}`); + await channel.send({ + type: "broadcast", + event: "notification", + payload: { message: "poke" }, + }); + await supabaseServerClient.removeChannel(channel); }