fix: hide blocked user content
This commit is contained in:
@@ -42,6 +42,10 @@ interface UserResult {
|
|||||||
interface UserSearchResponse {
|
interface UserSearchResponse {
|
||||||
query: string;
|
query: string;
|
||||||
results: UserResult[];
|
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.
|
// User-search branch — strip the @ prefix server-side too.
|
||||||
// Clear content data so we don't flash old content results.
|
// Clear content data so we don't flash old content results.
|
||||||
setData(null);
|
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()}`, {
|
fetch(`/api/search/users?${userParams.toString()}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@@ -319,7 +327,14 @@ function SearchInner() {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err.name !== "AbortError") {
|
if (err.name !== "AbortError") {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setUserData({ query: userQuery, results: [] });
|
setUserData({
|
||||||
|
query: userQuery,
|
||||||
|
results: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
total_pages: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
@@ -540,16 +555,40 @@ function SearchInner() {
|
|||||||
userData &&
|
userData &&
|
||||||
userData.results.length > 0 && (
|
userData.results.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
{/* Match count + top pagination */}
|
||||||
{userData.results.length}{" "}
|
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
|
||||||
{userData.results.length === 1 ? "user" : "users"} matching
|
<p className="text-sm text-muted-foreground">
|
||||||
“@{userQuery}”
|
{userData.total.toLocaleString()}{" "}
|
||||||
|
{userData.total === 1 ? "user" : "users"} matching “@
|
||||||
|
{userQuery}”
|
||||||
</p>
|
</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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{userData.results.map((u) => (
|
{userData.results.map((u) => (
|
||||||
<UserResultCard key={u.handle} user={u} />
|
<UserResultCard key={u.handle} user={u} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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[] = [
|
const tabs: ProfileTab[] = [
|
||||||
{
|
{
|
||||||
href: `/u/${data.handle}`,
|
href: `/u/${data.handle}`,
|
||||||
|
|||||||
@@ -15,14 +15,26 @@ const BFF_BASE = process.env.BFF_URL || "http://localhost:8080";
|
|||||||
type EmptyShape = {
|
type EmptyShape = {
|
||||||
query: string;
|
query: string;
|
||||||
results: Array<{ handle: string; name: string; avatar_url: string | null }>;
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const q = (searchParams.get("q") || "").trim();
|
const q = (searchParams.get("q") || "").trim();
|
||||||
const limit = searchParams.get("limit") || "20";
|
const limit = searchParams.get("limit") || "20";
|
||||||
|
const page = searchParams.get("page") || "1";
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return NextResponse.json(empty(""));
|
return NextResponse.json(empty(""));
|
||||||
@@ -32,6 +44,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const url = new URL("/api/v1/users/search", BFF_BASE);
|
const url = new URL("/api/v1/users/search", BFF_BASE);
|
||||||
url.searchParams.set("q", q);
|
url.searchParams.set("q", q);
|
||||||
url.searchParams.set("limit", limit);
|
url.searchParams.set("limit", limit);
|
||||||
|
url.searchParams.set("page", page);
|
||||||
|
|
||||||
const res = await fetch(url.toString(), {
|
const res = await fetch(url.toString(), {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export function ProfileHeader({
|
|||||||
relationship,
|
relationship,
|
||||||
enableActions = false,
|
enableActions = false,
|
||||||
country,
|
country,
|
||||||
|
interactiveCounts = true,
|
||||||
}: {
|
}: {
|
||||||
avatarURL: string | null;
|
avatarURL: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +76,10 @@ export function ProfileHeader({
|
|||||||
* this header with key={handle} so the live count + relationship state
|
* this header with key={handle} so the live count + relationship state
|
||||||
* below re-seeds cleanly when navigating between profiles. */
|
* below re-seeds cleanly when navigating between profiles. */
|
||||||
enableActions?: boolean;
|
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
|
// Owner affordances ride on `editHref`; the follow-action row rides on
|
||||||
// `enableActions`. Mutually exclusive by construction.
|
// `enableActions`. Mutually exclusive by construction.
|
||||||
@@ -128,12 +133,20 @@ export function ProfileHeader({
|
|||||||
<Stat
|
<Stat
|
||||||
n={followers}
|
n={followers}
|
||||||
label="followers"
|
label="followers"
|
||||||
onClick={() => setFollowList("followers")}
|
onClick={
|
||||||
|
interactiveCounts
|
||||||
|
? () => setFollowList("followers")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
n={following}
|
n={following}
|
||||||
label="following"
|
label="following"
|
||||||
onClick={() => setFollowList("following")}
|
onClick={
|
||||||
|
interactiveCounts
|
||||||
|
? () => setFollowList("following")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -181,7 +194,7 @@ export function ProfileHeader({
|
|||||||
onClose={() => setZoomOpen(false)}
|
onClose={() => setZoomOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{showCounts ? (
|
{showCounts && interactiveCounts ? (
|
||||||
<FollowListDrawer
|
<FollowListDrawer
|
||||||
open={followList !== null}
|
open={followList !== null}
|
||||||
mode={followList ?? "followers"}
|
mode={followList ?? "followers"}
|
||||||
|
|||||||
Reference in New Issue
Block a user