fix: hide blocked user content
This commit is contained in:
@@ -42,6 +42,10 @@ interface UserResult {
|
||||
interface UserSearchResponse {
|
||||
query: string;
|
||||
results: UserResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -307,7 +311,11 @@ function SearchInner() {
|
||||
// User-search branch — strip the @ prefix server-side too.
|
||||
// Clear content data so we don't flash old content results.
|
||||
setData(null);
|
||||
const userParams = new URLSearchParams({ q: userQuery, limit: "20" });
|
||||
const userParams = new URLSearchParams({
|
||||
q: userQuery,
|
||||
page: String(currentPage),
|
||||
limit: "20",
|
||||
});
|
||||
fetch(`/api/search/users?${userParams.toString()}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -319,7 +327,14 @@ function SearchInner() {
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
setIsLoading(false);
|
||||
setUserData({ query: userQuery, results: [] });
|
||||
setUserData({
|
||||
query: userQuery,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total_pages: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
@@ -540,16 +555,40 @@ function SearchInner() {
|
||||
userData &&
|
||||
userData.results.length > 0 && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{userData.results.length}{" "}
|
||||
{userData.results.length === 1 ? "user" : "users"} matching
|
||||
“@{userQuery}”
|
||||
</p>
|
||||
{/* Match count + top pagination */}
|
||||
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{userData.total.toLocaleString()}{" "}
|
||||
{userData.total === 1 ? "user" : "users"} matching “@
|
||||
{userQuery}”
|
||||
</p>
|
||||
{userData.total_pages > 1 && (
|
||||
<PaginationControls
|
||||
page={userData.page}
|
||||
totalPages={userData.total_pages}
|
||||
onPageChange={handlePageChange}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{userData.results.map((u) => (
|
||||
<UserResultCard key={u.handle} user={u} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom pagination */}
|
||||
{userData.total_pages > 1 && (
|
||||
<div className="mt-8">
|
||||
<PaginationControls
|
||||
page={userData.page}
|
||||
totalPages={userData.total_pages}
|
||||
total={userData.total}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -98,6 +98,43 @@ export default function PublicProfileLayout(props: {
|
||||
);
|
||||
}
|
||||
|
||||
// A block in either direction hides this profile's content. The backend
|
||||
// already refuses the feed/mentions/followers endpoints with 403, so this
|
||||
// is the matching client state: keep the header (its action row carries the
|
||||
// Unblock button when *you* blocked them) but replace the tabs + feed with
|
||||
// a notice instead of a misleading empty grid.
|
||||
const rel = data.relationship;
|
||||
const youBlocked = !!rel?.blocked;
|
||||
const theyBlocked = !!rel?.blocked_by;
|
||||
const blocked = youBlocked || theyBlocked;
|
||||
|
||||
if (blocked) {
|
||||
return (
|
||||
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
|
||||
<ProfileHeader
|
||||
key={data.handle}
|
||||
avatarURL={data.avatar_url}
|
||||
name={data.name}
|
||||
handle={data.handle}
|
||||
followerCount={data.follower_count}
|
||||
followingCount={data.following_count}
|
||||
relationship={data.relationship}
|
||||
enableActions
|
||||
interactiveCounts={false}
|
||||
joinedAt={data.joined_at}
|
||||
country={data.country}
|
||||
/>
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{youBlocked
|
||||
? `You've blocked @${data.handle}. Unblock them to see their posts.`
|
||||
: `@${data.handle}'s posts aren't available.`}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: ProfileTab[] = [
|
||||
{
|
||||
href: `/u/${data.handle}`,
|
||||
|
||||
@@ -15,14 +15,26 @@ const BFF_BASE = process.env.BFF_URL || "http://localhost:8080";
|
||||
type EmptyShape = {
|
||||
query: string;
|
||||
results: Array<{ handle: string; name: string; avatar_url: string | null }>;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
};
|
||||
|
||||
const empty = (q: string): EmptyShape => ({ query: q, results: [] });
|
||||
const empty = (q: string): EmptyShape => ({
|
||||
query: q,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total_pages: 0,
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const q = (searchParams.get("q") || "").trim();
|
||||
const limit = searchParams.get("limit") || "20";
|
||||
const page = searchParams.get("page") || "1";
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json(empty(""));
|
||||
@@ -32,6 +44,7 @@ export async function GET(request: NextRequest) {
|
||||
const url = new URL("/api/v1/users/search", BFF_BASE);
|
||||
url.searchParams.set("q", q);
|
||||
url.searchParams.set("limit", limit);
|
||||
url.searchParams.set("page", page);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
|
||||
@@ -51,6 +51,7 @@ export function ProfileHeader({
|
||||
relationship,
|
||||
enableActions = false,
|
||||
country,
|
||||
interactiveCounts = true,
|
||||
}: {
|
||||
avatarURL: string | null;
|
||||
name: string;
|
||||
@@ -75,6 +76,10 @@ export function ProfileHeader({
|
||||
* this header with key={handle} so the live count + relationship state
|
||||
* below re-seeds cleanly when navigating between profiles. */
|
||||
enableActions?: boolean;
|
||||
/** When false, the follower/following stats render as plain text (no
|
||||
* click target) and the follow-list drawer isn't mounted — used on a
|
||||
* blocked profile, where the lists endpoint is gated server-side. */
|
||||
interactiveCounts?: boolean;
|
||||
}) {
|
||||
// Owner affordances ride on `editHref`; the follow-action row rides on
|
||||
// `enableActions`. Mutually exclusive by construction.
|
||||
@@ -128,12 +133,20 @@ export function ProfileHeader({
|
||||
<Stat
|
||||
n={followers}
|
||||
label="followers"
|
||||
onClick={() => setFollowList("followers")}
|
||||
onClick={
|
||||
interactiveCounts
|
||||
? () => setFollowList("followers")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
n={following}
|
||||
label="following"
|
||||
onClick={() => setFollowList("following")}
|
||||
onClick={
|
||||
interactiveCounts
|
||||
? () => setFollowList("following")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -181,7 +194,7 @@ export function ProfileHeader({
|
||||
onClose={() => setZoomOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{showCounts ? (
|
||||
{showCounts && interactiveCounts ? (
|
||||
<FollowListDrawer
|
||||
open={followList !== null}
|
||||
mode={followList ?? "followers"}
|
||||
|
||||
Reference in New Issue
Block a user