a tool for shared writing and social publishing

Feature/notifications (#234)

* add basic notification page

* added an avatar component, styled notification, wip styling mobile
footer to fit everything

* move pub theme into the settings button

* remove page name from footer

* moved home theme into settings, adjusted menu styles

* adjusted the side that popover menus open on in footer

* added designs for a bunch on notification types

* some minor adjustments for mobile

* minor padding tweak

* more padding tweaks

* simplify notification component structure

* wire up notifications

* pull out unused button props

* fix open logic in popover

* remove logs

* added cardBordHidden styling to notifications

* passed how cardBorder Hidden to other notifcation types

* changed name of publication prop in notifcations, tweak to dropdown menu styling in settings

* fixed type errors

* more fixing type errors

* more more fixing type errors

* add an empty state if no notifications

* add reply notifications

* refactoring to add notification list in popover as well, added timestamp

* add compact rendering for notification list - mostly credit to jared

* changed some labels in the settings menu

* set notification action button to direct to notification page rather than popover a panel

* small tweaks

* ping notifications and fix reply notifications

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space celine and committed by GitHub bc04bdf9 016b2e1d

+2
actions/getIdentityData.ts
··· 17 17 identities( 18 18 *, 19 19 bsky_profiles(*), 20 + notifications(count), 20 21 publication_subscriptions(*), 21 22 custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), 22 23 home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*, ··· 33 34 ) 34 35 )`, 35 36 ) 37 + .eq("identities.notifications.read", false) 36 38 .eq("id", auth_token) 37 39 .eq("confirmed", true) 38 40 .single()
+74 -22
app/(home-pages)/discover/SortedPublicationList.tsx
··· 1 1 "use client"; 2 2 import Link from "next/link"; 3 - import { useState } from "react"; 3 + import { useState, useEffect, useRef } from "react"; 4 4 import { theme } from "tailwind.config"; 5 - import { PublicationsList } from "./page"; 6 5 import { PubListing } from "./PubListing"; 6 + import useSWRInfinite from "swr/infinite"; 7 + import { getPublications, type Cursor, type Publication } from "./getPublications"; 7 8 8 9 export function SortedPublicationList(props: { 9 - publications: PublicationsList; 10 + publications: Publication[]; 10 11 order: string; 12 + nextCursor: Cursor | null; 11 13 }) { 12 14 let [order, setOrder] = useState(props.order); 15 + 16 + const getKey = ( 17 + pageIndex: number, 18 + previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null, 19 + ) => { 20 + // Reached the end 21 + if (previousPageData && !previousPageData.nextCursor) return null; 22 + 23 + // First page, we don't have previousPageData 24 + if (pageIndex === 0) return ["discover-publications", order, null] as const; 25 + 26 + // Add the cursor to the key 27 + return ["discover-publications", order, previousPageData?.nextCursor] as const; 28 + }; 29 + 30 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 31 + getKey, 32 + ([_, orderValue, cursor]) => { 33 + const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated"; 34 + return getPublications(orderParam, cursor); 35 + }, 36 + { 37 + fallbackData: order === props.order 38 + ? [{ publications: props.publications, nextCursor: props.nextCursor }] 39 + : undefined, 40 + revalidateFirstPage: false, 41 + }, 42 + ); 43 + 44 + const loadMoreRef = useRef<HTMLDivElement>(null); 45 + 46 + // Set up intersection observer to load more when trigger element is visible 47 + useEffect(() => { 48 + const observer = new IntersectionObserver( 49 + (entries) => { 50 + if (entries[0].isIntersecting && !isValidating) { 51 + const hasMore = data && data[data.length - 1]?.nextCursor; 52 + if (hasMore) { 53 + setSize(size + 1); 54 + } 55 + } 56 + }, 57 + { threshold: 0.1 }, 58 + ); 59 + 60 + if (loadMoreRef.current) { 61 + observer.observe(loadMoreRef.current); 62 + } 63 + 64 + return () => observer.disconnect(); 65 + }, [data, size, setSize, isValidating]); 66 + 67 + const allPublications = data ? data.flatMap((page) => page.publications) : []; 68 + 13 69 return ( 14 70 <div className="discoverHeader flex flex-col items-center "> 15 71 <SortButtons ··· 21 77 setOrder(o); 22 78 }} 23 79 /> 24 - <div className="discoverPubList flex flex-col gap-3 pt-6 w-full"> 25 - {props.publications 26 - ?.filter((pub) => pub.documents_in_publications.length > 0) 27 - ?.sort((a, b) => { 28 - if (order === "popular") { 29 - return ( 30 - b.publication_subscriptions[0].count - 31 - a.publication_subscriptions[0].count 32 - ); 33 - } 34 - const aDate = new Date( 35 - a.documents_in_publications[0]?.indexed_at || 0, 36 - ); 37 - const bDate = new Date( 38 - b.documents_in_publications[0]?.indexed_at || 0, 39 - ); 40 - return bDate.getTime() - aDate.getTime(); 41 - }) 42 - .map((pub) => <PubListing resizeHeight key={pub.uri} {...pub} />)} 80 + <div className="discoverPubList flex flex-col gap-3 pt-6 w-full relative"> 81 + {allPublications.map((pub) => ( 82 + <PubListing resizeHeight key={pub.uri} {...pub} /> 83 + ))} 84 + {/* Trigger element for loading more publications */} 85 + <div 86 + ref={loadMoreRef} 87 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 88 + aria-hidden="true" 89 + /> 90 + {isValidating && ( 91 + <div className="text-center text-tertiary py-4"> 92 + Loading more publications... 93 + </div> 94 + )} 43 95 </div> 44 96 </div> 45 97 );
+119
app/(home-pages)/discover/getPublications.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + export type Cursor = { 6 + indexed_at?: string; 7 + count?: number; 8 + uri: string; 9 + }; 10 + 11 + export type Publication = Awaited< 12 + ReturnType<typeof getPublications> 13 + >["publications"][number]; 14 + 15 + export async function getPublications( 16 + order: "recentlyUpdated" | "popular" = "recentlyUpdated", 17 + cursor?: Cursor | null, 18 + ): Promise<{ publications: any[]; nextCursor: Cursor | null }> { 19 + const limit = 25; 20 + 21 + // Fetch all publications with their most recent document 22 + let { data: publications, error } = await supabaseServerClient 23 + .from("publications") 24 + .select( 25 + "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 26 + ) 27 + .or( 28 + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 29 + ) 30 + .order("indexed_at", { 31 + referencedTable: "documents_in_publications", 32 + ascending: false, 33 + }) 34 + .limit(1, { referencedTable: "documents_in_publications" }); 35 + 36 + if (error) { 37 + console.error("Error fetching publications:", error); 38 + return { publications: [], nextCursor: null }; 39 + } 40 + 41 + // Filter out publications without documents 42 + const allPubs = (publications || []).filter( 43 + (pub) => pub.documents_in_publications.length > 0, 44 + ); 45 + 46 + // Sort on the server 47 + allPubs.sort((a, b) => { 48 + if (order === "popular") { 49 + const aCount = a.publication_subscriptions[0]?.count || 0; 50 + const bCount = b.publication_subscriptions[0]?.count || 0; 51 + if (bCount !== aCount) { 52 + return bCount - aCount; 53 + } 54 + // Secondary sort by uri for stability 55 + return b.uri.localeCompare(a.uri); 56 + } else { 57 + // recentlyUpdated 58 + const aDate = new Date( 59 + a.documents_in_publications[0]?.indexed_at || 0, 60 + ).getTime(); 61 + const bDate = new Date( 62 + b.documents_in_publications[0]?.indexed_at || 0, 63 + ).getTime(); 64 + if (bDate !== aDate) { 65 + return bDate - aDate; 66 + } 67 + // Secondary sort by uri for stability 68 + return b.uri.localeCompare(a.uri); 69 + } 70 + }); 71 + 72 + // Find cursor position and slice 73 + let startIndex = 0; 74 + if (cursor) { 75 + startIndex = allPubs.findIndex((pub) => { 76 + if (order === "popular") { 77 + const pubCount = pub.publication_subscriptions[0]?.count || 0; 78 + // Find first pub after cursor 79 + return ( 80 + pubCount < (cursor.count || 0) || 81 + (pubCount === cursor.count && pub.uri < cursor.uri) 82 + ); 83 + } else { 84 + const pubDate = pub.documents_in_publications[0]?.indexed_at || ""; 85 + // Find first pub after cursor 86 + return ( 87 + pubDate < (cursor.indexed_at || "") || 88 + (pubDate === cursor.indexed_at && pub.uri < cursor.uri) 89 + ); 90 + } 91 + }); 92 + // If not found, we're at the end 93 + if (startIndex === -1) { 94 + return { publications: [], nextCursor: null }; 95 + } 96 + } 97 + 98 + // Get the page 99 + const page = allPubs.slice(startIndex, startIndex + limit); 100 + 101 + // Create next cursor 102 + const nextCursor = 103 + page.length === limit && startIndex + limit < allPubs.length 104 + ? order === "recentlyUpdated" 105 + ? { 106 + indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, 107 + uri: page[page.length - 1].uri, 108 + } 109 + : { 110 + count: page[page.length - 1].publication_subscriptions[0]?.count || 0, 111 + uri: page[page.length - 1].uri, 112 + } 113 + : null; 114 + 115 + return { 116 + publications: page, 117 + nextCursor, 118 + }; 119 + }
+9 -21
app/(home-pages)/discover/page.tsx
··· 1 - import { supabaseServerClient } from "supabase/serverClient"; 2 1 import Link from "next/link"; 3 2 import { SortedPublicationList } from "./SortedPublicationList"; 4 3 import { Metadata } from "next"; 5 4 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 6 - 7 - export type PublicationsList = Awaited<ReturnType<typeof getPublications>>; 8 - async function getPublications() { 9 - let { data: publications, error } = await supabaseServerClient 10 - .from("publications") 11 - .select( 12 - "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 13 - ) 14 - .or( 15 - "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 16 - ) 17 - .order("indexed_at", { 18 - referencedTable: "documents_in_publications", 19 - ascending: false, 20 - }) 21 - .limit(1, { referencedTable: "documents_in_publications" }); 22 - return publications; 23 - } 5 + import { getPublications } from "./getPublications"; 24 6 25 7 export const metadata: Metadata = { 26 8 title: "Leaflet Discover", ··· 50 32 } 51 33 52 34 const DiscoverContent = async (props: { order: string }) => { 53 - let publications = await getPublications(); 35 + const orderValue = 36 + props.order === "popular" ? "popular" : "recentlyUpdated"; 37 + let { publications, nextCursor } = await getPublications(orderValue); 54 38 55 39 return ( 56 40 <div className="max-w-prose mx-auto w-full"> ··· 61 45 <Link href="/lish/createPub">make your own</Link>! 62 46 </p> 63 47 </div> 64 - <SortedPublicationList publications={publications} order={props.order} /> 48 + <SortedPublicationList 49 + publications={publications} 50 + order={props.order} 51 + nextCursor={nextCursor} 52 + /> 65 53 </div> 66 54 ); 67 55 };
+115 -8
app/(home-pages)/home/Actions/AccountSettings.tsx
··· 1 1 "use client"; 2 2 3 3 import { ActionButton } from "components/ActionBar/ActionButton"; 4 - import { Menu, MenuItem } from "components/Layout"; 5 4 import { mutate } from "swr"; 6 5 import { AccountSmall } from "components/Icons/AccountSmall"; 7 6 import { LogoutSmall } from "components/Icons/LogoutSmall"; 7 + import { Popover } from "components/Popover"; 8 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 9 + import { SpeedyLink } from "components/SpeedyLink"; 10 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 + import { useState } from "react"; 12 + import { ThemeSetterContent } from "components/ThemeManager/ThemeSetter"; 13 + import { useIsMobile } from "src/hooks/isMobile"; 8 14 9 - // it was going have a popover with a log out button 10 - export const AccountSettings = () => { 15 + export const AccountSettings = (props: { entityID: string }) => { 16 + let [state, setState] = useState<"menu" | "general" | "theme">("menu"); 17 + let isMobile = useIsMobile(); 18 + 11 19 return ( 12 - <Menu 20 + <Popover 13 21 asChild 22 + onOpenChange={() => setState("menu")} 23 + side={isMobile ? "top" : "right"} 24 + align={isMobile ? "center" : "start"} 25 + className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 14 26 trigger={<ActionButton icon=<AccountSmall /> label="Settings" />} 15 27 > 16 - <MenuItem 17 - onSelect={async () => { 28 + {state === "general" ? ( 29 + <GeneralSettings backToMenu={() => setState("menu")} /> 30 + ) : state === "theme" ? ( 31 + <AccountThemeSettings 32 + entityID={props.entityID} 33 + backToMenu={() => setState("menu")} 34 + /> 35 + ) : ( 36 + <SettingsMenu state={state} setState={setState} /> 37 + )} 38 + </Popover> 39 + ); 40 + }; 41 + 42 + const SettingsMenu = (props: { 43 + state: "menu" | "general" | "theme"; 44 + setState: (s: typeof props.state) => void; 45 + }) => { 46 + let menuItemClassName = 47 + "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 48 + 49 + return ( 50 + <div className="flex flex-col gap-0.5"> 51 + <AccountSettingsHeader state={"menu"} /> 52 + <button 53 + className={menuItemClassName} 54 + type="button" 55 + onClick={() => { 56 + props.setState("general"); 57 + }} 58 + > 59 + General 60 + <ArrowRightTiny /> 61 + </button> 62 + <button 63 + className={menuItemClassName} 64 + type="button" 65 + onClick={() => props.setState("theme")} 66 + > 67 + Account Theme 68 + <ArrowRightTiny /> 69 + </button> 70 + </div> 71 + ); 72 + }; 73 + 74 + const GeneralSettings = (props: { backToMenu: () => void }) => { 75 + return ( 76 + <div className="flex flex-col gap-0.5"> 77 + <AccountSettingsHeader 78 + state={"general"} 79 + backToMenuAction={() => props.backToMenu()} 80 + /> 81 + 82 + <button 83 + className="flex gap-2 font-bold" 84 + onClick={async () => { 18 85 await fetch("/api/auth/logout"); 19 86 mutate("identity", null); 20 87 }} 21 88 > 22 89 <LogoutSmall /> 23 90 Logout 24 - </MenuItem> 25 - </Menu> 91 + </button> 92 + </div> 93 + ); 94 + }; 95 + const AccountThemeSettings = (props: { 96 + entityID: string; 97 + backToMenu: () => void; 98 + }) => { 99 + return ( 100 + <div className="flex flex-col gap-0.5"> 101 + <AccountSettingsHeader 102 + state={"theme"} 103 + backToMenuAction={() => props.backToMenu()} 104 + /> 105 + <ThemeSetterContent entityID={props.entityID} home /> 106 + </div> 107 + ); 108 + }; 109 + export const AccountSettingsHeader = (props: { 110 + state: "menu" | "general" | "theme"; 111 + backToMenuAction?: () => void; 112 + }) => { 113 + return ( 114 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 115 + {props.state === "menu" 116 + ? "Settings" 117 + : props.state === "general" 118 + ? "General" 119 + : props.state === "theme" 120 + ? "Account Theme" 121 + : ""} 122 + {props.backToMenuAction && ( 123 + <button 124 + type="button" 125 + onClick={() => { 126 + props.backToMenuAction && props.backToMenuAction(); 127 + }} 128 + > 129 + <GoBackSmall className="text-accent-contrast" /> 130 + </button> 131 + )} 132 + </div> 26 133 ); 27 134 };
+5 -3
app/(home-pages)/home/Actions/Actions.tsx
··· 13 13 return ( 14 14 <> 15 15 <CreateNewLeafletButton /> 16 - {identity ? <AccountSettings /> : <LoginActionButton />} 17 - {/*<HelpPopover noShortcuts />*/} 18 - <ThemePopover entityID={rootEntity} home /> 16 + {identity ? ( 17 + <AccountSettings entityID={rootEntity} /> 18 + ) : ( 19 + <LoginActionButton /> 20 + )} 19 21 <HelpPopover /> 20 22 </> 21 23 );
+2 -2
app/(home-pages)/home/Actions/CreateNewButton.tsx
··· 51 51 return ( 52 52 <Menu 53 53 asChild 54 - side="right" 55 - align="start" 54 + side={isMobile ? "top" : "right"} 55 + align={isMobile ? "center" : "start"} 56 56 trigger={ 57 57 <ActionButton 58 58 id="new-leaflet-button"
+59
app/(home-pages)/notifications/CommentNotication.tsx
··· 1 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 2 + import { 3 + AppBskyActorProfile, 4 + PubLeafletComment, 5 + PubLeafletDocument, 6 + PubLeafletPublication, 7 + } from "lexicons/api"; 8 + import { HydratedCommentNotification } from "src/notifications"; 9 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 10 + import { Avatar } from "components/Avatar"; 11 + import { CommentTiny } from "components/Icons/CommentTiny"; 12 + import { 13 + CommentInNotification, 14 + ContentLayout, 15 + Notification, 16 + } from "./Notification"; 17 + import { AtUri } from "@atproto/api"; 18 + 19 + export const CommentNotification = (props: HydratedCommentNotification) => { 20 + let docRecord = props.commentData.documents 21 + ?.data as PubLeafletDocument.Record; 22 + let commentRecord = props.commentData.record as PubLeafletComment.Record; 23 + let profileRecord = props.commentData.bsky_profiles 24 + ?.record as AppBskyActorProfile.Record; 25 + const displayName = 26 + profileRecord.displayName || 27 + props.commentData.bsky_profiles?.handle || 28 + "Someone"; 29 + const pubRecord = props.commentData.documents?.documents_in_publications[0] 30 + ?.publications?.record as PubLeafletPublication.Record; 31 + let rkey = new AtUri(props.commentData.documents?.uri!).rkey; 32 + 33 + return ( 34 + <Notification 35 + timestamp={props.commentData.indexed_at} 36 + href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`} 37 + icon={<CommentTiny />} 38 + actionText={<>{displayName} commented on your post</>} 39 + content={ 40 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 41 + <CommentInNotification 42 + className="" 43 + avatar={ 44 + profileRecord?.avatar?.ref && 45 + blobRefToSrc( 46 + profileRecord?.avatar?.ref, 47 + props.commentData.bsky_profiles?.did || "", 48 + ) 49 + } 50 + displayName={displayName} 51 + index={[]} 52 + plaintext={commentRecord.plaintext} 53 + facets={commentRecord.facets} 54 + /> 55 + </ContentLayout> 56 + } 57 + /> 58 + ); 59 + };
+19
app/(home-pages)/notifications/FollowNotification.tsx
··· 1 + import { Avatar } from "components/Avatar"; 2 + import { Notification } from "./Notification"; 3 + 4 + export const DummyFollowNotification = (props: {}) => { 5 + const identity = "celine"; 6 + const pubName = "Pub Name Here"; 7 + return ( 8 + <Notification 9 + timestamp={""} 10 + href="/" 11 + icon={<Avatar src={undefined} displayName={identity} tiny />} 12 + actionText={ 13 + <> 14 + {identity} followed {pubName}! 15 + </> 16 + } 17 + /> 18 + ); 19 + };
+53
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 + import { MentionTiny } from "components/Icons/MentionTiny"; 2 + import { ContentLayout, Notification } from "./Notification"; 3 + 4 + export const DummyPostMentionNotification = (props: {}) => { 5 + return ( 6 + <Notification 7 + timestamp={""} 8 + href="/" 9 + icon={<MentionTiny />} 10 + actionText={<>celine mentioned your post</>} 11 + content={ 12 + <ContentLayout 13 + postTitle={"Post Title Here"} 14 + pubRecord={{ name: "My Publication" } as any} 15 + > 16 + I'm just gonna put the description here. The surrounding context is 17 + just sort of a pain to figure out 18 + <div className="border border-border-light rounded-md p-1 my-1 text-xs text-secondary"> 19 + <div className="font-bold">Title of the Mentioned Post</div> 20 + <div className="text-tertiary"> 21 + And here is the description that follows it 22 + </div> 23 + </div> 24 + </ContentLayout> 25 + } 26 + /> 27 + ); 28 + }; 29 + 30 + export const DummyUserMentionNotification = (props: { 31 + cardBorderHidden: boolean; 32 + }) => { 33 + return ( 34 + <Notification 35 + timestamp={""} 36 + href="/" 37 + icon={<MentionTiny />} 38 + actionText={<>celine mentioned you</>} 39 + content={ 40 + <ContentLayout 41 + postTitle={"Post Title Here"} 42 + pubRecord={{ name: "My Publication" } as any} 43 + > 44 + <div> 45 + ...llo this is the content of a post or whatever here it comes{" "} 46 + <span className="text-accent-contrast">@celine </span> and here it 47 + was! ooooh heck yeah the high is unre... 48 + </div> 49 + </ContentLayout> 50 + } 51 + /> 52 + ); 53 + };
+116
app/(home-pages)/notifications/Notification.tsx
··· 1 + "use client"; 2 + import { Avatar } from "components/Avatar"; 3 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 4 + import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; 5 + import { timeAgo } from "src/utils/timeAgo"; 6 + import { useReplicache, useEntity } from "src/replicache"; 7 + 8 + export const Notification = (props: { 9 + icon: React.ReactNode; 10 + actionText: React.ReactNode; 11 + content?: React.ReactNode; 12 + timestamp: string; 13 + href: string; 14 + }) => { 15 + let { rootEntity } = useReplicache(); 16 + let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data 17 + .value; 18 + 19 + // If compact mode, always hide border 20 + 21 + return ( 22 + <div 23 + className={`relative flex flex-col w-full pb-3 sm:pb-4 pt-2 ${ 24 + cardBorderHidden 25 + ? " first:pt-0! " 26 + : " block-border border-border! hover:outline-border sm:px-4 px-3 pl-2 sm:pl-3 " 27 + }`} 28 + style={{ 29 + backgroundColor: cardBorderHidden 30 + ? "transparent" 31 + : "rgba(var(--bg-page), var(--bg-page-alpha))", 32 + }} 33 + > 34 + <a 35 + href={props.href} 36 + className=" absolute top-0 bottom-0 left-0 right-0" 37 + /> 38 + <div className="flex justify-between items-center gap-3 w-full "> 39 + <div className={`flex flex-row gap-2 items-center grow w-full min-w-0`}> 40 + <div className="text-secondary shrink-0">{props.icon}</div> 41 + <div className={`text-secondary font-bold grow truncate min-w-0 }`}> 42 + {props.actionText} 43 + </div> 44 + </div> 45 + <div className={`text-tertiary shrink-0 min-w-8 text-sm`}> 46 + {timeAgo(props.timestamp)} 47 + </div> 48 + </div> 49 + {props.content && ( 50 + <div className="flex flex-row gap-2 mt-1 w-full"> 51 + <div className="w-4 shrink-0" /> 52 + {props.content} 53 + </div> 54 + )} 55 + </div> 56 + ); 57 + }; 58 + 59 + export const ContentLayout = (props: { 60 + children: React.ReactNode; 61 + postTitle: string; 62 + pubRecord?: PubLeafletPublication.Record; 63 + }) => { 64 + let { rootEntity } = useReplicache(); 65 + let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data 66 + .value; 67 + 68 + return ( 69 + <div 70 + className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`} 71 + > 72 + <div className="text-tertiary text-sm italic font-bold pb-1"> 73 + {props.postTitle} 74 + </div> 75 + {props.children} 76 + {props.pubRecord && ( 77 + <> 78 + <hr className="mt-3 mb-1 border-border-light" /> 79 + <a 80 + href={`https://${props.pubRecord.base_path}`} 81 + className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!" 82 + > 83 + {props.pubRecord.name} 84 + </a> 85 + </> 86 + )} 87 + </div> 88 + ); 89 + }; 90 + 91 + type Facet = PubLeafletRichtextFacet.Main; 92 + export const CommentInNotification = (props: { 93 + avatar: string | undefined; 94 + displayName: string; 95 + plaintext: string; 96 + facets?: Facet[]; 97 + index: number[]; 98 + className?: string; 99 + }) => { 100 + return ( 101 + <div className=" flex gap-2 text-sm w-full "> 102 + <Avatar src={props.avatar} displayName={props.displayName} /> 103 + <pre 104 + style={{ wordBreak: "break-word" }} 105 + className={`whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6 ${props.className}`} 106 + > 107 + <BaseTextBlock 108 + preview 109 + index={props.index} 110 + plaintext={props.plaintext} 111 + facets={props.facets} 112 + /> 113 + </pre> 114 + </div> 115 + ); 116 + };
+43
app/(home-pages)/notifications/NotificationList.tsx
··· 1 + "use client"; 2 + 3 + import { HydratedNotification } from "src/notifications"; 4 + import { CommentNotification } from "./CommentNotication"; 5 + import { useEffect, createContext } from "react"; 6 + import { markAsRead } from "./getNotifications"; 7 + import { ReplyNotification } from "./ReplyNotification"; 8 + import { useIdentityData } from "components/IdentityProvider"; 9 + 10 + export function NotificationList({ 11 + notifications, 12 + compact, 13 + }: { 14 + notifications: HydratedNotification[]; 15 + compact?: boolean; 16 + }) { 17 + let { mutate } = useIdentityData(); 18 + useEffect(() => { 19 + setTimeout(async () => { 20 + await markAsRead(); 21 + mutate(); 22 + }, 500); 23 + }, []); 24 + 25 + if (notifications.length === 0) 26 + return ( 27 + <div className="w-full container italic text-tertiary text-center sm:p-4 p-3"> 28 + no notifications yet... 29 + </div> 30 + ); 31 + return ( 32 + <div className="max-w-prose mx-auto w-full"> 33 + <div className={`flex flex-col gap-2`}> 34 + {notifications.map((n) => { 35 + if (n.type === "comment") { 36 + if (n.parentData) return <ReplyNotification key={n.id} {...n} />; 37 + return <CommentNotification key={n.id} {...n} />; 38 + } 39 + })} 40 + </div> 41 + </div> 42 + ); 43 + }
+82
app/(home-pages)/notifications/ReplyNotification.tsx
··· 1 + import { Avatar } from "components/Avatar"; 2 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 3 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 + import { 5 + CommentInNotification, 6 + ContentLayout, 7 + Notification, 8 + } from "./Notification"; 9 + import { HydratedCommentNotification } from "src/notifications"; 10 + import { 11 + PubLeafletComment, 12 + PubLeafletDocument, 13 + PubLeafletPublication, 14 + } from "lexicons/api"; 15 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 16 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 + 18 + export const ReplyNotification = (props: HydratedCommentNotification) => { 19 + let docRecord = props.commentData.documents 20 + ?.data as PubLeafletDocument.Record; 21 + let commentRecord = props.commentData.record as PubLeafletComment.Record; 22 + let profileRecord = props.commentData.bsky_profiles 23 + ?.record as AppBskyActorProfile.Record; 24 + const displayName = 25 + profileRecord.displayName || 26 + props.commentData.bsky_profiles?.handle || 27 + "Someone"; 28 + 29 + let parentRecord = props.parentData?.record as PubLeafletComment.Record; 30 + let parentProfile = props.parentData?.bsky_profiles 31 + ?.record as AppBskyActorProfile.Record; 32 + const parentDisplayName = 33 + parentProfile.displayName || 34 + props.parentData?.bsky_profiles?.handle || 35 + "Someone"; 36 + 37 + let rkey = new AtUri(props.commentData.documents?.uri!).rkey; 38 + const pubRecord = props.commentData.documents?.documents_in_publications[0] 39 + ?.publications?.record as PubLeafletPublication.Record; 40 + 41 + return ( 42 + <Notification 43 + timestamp={props.commentData.indexed_at} 44 + href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`} 45 + icon={<ReplyTiny />} 46 + actionText={`${displayName} replied to your comment`} 47 + content={ 48 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 49 + <CommentInNotification 50 + className="" 51 + avatar={ 52 + parentProfile?.avatar?.ref && 53 + blobRefToSrc( 54 + parentProfile?.avatar?.ref, 55 + props.parentData?.bsky_profiles?.did || "", 56 + ) 57 + } 58 + displayName={parentDisplayName} 59 + index={[]} 60 + plaintext={parentRecord.plaintext} 61 + facets={parentRecord.facets} 62 + /> 63 + <div className="h-3 -mt-[1px] ml-[10px] border-l border-border" /> 64 + <CommentInNotification 65 + className="" 66 + avatar={ 67 + profileRecord?.avatar?.ref && 68 + blobRefToSrc( 69 + profileRecord?.avatar?.ref, 70 + props.commentData.bsky_profiles?.did || "", 71 + ) 72 + } 73 + displayName={displayName} 74 + index={[]} 75 + plaintext={commentRecord.plaintext} 76 + facets={commentRecord.facets} 77 + /> 78 + </ContentLayout> 79 + } 80 + /> 81 + ); 82 + };
+28
app/(home-pages)/notifications/getNotifications.ts
··· 1 + "use server"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { hydrateNotifications } from "src/notifications"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function getNotifications(limit?: number) { 7 + let identity = await getIdentityData(); 8 + if (!identity?.atp_did) return []; 9 + let query = supabaseServerClient 10 + .from("notifications") 11 + .select("*") 12 + .eq("recipient", identity.atp_did) 13 + .order("created_at", { ascending: false }); 14 + if (limit) query.limit(limit); 15 + let { data } = await query; 16 + let notifications = await hydrateNotifications(data || []); 17 + return notifications; 18 + } 19 + 20 + export async function markAsRead() { 21 + let identity = await getIdentityData(); 22 + if (!identity?.atp_did) return []; 23 + await supabaseServerClient 24 + .from("notifications") 25 + .update({ read: true }) 26 + .eq("recipient", identity.atp_did); 27 + return; 28 + }
+34 -1
app/(home-pages)/notifications/page.tsx
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 + import { redirect } from "next/navigation"; 4 + import { hydrateNotifications } from "src/notifications"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { CommentNotification } from "./CommentNotication"; 7 + import { NotificationList } from "./NotificationList"; 8 + 1 9 export default async function Notifications() { 2 - return <div>Notifications</div>; 10 + return ( 11 + <DashboardLayout 12 + id="discover" 13 + cardBorderHidden={true} 14 + currentPage="notifications" 15 + defaultTab="default" 16 + actions={null} 17 + tabs={{ 18 + default: { 19 + controls: null, 20 + content: <NotificationContent />, 21 + }, 22 + }} 23 + /> 24 + ); 3 25 } 26 + 27 + const NotificationContent = async () => { 28 + let identity = await getIdentityData(); 29 + if (!identity?.atp_did) return redirect("/home"); 30 + let { data } = await supabaseServerClient 31 + .from("notifications") 32 + .select("*") 33 + .eq("recipient", identity.atp_did); 34 + let notifications = await hydrateNotifications(data || []); 35 + return <NotificationList notifications={notifications} />; 36 + };
+1 -1
app/[leaflet_id]/publish/PublishPost.tsx
··· 146 146 <div className="opaque-container p-3 rounded-lg!"> 147 147 <div className="flex gap-2"> 148 148 <img 149 - className="bg-test rounded-full w-[42px] h-[42px] shrink-0" 149 + className="rounded-full w-[42px] h-[42px] shrink-0" 150 150 src={props.profile.avatar} 151 151 /> 152 152 <div className="flex flex-col w-full">
-30
app/about/page.tsx
··· 1 - import { LegalContent } from "app/legal/content"; 2 - import Link from "next/link"; 3 - 4 - export default function AboutPage() { 5 - return ( 6 - <div className="flex flex-col gap-2"> 7 - <div className="flex flex-col h-[80vh] mx-auto sm:px-4 px-3 sm:py-6 py-4 max-w-prose gap-4 text-lg"> 8 - <p> 9 - Leaflet.pub is a web app for instantly creating and collaborating on 10 - documents.{" "} 11 - <Link href="/" prefetch={false}> 12 - Click here 13 - </Link>{" "} 14 - to create one and get started! 15 - </p> 16 - 17 - <p> 18 - Leaflet is made by Learning Futures Inc. Previously we built{" "} 19 - <a href="https://hyperlink.academy">hyperlink.academy</a> 20 - </p> 21 - <p> 22 - You can find us on{" "} 23 - <a href="https://bsky.app/profile/leaflet.pub">Bluesky</a> or email as 24 - at <a href="mailto:contact@leaflet.pub">contact@leaflet.pub</a> 25 - </p> 26 - </div> 27 - <LegalContent /> 28 - </div> 29 - ); 30 - }
+21
app/globals.css
··· 370 370 @apply pl-3; 371 371 @apply ml-2; 372 372 } 373 + 373 374 .transparent-container { 374 375 @apply border; 375 376 @apply border-border-light; ··· 397 398 rgb(var(--bg-page)) 85% 398 399 ); 399 400 @apply rounded-md; 401 + } 402 + 403 + .menuItem { 404 + @apply text-secondary; 405 + @apply hover:text-secondary; 406 + @apply data-highlighted:bg-[var(--accent-light)]; 407 + @apply data-highlighted:outline-none; 408 + @apply hover:bg-[var(--accent-light)]; 409 + text-align: left; 410 + font-weight: 800; 411 + padding: 0.25rem 0.5rem; 412 + border-radius: 0.25rem; 413 + outline: none !important; 414 + cursor: pointer; 415 + background-color: transparent; 416 + 417 + :hover { 418 + text-decoration: none !important; 419 + background-color: rgb(var(--accent-light)); 420 + } 400 421 } 401 422 402 423 .pwa-padding {
+19 -13
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 8 8 import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; 9 9 import { supabaseServerClient } from "supabase/serverClient"; 10 10 import { Json } from "supabase/database.types"; 11 - import { Notification } from "src/notifications"; 11 + import { 12 + Notification, 13 + pingIdentityToUpdateNotification, 14 + } from "src/notifications"; 12 15 import { v7 } from "uuid"; 13 16 14 17 export async function publishComment(args: { ··· 67 70 } as unknown as Json, 68 71 }) 69 72 .select(); 70 - let notifications: Notification[] = [ 71 - { 72 - id: v7(), 73 - recipient: new AtUri(args.document).host, 74 - data: { type: "comment", comment_uri: uri.toString() }, 75 - }, 76 - ]; 77 - if (args.comment.replyTo) 73 + let notifications: Notification[] = []; 74 + let recipient = args.comment.replyTo 75 + ? new AtUri(args.comment.replyTo).host 76 + : new AtUri(args.document).host; 77 + if (recipient !== credentialSession.did) { 78 78 notifications.push({ 79 79 id: v7(), 80 - recipient: new AtUri(args.comment.replyTo).host, 81 - data: { type: "comment", comment_uri: uri.toString() }, 80 + recipient, 81 + data: { 82 + type: "comment", 83 + comment_uri: uri.toString(), 84 + parent_uri: args.comment.replyTo, 85 + }, 82 86 }); 83 - // SOMEDAY: move this out the action with inngest or workflows 84 - await supabaseServerClient.from("notifications").insert(notifications); 87 + // SOMEDAY: move this out the action with inngest or workflows 88 + await supabaseServerClient.from("notifications").insert(notifications); 89 + await pingIdentityToUpdateNotification(recipient); 90 + } 85 91 86 92 return { 87 93 record: data?.[0].record as Json,
+1 -48
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 1 1 "use client"; 2 2 3 - import { Media } from "components/Media"; 4 3 import { NewDraftActionButton } from "./NewDraftButton"; 4 + import { PublicationSettingsButton } from "./PublicationSettings"; 5 5 import { ActionButton } from "components/ActionBar/ActionButton"; 6 - import { useRouter } from "next/navigation"; 7 - import { Popover } from "components/Popover"; 8 - import { SettingsSmall } from "components/Icons/SettingsSmall"; 9 6 import { ShareSmall } from "components/Icons/ShareSmall"; 10 7 import { Menu } from "components/Layout"; 11 8 import { MenuItem } from "components/Layout"; 12 - import { HomeSmall } from "components/Icons/HomeSmall"; 13 - import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 14 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 10 import { usePublicationData } from "./PublicationSWRProvider"; 16 11 import { useSmoker } from "components/Toast"; 17 - import { PaintSmall } from "components/Icons/PaintSmall"; 18 - import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 19 12 import { useIsMobile } from "src/hooks/isMobile"; 20 13 import { SpeedyLink } from "components/SpeedyLink"; 21 14 ··· 24 17 <> 25 18 <NewDraftActionButton publication={props.publication} /> 26 19 <PublicationShareButton /> 27 - <PublicationThemeButton /> 28 20 <PublicationSettingsButton publication={props.publication} /> 29 21 </> 30 22 ); ··· 85 77 </Menu> 86 78 ); 87 79 } 88 - 89 - function PublicationSettingsButton(props: { publication: string }) { 90 - let isMobile = useIsMobile(); 91 - return ( 92 - <Popover 93 - asChild 94 - side={isMobile ? "top" : "right"} 95 - align={isMobile ? "center" : "start"} 96 - className="max-w-xs" 97 - trigger={ 98 - <ActionButton 99 - id="pub-settings-button" 100 - icon=<SettingsSmall /> 101 - label="Settings" 102 - /> 103 - } 104 - > 105 - <EditPubForm /> 106 - </Popover> 107 - ); 108 - } 109 - 110 - function PublicationThemeButton() { 111 - let isMobile = useIsMobile(); 112 - 113 - return ( 114 - <Popover 115 - asChild 116 - className="max-w-xs pb-0 bg-white!" 117 - side={isMobile ? "top" : "right"} 118 - align={isMobile ? "center" : "start"} 119 - trigger={ 120 - <ActionButton id="pub-theme-button" icon=<PaintSmall /> label="Theme" /> 121 - } 122 - > 123 - <PubThemeSetter /> 124 - </Popover> 125 - ); 126 - }
+132
app/lish/[did]/[publication]/dashboard/PublicationSettings.tsx
··· 1 + "use client"; 2 + 3 + import { ActionButton } from "components/ActionBar/ActionButton"; 4 + import { Popover } from "components/Popover"; 5 + import { SettingsSmall } from "components/Icons/SettingsSmall"; 6 + import { EditPubForm } from "app/lish/createPub/UpdatePubForm"; 7 + import { PubThemeSetter } from "components/ThemeManager/PubThemeSetter"; 8 + import { useIsMobile } from "src/hooks/isMobile"; 9 + import { useState } from "react"; 10 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 11 + import { theme } from "tailwind.config"; 12 + import { ButtonPrimary } from "components/Buttons"; 13 + import { DotLoader } from "components/utils/DotLoader"; 14 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 15 + 16 + export function PublicationSettingsButton(props: { publication: string }) { 17 + let isMobile = useIsMobile(); 18 + let [state, setState] = useState<"menu" | "general" | "theme">("menu"); 19 + let [loading, setLoading] = useState(false); 20 + 21 + return ( 22 + <Popover 23 + asChild 24 + onOpenChange={() => setState("menu")} 25 + side={isMobile ? "top" : "right"} 26 + align={isMobile ? "center" : "start"} 27 + className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`} 28 + arrowFill={theme.colors["border-light"]} 29 + trigger={ 30 + <ActionButton 31 + id="pub-settings-button" 32 + icon=<SettingsSmall /> 33 + label="Settings" 34 + /> 35 + } 36 + > 37 + {state === "general" ? ( 38 + <EditPubForm 39 + backToMenuAction={() => setState("menu")} 40 + loading={loading} 41 + setLoadingAction={setLoading} 42 + /> 43 + ) : state === "theme" ? ( 44 + <PubThemeSetter 45 + backToMenu={() => setState("menu")} 46 + loading={loading} 47 + setLoading={setLoading} 48 + /> 49 + ) : ( 50 + <PubSettingsMenu 51 + state={state} 52 + setState={setState} 53 + loading={loading} 54 + setLoading={setLoading} 55 + /> 56 + )} 57 + </Popover> 58 + ); 59 + } 60 + 61 + const PubSettingsMenu = (props: { 62 + state: "menu" | "general" | "theme"; 63 + setState: (s: typeof props.state) => void; 64 + loading: boolean; 65 + setLoading: (l: boolean) => void; 66 + }) => { 67 + let menuItemClassName = 68 + "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 69 + 70 + return ( 71 + <div className="flex flex-col gap-0.5"> 72 + <PubSettingsHeader 73 + loading={props.loading} 74 + setLoadingAction={props.setLoading} 75 + state={"menu"} 76 + /> 77 + <button 78 + className={menuItemClassName} 79 + type="button" 80 + onClick={() => { 81 + props.setState("general"); 82 + }} 83 + > 84 + Publication Settings 85 + <ArrowRightTiny /> 86 + </button> 87 + <button 88 + className={menuItemClassName} 89 + type="button" 90 + onClick={() => props.setState("theme")} 91 + > 92 + Publication Theme 93 + <ArrowRightTiny /> 94 + </button> 95 + </div> 96 + ); 97 + }; 98 + 99 + export const PubSettingsHeader = (props: { 100 + state: "menu" | "general" | "theme"; 101 + backToMenuAction?: () => void; 102 + loading: boolean; 103 + setLoadingAction: (l: boolean) => void; 104 + }) => { 105 + return ( 106 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 107 + {props.state === "menu" 108 + ? "Settings" 109 + : props.state === "general" 110 + ? "General" 111 + : props.state === "theme" 112 + ? "Publication Theme" 113 + : ""} 114 + {props.state !== "menu" && ( 115 + <div className="flex gap-2"> 116 + <button 117 + type="button" 118 + onClick={() => { 119 + props.backToMenuAction && props.backToMenuAction(); 120 + }} 121 + > 122 + <GoBackSmall className="text-accent-contrast" /> 123 + </button> 124 + 125 + <ButtonPrimary compact type="submit"> 126 + {props.loading ? <DotLoader /> : "Update"} 127 + </ButtonPrimary> 128 + </div> 129 + )} 130 + </div> 131 + ); 132 + };
+106 -98
app/lish/createPub/UpdatePubForm.tsx
··· 20 20 import Link from "next/link"; 21 21 import { Checkbox } from "components/Checkbox"; 22 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 + import { PubSettingsHeader } from "../[did]/[publication]/dashboard/PublicationSettings"; 23 24 24 - export const EditPubForm = () => { 25 + export const EditPubForm = (props: { 26 + backToMenuAction: () => void; 27 + loading: boolean; 28 + setLoadingAction: (l: boolean) => void; 29 + }) => { 25 30 let { data } = usePublicationData(); 26 31 let { publication: pubData } = data || {}; 27 32 let record = pubData?.record as PubLeafletPublication.Record; ··· 57 62 58 63 return ( 59 64 <form 60 - className="flex flex-col gap-3 w-[1000px] max-w-full py-1" 61 65 onSubmit={async (e) => { 62 66 if (!pubData) return; 63 67 e.preventDefault(); 64 - setFormState("loading"); 68 + props.setLoadingAction(true); 69 + console.log("step 1:update"); 65 70 let data = await updatePublication({ 66 71 uri: pubData.uri, 67 72 name: nameValue, ··· 73 78 }, 74 79 }); 75 80 toast({ type: "success", content: "Updated!" }); 76 - setFormState("normal"); 81 + props.setLoadingAction(false); 77 82 mutate("publication-data"); 78 83 }} 79 84 > 80 - <div className="flex items-center justify-between gap-2 "> 81 - <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 82 - Logo <span className="font-normal">(optional)</span> 83 - </p> 84 - <div 85 - className={`w-8 h-8 rounded-full flex items-center justify-center cursor-pointer ${iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`} 86 - onClick={() => fileInputRef.current?.click()} 87 - > 88 - {iconPreview ? ( 89 - <img 90 - src={iconPreview} 91 - alt="Logo preview" 92 - className="w-full h-full rounded-full object-cover" 93 - /> 94 - ) : ( 95 - <AddTiny className="text-accent-1" /> 96 - )} 85 + <PubSettingsHeader 86 + loading={props.loading} 87 + setLoadingAction={props.setLoadingAction} 88 + backToMenuAction={props.backToMenuAction} 89 + state={"theme"} 90 + /> 91 + <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 92 + <div className="flex items-center justify-between gap-2 "> 93 + <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 94 + Logo <span className="font-normal">(optional)</span> 95 + </p> 96 + <div 97 + className={`w-8 h-8 rounded-full flex items-center justify-center cursor-pointer ${iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`} 98 + onClick={() => fileInputRef.current?.click()} 99 + > 100 + {iconPreview ? ( 101 + <img 102 + src={iconPreview} 103 + alt="Logo preview" 104 + className="w-full h-full rounded-full object-cover" 105 + /> 106 + ) : ( 107 + <AddTiny className="text-accent-1" /> 108 + )} 109 + </div> 110 + <input 111 + type="file" 112 + accept="image/*" 113 + className="hidden" 114 + ref={fileInputRef} 115 + onChange={(e) => { 116 + const file = e.target.files?.[0]; 117 + if (file) { 118 + setIconFile(file); 119 + const reader = new FileReader(); 120 + reader.onload = (e) => { 121 + setIconPreview(e.target?.result as string); 122 + }; 123 + reader.readAsDataURL(file); 124 + } 125 + }} 126 + /> 97 127 </div> 98 - <input 99 - type="file" 100 - accept="image/*" 101 - className="hidden" 102 - ref={fileInputRef} 103 - onChange={(e) => { 104 - const file = e.target.files?.[0]; 105 - if (file) { 106 - setIconFile(file); 107 - const reader = new FileReader(); 108 - reader.onload = (e) => { 109 - setIconPreview(e.target?.result as string); 110 - }; 111 - reader.readAsDataURL(file); 112 - } 113 - }} 114 - /> 115 - </div> 116 128 117 - <label> 118 - <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 119 - Publication Name 120 - </p> 121 - <Input 122 - className="input-with-border w-full text-primary" 123 - type="text" 124 - id="pubName" 125 - value={nameValue} 126 - onChange={(e) => { 127 - setNameValue(e.currentTarget.value); 128 - }} 129 - /> 130 - </label> 131 - <label> 132 - <p className="text-tertiary italic text-sm font-bold pl-0.5 pb-0.5"> 133 - Description <span className="font-normal">(optional)</span> 134 - </p> 135 - <Input 136 - textarea 137 - className="input-with-border w-full text-primary" 138 - rows={3} 139 - id="pubDescription" 140 - value={descriptionValue} 141 - onChange={(e) => { 142 - setDescriptionValue(e.currentTarget.value); 143 - }} 144 - /> 145 - </label> 146 - 147 - <CustomDomainForm /> 148 - <hr className="border-border-light" /> 149 - 150 - <Checkbox 151 - checked={showInDiscover} 152 - onChange={(e) => setShowInDiscover(e.target.checked)} 153 - > 154 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 155 - <p className="font-bold"> 156 - Show In{" "} 157 - <a href="/discover" target="_blank"> 158 - Discover 159 - </a> 129 + <label> 130 + <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 131 + Publication Name 160 132 </p> 161 - <p className="text-xs text-tertiary font-normal"> 162 - This publication will appear on our public Discover page 133 + <Input 134 + className="input-with-border w-full text-primary" 135 + type="text" 136 + id="pubName" 137 + value={nameValue} 138 + onChange={(e) => { 139 + setNameValue(e.currentTarget.value); 140 + }} 141 + /> 142 + </label> 143 + <label> 144 + <p className="text-tertiary italic text-sm font-bold pl-0.5 pb-0.5"> 145 + Description <span className="font-normal">(optional)</span> 163 146 </p> 164 - </div> 165 - </Checkbox> 147 + <Input 148 + textarea 149 + className="input-with-border w-full text-primary" 150 + rows={3} 151 + id="pubDescription" 152 + value={descriptionValue} 153 + onChange={(e) => { 154 + setDescriptionValue(e.currentTarget.value); 155 + }} 156 + /> 157 + </label> 166 158 167 - <Checkbox 168 - checked={showComments} 169 - onChange={(e) => setShowComments(e.target.checked)} 170 - > 171 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 172 - <p className="font-bold">Show comments on posts</p> 173 - </div> 174 - </Checkbox> 175 - <hr className="border-border-light" /> 159 + <CustomDomainForm /> 160 + <hr className="border-border-light" /> 161 + 162 + <Checkbox 163 + checked={showInDiscover} 164 + onChange={(e) => setShowInDiscover(e.target.checked)} 165 + > 166 + <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 167 + <p className="font-bold"> 168 + Show In{" "} 169 + <a href="/discover" target="_blank"> 170 + Discover 171 + </a> 172 + </p> 173 + <p className="text-xs text-tertiary font-normal"> 174 + This publication will appear on our public Discover page 175 + </p> 176 + </div> 177 + </Checkbox> 176 178 177 - <ButtonPrimary className="place-self-end" type="submit"> 178 - {formState === "loading" ? <DotLoader /> : "Update!"} 179 - </ButtonPrimary> 179 + <Checkbox 180 + checked={showComments} 181 + onChange={(e) => setShowComments(e.target.checked)} 182 + > 183 + <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 184 + <p className="font-bold">Show comments on posts</p> 185 + </div> 186 + </Checkbox> 187 + </div> 180 188 </form> 181 189 ); 182 190 };
+28 -10
components/ActionBar/ActionButton.tsx
··· 8 8 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 9 9 10 10 export const ActionButton = ( 11 - props: ButtonProps & { 11 + _props: ButtonProps & { 12 12 id?: string; 13 13 icon: React.ReactNode; 14 14 label: React.ReactNode; ··· 17 17 nav?: boolean; 18 18 className?: string; 19 19 subtext?: string; 20 + labelOnMobile?: boolean; 21 + z?: boolean; 20 22 }, 21 23 ) => { 22 - let { id, icon, label, primary, secondary, nav, ...buttonProps } = props; 24 + let { 25 + id, 26 + icon, 27 + label, 28 + primary, 29 + secondary, 30 + nav, 31 + labelOnMobile, 32 + subtext, 33 + className, 34 + ...buttonProps 35 + } = _props; 23 36 let sidebar = useContext(SidebarContext); 24 37 let inOpenPopover = useContext(PopoverOpenContext); 25 38 useEffect(() => { ··· 30 43 }; 31 44 } 32 45 }, [sidebar, inOpenPopover]); 46 + 47 + let showLabelOnMobile = 48 + labelOnMobile !== false && (primary || secondary || nav); 49 + 33 50 return ( 34 51 <button 35 52 {...buttonProps} ··· 38 55 rounded-md border 39 56 flex gap-2 items-start sm:justify-start justify-center 40 57 p-1 sm:mx-0 58 + ${showLabelOnMobile && !secondary ? "w-full" : "sm:w-full w-max"} 41 59 ${ 42 60 primary 43 - ? "w-full bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 mx-1 first:ml-0" 61 + ? "bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 mx-1 first:ml-0" 44 62 : secondary 45 - ? "sm:w-full w-max bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1 mx-1 first:ml-0" 63 + ? " bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1 mx-1 first:ml-0" 46 64 : nav 47 - ? "w-full border-transparent text-secondary sm:hover:border-border justify-start!" 48 - : "sm:w-full border-transparent text-accent-contrast sm:hover:border-accent-contrast" 65 + ? "border-transparent text-secondary sm:hover:border-border justify-start!" 66 + : "border-transparent text-accent-contrast sm:hover:border-accent-contrast" 49 67 } 50 - ${props.className} 68 + ${className} 51 69 `} 52 70 > 53 71 <div className="shrink-0">{icon}</div> 54 72 <div 55 - className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : primary || secondary || nav ? "sm:hidden block" : "hidden"}`} 73 + className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 56 74 > 57 75 <div className="truncate text-left pt-[1px]">{label}</div> 58 - {props.subtext && ( 76 + {subtext && ( 59 77 <div className="text-xs text-tertiary font-normal text-left"> 60 - {props.subtext} 78 + {subtext} 61 79 </div> 62 80 )} 63 81 </div>
+65 -43
components/ActionBar/Navigation.tsx
··· 11 11 ReaderReadSmall, 12 12 ReaderUnreadSmall, 13 13 } from "components/Icons/ReaderSmall"; 14 + import { 15 + NotificationsReadSmall, 16 + NotificationsUnreadSmall, 17 + } from "components/Icons/NotificationSmall"; 18 + import { SpeedyLink } from "components/SpeedyLink"; 19 + import { Separator } from "components/Layout"; 14 20 15 - export type navPages = "home" | "reader" | "pub" | "discover"; 21 + export type navPages = "home" | "reader" | "pub" | "discover" | "notifications"; 16 22 17 23 export const DesktopNavigation = (props: { 18 24 currentPage: navPages; 19 25 publication?: string; 20 26 }) => { 27 + let { identity } = useIdentityData(); 21 28 return ( 22 - <div className="flex flex-col gap-4"> 29 + <div className="flex flex-col gap-3"> 23 30 <Sidebar alwaysOpen> 24 31 <NavigationOptions 25 32 currentPage={props.currentPage} 26 33 publication={props.publication} 27 34 /> 28 35 </Sidebar> 29 - {/*<Sidebar alwaysOpen> 30 - <ActionButton 31 - icon={ 32 - unreadNotifications ? ( 33 - <NotificationsUnreadSmall /> 34 - ) : ( 35 - <NotificationsReadSmall /> 36 - ) 37 - } 38 - label="Notifications" 39 - /> 40 - </Sidebar>*/} 36 + {identity?.atp_did && ( 37 + <Sidebar alwaysOpen> 38 + <NotificationButton current={props.currentPage === "notifications"} /> 39 + </Sidebar> 40 + )} 41 41 </div> 42 42 ); 43 43 }; ··· 51 51 (pub) => pub.uri === props.publication, 52 52 ); 53 53 return ( 54 - <Popover 55 - onOpenAutoFocus={(e) => e.preventDefault()} 56 - asChild 57 - className="px-2! !max-w-[256px]" 58 - trigger={ 59 - <div className="shrink-0 p-1 pr-2 text-accent-contrast h-full flex gap-2 font-bold items-center"> 60 - <MenuSmall /> 61 - <div className="truncate max-w-[72px]"> 62 - {props.currentPage === "home" ? ( 63 - <>Home</> 64 - ) : props.currentPage === "reader" ? ( 65 - <>Reader</> 66 - ) : props.currentPage === "discover" ? ( 67 - <>Discover</> 68 - ) : props.currentPage === "pub" ? ( 69 - thisPublication && <>{thisPublication.name}</> 70 - ) : null} 54 + <div className="flex gap-1 "> 55 + <Popover 56 + onOpenAutoFocus={(e) => e.preventDefault()} 57 + asChild 58 + className="px-2! !max-w-[256px]" 59 + trigger={ 60 + <div className="shrink-0 p-1 text-accent-contrast h-full flex gap-2 font-bold items-center"> 61 + <MenuSmall /> 71 62 </div> 72 - </div> 73 - } 74 - > 75 - <NavigationOptions 76 - currentPage={props.currentPage} 77 - publication={props.publication} 78 - /> 79 - </Popover> 63 + } 64 + > 65 + <NavigationOptions 66 + currentPage={props.currentPage} 67 + publication={props.publication} 68 + isMobile 69 + /> 70 + </Popover> 71 + {identity?.atp_did && ( 72 + <> 73 + <Separator /> 74 + <NotificationButton /> 75 + </> 76 + )} 77 + </div> 80 78 ); 81 79 }; 82 80 83 81 const NavigationOptions = (props: { 84 82 currentPage: navPages; 85 83 publication?: string; 84 + isMobile?: boolean; 86 85 }) => { 87 86 let { identity } = useIdentityData(); 88 87 let thisPublication = identity?.publications?.find( ··· 108 107 109 108 const HomeButton = (props: { current?: boolean }) => { 110 109 return ( 111 - <Link href={"/home"} className="hover:!no-underline"> 110 + <SpeedyLink href={"/home"} className="hover:!no-underline"> 112 111 <ActionButton 113 112 nav 114 113 icon={<HomeSmall />} 115 114 label="Home" 116 115 className={props.current ? "bg-bg-page! border-border-light!" : ""} 117 116 /> 118 - </Link> 117 + </SpeedyLink> 119 118 ); 120 119 }; 121 120 ··· 124 123 125 124 if (!props.subs) return; 126 125 return ( 127 - <Link href={"/reader"} className="hover:no-underline!"> 126 + <SpeedyLink href={"/reader"} className="hover:no-underline!"> 128 127 <ActionButton 129 128 nav 130 129 icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} ··· 134 133 ${props.current && "border-accent-contrast!"} 135 134 `} 136 135 /> 137 - </Link> 136 + </SpeedyLink> 138 137 ); 139 138 }; 140 139 ··· 151 150 </Link> 152 151 ); 153 152 }; 153 + 154 + export function NotificationButton(props: { current?: boolean }) { 155 + let { identity } = useIdentityData(); 156 + let unreads = identity?.notifications[0]?.count; 157 + 158 + return ( 159 + <SpeedyLink href={"/notifications"} className="hover:no-underline!"> 160 + <ActionButton 161 + nav 162 + labelOnMobile={false} 163 + icon={ 164 + unreads ? ( 165 + <NotificationsUnreadSmall className="text-accent-contrast" /> 166 + ) : ( 167 + <NotificationsReadSmall /> 168 + ) 169 + } 170 + label="Notifications" 171 + className={`${props.current ? "bg-bg-page! border-border-light!" : ""} ${unreads ? "text-accent-contrast!" : ""}`} 172 + /> 173 + </SpeedyLink> 174 + ); 175 + }
+28
components/Avatar.tsx
··· 1 + import { AccountTiny } from "./Icons/AccountTiny"; 2 + 3 + export const Avatar = (props: { 4 + src: string | undefined; 5 + displayName: string | undefined; 6 + tiny?: boolean; 7 + }) => { 8 + if (props.src) 9 + return ( 10 + <img 11 + className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`} 12 + src={props.src} 13 + alt={ 14 + props.displayName 15 + ? `${props.displayName}'s avatar` 16 + : "someone's avatar" 17 + } 18 + /> 19 + ); 20 + else 21 + return ( 22 + <div 23 + className={`bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${props.tiny ? "w-4 h-4" : "w-5 h-5"}`} 24 + > 25 + <AccountTiny className={props.tiny ? "scale-80" : "scale-90"} /> 26 + </div> 27 + ); 28 + };
+1 -1
components/Blocks/Block.tsx
··· 430 430 className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 431 431 > 432 432 <div 433 - className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-1 outline-offset-1 433 + className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-offset-1 434 434 ${ 435 435 folded 436 436 ? "outline-secondary"
-2
components/Blocks/PublicationPollBlock.tsx
··· 31 31 32 32 const docRecord = publicationData.documents 33 33 .data as PubLeafletDocument.Record; 34 - console.log(docRecord); 35 34 36 35 // Search through all pages and blocks to find if this poll entity has been published 37 36 for (const page of docRecord.pages || []) { ··· 40 39 for (const blockWrapper of linearPage.blocks || []) { 41 40 if (blockWrapper.block?.$type === ids.PubLeafletBlocksPoll) { 42 41 const pollBlock = blockWrapper.block as PubLeafletBlocksPoll.Main; 43 - console.log(pollBlock); 44 42 // Check if this poll's rkey matches our entity ID 45 43 const rkey = pollBlock.pollRef.uri.split("/").pop(); 46 44 if (rkey === props.entityID) {
+19
components/Icons/AccountTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const AccountTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M11.9995 11.6042C12.2359 11.3531 12.6319 11.3406 12.8833 11.5768C13.1345 11.8133 13.1469 12.2102 12.9106 12.4616C10.9942 14.4996 8.48343 14.9669 5.82467 14.4899C5.48536 14.4287 5.25917 14.1047 5.31979 13.7653C5.38075 13.4255 5.7066 13.1985 6.04635 13.2594C8.41545 13.6844 10.4511 13.2509 11.9995 11.6042ZM7.40377 1.64517C7.57942 1.34822 7.96315 1.25 8.26022 1.42544C8.55725 1.60111 8.65554 1.98479 8.47995 2.28189L4.62155 8.80923C4.68119 8.84969 4.74613 8.89372 4.81686 8.93716C5.20557 9.17585 5.72696 9.42535 6.30123 9.51724C7.59938 9.72475 8.32429 9.55762 8.60495 9.41959C8.91451 9.26714 9.28927 9.39433 9.44186 9.70376C9.59429 10.0133 9.46707 10.3881 9.15768 10.5407C8.55667 10.8366 7.53939 10.9811 6.10397 10.7516C5.31168 10.6249 4.63266 10.2913 4.16256 10.0026C3.92499 9.85669 3.73326 9.71756 3.60006 9.61392C3.53354 9.56215 3.48092 9.51848 3.44381 9.48697C3.42534 9.47127 3.41058 9.45834 3.39987 9.44888C3.39453 9.44418 3.38953 9.44016 3.3862 9.43716C3.38469 9.43579 3.38337 9.43423 3.38229 9.43326L3.38034 9.43228V9.4313H3.37936C3.16132 9.23186 3.11298 8.90647 3.26315 8.65201L7.40377 1.64517ZM12.4995 2.25259C13.2777 2.19942 13.9584 2.87497 14.019 3.76138C14.0795 4.64775 13.4974 5.40938 12.7192 5.46255C11.941 5.51572 11.2612 4.84018 11.2006 3.95376C11.1401 3.06754 11.7215 2.306 12.4995 2.25259ZM2.08444 2.98501C2.35274 2.19505 3.03678 1.71257 3.61178 1.90787C4.18673 2.1032 4.43574 2.90212 4.16745 3.69205C3.89911 4.48193 3.21507 4.9635 2.6401 4.76822C2.06529 4.57291 1.81644 3.77476 2.08444 2.98501Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+1
components/Icons/GoBackSmall.tsx
··· 8 8 viewBox="0 0 24 24" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 14 d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12.6826 5.96582C12.2921 5.57556 11.659 5.57557 11.2686 5.96582L5.94141 11.293C5.55114 11.6834 5.55114 12.3166 5.94141 12.707L11.2686 18.0332L11.3438 18.1025C11.7365 18.4229 12.3165 18.3993 12.6826 18.0332C13.0484 17.6671 13.0712 17.088 12.751 16.6953L12.6826 16.6191L9.06348 13H17.9473L18.0498 12.9951C18.5538 12.9438 18.9471 12.5175 18.9473 12C18.9472 11.4824 18.5538 11.0563 18.0498 11.0049L17.9473 11H9.06152L12.6826 7.37988C13.0729 6.98941 13.0729 6.35629 12.6826 5.96582Z"
+21
components/Icons/MentionTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const MentionTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M8.7548 1.48131C8.37164 1.12732 7.78076 1.12733 7.39761 1.48131L6.31216 2.48412C6.11579 2.66553 5.85496 2.76077 5.58789 2.74856L4.02324 2.67702C3.42714 2.64976 2.93999 3.14727 2.97979 3.74267L3.04008 4.64469C3.06279 4.98446 2.91104 5.31244 2.63737 5.51507L1.84867 6.09903C1.30874 6.4988 1.30874 7.30663 1.84867 7.7064L2.63737 8.29036C2.91104 8.49299 3.06279 8.82097 3.04008 9.16074L2.97979 10.0628C2.93999 10.6582 3.42714 11.1557 4.02324 11.1284L5.58789 11.0569C5.85496 11.0447 6.11579 11.1399 6.31216 11.3213L7.39761 12.3241C7.78076 12.6781 8.37165 12.6781 8.7548 12.3241L9.84025 11.3213C9.8673 11.2963 9.89557 11.273 9.92492 11.2513C10.379 11.5423 10.9394 11.8764 11.3808 12.0072C12.1456 12.2339 12.9198 12.3728 13.6513 12.2853C14.4861 12.1855 14.9021 12.0899 15.3797 11.8006C14.3597 11.4989 13.8748 11.0143 13.4688 10.4865C13.2705 10.2287 13.1568 9.97205 13.0619 9.71255L12.8396 9.00919C12.8217 8.74234 13.0797 8.57625 13.3239 8.41908C13.3906 8.37613 13.4563 8.33385 13.515 8.29036L14.3037 7.7064C14.8437 7.30663 14.8437 6.4988 14.3037 6.09903L13.515 5.51507C13.2414 5.31244 13.0896 4.98447 13.1123 4.6447L13.1726 3.74267C13.2124 3.14727 12.7253 2.64976 12.1292 2.67702L10.5645 2.74856C10.2975 2.76077 10.0366 2.66553 9.84025 2.48412L8.7548 1.48131ZM0.724555 8.54935C0.893092 8.33061 1.20705 8.2899 1.42579 8.45844L1.95221 8.86403C2.17095 9.03256 2.21166 9.34652 2.04312 9.56526C1.87458 9.78401 1.56063 9.82471 1.34188 9.65618L0.815467 9.25059C0.596721 9.08206 0.556018 8.7681 0.724555 8.54935ZM2.22796 11.7918C2.40295 11.5782 2.71798 11.5469 2.9316 11.7219C3.05506 11.823 3.21912 11.8981 3.3887 11.9473C3.55945 11.9968 3.70151 12.0103 3.75507 12.0103C4.04231 12.0103 4.26845 11.9927 4.45796 11.9729C4.51258 11.9672 4.56967 11.9606 4.62708 11.954C4.75105 11.9396 4.8765 11.9251 4.98174 11.9192C5.15574 11.9095 5.34286 11.9165 5.54475 11.9825C5.74373 12.0475 5.92168 12.158 6.10009 12.3035C6.28301 12.4526 6.47827 12.6379 6.65933 12.8097C6.70174 12.8499 6.74338 12.8894 6.7839 12.9276C7.01119 13.1415 7.20968 13.3186 7.38302 13.4332C7.50037 13.5108 7.73051 13.5859 8.01215 13.6062C8.29295 13.6264 8.53643 13.5857 8.67785 13.5166C8.81401 13.4501 8.97286 13.3418 9.18171 13.1869C9.235 13.1474 9.2917 13.1048 9.35123 13.06L9.3513 13.0599L9.35133 13.0599C9.51197 12.9391 9.69328 12.8027 9.88425 12.6704C10.0346 12.5664 10.2298 12.5526 10.3932 12.6347C11.1162 12.9977 11.6692 13.1581 12.1996 13.2235C12.7423 13.2903 13.2802 13.261 14.0061 13.217C14.2817 13.2003 14.5187 13.4102 14.5354 13.6858C14.5521 13.9615 14.3422 14.1984 14.0666 14.2151C13.3566 14.2582 12.7265 14.2959 12.0773 14.216C11.492 14.1438 10.9056 13.9787 10.2184 13.6606C10.133 13.7233 10.0503 13.7855 9.96791 13.8475L9.96771 13.8477C9.90441 13.8953 9.84129 13.9428 9.77726 13.9902C9.56766 14.1456 9.34378 14.3043 9.11669 14.4152C8.76159 14.5886 8.32363 14.6312 7.94034 14.6036C7.55787 14.5761 7.14126 14.4722 6.83145 14.2673C6.57763 14.0995 6.32221 13.8663 6.09857 13.6558C6.05019 13.6103 6.00322 13.5657 5.9575 13.5224L5.95731 13.5222L5.9573 13.5222C5.77867 13.3528 5.61903 13.2015 5.46819 13.0785C5.34858 12.981 5.27842 12.9475 5.23423 12.933C5.19296 12.9196 5.14185 12.9118 5.03761 12.9176C4.96094 12.9219 4.88552 12.9308 4.78482 12.9425L4.78461 12.9426L4.78437 12.9426C4.72196 12.9499 4.64983 12.9583 4.56169 12.9675C4.34798 12.9898 4.08593 13.0103 3.75507 13.0103C3.59643 13.0103 3.36037 12.9803 3.11004 12.9077C2.85853 12.8347 2.55853 12.7089 2.29792 12.4955C2.0843 12.3205 2.05298 12.0054 2.22796 11.7918ZM7.36287 5.60901L7.868 7.84218H8.29336L8.79849 5.60901V3.81006H7.36287V5.60901ZM8.89597 9.99561V8.4182H7.25653V9.99561H8.89597Z" 17 + fill="currentColor" 18 + /> 19 + </svg> 20 + ); 21 + };
+17
components/Icons/NotificationSmall.tsx
··· 26 26 viewBox="0 0 24 24" 27 27 fill="none" 28 28 xmlns="http://www.w3.org/2000/svg" 29 + > 30 + <path 31 + d="M12.3779 0.890636C13.5297 0.868361 14.2312 1.35069 14.6104 1.8047C15.1942 2.50387 15.2636 3.34086 15.2129 3.95314C17.7074 4.96061 18.8531 7.45818 19.375 10.3975C19.5903 11.1929 20.0262 11.5635 20.585 11.9336C21.1502 12.3079 22.0847 12.7839 22.5879 13.7998C23.4577 15.556 22.8886 17.8555 20.9297 19.083C20.1439 19.5754 19.2029 20.1471 17.8496 20.5869C17.1962 20.7993 16.454 20.9768 15.5928 21.1055C15.2068 22.4811 13.9287 23.4821 12.4238 23.4824C10.9225 23.4824 9.64464 22.4867 9.25489 21.1162C8.37384 20.9871 7.61998 20.8046 6.95899 20.5869C5.62158 20.1464 4.69688 19.5723 3.91602 19.083C1.95717 17.8555 1.38802 15.556 2.25782 13.7998C2.76329 12.7794 3.60199 12.3493 4.18653 12.0068C4.7551 11.6737 5.1753 11.386 5.45606 10.7432C5.62517 9.31217 5.93987 8.01645 6.4668 6.92482C7.1312 5.54855 8.13407 4.49633 9.56251 3.92482C9.53157 3.34709 9.6391 2.63284 10.1133 1.98927C10.1972 1.87543 10.4043 1.594 10.7822 1.34669C11.1653 1.09611 11.6872 0.904101 12.3779 0.890636ZM14.1709 21.2608C13.6203 21.3007 13.0279 21.3242 12.3887 21.3242C11.7757 21.3242 11.2072 21.3024 10.6777 21.2656C11.0335 21.8421 11.6776 22.2324 12.4238 22.2324C13.1718 22.2321 13.816 21.8396 14.1709 21.2608ZM12.4004 2.38966C11.9872 2.39776 11.7419 2.50852 11.5996 2.60157C11.4528 2.6977 11.3746 2.801 11.3193 2.87599C11.088 3.19 11.031 3.56921 11.0664 3.92677C11.084 4.10311 11.1233 4.258 11.1631 4.37013C11.1875 4.43883 11.205 4.47361 11.21 4.48341C11.452 4.78119 11.4299 5.22068 11.1484 5.49415C10.8507 5.78325 10.3748 5.77716 10.0869 5.48048C10.0533 5.44582 10.0231 5.40711 9.99415 5.3672C9.0215 5.79157 8.31886 6.53162 7.81641 7.57228C7.21929 8.80941 6.91013 10.4656 6.82129 12.4746L6.81934 12.5137L6.81446 12.5518C6.73876 13.0607 6.67109 13.5103 6.53418 13.9121C6.38567 14.3476 6.16406 14.7061 5.82032 15.0899C5.54351 15.3988 5.06973 15.4268 4.76172 15.1514C4.45392 14.8758 4.42871 14.4019 4.70508 14.0928C4.93763 13.8332 5.04272 13.6453 5.11524 13.4326C5.14365 13.3492 5.16552 13.2588 5.18848 13.1553C5.10586 13.2062 5.02441 13.2544 4.94532 13.3008C4.28651 13.6868 3.87545 13.9129 3.60157 14.4658C3.08548 15.5082 3.38433 16.9793 4.71192 17.8115C5.4776 18.2913 6.27423 18.7818 7.42872 19.1621C8.58507 19.543 10.1358 19.8242 12.3887 19.8242C14.6416 19.8242 16.2108 19.5429 17.3857 19.1611C18.5582 18.7801 19.3721 18.2882 20.1328 17.8115C21.4611 16.9793 21.7595 15.5084 21.2432 14.4658C20.9668 13.9081 20.515 13.6867 19.7568 13.1846C19.7553 13.1835 19.7535 13.1827 19.752 13.1817C19.799 13.3591 19.8588 13.5202 19.9287 13.6514C20.021 13.8244 20.1034 13.8927 20.1533 13.917C20.5249 14.0981 20.6783 14.5465 20.4961 14.919C20.3135 15.2913 19.8639 15.4467 19.4922 15.2656C19.0607 15.0553 18.7821 14.6963 18.6035 14.3613C18.4238 14.0242 18.3154 13.6559 18.2471 13.3379C18.1778 13.0155 18.1437 12.7147 18.127 12.4971C18.1185 12.3873 18.1145 12.2956 18.1123 12.2305C18.1115 12.2065 18.1107 12.1856 18.1104 12.169C18.0569 11.6585 17.9885 11.1724 17.9082 10.7109C17.9002 10.6794 17.8913 10.6476 17.8838 10.6152L17.8906 10.6133C17.4166 7.97573 16.4732 6.17239 14.791 5.40821C14.5832 5.64607 14.2423 5.73912 13.9365 5.61036C13.5557 5.44988 13.3777 5.01056 13.5391 4.62892C13.5394 4.62821 13.5397 4.62699 13.54 4.62599C13.5425 4.61977 13.5479 4.6087 13.5537 4.59278C13.5658 4.55999 13.5837 4.50758 13.6035 4.44142C13.6438 4.30713 13.6903 4.12034 13.7139 3.91212C13.7631 3.47644 13.7038 3.06402 13.457 2.76857C13.3434 2.63264 13.0616 2.37678 12.4004 2.38966ZM10.1055 16.625C11.6872 16.8411 12.8931 16.8585 13.8174 16.7539C14.2287 16.7076 14.5997 17.0028 14.6465 17.4141C14.693 17.8256 14.3969 18.1976 13.9854 18.2442C12.9038 18.3665 11.5684 18.3389 9.90235 18.1113C9.49223 18.0551 9.20488 17.6768 9.26075 17.2666C9.3168 16.8563 9.6952 16.5691 10.1055 16.625ZM16.3887 16.3047C16.7403 16.086 17.203 16.1935 17.4219 16.5449C17.6406 16.8967 17.5324 17.3594 17.1807 17.5781C16.9689 17.7097 16.6577 17.8424 16.4033 17.9131C16.0045 18.0237 15.5914 17.7904 15.4805 17.3916C15.3696 16.9926 15.6031 16.5788 16.002 16.4678C16.1344 16.431 16.3112 16.3527 16.3887 16.3047Z" 32 + fill="currentColor" 33 + /> 34 + </svg> 35 + ); 36 + }; 37 + 38 + export const ReaderUnread = (props: Props) => { 39 + return ( 40 + <svg 41 + width="24" 42 + height="24" 43 + viewBox="0 0 24 24" 44 + fill="none" 45 + xmlns="http://www.w3.org/2000/svg" 29 46 {...props} 30 47 > 31 48 <path
+20
components/Icons/ReplyTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const ReplyTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + fillRule="evenodd" 14 + clipRule="evenodd" 15 + d="M10.7767 3.01749C11.6289 3.39627 12.1593 3.79765 12.4801 4.2201C12.7868 4.62405 12.9578 5.12048 12.9578 5.81175C12.9578 6.45434 12.7165 7.17288 12.2111 7.72195C11.7245 8.25058 10.9456 8.67427 9.75117 8.67427L4.45638 8.67427L6.97173 6.15892C7.36226 5.7684 7.36226 5.13523 6.97173 4.74471C6.58121 4.35418 5.94804 4.35418 5.55752 4.74471L1.33513 8.9671C0.944605 9.35762 0.944605 9.99079 1.33513 10.3813L5.55752 14.6037C5.94804 14.9942 6.58121 14.9942 6.97173 14.6037C7.36226 14.2132 7.36226 13.58 6.97173 13.1895L4.45652 10.6743L9.75117 10.6743C11.4697 10.6743 12.7941 10.0416 13.6826 9.07646C14.5522 8.13173 14.9578 6.91901 14.9578 5.81175C14.9578 4.75316 14.6829 3.81405 14.073 3.01069C13.4771 2.22581 12.62 1.64809 11.589 1.18986C11.0843 0.965558 10.4933 1.19285 10.269 1.69754C10.0447 2.20222 10.272 2.79318 10.7767 3.01749Z" 16 + fill="currentColor" 17 + /> 18 + </svg> 19 + ); 20 + };
+14 -1
components/IdentityProvider.tsx
··· 1 1 "use client"; 2 2 import { getIdentityData } from "actions/getIdentityData"; 3 - import { createContext, useContext } from "react"; 3 + import { createContext, useContext, useEffect } from "react"; 4 4 import useSWR, { KeyedMutator, mutate } from "swr"; 5 5 import { DashboardState } from "./PageLayouts/DashboardLayout"; 6 + import { supabaseBrowserClient } from "supabase/browserClient"; 6 7 7 8 export type InterfaceState = { 8 9 dashboards: { [id: string]: DashboardState | undefined }; ··· 20 21 let { data: identity, mutate } = useSWR("identity", () => getIdentityData(), { 21 22 fallbackData: props.initialValue, 22 23 }); 24 + useEffect(() => { 25 + if (!identity?.atp_did) return; 26 + let supabase = supabaseBrowserClient(); 27 + let channel = supabase.channel(`identity.atp_did:${identity.atp_did}`); 28 + channel.on("broadcast", { event: "notification" }, () => { 29 + mutate(); 30 + }); 31 + channel.subscribe(); 32 + return () => { 33 + channel.unsubscribe(); 34 + }; 35 + }, [identity?.atp_did]); 23 36 return ( 24 37 <IdentityContext.Provider value={{ identity, mutate }}> 25 38 {props.children}
+3 -8
components/Layout.tsx
··· 45 45 alignOffset={props.alignOffset ? props.alignOffset : undefined} 46 46 sideOffset={4} 47 47 collisionPadding={16} 48 - className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 48 + className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-0.5 border border-border rounded-md shadow-md ${props.className}`} 49 49 > 50 50 {props.children} 51 51 <DropdownMenu.Arrow ··· 86 86 props.onSelect(event); 87 87 }} 88 88 className={` 89 - MenuItem 90 - font-bold z-10 py-1 px-3 91 - text-left text-secondary 89 + menuItem 90 + z-10 py-1! px-2! 92 91 flex gap-2 93 - data-highlighted:bg-border-light data-highlighted:text-secondary 94 - hover:bg-border-light hover:text-secondary 95 - outline-hidden 96 - cursor-pointer 97 92 ${props.className} 98 93 `} 99 94 >
+4 -6
components/PageLayouts/DashboardLayout.tsx
··· 8 8 DesktopNavigation, 9 9 MobileNavigation, 10 10 navPages, 11 + NotificationButton, 11 12 } from "components/ActionBar/Navigation"; 12 13 import { create } from "zustand"; 13 14 import { Popover } from "components/Popover"; ··· 50 51 export const useDashboardStore = create<DashboardStore>((set, get) => ({ 51 52 dashboards: {}, 52 53 setDashboard: (id: string, partial: Partial<DashboardState>) => { 53 - console.log(partial); 54 54 set((state) => ({ 55 55 dashboards: { 56 56 ...state.dashboards, ··· 139 139 const tabParam = searchParams.get("tab"); 140 140 141 141 // Initialize tab from search param if valid, otherwise use default 142 - const initialTab = tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; 142 + const initialTab = 143 + tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; 143 144 let [tab, setTab] = useState<keyof T>(initialTab); 144 145 145 146 // Custom setter that updates both state and URL ··· 165 166 className={`dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6`} 166 167 > 167 168 <MediaContents mobile={false}> 168 - <div className="flex flex-col gap-4 my-6"> 169 + <div className="flex flex-col gap-3 my-6"> 169 170 <DesktopNavigation 170 171 currentPage={props.currentPage} 171 172 publication={props.publication} ··· 257 258 hasTemplates: boolean; 258 259 }) => { 259 260 let { display, sort } = useDashboardState(); 260 - console.log({ display, props }); 261 261 display = display || props.defaultDisplay; 262 262 let setState = useSetDashboardState(); 263 263 264 264 let { identity } = useIdentityData(); 265 - console.log(props); 266 265 267 266 return ( 268 267 <div className="dashboardControls w-full flex gap-4"> ··· 301 300 defaultDisplay: Exclude<DashboardState["display"], undefined>; 302 301 }) => { 303 302 let { display, sort } = useDashboardState(); 304 - console.log({ display, props }); 305 303 display = display || props.defaultDisplay; 306 304 let setState = useSetDashboardState(); 307 305 return (
+5 -2
components/Popover.tsx
··· 2 2 import * as RadixPopover from "@radix-ui/react-popover"; 3 3 import { theme } from "tailwind.config"; 4 4 import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider"; 5 - import { createContext, useState } from "react"; 5 + import { createContext, useEffect, useState } from "react"; 6 6 import { PopoverArrow } from "./Icons/PopoverArrow"; 7 7 8 8 export const PopoverOpenContext = createContext(false); ··· 22 22 arrowFill?: string; 23 23 }) => { 24 24 let [open, setOpen] = useState(props.open || false); 25 + useEffect(() => { 26 + if (props.open !== undefined) setOpen(props.open); 27 + }, [props.open]); 25 28 return ( 26 29 <RadixPopover.Root 27 30 open={props.open} 28 31 onOpenChange={(o) => { 29 32 setOpen(o); 30 - props.onOpenChange?.(open); 33 + props.onOpenChange?.(o); 31 34 }} 32 35 > 33 36 <PopoverOpenContext value={open}>
+103 -102
components/ThemeManager/PubThemeSetter.tsx
··· 10 10 import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 11 import { BaseThemeProvider } from "./ThemeProvider"; 12 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 - import { ButtonSecondary } from "components/Buttons"; 14 13 import { updatePublicationTheme } from "app/lish/createPub/updatePublication"; 15 - import { DotLoader } from "components/utils/DotLoader"; 16 14 import { PagePickers } from "./PubPickers/PubTextPickers"; 17 15 import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers"; 18 16 import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; 19 17 import { Separator } from "components/Layout"; 18 + import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; 20 19 21 20 export type ImageState = { 22 21 src: string; 23 22 file?: File; 24 23 repeat: number | null; 25 24 }; 26 - export const PubThemeSetter = () => { 27 - let [loading, setLoading] = useState(false); 25 + export const PubThemeSetter = (props: { 26 + backToMenu: () => void; 27 + loading: boolean; 28 + setLoading: (l: boolean) => void; 29 + }) => { 28 30 let [sample, setSample] = useState<"pub" | "post">("pub"); 29 31 let [openPicker, setOpenPicker] = useState<pickers>("null"); 30 32 let { data, mutate } = usePublicationData(); ··· 58 60 return ( 59 61 <BaseThemeProvider local {...localPubTheme}> 60 62 <form 61 - className="bg-accent-1 -mx-3 -mt-2 px-3 py-1 mb-1 flex justify-between items-center" 62 63 onSubmit={async (e) => { 63 64 e.preventDefault(); 64 65 if (!pub) return; 65 - setLoading(true); 66 + props.setLoading(true); 66 67 let result = await updatePublicationTheme({ 67 68 uri: pub.uri, 68 69 theme: { ··· 86 87 }; 87 88 return pub; 88 89 }, false); 89 - setLoading(false); 90 + props.setLoading(false); 90 91 }} 91 92 > 92 - <h4 className="text-accent-2">Publication Theme</h4> 93 - <ButtonSecondary compact> 94 - {loading ? <DotLoader /> : "Update"} 95 - </ButtonSecondary> 93 + <PubSettingsHeader 94 + loading={props.loading} 95 + setLoadingAction={props.setLoading} 96 + backToMenuAction={props.backToMenu} 97 + state={"theme"} 98 + /> 96 99 </form> 97 100 98 - <div> 99 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 100 - <div className="themeBGLeaflet flex"> 101 - <div 102 - className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 103 - > 104 - <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 105 - <BackgroundPicker 106 - bgImage={image} 107 - setBgImage={setImage} 108 - backgroundColor={localPubTheme.bgLeaflet} 109 - pageBackground={localPubTheme.bgPage} 110 - setPageBackground={(color) => { 111 - setTheme((t) => ({ ...t, bgPage: color })); 112 - }} 113 - setBackgroundColor={(color) => { 114 - setTheme((t) => ({ ...t, bgLeaflet: color })); 115 - }} 116 - openPicker={openPicker} 117 - setOpenPicker={setOpenPicker} 118 - hasPageBackground={!!showPageBackground} 119 - setHasPageBackground={setShowPageBackground} 120 - /> 121 - </div> 122 - 123 - <SectionArrow 124 - fill="white" 125 - stroke="#CCCCCC" 126 - className="ml-2 -mt-px" 127 - /> 128 - </div> 129 - </div> 130 - 101 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll -mb-2 "> 102 + <div className="themeBGLeaflet flex"> 131 103 <div 132 - style={{ 133 - backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 134 - backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 135 - backgroundPosition: "center", 136 - backgroundSize: !leafletBGRepeat 137 - ? "cover" 138 - : `calc(${leafletBGRepeat}px / 2 )`, 139 - }} 140 - className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 104 + className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 141 105 > 142 - <div className={`flex flex-col gap-3 z-10`}> 143 - <PagePickers 106 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white"> 107 + <BackgroundPicker 108 + bgImage={image} 109 + setBgImage={setImage} 110 + backgroundColor={localPubTheme.bgLeaflet} 144 111 pageBackground={localPubTheme.bgPage} 145 - primary={localPubTheme.primary} 146 112 setPageBackground={(color) => { 147 113 setTheme((t) => ({ ...t, bgPage: color })); 148 114 }} 149 - setPrimary={(color) => { 150 - setTheme((t) => ({ ...t, primary: color })); 115 + setBackgroundColor={(color) => { 116 + setTheme((t) => ({ ...t, bgLeaflet: color })); 151 117 }} 152 118 openPicker={openPicker} 153 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 154 - hasPageBackground={showPageBackground} 155 - /> 156 - <PubAccentPickers 157 - accent1={localPubTheme.accent1} 158 - setAccent1={(color) => { 159 - setTheme((t) => ({ ...t, accent1: color })); 160 - }} 161 - accent2={localPubTheme.accent2} 162 - setAccent2={(color) => { 163 - setTheme((t) => ({ ...t, accent2: color })); 164 - }} 165 - openPicker={openPicker} 166 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 119 + setOpenPicker={setOpenPicker} 120 + hasPageBackground={!!showPageBackground} 121 + setHasPageBackground={setShowPageBackground} 167 122 /> 168 123 </div> 124 + 125 + <SectionArrow 126 + fill="white" 127 + stroke="#CCCCCC" 128 + className="ml-2 -mt-[1px]" 129 + /> 169 130 </div> 170 - <div className="flex flex-col mt-4 "> 171 - <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 172 - <div className="text-sm">Preview</div> 173 - <Separator classname="h-4!" />{" "} 174 - <button 175 - className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 176 - onClick={() => setSample("pub")} 177 - > 178 - Pub 179 - </button> 180 - <button 181 - className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 182 - onClick={() => setSample("post")} 183 - > 184 - Post 185 - </button> 186 - </div> 187 - {sample === "pub" ? ( 188 - <SamplePub 189 - pubBGImage={pubBGImage} 190 - pubBGRepeat={leafletBGRepeat} 191 - showPageBackground={showPageBackground} 192 - /> 193 - ) : ( 194 - <SamplePost 195 - pubBGImage={pubBGImage} 196 - pubBGRepeat={leafletBGRepeat} 197 - showPageBackground={showPageBackground} 198 - /> 199 - )} 131 + </div> 132 + 133 + <div 134 + style={{ 135 + backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined, 136 + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 137 + backgroundPosition: "center", 138 + backgroundSize: !leafletBGRepeat 139 + ? "cover" 140 + : `calc(${leafletBGRepeat}px / 2 )`, 141 + }} 142 + className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `} 143 + > 144 + <div className={`flex flex-col gap-3 z-10`}> 145 + <PagePickers 146 + pageBackground={localPubTheme.bgPage} 147 + primary={localPubTheme.primary} 148 + setPageBackground={(color) => { 149 + setTheme((t) => ({ ...t, bgPage: color })); 150 + }} 151 + setPrimary={(color) => { 152 + setTheme((t) => ({ ...t, primary: color })); 153 + }} 154 + openPicker={openPicker} 155 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 156 + hasPageBackground={showPageBackground} 157 + /> 158 + <PubAccentPickers 159 + accent1={localPubTheme.accent1} 160 + setAccent1={(color) => { 161 + setTheme((t) => ({ ...t, accent1: color })); 162 + }} 163 + accent2={localPubTheme.accent2} 164 + setAccent2={(color) => { 165 + setTheme((t) => ({ ...t, accent2: color })); 166 + }} 167 + openPicker={openPicker} 168 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 169 + /> 200 170 </div> 171 + </div> 172 + <div className="flex flex-col mt-4 "> 173 + <div className="flex gap-2 items-center text-sm text-[#8C8C8C]"> 174 + <div className="text-sm">Preview</div> 175 + <Separator classname="h-4!" />{" "} 176 + <button 177 + className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`} 178 + onClick={() => setSample("pub")} 179 + > 180 + Pub 181 + </button> 182 + <button 183 + className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`} 184 + onClick={() => setSample("post")} 185 + > 186 + Post 187 + </button> 188 + </div> 189 + {sample === "pub" ? ( 190 + <SamplePub 191 + pubBGImage={pubBGImage} 192 + pubBGRepeat={leafletBGRepeat} 193 + showPageBackground={showPageBackground} 194 + /> 195 + ) : ( 196 + <SamplePost 197 + pubBGImage={pubBGImage} 198 + pubBGRepeat={leafletBGRepeat} 199 + showPageBackground={showPageBackground} 200 + /> 201 + )} 201 202 </div> 202 203 </div> 203 204 </BaseThemeProvider>
+100 -78
components/ThemeManager/ThemeSetter.tsx
··· 82 82 align={isMobile ? "center" : "start"} 83 83 trigger={<ActionButton icon={<PaintSmall />} label="Theme" />} 84 84 > 85 - <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 86 - <div className="themeBGLeaflet flex"> 87 - <div 88 - className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `} 89 - > 90 - <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 91 - <LeafletBGPicker 92 - entityID={props.entityID} 93 - thisPicker={"leaflet"} 94 - openPicker={openPicker} 95 - setOpenPicker={setOpenPicker} 96 - closePicker={() => setOpenPicker("null")} 97 - setValue={set("theme/page-background")} 98 - /> 99 - <PageBackgroundPicker 100 - entityID={props.entityID} 101 - setValue={set("theme/card-background")} 102 - openPicker={openPicker} 103 - setOpenPicker={setOpenPicker} 104 - home={props.home} 105 - /> 106 - <hr className=" border-[#CCCCCC]" /> 107 - <PageBorderHider 108 - entityID={props.entityID} 109 - openPicker={openPicker} 110 - setOpenPicker={setOpenPicker} 111 - /> 112 - </div> 85 + <ThemeSetterContent {...props} /> 86 + </Popover> 87 + </> 88 + ); 89 + }; 90 + 91 + export const ThemeSetterContent = (props: { 92 + entityID: string; 93 + home?: boolean; 94 + }) => { 95 + let { rep } = useReplicache(); 96 + let { data: pub } = useLeafletPublicationData(); 97 + 98 + // I need to get these variables from replicache and then write them to the DB. I also need to parse them into a state that can be used here. 99 + let permission = useEntitySetContext().permissions.write; 100 + let leafletBGImage = useEntity(props.entityID, "theme/background-image"); 101 + let leafletBGRepeat = useEntity( 102 + props.entityID, 103 + "theme/background-image-repeat", 104 + ); 113 105 114 - <SectionArrow 115 - fill="white" 116 - stroke="#CCCCCC" 117 - className="ml-2 -mt-px" 118 - /> 119 - </div> 120 - </div> 106 + let [openPicker, setOpenPicker] = useState<pickers>( 107 + props.home === true ? "leaflet" : "null", 108 + ); 109 + let set = useMemo(() => { 110 + return setColorAttribute(rep, props.entityID); 111 + }, [rep, props.entityID]); 121 112 122 - <div 123 - onClick={(e) => { 124 - e.currentTarget === e.target && setOpenPicker("leaflet"); 125 - }} 126 - style={{ 127 - backgroundImage: leafletBGImage 128 - ? `url(${leafletBGImage.data.src})` 129 - : undefined, 130 - backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 131 - backgroundPosition: "center", 132 - backgroundSize: !leafletBGRepeat 133 - ? "cover" 134 - : `calc(${leafletBGRepeat.data.value}px / 2 )`, 135 - }} 136 - className={`bg-bg-leaflet px-3 pt-4 pb-0 mb-2 flex flex-col gap-4 rounded-md border border-border`} 137 - > 138 - <PageThemePickers 113 + if (!permission) return null; 114 + if (pub) return null; 115 + return ( 116 + <div className="themeSetterContent flex flex-col w-full overflow-y-scroll no-scrollbar"> 117 + <div className="themeBGLeaflet flex"> 118 + <div className={`bgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}> 119 + <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md"> 120 + <LeafletBGPicker 139 121 entityID={props.entityID} 122 + thisPicker={"leaflet"} 140 123 openPicker={openPicker} 141 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 124 + setOpenPicker={setOpenPicker} 125 + closePicker={() => setOpenPicker("null")} 126 + setValue={set("theme/page-background")} 142 127 /> 143 - <div className="flex flex-col -gap-[6px]"> 144 - <div className={`flex flex-col z-10 -mb-[6px] `}> 145 - <AccentPickers 146 - entityID={props.entityID} 147 - openPicker={openPicker} 148 - setOpenPicker={(pickers) => setOpenPicker(pickers)} 149 - /> 150 - <SectionArrow 151 - fill={theme.colors["accent-2"]} 152 - stroke={theme.colors["accent-1"]} 153 - className="ml-2" 154 - /> 155 - </div> 128 + <PageBackgroundPicker 129 + entityID={props.entityID} 130 + setValue={set("theme/card-background")} 131 + openPicker={openPicker} 132 + setOpenPicker={setOpenPicker} 133 + home={props.home} 134 + /> 135 + <hr className=" border-[#CCCCCC]" /> 136 + <PageBorderHider 137 + entityID={props.entityID} 138 + openPicker={openPicker} 139 + setOpenPicker={setOpenPicker} 140 + /> 141 + </div> 156 142 157 - <SampleButton 158 - entityID={props.entityID} 159 - setOpenPicker={setOpenPicker} 160 - /> 161 - </div> 143 + <SectionArrow fill="white" stroke="#CCCCCC" className="ml-2 -mt-px" /> 144 + </div> 145 + </div> 162 146 163 - <SamplePage 164 - setOpenPicker={setOpenPicker} 165 - home={props.home} 147 + <div 148 + onClick={(e) => { 149 + e.currentTarget === e.target && setOpenPicker("leaflet"); 150 + }} 151 + style={{ 152 + backgroundImage: leafletBGImage 153 + ? `url(${leafletBGImage.data.src})` 154 + : undefined, 155 + backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat", 156 + backgroundPosition: "center", 157 + backgroundSize: !leafletBGRepeat 158 + ? "cover" 159 + : `calc(${leafletBGRepeat.data.value}px / 2 )`, 160 + }} 161 + className={`bg-bg-leaflet px-3 pt-4 pb-0 mb-2 flex flex-col gap-4 rounded-md border border-border`} 162 + > 163 + <PageThemePickers 164 + entityID={props.entityID} 165 + openPicker={openPicker} 166 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 167 + /> 168 + <div className="flex flex-col -gap-[6px]"> 169 + <div className={`flex flex-col z-10 -mb-[6px] `}> 170 + <AccentPickers 166 171 entityID={props.entityID} 172 + openPicker={openPicker} 173 + setOpenPicker={(pickers) => setOpenPicker(pickers)} 174 + /> 175 + <SectionArrow 176 + fill={theme.colors["accent-2"]} 177 + stroke={theme.colors["accent-1"]} 178 + className="ml-2" 167 179 /> 168 180 </div> 169 - {!props.home && <WatermarkSetter entityID={props.entityID} />} 181 + 182 + <SampleButton 183 + entityID={props.entityID} 184 + setOpenPicker={setOpenPicker} 185 + /> 170 186 </div> 171 - </Popover> 172 - </> 187 + 188 + <SamplePage 189 + setOpenPicker={setOpenPicker} 190 + home={props.home} 191 + entityID={props.entityID} 192 + /> 193 + </div> 194 + {!props.home && <WatermarkSetter entityID={props.entityID} />} 195 + </div> 173 196 ); 174 197 }; 175 - 176 198 function WatermarkSetter(props: { entityID: string }) { 177 199 let { rep } = useReplicache(); 178 200 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark");
+2 -6
components/Toast.tsx
··· 95 95 from: { top: -40 }, 96 96 enter: { top: 8 }, 97 97 leave: { top: -40 }, 98 - config: { 99 - mass: 8, 100 - friction: 150, 101 - tension: 2000, 102 - }, 98 + config: {}, 103 99 }); 104 100 105 101 return transitions((style, item) => { 106 102 return item ? ( 107 103 <animated.div 108 104 style={style} 109 - className={`toastAnimationWrapper fixed bottom-0 right-0 left-0 z-50 h-fit`} 105 + className={`toastAnimationWrapper fixed top-0 bottom-0 right-0 left-0 z-50 h-fit`} 110 106 > 111 107 <div 112 108 className={`toast absolute right-2 w-max shadow-md px-3 py-1 flex flex-row gap-2 rounded-full border text-center ${
+56 -59
src/notifications.ts
··· 8 8 export type Notification = Omit<TablesInsert<"notifications">, "data"> & { 9 9 data: NotificationData; 10 10 }; 11 - // Notification data types (for writing to the notifications table) 11 + 12 12 export type NotificationData = 13 - | { type: "comment"; comment_uri: string } 13 + | { type: "comment"; comment_uri: string; parent_uri?: string } 14 14 | { type: "subscribe"; subscription_uri: string }; 15 15 16 - // Hydrated notification types 17 - export type HydratedCommentNotification = { 18 - id: string; 19 - recipient: string; 20 - created_at: string; 21 - type: "comment"; 22 - comment_uri: string; 23 - commentData?: Tables<"comments_on_documents">; 24 - }; 25 - 26 - export type HydratedSubscribeNotification = { 27 - id: string; 28 - recipient: string; 29 - created_at: string; 30 - type: "subscribe"; 31 - subscription_uri: string; 32 - subscriptionData?: Tables<"publication_subscriptions">; 33 - }; 34 - 35 16 export type HydratedNotification = 36 17 | HydratedCommentNotification 37 18 | HydratedSubscribeNotification; 19 + export async function hydrateNotifications( 20 + notifications: NotificationRow[], 21 + ): Promise<Array<HydratedNotification>> { 22 + // Call all hydrators in parallel 23 + const [commentNotifications, subscribeNotifications] = await Promise.all([ 24 + hydrateCommentNotifications(notifications), 25 + hydrateSubscribeNotifications(notifications), 26 + ]); 27 + 28 + // Combine all hydrated notifications 29 + const allHydrated = [...commentNotifications, ...subscribeNotifications]; 30 + 31 + // Sort by created_at to maintain order 32 + allHydrated.sort( 33 + (a, b) => 34 + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), 35 + ); 36 + 37 + return allHydrated; 38 + } 38 39 39 40 // Type guard to extract notification type 40 41 type ExtractNotificationType<T extends NotificationData["type"]> = Extract< ··· 42 43 { type: T } 43 44 >; 44 45 45 - // Hydrator function type 46 - type NotificationHydrator<T extends NotificationData["type"]> = ( 47 - notifications: NotificationRow[], 48 - ) => Promise<Array<HydratedNotification & { type: T }>>; 46 + export type HydratedCommentNotification = Awaited< 47 + ReturnType<typeof hydrateCommentNotifications> 48 + >[0]; 49 49 50 - /** 51 - * Hydrates comment notifications 52 - */ 53 - async function hydrateCommentNotifications( 54 - notifications: NotificationRow[], 55 - ): Promise<HydratedCommentNotification[]> { 50 + async function hydrateCommentNotifications(notifications: NotificationRow[]) { 56 51 const commentNotifications = notifications.filter( 57 52 (n): n is NotificationRow & { data: ExtractNotificationType<"comment"> } => 58 53 (n.data as NotificationData)?.type === "comment", ··· 63 58 } 64 59 65 60 // Fetch comment data from the database 66 - const commentUris = commentNotifications.map((n) => n.data.comment_uri); 61 + const commentUris = commentNotifications.flatMap((n) => 62 + n.data.parent_uri 63 + ? [n.data.comment_uri, n.data.parent_uri] 64 + : [n.data.comment_uri], 65 + ); 67 66 const { data: comments } = await supabaseServerClient 68 67 .from("comments_on_documents") 69 - .select("*") 68 + .select( 69 + "*,bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 70 + ) 70 71 .in("uri", commentUris); 71 72 72 73 return commentNotifications.map((notification) => ({ ··· 75 76 created_at: notification.created_at, 76 77 type: "comment" as const, 77 78 comment_uri: notification.data.comment_uri, 78 - commentData: comments?.find((c) => c.uri === notification.data.comment_uri), 79 + parentData: notification.data.parent_uri 80 + ? comments?.find((c) => c.uri === notification.data.parent_uri)! 81 + : undefined, 82 + commentData: comments?.find( 83 + (c) => c.uri === notification.data.comment_uri, 84 + )!, 79 85 })); 80 86 } 81 87 82 - /** 83 - * Hydrates subscribe notifications 84 - */ 88 + export type HydratedSubscribeNotification = { 89 + id: string; 90 + recipient: string; 91 + created_at: string; 92 + type: "subscribe"; 93 + subscription_uri: string; 94 + subscriptionData?: Tables<"publication_subscriptions">; 95 + }; 85 96 async function hydrateSubscribeNotifications( 86 97 notifications: NotificationRow[], 87 98 ): Promise<HydratedSubscribeNotification[]> { ··· 117 128 })); 118 129 } 119 130 120 - /** 121 - * Main hydration function that processes all notifications 122 - */ 123 - export async function hydrateNotifications( 124 - notifications: NotificationRow[], 125 - ): Promise<HydratedNotification[]> { 126 - // Call all hydrators in parallel 127 - const [commentNotifications, subscribeNotifications] = await Promise.all([ 128 - hydrateCommentNotifications(notifications), 129 - hydrateSubscribeNotifications(notifications), 130 - ]); 131 - 132 - // Combine all hydrated notifications 133 - const allHydrated = [...commentNotifications, ...subscribeNotifications]; 134 - 135 - // Sort by created_at to maintain order 136 - allHydrated.sort( 137 - (a, b) => 138 - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), 139 - ); 140 - 141 - return allHydrated; 131 + export async function pingIdentityToUpdateNotification(did: string) { 132 + let channel = supabaseServerClient.channel(`identity.atp_did:${did}`); 133 + await channel.send({ 134 + type: "broadcast", 135 + event: "notification", 136 + payload: { message: "poke" }, 137 + }); 138 + await supabaseServerClient.removeChannel(channel); 142 139 }