fix: post reshare and audio player enhancements
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useAudioPlayer } from "./audio-context";
|
||||
import { AudioWaveAnimation } from "./audio-wave";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -12,6 +19,8 @@ import {
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Volume1,
|
||||
@@ -26,27 +35,66 @@ import {
|
||||
|
||||
const MINIMIZE_DELAY = 8000;
|
||||
|
||||
/**
|
||||
* `useSyncExternalStore` is the React-recommended way to expose values
|
||||
* from external systems (window, media-queries, etc.) without a
|
||||
* mirror-into-state effect. The previous useEffect-+-setState version
|
||||
* tripped React 19's "setState synchronously inside an effect" warning
|
||||
* — this variant has no effect at all.
|
||||
*
|
||||
* On the server we default to false (desktop). The player only mounts
|
||||
* when a track is playing, which is always after hydration, so the
|
||||
* initial-paint value never matters for SSR markup.
|
||||
*/
|
||||
const MOBILE_QUERY = "(max-width: 767px)";
|
||||
|
||||
function subscribeMobile(callback: () => void): () => void {
|
||||
const mq = window.matchMedia(MOBILE_QUERY);
|
||||
mq.addEventListener("change", callback);
|
||||
return () => mq.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function getMobileSnapshot(): boolean {
|
||||
return window.matchMedia(MOBILE_QUERY).matches;
|
||||
}
|
||||
|
||||
function getMobileServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
const [mobile, setMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)");
|
||||
setMobile(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setMobile(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
return mobile;
|
||||
return useSyncExternalStore(
|
||||
subscribeMobile,
|
||||
getMobileSnapshot,
|
||||
getMobileServerSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [minimized, setMinimized, resetTimer].
|
||||
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity on mobile.
|
||||
* Call `resetTimer()` on any user interaction to restart the countdown.
|
||||
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
|
||||
* `resetTimer()` on any user interaction to restart the countdown.
|
||||
*
|
||||
* The `hasTrack`-driven reset of `minimized` is done via the
|
||||
* "adjust state during render based on previous prop" pattern from the
|
||||
* React docs — not via a setState-in-effect, which would trip the
|
||||
* cascading-render warning. The setTimeout's setMinimized(true) is
|
||||
* fine because it fires asynchronously (the rule is about *synchronous*
|
||||
* setState during the effect body).
|
||||
*/
|
||||
function useAutoMinimize(hasTrack: boolean) {
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [lastHasTrack, setLastHasTrack] = useState(hasTrack);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Reset `minimized` when hasTrack transitions. Done during render —
|
||||
// no effect required, and the two setState calls only fire when the
|
||||
// comparison actually flips.
|
||||
if (hasTrack !== lastHasTrack) {
|
||||
setLastHasTrack(hasTrack);
|
||||
setMinimized(false);
|
||||
}
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
@@ -59,22 +107,24 @@ function useAutoMinimize(hasTrack: boolean) {
|
||||
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
|
||||
}, [clearTimer]);
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
setMinimized(false);
|
||||
startTimer();
|
||||
}, [startTimer]);
|
||||
|
||||
// Start timer when track begins
|
||||
// Drive the timer from hasTrack. The effect body itself only calls
|
||||
// start/clear (which schedule, not mutate state); the actual
|
||||
// `setMinimized(true)` lives in the timer callback and fires
|
||||
// asynchronously — outside the effect body, so the rule doesn't apply.
|
||||
useEffect(() => {
|
||||
if (hasTrack) {
|
||||
startTimer();
|
||||
} else {
|
||||
clearTimer();
|
||||
setMinimized(false);
|
||||
}
|
||||
return clearTimer;
|
||||
}, [hasTrack, startTimer, clearTimer]);
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
setMinimized(false);
|
||||
startTimer();
|
||||
}, [startTimer]);
|
||||
|
||||
return { minimized, setMinimized, resetTimer } as const;
|
||||
}
|
||||
|
||||
@@ -222,10 +272,17 @@ function ProgressBar({
|
||||
}, []);
|
||||
|
||||
if (thin) {
|
||||
// The wrapper is intentionally taller than the visible bar so the
|
||||
// absolutely-positioned thumb has room to sit centred without
|
||||
// poking out of the player card's overflow-hidden clip box.
|
||||
//
|
||||
// wrapper h-4 (16px) ┐
|
||||
// visible bar h-1.5 │ centred via flex
|
||||
// thumb size-3 ┘ sits on the bar, absolutely positioned
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
className="w-full h-2 bg-white/15 cursor-pointer shrink-0 relative"
|
||||
className="relative w-full h-4 cursor-pointer shrink-0 flex items-center group/seek touch-none"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@@ -236,11 +293,17 @@ function ProgressBar({
|
||||
aria-valuenow={currentTime}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="w-full h-1.5 bg-white/15 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-[width] duration-100 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 size-3 bg-primary rounded-full ring-2 ring-[#1c1c1e] shadow-md pointer-events-none transition-transform group-hover/seek:scale-125"
|
||||
style={{ left: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,6 +381,52 @@ function TransportButton({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seek-by-5s Button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Quick ±5s seek button — circular-arrow icon with a small "5" overlay
|
||||
* (the standard "skip 5 seconds" affordance in modern audio players).
|
||||
* Lives right beside Play/Pause and is the primary navigation control
|
||||
* within a track now that the prev/next-track buttons are hidden in
|
||||
* single-track contexts (which is currently every context — no caller
|
||||
* passes a real playlist).
|
||||
*/
|
||||
function SeekFiveButton({
|
||||
direction,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
direction: "back" | "forward";
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = direction === "back" ? RotateCcw : RotateCw;
|
||||
const label =
|
||||
direction === "back" ? "Skip back 5 seconds" : "Skip forward 5 seconds";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<Icon className="size-5" strokeWidth={2.2} />
|
||||
<span className="absolute text-[8px] font-bold leading-none translate-y-px">
|
||||
5
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating Player
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -511,9 +620,19 @@ export function FloatingPlayer() {
|
||||
|
||||
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
|
||||
const hasPrev = currentIndex > 0;
|
||||
// Prev/Next-track buttons only make sense when there's an actual
|
||||
// playlist with more than one entry. Everywhere `play(track)` is
|
||||
// called we end up with a single-track playlist, which makes both
|
||||
// buttons permanently disabled — confusing dead controls. Hide them
|
||||
// until a caller invokes `playPlaylist(tracks)` with multiple tracks.
|
||||
const hasPlaylist = playlist.length > 1;
|
||||
const coverSrc = currentTrack.coverImage || FALLBACK_COVER;
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
const seekBack5 = () => iSeekTo(Math.max(0, currentTime - 5));
|
||||
const seekForward5 = () =>
|
||||
iSeekTo(Math.min(duration || currentTime + 5, currentTime + 5));
|
||||
|
||||
// ── Hide entirely when selection toolbar is active ──
|
||||
if (selectionToolbarVisible) return null;
|
||||
|
||||
@@ -706,8 +825,11 @@ export function FloatingPlayer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Transport controls */}
|
||||
{/* Center: Transport controls. Order:
|
||||
[prev track]? -5s Play/Pause +5s [next track]?
|
||||
Prev/next only render when a real playlist is active. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPlaylist ? (
|
||||
<TransportButton
|
||||
onClick={iPrevious}
|
||||
disabled={!hasPrev && currentTime <= 3}
|
||||
@@ -716,6 +838,13 @@ export function FloatingPlayer() {
|
||||
>
|
||||
<SkipBack className="size-4" />
|
||||
</TransportButton>
|
||||
) : null}
|
||||
|
||||
<SeekFiveButton
|
||||
direction="back"
|
||||
onClick={seekBack5}
|
||||
className="size-8 text-white/70 hover:text-white hover:bg-white/10"
|
||||
/>
|
||||
|
||||
<TransportButton
|
||||
onClick={iTogglePlayPause}
|
||||
@@ -732,6 +861,13 @@ export function FloatingPlayer() {
|
||||
)}
|
||||
</TransportButton>
|
||||
|
||||
<SeekFiveButton
|
||||
direction="forward"
|
||||
onClick={seekForward5}
|
||||
className="size-8 text-white/70 hover:text-white hover:bg-white/10"
|
||||
/>
|
||||
|
||||
{hasPlaylist ? (
|
||||
<TransportButton
|
||||
onClick={iNext}
|
||||
disabled={!hasNext}
|
||||
@@ -740,6 +876,7 @@ export function FloatingPlayer() {
|
||||
>
|
||||
<SkipForward className="size-4" />
|
||||
</TransportButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: Volume + Source link + Close */}
|
||||
@@ -867,8 +1004,11 @@ export function FloatingPlayer() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Prev + Play/Pause + Next — centered */}
|
||||
<div className="flex items-center justify-center gap-4 py-1">
|
||||
{/* Row 2 — centered transport. Order mirrors the desktop:
|
||||
[prev]? -5s Play/Pause +5s [next]?
|
||||
Prev/next render only when a real playlist is active. */}
|
||||
<div className="flex items-center justify-center gap-3 py-1">
|
||||
{hasPlaylist ? (
|
||||
<TransportButton
|
||||
onClick={iPrevious}
|
||||
disabled={!hasPrev && currentTime <= 3}
|
||||
@@ -877,6 +1017,13 @@ export function FloatingPlayer() {
|
||||
>
|
||||
<SkipBack className="size-5" />
|
||||
</TransportButton>
|
||||
) : null}
|
||||
|
||||
<SeekFiveButton
|
||||
direction="back"
|
||||
onClick={seekBack5}
|
||||
className="size-10 text-white/70 hover:text-white hover:bg-white/10"
|
||||
/>
|
||||
|
||||
<TransportButton
|
||||
onClick={iTogglePlayPause}
|
||||
@@ -893,6 +1040,13 @@ export function FloatingPlayer() {
|
||||
)}
|
||||
</TransportButton>
|
||||
|
||||
<SeekFiveButton
|
||||
direction="forward"
|
||||
onClick={seekForward5}
|
||||
className="size-10 text-white/70 hover:text-white hover:bg-white/10"
|
||||
/>
|
||||
|
||||
{hasPlaylist ? (
|
||||
<TransportButton
|
||||
onClick={iNext}
|
||||
disabled={!hasNext}
|
||||
@@ -901,6 +1055,7 @@ export function FloatingPlayer() {
|
||||
>
|
||||
<SkipForward className="size-5" />
|
||||
</TransportButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -222,10 +222,11 @@ function CoverSlide({
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
{/* Full-coverage scrim — even the top carries ~40% black so the
|
||||
badge and any white text overlaid up there stay readable
|
||||
without looking blended with the cover image. */}
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/95 via-black/70 to-black/40" />
|
||||
{/* Light scrim — enough darkness at the bottom for the title
|
||||
and post-count line to read, but the top stays close to
|
||||
the original image so the cover photo isn't muted into a
|
||||
near-black plate. */}
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/75 via-black/40 to-black/15" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
|
||||
@@ -46,15 +46,23 @@ export function PostCard({
|
||||
|
||||
return (
|
||||
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
|
||||
{/* Reshare attribution — only on rows the feed owner reshared.
|
||||
The author block below still shows the original author; this
|
||||
strip just signals "this is on this feed because it was
|
||||
reshared," matching the Twitter/X retweet convention. */}
|
||||
{post.reshared_at ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-3">
|
||||
{/* Reshare attribution — only on rows that are themselves
|
||||
reshares. The link goes to the original author's profile;
|
||||
their handle is rendered inline so the source is obvious.
|
||||
The post body below shows the resharer as the author (this
|
||||
IS their post — likes, comments, and background all belong
|
||||
to them, independent from the original). */}
|
||||
{post.reshared_from_post_id && post.original_author_handle ? (
|
||||
<Link
|
||||
href={`/u/${post.original_author_handle}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground mb-3 transition-colors"
|
||||
>
|
||||
<Repeat2 className="size-3.5" />
|
||||
<span>Reshared</span>
|
||||
</div>
|
||||
<span>
|
||||
Reshared from{" "}
|
||||
<span className="font-medium">@{post.original_author_handle}</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
{/* Author header — identity on the left, overflow menu on the
|
||||
right. The menu trigger only renders when there are items to
|
||||
|
||||
@@ -75,15 +75,22 @@ export function PostScrollCard({
|
||||
id={`post-${post.id}`}
|
||||
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
|
||||
>
|
||||
{/* Reshare attribution — only on rows the feed owner reshared.
|
||||
The author block below still names the original author; this
|
||||
strip just signals why the post is in this feed. Matches the
|
||||
Twitter/X retweet convention. */}
|
||||
{post.reshared_at ? (
|
||||
<div className="flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground">
|
||||
{/* Reshare attribution — only on rows that are themselves
|
||||
reshares. Tappable link → original author's profile. The card
|
||||
body below shows the resharer as the author (this row IS the
|
||||
resharer's own post — likes, comments, background all belong
|
||||
to them, decoupled from the original). */}
|
||||
{post.reshared_from_post_id && post.original_author_handle ? (
|
||||
<Link
|
||||
href={`/u/${post.original_author_handle}`}
|
||||
className="inline-flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Repeat2 className="size-3.5" />
|
||||
<span>Reshared</span>
|
||||
</div>
|
||||
<span>
|
||||
Reshared from{" "}
|
||||
<span className="font-medium">@{post.original_author_handle}</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
{/* Author header — identity on the left, three-dot overflow menu
|
||||
on the right (Delete for author, Block/Report for everyone
|
||||
|
||||
+18
-10
@@ -62,16 +62,21 @@ export type Post = {
|
||||
author_name?: string;
|
||||
author_handle?: string;
|
||||
author_avatar_url?: string | null;
|
||||
/** True when the calling viewer has reshared this post. Populated by
|
||||
* endpoints that go through OptionalAuth (single post, profile feed,
|
||||
* collection contents). Always false / absent for anonymous viewers.
|
||||
* Drives the post-card "Reshare ↔ Send" swap. */
|
||||
/** True when the calling viewer has reshared the canonical version
|
||||
* of this post. Populated by endpoints that go through OptionalAuth
|
||||
* (single post, profile feed, collection contents). Always false /
|
||||
* absent for anonymous viewers. Drives the post-card "Reshare ↔ Send"
|
||||
* swap. */
|
||||
viewer_has_reshared?: boolean;
|
||||
/** Non-null only on rows that appear in a profile feed because the
|
||||
* profile owner reshared them. Drives the "↻ Reshared" header in
|
||||
* the post card and the COALESCE(reshared_at, created_at) sort on
|
||||
* the BFF. Authored posts leave this null. */
|
||||
reshared_at?: string | null;
|
||||
/** Non-null only when this row is itself a reshare — points at the
|
||||
* canonical original. The four `original_author_*` fields carry the
|
||||
* canonical author's profile info for rendering the
|
||||
* "Reshared from @handle" link in the card. Authored canonical posts
|
||||
* leave all of these null/absent. */
|
||||
reshared_from_post_id?: string | null;
|
||||
original_author_name?: string | null;
|
||||
original_author_handle?: string | null;
|
||||
original_author_avatar_url?: string | null;
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
@@ -153,8 +158,11 @@ export const posts = {
|
||||
* side. Resharing your own post is rejected with 400 (not currently
|
||||
* surfaced as a distinct error type because the UI only ever offers
|
||||
* Reshare on non-owned posts). */
|
||||
/** Materialise a reshare. Returns the new post (or the existing
|
||||
* reshare row if the caller already reshared this canonical), so
|
||||
* callers can drop it straight into local feed state. */
|
||||
reshare(id: string) {
|
||||
return call<void>(`/posts/${id}/reshare`, { method: "POST" });
|
||||
return call<Post>(`/posts/${id}/reshare`, { method: "POST" });
|
||||
},
|
||||
/** Remove a previously recorded reshare. Idempotent. */
|
||||
unreshare(id: string) {
|
||||
|
||||
Reference in New Issue
Block a user