Feed browser: Mobile immersive feed #4
@@ -40,10 +40,16 @@ export interface FeedViewProps {
|
|||||||
now?: number;
|
now?: number;
|
||||||
/** Another page can load; the phone feed ends with a loading slide. */
|
/** Another page can load; the phone feed ends with a loading slide. */
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
/** Phone layout only: the slide on screen changed (for the dock). */
|
|
||||||
/** Phone layout only: experts for the featured slide after the second post. */
|
/** Phone layout only: experts for the featured slide after the second post. */
|
||||||
featuredExperts?: DiscoverExpert[];
|
featuredExperts?: DiscoverExpert[];
|
||||||
|
/** Phone layout only: the slide on screen changed (for the dock). */
|
||||||
onCurrentChange?: (slide: CurrentSlide) => void;
|
onCurrentChange?: (slide: CurrentSlide) => void;
|
||||||
|
/** Phone layout only: the dock's comments button, for the post on screen. */
|
||||||
|
onOpenComments?: (postId: string) => void;
|
||||||
|
/** Phone layout only: the dock's new-post button. */
|
||||||
|
onOpenComposer?: () => void;
|
||||||
|
/** Phone layout only: the dock's More button (the Discover sheet). */
|
||||||
|
onOpenMore?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { commentsLabel } from "@/lib/feed-model";
|
||||||
|
import { SparkleButton } from "./Buttons";
|
||||||
|
import type { CurrentSlide } from "./MobileFeed";
|
||||||
|
import type { CardPost } from "@/lib/feed-model";
|
||||||
|
|
||||||
|
const icon = {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 1.7,
|
||||||
|
strokeLinecap: "round" as const,
|
||||||
|
strokeLinejoin: "round" as const,
|
||||||
|
"aria-hidden": true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MobileDockProps {
|
||||||
|
/** The slide on screen; like and comments act on its post. */
|
||||||
|
slide: CurrentSlide;
|
||||||
|
onToggleLike?: (post: CardPost) => void;
|
||||||
|
likeBusy?: boolean;
|
||||||
|
onOpenComments?: (postId: string) => void;
|
||||||
|
onOpenComposer?: () => void;
|
||||||
|
onOpenMore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the like and comment buttons show and whether they work, for the slide on screen. */
|
||||||
|
export function dockState(slide: CurrentSlide, opts: { canLike?: boolean; likeBusy?: boolean } = {}) {
|
||||||
|
const post = slide.kind === "post" ? slide.post : null;
|
||||||
|
if (!post) {
|
||||||
|
return { post: null, liked: false, likes: null, comments: null, likeLabel: "Like", commentsLabel: "Comments", likeDisabled: true, commentsDisabled: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
post,
|
||||||
|
liked: post.isLiked,
|
||||||
|
likes: post.likesCount,
|
||||||
|
comments: post.commentsCount,
|
||||||
|
likeLabel: post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`,
|
||||||
|
commentsLabel: `Open comments, ${commentsLabel(post.commentsCount).toLowerCase()}`,
|
||||||
|
likeDisabled: !post.id || !opts.canLike || !!opts.likeBusy,
|
||||||
|
commentsDisabled: !post.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone dock (spec "Mobile layout" > "Bottom dock"): a frosted bar 16px above the
|
||||||
|
* bottom. Like and comments act on the post on screen and are disabled on the
|
||||||
|
* featured and loading slides. Liked state and counts come from the post itself,
|
||||||
|
* the same source as the desktop like button and double-tap.
|
||||||
|
*/
|
||||||
|
export function MobileDock({ slide, onToggleLike, likeBusy, onOpenComments, onOpenComposer, onOpenMore }: MobileDockProps) {
|
||||||
|
const s = dockState(slide, { canLike: !!onToggleLike, likeBusy });
|
||||||
|
const [beat, setBeat] = useState(false);
|
||||||
|
return (
|
||||||
|
<nav className="dock" aria-label="Post actions" data-testid="feed-dock">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dk dk-like${s.liked ? " dk-like-on" : ""}${beat && s.liked ? " dk-beat" : ""}`}
|
||||||
|
aria-pressed={s.liked}
|
||||||
|
aria-label={s.likeLabel}
|
||||||
|
disabled={s.likeDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!s.post) return;
|
||||||
|
setBeat(!s.post.isLiked);
|
||||||
|
onToggleLike?.(s.post);
|
||||||
|
}}
|
||||||
|
onAnimationEnd={() => setBeat(false)}
|
||||||
|
>
|
||||||
|
<svg {...icon} fill={s.liked ? "currentColor" : "none"}>
|
||||||
|
<path d="M12 20.5s-7.5-4.4-9.4-9.1C1.3 8 3.1 4.5 6.6 4.5c2 0 3.5 1.1 4.3 2.4.5.7 1.7.7 2.2 0 .8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.3 3.5 4 6.9-1.9 4.7-9.4 9.1-9.4 9.1z" />
|
||||||
|
</svg>
|
||||||
|
{s.likes !== null ? <span className="dk-n" data-testid="dock-likes">{s.likes}</span> : null}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dk dk-comments"
|
||||||
|
aria-label={s.commentsLabel}
|
||||||
|
disabled={s.commentsDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (s.post?.id) onOpenComments?.(s.post.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg {...icon}>
|
||||||
|
<path d="M21 12a8.5 8.5 0 0 1-12.4 7.6L3.5 21l1.4-4.6A8.5 8.5 0 1 1 21 12z" />
|
||||||
|
</svg>
|
||||||
|
{s.comments !== null ? <span className="dk-n" data-testid="dock-comments">{s.comments}</span> : null}
|
||||||
|
</button>
|
||||||
|
<SparkleButton variant="fab" label="New post" className="dk-plus" onClick={() => onOpenComposer?.()} />
|
||||||
|
<button type="button" className="dk dk-more" aria-label="View more" onClick={() => onOpenMore?.()}>
|
||||||
|
<svg {...icon}>
|
||||||
|
<rect x="3.5" y="3.5" width="7" height="7" rx="2" />
|
||||||
|
<rect x="13.5" y="3.5" width="7" height="7" rx="2" />
|
||||||
|
<rect x="3.5" y="13.5" width="7" height="7" rx="2" />
|
||||||
|
<rect x="13.5" y="13.5" width="7" height="7" rx="2" />
|
||||||
|
</svg>
|
||||||
|
More
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { HexAvatar } from "./HexAvatar";
|
|||||||
import { PlayIcon, PostMenu } from "./PostCard";
|
import { PlayIcon, PostMenu } from "./PostCard";
|
||||||
import { VideoPlayer } from "./VideoPlayer";
|
import { VideoPlayer } from "./VideoPlayer";
|
||||||
import { FeaturedCarousel } from "./FeaturedCarousel";
|
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||||
|
import { MobileDock } from "./MobileDock";
|
||||||
import type { DiscoverExpert } from "@/lib/discover";
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
import type { FeedViewProps } from "./FeedView";
|
import type { FeedViewProps } from "./FeedView";
|
||||||
|
|
||||||
@@ -415,6 +416,16 @@ export function MobileFeed(p: FeedViewProps) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{p.status !== "error" ? (
|
||||||
|
<MobileDock
|
||||||
|
slide={{ index, kind, post: currentPost }}
|
||||||
|
onToggleLike={p.onToggleLike}
|
||||||
|
likeBusy={currentPost ? p.likeBusy?.has(currentPost.key) : false}
|
||||||
|
onOpenComments={p.onOpenComments}
|
||||||
|
onOpenComposer={p.onOpenComposer}
|
||||||
|
onOpenMore={p.onOpenMore}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-2
@@ -73,7 +73,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 84px;
|
bottom: 90px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -320,12 +320,60 @@
|
|||||||
}
|
}
|
||||||
.reel-pill > * { pointer-events: auto; }
|
.reel-pill > * { pointer-events: auto; }
|
||||||
|
|
||||||
|
/* Bottom dock (spec "Bottom dock"): a frosted bar 16px above the bottom. Like and
|
||||||
|
comments act on the post on screen. Disabled buttons stay fully opaque and use
|
||||||
|
a solid muted ink instead of fading. */
|
||||||
|
.dock {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--glass);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.8);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.8);
|
||||||
|
box-shadow: var(--shadow-pop), inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.dk {
|
||||||
|
appearance: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .25s, color .25s;
|
||||||
|
}
|
||||||
|
.dk svg { width: 22px; height: 22px; flex: none; }
|
||||||
|
@media (hover: hover) { .dk:hover:not(:disabled) { background: var(--tile-3); } }
|
||||||
|
.dk:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
|
.dk.dk-like-on { color: var(--accent); }
|
||||||
|
.dk-beat svg { animation: post-beat .5s cubic-bezier(.2,.9,.3,1.4); }
|
||||||
|
.dk:disabled { opacity: 1; color: var(--ink-3); cursor: default; }
|
||||||
|
.btn-spark.btn-spark-fab.dk-plus { flex: 0 0 64px; width: 64px; height: 52px; }
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.is-cur .slide-meta,
|
.is-cur .slide-meta,
|
||||||
.is-cur .slide-quote-w,
|
.is-cur .slide-quote-w,
|
||||||
.slide-skel i,
|
.slide-skel i,
|
||||||
.slide-burst,
|
.slide-burst,
|
||||||
.slide-hint svg,
|
.slide-hint svg,
|
||||||
.reel-pill { animation: none !important; }
|
.reel-pill,
|
||||||
|
.dk-beat svg { animation: none !important; }
|
||||||
.slide-art { transition: none !important; }
|
.slide-art { transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
||||||
|
import { MobileDock, dockState } from "@/components/MobileDock";
|
||||||
|
import { CompactLayoutContext } from "@/components/layout-context";
|
||||||
|
import { toCardPosts } from "@/lib/feed-model";
|
||||||
|
|
||||||
|
const item = (id: string, likes: number, comments: number, liked = false) => ({
|
||||||
|
type: "post",
|
||||||
|
publishedAt: "2026-10-19T11:00:00Z",
|
||||||
|
likesCount: likes,
|
||||||
|
commentsCount: comments,
|
||||||
|
isLiked: liked,
|
||||||
|
post: { id, content: `text ${id}`, author: { id: "a", displayName: `Author ${id}` }, mediaItems: [] },
|
||||||
|
});
|
||||||
|
const POSTS = toCardPosts([item("p1", 12, 3), item("p2", 40, 1, true)]);
|
||||||
|
|
||||||
|
function feed(over: Partial<FeedViewProps>, compact = true) {
|
||||||
|
const props: FeedViewProps = {
|
||||||
|
posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false,
|
||||||
|
newCount: 0, onRetry: () => {}, onShowNew: () => {}, onToggleLike: () => {}, ...over,
|
||||||
|
};
|
||||||
|
return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dock state", () => {
|
||||||
|
it("shows the counts and liked state of the post on screen", () => {
|
||||||
|
const s = dockState({ index: 1, kind: "post", post: POSTS[1] }, { canLike: true });
|
||||||
|
expect(s.likes).toBe(POSTS[1].likesCount);
|
||||||
|
expect(s.comments).toBe(POSTS[1].commentsCount);
|
||||||
|
expect(s.liked).toBe(POSTS[1].isLiked);
|
||||||
|
expect(s.likeDisabled).toBe(false);
|
||||||
|
expect(s.commentsDisabled).toBe(false);
|
||||||
|
});
|
||||||
|
it("disables like and comments on the featured and loading slides", () => {
|
||||||
|
for (const kind of ["featured", "load"] as const) {
|
||||||
|
const s = dockState({ index: 2, kind, post: null });
|
||||||
|
expect(s.likeDisabled).toBe(true);
|
||||||
|
expect(s.commentsDisabled).toBe(true);
|
||||||
|
expect(s.likes).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("disables like while the like call is in flight", () => {
|
||||||
|
expect(dockState({ index: 0, kind: "post", post: POSTS[0] }, { canLike: true, likeBusy: true }).likeDisabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dock markup", () => {
|
||||||
|
it("renders like with aria-pressed, comments, the sparkle new-post button and More", () => {
|
||||||
|
const out = renderToStaticMarkup(createElement(MobileDock, { slide: { index: 1, kind: "post", post: POSTS[1] }, onToggleLike: () => {} }));
|
||||||
|
expect(out).toContain('aria-label="Post actions"');
|
||||||
|
expect(out).toMatch(/class="dk dk-like dk-like-on"[^>]*aria-pressed="true"/);
|
||||||
|
expect(out).toContain(`>${POSTS[1].likesCount}</span>`);
|
||||||
|
expect(out).toContain(`Open comments, ${POSTS[1].commentsCount} comment`);
|
||||||
|
expect(out).toContain("btn-spark btn-spark-fab dk-plus");
|
||||||
|
expect(out).not.toContain("btn-spark-fold");
|
||||||
|
expect(out).toContain('aria-label="View more"');
|
||||||
|
});
|
||||||
|
it("is part of the phone feed and follows the first slide", () => {
|
||||||
|
const out = feed({});
|
||||||
|
expect(out).toContain('data-testid="feed-dock"');
|
||||||
|
expect(out).toContain(`data-testid="dock-likes">${POSTS[0].likesCount}<`);
|
||||||
|
});
|
||||||
|
it("is absent on desktop", () => {
|
||||||
|
expect(feed({}, false)).not.toContain("feed-dock");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dock stylesheet", () => {
|
||||||
|
const css = readFileSync("src/styles/mobile.css", "utf8");
|
||||||
|
it("floats 16px above the bottom on frosted glass", () => {
|
||||||
|
expect(css).toMatch(/\.dock \{[^}]*bottom: calc\(16px[^}]*backdrop-filter: blur/);
|
||||||
|
});
|
||||||
|
it("keeps touch targets at least 44px and disabled buttons fully opaque", () => {
|
||||||
|
expect(css).toMatch(/\.dk \{[^}]*min-width: 44px;[^}]*height: 52px/);
|
||||||
|
expect(css).toMatch(/\.dk:disabled \{ opacity: 1;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user