fix: post reshare and audio player enhancements
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"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 { useAudioPlayer } from "./audio-context";
|
||||||
import { AudioWaveAnimation } from "./audio-wave";
|
import { AudioWaveAnimation } from "./audio-wave";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -12,6 +19,8 @@ import {
|
|||||||
Pause,
|
Pause,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
Volume1,
|
Volume1,
|
||||||
@@ -26,27 +35,66 @@ import {
|
|||||||
|
|
||||||
const MINIMIZE_DELAY = 8000;
|
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() {
|
function useIsMobile() {
|
||||||
const [mobile, setMobile] = useState(false);
|
return useSyncExternalStore(
|
||||||
useEffect(() => {
|
subscribeMobile,
|
||||||
const mq = window.matchMedia("(max-width: 767px)");
|
getMobileSnapshot,
|
||||||
setMobile(mq.matches);
|
getMobileServerSnapshot,
|
||||||
const handler = (e: MediaQueryListEvent) => setMobile(e.matches);
|
);
|
||||||
mq.addEventListener("change", handler);
|
|
||||||
return () => mq.removeEventListener("change", handler);
|
|
||||||
}, []);
|
|
||||||
return mobile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns [minimized, setMinimized, resetTimer].
|
* Returns [minimized, setMinimized, resetTimer].
|
||||||
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity on mobile.
|
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
|
||||||
* Call `resetTimer()` on any user interaction to restart the countdown.
|
* `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) {
|
function useAutoMinimize(hasTrack: boolean) {
|
||||||
const [minimized, setMinimized] = useState(false);
|
const [minimized, setMinimized] = useState(false);
|
||||||
|
const [lastHasTrack, setLastHasTrack] = useState(hasTrack);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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(() => {
|
const clearTimer = useCallback(() => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
@@ -59,22 +107,24 @@ function useAutoMinimize(hasTrack: boolean) {
|
|||||||
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
|
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
|
||||||
}, [clearTimer]);
|
}, [clearTimer]);
|
||||||
|
|
||||||
const resetTimer = useCallback(() => {
|
// Drive the timer from hasTrack. The effect body itself only calls
|
||||||
setMinimized(false);
|
// start/clear (which schedule, not mutate state); the actual
|
||||||
startTimer();
|
// `setMinimized(true)` lives in the timer callback and fires
|
||||||
}, [startTimer]);
|
// asynchronously — outside the effect body, so the rule doesn't apply.
|
||||||
|
|
||||||
// Start timer when track begins
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasTrack) {
|
if (hasTrack) {
|
||||||
startTimer();
|
startTimer();
|
||||||
} else {
|
} else {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
setMinimized(false);
|
|
||||||
}
|
}
|
||||||
return clearTimer;
|
return clearTimer;
|
||||||
}, [hasTrack, startTimer, clearTimer]);
|
}, [hasTrack, startTimer, clearTimer]);
|
||||||
|
|
||||||
|
const resetTimer = useCallback(() => {
|
||||||
|
setMinimized(false);
|
||||||
|
startTimer();
|
||||||
|
}, [startTimer]);
|
||||||
|
|
||||||
return { minimized, setMinimized, resetTimer } as const;
|
return { minimized, setMinimized, resetTimer } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,10 +272,17 @@ function ProgressBar({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (thin) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={barRef}
|
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}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
@@ -236,9 +293,15 @@ function ProgressBar({
|
|||||||
aria-valuenow={currentTime}
|
aria-valuenow={currentTime}
|
||||||
tabIndex={0}
|
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
|
<div
|
||||||
className="h-full bg-primary transition-[width] duration-100 ease-linear"
|
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={{ width: `${progress}%` }}
|
style={{ left: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
// Floating Player
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -511,9 +620,19 @@ export function FloatingPlayer() {
|
|||||||
|
|
||||||
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
|
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
|
||||||
const hasPrev = currentIndex > 0;
|
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 coverSrc = currentTrack.coverImage || FALLBACK_COVER;
|
||||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
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 ──
|
// ── Hide entirely when selection toolbar is active ──
|
||||||
if (selectionToolbarVisible) return null;
|
if (selectionToolbarVisible) return null;
|
||||||
|
|
||||||
@@ -706,16 +825,26 @@ export function FloatingPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-1.5">
|
||||||
<TransportButton
|
{hasPlaylist ? (
|
||||||
onClick={iPrevious}
|
<TransportButton
|
||||||
disabled={!hasPrev && currentTime <= 3}
|
onClick={iPrevious}
|
||||||
label="Previous track"
|
disabled={!hasPrev && currentTime <= 3}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
label="Previous track"
|
||||||
>
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
<SkipBack className="size-4" />
|
>
|
||||||
</TransportButton>
|
<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
|
<TransportButton
|
||||||
onClick={iTogglePlayPause}
|
onClick={iTogglePlayPause}
|
||||||
@@ -732,14 +861,22 @@ export function FloatingPlayer() {
|
|||||||
)}
|
)}
|
||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<SeekFiveButton
|
||||||
onClick={iNext}
|
direction="forward"
|
||||||
disabled={!hasNext}
|
onClick={seekForward5}
|
||||||
label="Next track"
|
className="size-8 text-white/70 hover:text-white hover:bg-white/10"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
/>
|
||||||
>
|
|
||||||
<SkipForward className="size-4" />
|
{hasPlaylist ? (
|
||||||
</TransportButton>
|
<TransportButton
|
||||||
|
onClick={iNext}
|
||||||
|
disabled={!hasNext}
|
||||||
|
label="Next track"
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<SkipForward className="size-4" />
|
||||||
|
</TransportButton>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Volume + Source link + Close */}
|
{/* Right: Volume + Source link + Close */}
|
||||||
@@ -867,16 +1004,26 @@ export function FloatingPlayer() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Prev + Play/Pause + Next — centered */}
|
{/* Row 2 — centered transport. Order mirrors the desktop:
|
||||||
<div className="flex items-center justify-center gap-4 py-1">
|
[prev]? -5s Play/Pause +5s [next]?
|
||||||
<TransportButton
|
Prev/next render only when a real playlist is active. */}
|
||||||
onClick={iPrevious}
|
<div className="flex items-center justify-center gap-3 py-1">
|
||||||
disabled={!hasPrev && currentTime <= 3}
|
{hasPlaylist ? (
|
||||||
label="Previous track"
|
<TransportButton
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
onClick={iPrevious}
|
||||||
>
|
disabled={!hasPrev && currentTime <= 3}
|
||||||
<SkipBack className="size-5" />
|
label="Previous track"
|
||||||
</TransportButton>
|
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
||||||
|
>
|
||||||
|
<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
|
<TransportButton
|
||||||
onClick={iTogglePlayPause}
|
onClick={iTogglePlayPause}
|
||||||
@@ -893,14 +1040,22 @@ export function FloatingPlayer() {
|
|||||||
)}
|
)}
|
||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<SeekFiveButton
|
||||||
onClick={iNext}
|
direction="forward"
|
||||||
disabled={!hasNext}
|
onClick={seekForward5}
|
||||||
label="Next track"
|
className="size-10 text-white/70 hover:text-white hover:bg-white/10"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
/>
|
||||||
>
|
|
||||||
<SkipForward className="size-5" />
|
{hasPlaylist ? (
|
||||||
</TransportButton>
|
<TransportButton
|
||||||
|
onClick={iNext}
|
||||||
|
disabled={!hasNext}
|
||||||
|
label="Next track"
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
||||||
|
>
|
||||||
|
<SkipForward className="size-5" />
|
||||||
|
</TransportButton>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -222,10 +222,11 @@ function CoverSlide({
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
{/* Full-coverage scrim — even the top carries ~40% black so the
|
{/* Light scrim — enough darkness at the bottom for the title
|
||||||
badge and any white text overlaid up there stay readable
|
and post-count line to read, but the top stays close to
|
||||||
without looking blended with the cover image. */}
|
the original image so the cover photo isn't muted into a
|
||||||
<div className="absolute inset-0 bg-linear-to-t from-black/95 via-black/70 to-black/40" />
|
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()} />
|
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||||
|
|||||||
@@ -46,15 +46,23 @@ export function PostCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
|
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
|
||||||
{/* Reshare attribution — only on rows the feed owner reshared.
|
{/* Reshare attribution — only on rows that are themselves
|
||||||
The author block below still shows the original author; this
|
reshares. The link goes to the original author's profile;
|
||||||
strip just signals "this is on this feed because it was
|
their handle is rendered inline so the source is obvious.
|
||||||
reshared," matching the Twitter/X retweet convention. */}
|
The post body below shows the resharer as the author (this
|
||||||
{post.reshared_at ? (
|
IS their post — likes, comments, and background all belong
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-3">
|
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" />
|
<Repeat2 className="size-3.5" />
|
||||||
<span>Reshared</span>
|
<span>
|
||||||
</div>
|
Reshared from{" "}
|
||||||
|
<span className="font-medium">@{post.original_author_handle}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Author header — identity on the left, overflow menu on the
|
{/* Author header — identity on the left, overflow menu on the
|
||||||
right. The menu trigger only renders when there are items to
|
right. The menu trigger only renders when there are items to
|
||||||
|
|||||||
@@ -75,15 +75,22 @@ export function PostScrollCard({
|
|||||||
id={`post-${post.id}`}
|
id={`post-${post.id}`}
|
||||||
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
|
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.
|
{/* Reshare attribution — only on rows that are themselves
|
||||||
The author block below still names the original author; this
|
reshares. Tappable link → original author's profile. The card
|
||||||
strip just signals why the post is in this feed. Matches the
|
body below shows the resharer as the author (this row IS the
|
||||||
Twitter/X retweet convention. */}
|
resharer's own post — likes, comments, background all belong
|
||||||
{post.reshared_at ? (
|
to them, decoupled from the original). */}
|
||||||
<div className="flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground">
|
{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" />
|
<Repeat2 className="size-3.5" />
|
||||||
<span>Reshared</span>
|
<span>
|
||||||
</div>
|
Reshared from{" "}
|
||||||
|
<span className="font-medium">@{post.original_author_handle}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Author header — identity on the left, three-dot overflow menu
|
{/* Author header — identity on the left, three-dot overflow menu
|
||||||
on the right (Delete for author, Block/Report for everyone
|
on the right (Delete for author, Block/Report for everyone
|
||||||
|
|||||||
+18
-10
@@ -62,16 +62,21 @@ export type Post = {
|
|||||||
author_name?: string;
|
author_name?: string;
|
||||||
author_handle?: string;
|
author_handle?: string;
|
||||||
author_avatar_url?: string | null;
|
author_avatar_url?: string | null;
|
||||||
/** True when the calling viewer has reshared this post. Populated by
|
/** True when the calling viewer has reshared the canonical version
|
||||||
* endpoints that go through OptionalAuth (single post, profile feed,
|
* of this post. Populated by endpoints that go through OptionalAuth
|
||||||
* collection contents). Always false / absent for anonymous viewers.
|
* (single post, profile feed, collection contents). Always false /
|
||||||
* Drives the post-card "Reshare ↔ Send" swap. */
|
* absent for anonymous viewers. Drives the post-card "Reshare ↔ Send"
|
||||||
|
* swap. */
|
||||||
viewer_has_reshared?: boolean;
|
viewer_has_reshared?: boolean;
|
||||||
/** Non-null only on rows that appear in a profile feed because the
|
/** Non-null only when this row is itself a reshare — points at the
|
||||||
* profile owner reshared them. Drives the "↻ Reshared" header in
|
* canonical original. The four `original_author_*` fields carry the
|
||||||
* the post card and the COALESCE(reshared_at, created_at) sort on
|
* canonical author's profile info for rendering the
|
||||||
* the BFF. Authored posts leave this null. */
|
* "Reshared from @handle" link in the card. Authored canonical posts
|
||||||
reshared_at?: string | null;
|
* 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 = {
|
export type Bookmark = {
|
||||||
@@ -153,8 +158,11 @@ export const posts = {
|
|||||||
* side. Resharing your own post is rejected with 400 (not currently
|
* side. Resharing your own post is rejected with 400 (not currently
|
||||||
* surfaced as a distinct error type because the UI only ever offers
|
* surfaced as a distinct error type because the UI only ever offers
|
||||||
* Reshare on non-owned posts). */
|
* 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) {
|
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. */
|
/** Remove a previously recorded reshare. Idempotent. */
|
||||||
unreshare(id: string) {
|
unreshare(id: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user