diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx index 5e344e8a..eeaca3e6 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx @@ -84,7 +84,7 @@ export function CommentsDrawerContent(props: {
)} -
+
{comments.length === 0 && No comments yet…} {comments.length > 0 && comments diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index 0d095cbd..13cf1a60 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -20,6 +20,8 @@ import { useDrawerOpen } from "./useDrawerOpen"; import { ThreadView } from "../ThreadPage"; import { StandardSitePostDrawerView } from "./StandardSitePostDrawerView"; import { useDocumentDiscussionData } from "./useDocumentDiscussionData"; +import { useIsMobile } from "src/hooks/isMobile"; +import { MobileSheet } from "components/MobileSheet"; export const InteractionDrawer = (props: { showPageBackground: boolean | undefined; @@ -28,8 +30,66 @@ export const InteractionDrawer = (props: { commentsSlot: React.ReactNode; did: string; pageId?: string; +}) => { + // Reset the drawer's scroll to the top whenever we navigate between views, so + // a pushed thread (or a back navigation) doesn't start scrolled partway down. + const scrollRef = useRef(null); + let { threadStack } = useInteractionState(props.document_uri); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [threadStack.length]); + + let isMobile = useIsMobile(); + + // On mobile the drawer slides up from the bottom as a sheet instead of sitting + // inline in the horizontal page sandwich. The content renders its own header + // and close button, so the sheet supplies no title/chrome of its own. This + // component only mounts while the drawer is open (gated in PostPages), so the + // sheet is always open; closing it clears the drawer state. + if (isMobile) { + return ( + { + if (!open) + setInteractionState(props.document_uri, { drawerOpen: false }); + }} + id="interaction-drawer" + contentRef={scrollRef} + > + + + ); + } + + return ( + <> + +
+
+ +
+
+ + ); +}; + +const InteractionDrawerContent = (props: { + showPageBackground: boolean | undefined; + document_uri: string; + quotesAndMentions: { uri: string; link?: string }[]; + commentsSlot: React.ReactNode; + did: string; + pageId?: string; }) => { let drawer = useDrawerOpen(props.document_uri); + if (!drawer) return null; + let { commentsCount } = useDocument(); let { threadStack } = useInteractionState(props.document_uri); const drawerNav = useMemo( @@ -57,14 +117,6 @@ export const InteractionDrawer = (props: { setSspTab("comments"); }, [sspUri]); - // Reset the drawer's scroll to the top whenever we navigate between views, so - // a pushed thread (or a back navigation) doesn't start scrolled partway down. - const scrollRef = useRef(null); - useEffect(() => { - scrollRef.current?.scrollTo({ top: 0 }); - }, [threadStack.length]); - if (!drawer) return null; - const sspCommentsAvailable = ssp.showComments && ssp.comments.length > 0; const sspMentionsAvailable = ssp.showMentions && ssp.quotesAndMentions.length > 0; @@ -95,138 +147,123 @@ export const InteractionDrawer = (props: { drawer.drawer === "quotes" ? "quotes" : "comments"; if (activeTab === "comments" && !commentsAvailable) activeTab = "quotes"; if (activeTab === "quotes" && !mentionsAvailable) activeTab = "comments"; - return ( <> - -
-
-
-
- {sspUri ? ( - sspBothAvailable ? ( - { - e?.preventDefault(); - setSspTab(value); - }} - options={[ - { - value: "comments", - label: - ssp.comments.length > 0 - ? `Comments (${ssp.comments.length})` - : "Comments", - }, - { - value: "quotes", - label: ( -
- Bluesky{" "} - Mentions{" "} - {ssp.quotesAndMentions.length > 0 && - `(${ssp.quotesAndMentions.length})`} -
- ), - }, - ]} - /> - ) : ( -

- {sspActiveTab === "quotes" - ? `Bluesky Mentions${ssp.quotesAndMentions.length > 0 ? ` (${ssp.quotesAndMentions.length})` : ""}` - : `Comments${ssp.comments.length > 0 ? ` (${ssp.comments.length})` : ""}`} -

- ) - ) : activeThread ? ( -
- {threadStack.length >= 2 && ( - - )} - -
- ) : bothAvailable ? ( - { - e?.preventDefault(); - setInteractionState(props.document_uri, { drawer: value }); - }} - options={[ - { - value: "comments", - label: - commentsCount > 0 - ? `Comments (${commentsCount})` - : "Comments", - }, - { - value: "quotes", - label: ( -
- Bluesky{" "} - Mentions{" "} - {filteredQuotesAndMentions.length > 0 && - `(${filteredQuotesAndMentions.length})`} -
- ), - }, - ]} - /> - ) : ( -

- {activeTab === "quotes" ? "Bluesky Mentions" : "Comments"} -

- )} -
- -
- - {sspUri ? ( - - ) : activeThread ? ( - - ) : activeTab === "quotes" ? ( - +
+ {sspUri ? ( + sspBothAvailable ? ( + { + e?.preventDefault(); + setSspTab(value); + }} + options={[ + { + value: "comments", + label: + ssp.comments.length > 0 + ? `Comments (${ssp.comments.length})` + : "Comments", + }, + { + value: "quotes", + label: ( +
+ Bluesky{" "} + Mentions{" "} + {ssp.quotesAndMentions.length > 0 && + `(${ssp.quotesAndMentions.length})`} +
+ ), + }, + ]} /> ) : ( - props.commentsSlot - )} - +

+ {sspActiveTab === "quotes" + ? `Bluesky Mentions${ssp.quotesAndMentions.length > 0 ? ` (${ssp.quotesAndMentions.length})` : ""}` + : `Comments${ssp.comments.length > 0 ? ` (${ssp.comments.length})` : ""}`} +

+ ) + ) : activeThread ? ( +
+ {threadStack.length >= 2 && ( + + )} + +
+ ) : bothAvailable ? ( + { + e?.preventDefault(); + setInteractionState(props.document_uri, { drawer: value }); + }} + options={[ + { + value: "comments", + label: + commentsCount > 0 + ? `Comments (${commentsCount})` + : "Comments", + }, + { + value: "quotes", + label: ( +
+ Bluesky Mentions{" "} + {filteredQuotesAndMentions.length > 0 && + `(${filteredQuotesAndMentions.length})`} +
+ ), + }, + ]} + /> + ) : ( +

{activeTab === "quotes" ? "Bluesky Mentions" : "Comments"}

+ )}
+
+ + {sspUri ? ( + + ) : activeThread ? ( + + ) : activeTab === "quotes" ? ( + + ) : ( + props.commentsSlot + )} + ); }; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 8aa0838a..ad10a9f1 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -134,7 +134,7 @@ export const MentionsDrawerContent = (props: {
) : ( sortedBskyMentions.length > 0 && ( -
+
{sortedBskyMentions.map((q, index) => { const post = postViewMap.get(q.uri); if (!post) return null; diff --git a/app/globals.css b/app/globals.css index 7368c344..50279bac 100644 --- a/app/globals.css +++ b/app/globals.css @@ -671,6 +671,29 @@ pre.shiki { animation: mobile-sidebar-slide-out 80ms ease-in; } +@keyframes bottom-sheet-slide-in { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} +@keyframes bottom-sheet-slide-out { + from { + transform: translateY(0); + } + to { + transform: translateY(100%); + } +} +.mobileSheet[data-state="open"] { + animation: bottom-sheet-slide-in 200ms ease-out; +} +.mobileSheet[data-state="closed"] { + animation: bottom-sheet-slide-out 200ms ease-in; +} + .footnote-side-item { overflow: hidden; transition: diff --git a/components/MobileSheet.tsx b/components/MobileSheet.tsx new file mode 100644 index 00000000..2aca48bb --- /dev/null +++ b/components/MobileSheet.tsx @@ -0,0 +1,100 @@ +"use client"; +import * as Dialog from "@radix-ui/react-dialog"; +import React from "react"; +import { isIOS } from "src/utils/isDevice"; +import { CloseTiny } from "./Icons/CloseTiny"; +import { useVisualViewport } from "./ViewportSizeLayout"; + +// A mobile drawer sheet that slides up from the bottom of the screen. Built on +// Radix Dialog so it traps focus, handles the escape key, and animates open and +// closed via the .bottom-sheet-content keyframes in globals.css. +export const MobileSheet = ({ + className, + open, + onOpenChange, + asChild, + trigger, + title, + id, + contentRef, + children, +}: { + className?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + asChild?: boolean; + trigger?: React.ReactNode; + title?: React.ReactNode; + // Forwarded to the scrolling content container so callers can scroll it into + // view or reset its scroll position. + id?: string; + contentRef?: React.Ref; + children: React.ReactNode; +}) => { + let { height, offsetTop, difference } = useVisualViewport(); + // iOS keyboard open: the layout viewport (and dvh) don't shrink for the + // keyboard, so a bottom-anchored sheet would sit behind it. Lift the sheet to + // the top of the keyboard and shrink it to the visual viewport. Android + // resizes the layout viewport via interactiveWidget: "resizes-content", so + // bottom-0 + dvh already follow the keyboard there. + let keyboardOpen = isIOS() && difference !== 0 && height > 0; + return ( + + {trigger !== undefined && ( + {trigger} + )} + + + +
+ {/* When a title is given the sheet supplies its own header + close + button; otherwise the children are expected to render their own + chrome. Radix still requires a Dialog.Title for accessibility. */} + {title ? ( +
+
+ +

{title}

+
+
+ + + +
+ ) : ( + + )} + +
+ {children} +
+
+ +
+ + + + ); +};