Add trait-based hide rules and searchable block list

This commit is contained in:
Ryan Salazar
2026-06-26 22:24:42 -04:00
parent dd075da6a4
commit 44c27c17b6
12 changed files with 1183 additions and 13 deletions

View File

@@ -25,6 +25,10 @@ import { IdVerificationModal } from "@/components/IdVerificationModal";
import { LocalGuidePanel } from "@/components/LocalGuidePanel"; import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { useAccess, useFeedPreferences, useAudienceView } from "@/components/Providers"; import { useAccess, useFeedPreferences, useAudienceView } from "@/components/Providers";
import { usePrivacy } from "@/contexts/PrivacyContext";
import HideFromTraitsPanel from "@/components/HideFromTraitsPanel";
import BlockListPanel from "@/components/BlockListPanel";
import { BODY_TYPE_OPTIONS } from "@/lib/privacy-rules";
import { import {
US_STATES, US_STATES,
getUserState, getUserState,
@@ -39,6 +43,15 @@ export default function ProfilePage() {
const { capabilities } = useAccess(); const { capabilities } = useAccess();
const { setFeedInterests } = useFeedPreferences(); const { setFeedInterests } = useFeedPreferences();
const { audienceView, setAudienceView } = useAudienceView(); const { audienceView, setAudienceView } = useAudienceView();
const {
hideRules,
blockList,
addHideRule,
removeHideRule,
unblockProfile,
unblockByUsername,
unblockAll,
} = usePrivacy();
const [authOpen, setAuthOpen] = useState(false); const [authOpen, setAuthOpen] = useState(false);
const [idModalOpen, setIdModalOpen] = useState(false); const [idModalOpen, setIdModalOpen] = useState(false);
const [isAnonymous, setIsAnonymous] = useState(true); const [isAnonymous, setIsAnonymous] = useState(true);
@@ -54,6 +67,10 @@ export default function ProfilePage() {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [community, setCommunity] = useState<AudienceCommunity>("gay-men"); const [community, setCommunity] = useState<AudienceCommunity>("gay-men");
const [seeking, setSeeking] = useState<SeekingPreference | null>(null); const [seeking, setSeeking] = useState<SeekingPreference | null>(null);
const [heightFt, setHeightFt] = useState("");
const [heightIn, setHeightIn] = useState("");
const [weightLb, setWeightLb] = useState("");
const [bodyType, setBodyType] = useState("");
useEffect(() => { useEffect(() => {
if (profile) { if (profile) {
@@ -70,6 +87,12 @@ export default function ProfilePage() {
setState(profile.state || getUserState() || ""); setState(profile.state || getUserState() || "");
setCommunity(profile.community ?? resolveProfileCommunity(profile)); setCommunity(profile.community ?? resolveProfileCommunity(profile));
setSeeking(profile.seeking ?? null); setSeeking(profile.seeking ?? null);
if (profile.height_in != null) {
setHeightFt(String(Math.floor(profile.height_in / 12)));
setHeightIn(String(profile.height_in % 12));
}
setWeightLb(profile.weight_lb?.toString() || "");
setBodyType(profile.body_type || "");
} else { } else {
const local = localStorage.getItem("eg_profile"); const local = localStorage.getItem("eg_profile");
setState(getUserState() || ""); setState(getUserState() || "");
@@ -86,6 +109,12 @@ export default function ProfilePage() {
setStiStatus(p.stiStatus || ""); setStiStatus(p.stiStatus || "");
if (p.community) setCommunity(p.community); if (p.community) setCommunity(p.community);
if (p.seeking) setSeeking(p.seeking); if (p.seeking) setSeeking(p.seeking);
if (typeof p.heightIn === "number") {
setHeightFt(String(Math.floor(p.heightIn / 12)));
setHeightIn(String(p.heightIn % 12));
}
if (typeof p.weightLb === "number") setWeightLb(String(p.weightLb));
if (typeof p.bodyType === "string") setBodyType(p.bodyType);
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -107,6 +136,12 @@ export default function ProfilePage() {
if (state) setUserState(state); if (state) setUserState(state);
const ft = parseInt(heightFt, 10);
const inches = parseInt(heightIn, 10);
const heightTotal =
!Number.isNaN(ft) && !Number.isNaN(inches) ? ft * 12 + inches : null;
const weight = weightLb.trim() ? parseInt(weightLb, 10) : null;
const data = { const data = {
display_name: isAnonymous ? null : displayName || null, display_name: isAnonymous ? null : displayName || null,
age: parseInt(age) || null, age: parseInt(age) || null,
@@ -125,6 +160,9 @@ export default function ProfilePage() {
city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale", city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale",
lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"), lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"),
lng: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373"), lng: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373"),
height_in: heightTotal,
weight_lb: weight,
body_type: bodyType || null,
}; };
if (user) { if (user) {
@@ -144,6 +182,9 @@ export default function ProfilePage() {
isAnonymous: data.is_anonymous, isAnonymous: data.is_anonymous,
community: data.community, community: data.community,
seeking: data.seeking, seeking: data.seeking,
heightIn: data.height_in,
weightLb: data.weight_lb,
bodyType: data.body_type,
}) })
); );
setMessage("Profile saved locally. Sign up free for cloud sync."); setMessage("Profile saved locally. Sign up free for cloud sync.");
@@ -212,11 +253,14 @@ export default function ProfilePage() {
</Card> </Card>
)} )}
<Card> <Card className="border-amber-500/20">
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Visibility</CardTitle> <CardTitle className="text-lg">Privacy &amp; visibility</CardTitle>
<p className="text-xs text-muted-foreground">
Control who can see you and who you never want to see again.
</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Browse anonymously</Label> <Label>Browse anonymously</Label>
<Switch checked={isAnonymous} onCheckedChange={setIsAnonymous} /> <Switch checked={isAnonymous} onCheckedChange={setIsAnonymous} />
@@ -232,6 +276,19 @@ export default function ProfilePage() {
/> />
</div> </div>
)} )}
<HideFromTraitsPanel
rules={hideRules}
onAdd={addHideRule}
onRemove={removeHideRule}
/>
<BlockListPanel
blocks={blockList}
onUnblock={unblockProfile}
onUnblockByUsername={unblockByUsername}
onUnblockAll={unblockAll}
/>
</CardContent> </CardContent>
</Card> </Card>
@@ -338,6 +395,62 @@ export default function ProfilePage() {
/> />
</div> </div>
<div>
<Label className="mb-1 block">Height</Label>
<p className="text-[10px] text-muted-foreground mb-2">
Used for hide rules people matching your traits won&apos;t see you if you block them by stats.
</p>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
min={4}
max={7}
value={heightFt}
onChange={(e) => setHeightFt(e.target.value)}
placeholder="ft"
className="h-11"
/>
<Input
type="number"
min={0}
max={11}
value={heightIn}
onChange={(e) => setHeightIn(e.target.value)}
placeholder="in"
className="h-11"
/>
</div>
</div>
<div>
<Label>Weight (lb)</Label>
<Input
type="number"
min={90}
max={400}
value={weightLb}
onChange={(e) => setWeightLb(e.target.value)}
placeholder="e.g. 180"
className="mt-1 h-11"
/>
</div>
<div>
<Label className="mb-2 block">Body type</Label>
<div className="flex flex-wrap gap-1.5">
{BODY_TYPE_OPTIONS.map((b) => (
<Badge
key={b}
variant={bodyType === b ? "pill-active" : "pill"}
className="cursor-pointer capitalize min-h-[36px] px-3 touch-manipulation"
onClick={() => setBodyType(bodyType === b ? "" : b)}
>
{b}
</Badge>
))}
</div>
</div>
<div> <div>
<Label className="mb-2 block">Position</Label> <Label className="mb-2 block">Position</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">

View File

@@ -0,0 +1,145 @@
'use client';
import { useMemo, useState } from 'react';
import { Ban, Search, UserX, Trash2 } from 'lucide-react';
import type { BlockEntry } from '@/lib/block-list';
import { displayBlockedName } from '@/lib/block-list';
interface BlockListPanelProps {
blocks: BlockEntry[];
onUnblock: (blockedUserId: string) => void;
onUnblockByUsername: (username: string) => boolean;
onUnblockAll: () => void;
}
export default function BlockListPanel({
blocks,
onUnblock,
onUnblockByUsername,
onUnblockAll,
}: BlockListPanelProps) {
const [search, setSearch] = useState('');
const [usernameInput, setUsernameInput] = useState('');
const [message, setMessage] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return blocks;
return blocks.filter(
(b) =>
b.username.toLowerCase().includes(q) ||
(b.display_name || '').toLowerCase().includes(q)
);
}, [blocks, search]);
const handleUnblockUsername = () => {
setMessage('');
const ok = onUnblockByUsername(usernameInput);
if (ok) {
setMessage(`Unblocked @${usernameInput.replace(/^@/, '')}`);
setUsernameInput('');
} else {
setMessage('No block found for that username.');
}
};
const handleUnblockAll = () => {
if (blocks.length === 0) return;
if (window.confirm(`Unblock all ${blocks.length} people?`)) {
onUnblockAll();
setMessage('Everyone unblocked.');
}
};
return (
<div className="rounded-2xl border border-zinc-700 bg-zinc-900/60 p-5 space-y-4">
<div className="flex items-start gap-3">
<div className="p-2 rounded-xl bg-red-500/15">
<Ban className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Block list</h3>
<p className="text-sm text-zinc-400 mt-1">
Blocked people can&apos;t see you and you won&apos;t see them anywhere on ExposedGays.
</p>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search blocked usernames…"
className="w-full pl-10 pr-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm"
/>
</div>
<div className="flex gap-2">
<input
type="text"
value={usernameInput}
onChange={(e) => setUsernameInput(e.target.value)}
placeholder="Unblock by @username"
className="flex-1 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleUnblockUsername()}
/>
<button
type="button"
onClick={handleUnblockUsername}
className="px-4 py-2 rounded-xl bg-zinc-700 hover:bg-zinc-600 text-white text-sm font-medium"
>
Unblock
</button>
</div>
{message && (
<p className={`text-xs ${message.includes('No block') ? 'text-amber-400' : 'text-emerald-400'}`}>
{message}
</p>
)}
{filtered.length === 0 ? (
<p className="text-sm text-zinc-500 py-4 text-center">
{blocks.length === 0 ? 'Nobody blocked yet.' : 'No matches for that search.'}
</p>
) : (
<ul className="space-y-2 max-h-64 overflow-y-auto">
{filtered.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl bg-zinc-800/80 border border-zinc-700/80"
>
<div className="min-w-0">
<p className="text-sm text-white truncate">{displayBlockedName(entry)}</p>
<p className="text-[10px] text-zinc-500">
Blocked {new Date(entry.blocked_at).toLocaleDateString()}
</p>
</div>
<button
type="button"
onClick={() => onUnblock(entry.id)}
className="shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs text-zinc-400 hover:text-white hover:bg-zinc-700"
>
<UserX className="w-3.5 h-3.5" />
Unblock
</button>
</li>
))}
</ul>
)}
{blocks.length > 0 && (
<button
type="button"
onClick={handleUnblockAll}
className="w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-red-500/30 text-red-400 hover:bg-red-500/10 text-sm"
>
<Trash2 className="w-4 h-4" />
Unblock everyone ({blocks.length})
</button>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { import {
ArrowLeft, ArrowLeft,
Ban,
ShieldCheck, ShieldCheck,
Share2, Share2,
Video, Video,
@@ -18,6 +19,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { useAccess } from "@/components/Providers"; import { useAccess } from "@/components/Providers";
import { usePrivacy } from "@/contexts/PrivacyContext";
import type { Profile } from "@/types"; import type { Profile } from "@/types";
import { formatStatsLine, formatDistance } from "@/lib/profile-stats"; import { formatStatsLine, formatDistance } from "@/lib/profile-stats";
import { timeAgo } from "@/lib/utils"; import { timeAgo } from "@/lib/utils";
@@ -84,7 +86,9 @@ export function CruiserProfileView({
vanillaMode, vanillaMode,
}: CruiserProfileViewProps) { }: CruiserProfileViewProps) {
const { capabilities, blurExplicit } = useAccess(); const { capabilities, blurExplicit } = useAccess();
const { blockProfile, isBlocked, unblockProfile } = usePrivacy();
const [tab, setTab] = useState<Tab>("about"); const [tab, setTab] = useState<Tab>("about");
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
const [activePhoto, setActivePhoto] = useState(0); const [activePhoto, setActivePhoto] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0); const [lightboxIndex, setLightboxIndex] = useState(0);
@@ -121,9 +125,20 @@ export function CruiserProfileView({
const handleChat = () => { const handleChat = () => {
if (!capabilities.canSendMessages) return; if (!capabilities.canSendMessages) return;
if (isBlocked(profile.id)) return;
onChat(profile); onChat(profile);
}; };
const handleBlock = () => {
if (isBlocked(profile.id)) {
unblockProfile(profile.id);
} else {
blockProfile(profile);
onClose();
}
setBlockMenuOpen(false);
};
const thumbs = photos.slice(0, 5); const thumbs = photos.slice(0, 5);
const openLightbox = (index: number) => { const openLightbox = (index: number) => {
@@ -413,9 +428,28 @@ export function CruiserProfileView({
{/* Bottom bar */} {/* Bottom bar */}
<div className="shrink-0 border-t border-white/10 bg-black px-4 py-3 flex items-center justify-between safe-bottom"> <div className="shrink-0 border-t border-white/10 bg-black px-4 py-3 flex items-center justify-between safe-bottom">
<button type="button" className="p-2 text-white/60" aria-label="More"> <div className="relative">
<MoreHorizontal className="h-5 w-5" /> <button
</button> type="button"
className="p-2 text-white/60 touch-manipulation"
aria-label="More"
onClick={() => setBlockMenuOpen((v) => !v)}
>
<MoreHorizontal className="h-5 w-5" />
</button>
{blockMenuOpen && (
<div className="absolute bottom-full left-0 mb-1 min-w-[160px] rounded-xl border border-white/15 bg-pulse-midnight shadow-lg overflow-hidden z-10">
<button
type="button"
onClick={handleBlock}
className="w-full flex items-center gap-2 px-4 py-3 text-sm text-left hover:bg-white/10 touch-manipulation"
>
<Ban className="h-4 w-4 text-red-400" />
{isBlocked(profile.id) ? "Unblock" : "Block user"}
</button>
</div>
)}
</div>
<div className="flex items-center gap-3 text-xs text-white/70"> <div className="flex items-center gap-3 text-xs text-white/70">
<span>{timeAgo(profile.last_active)}</span> <span>{timeAgo(profile.last_active)}</span>
{profile.distance_miles != null && ( {profile.distance_miles != null && (

View File

@@ -0,0 +1,249 @@
'use client';
import { useMemo, useState } from 'react';
import { EyeOff, Plus, Trash2, Info } from 'lucide-react';
import {
BODY_TYPE_OPTIONS,
buildHideRuleLabel,
type HideFromRule,
} from '@/lib/privacy-rules';
interface HideFromTraitsPanelProps {
rules: HideFromRule[];
onAdd: (rule: Omit<HideFromRule, 'id' | 'created_at'>) => void;
onRemove: (id: string) => void;
}
const POSITIONS = ['top', 'vers', 'bottom', 'side', 'oral', 'watching'] as const;
export default function HideFromTraitsPanel({
rules,
onAdd,
onRemove,
}: HideFromTraitsPanelProps) {
const [heightFt, setHeightFt] = useState('5');
const [heightIn, setHeightIn] = useState('8');
const [weightLb, setWeightLb] = useState('220');
const [ageMin, setAgeMin] = useState('');
const [ageMax, setAgeMax] = useState('');
const [positions, setPositions] = useState<string[]>([]);
const [bodyTypes, setBodyTypes] = useState<string[]>([]);
const [error, setError] = useState('');
const preview = useMemo(() => {
const ft = parseInt(heightFt, 10);
const inches = parseInt(heightIn, 10);
const weight = parseInt(weightLb, 10);
const parts: string[] = [];
if (!Number.isNaN(ft) && !Number.isNaN(inches)) {
parts.push(`${ft}'${inches}"`);
}
if (!Number.isNaN(weight)) parts.push(`${weight} lb`);
if (ageMin || ageMax) parts.push(`age ${ageMin || '…'}${ageMax || '…'}`);
if (positions.length) parts.push(positions.join(', '));
if (bodyTypes.length) parts.push(bodyTypes.join(', '));
return parts.length ? parts.join(' · ') : 'Add at least one trait';
}, [heightFt, heightIn, weightLb, ageMin, ageMax, positions, bodyTypes]);
const toggle = (list: string[], value: string, set: (v: string[]) => void) => {
set(list.includes(value) ? list.filter((x) => x !== value) : [...list, value]);
};
const handleAdd = () => {
setError('');
const ft = parseInt(heightFt, 10);
const inches = parseInt(heightIn, 10);
const weight = weightLb.trim() ? parseInt(weightLb, 10) : undefined;
const heightTotal =
!Number.isNaN(ft) && !Number.isNaN(inches) ? ft * 12 + inches : undefined;
const hasTrait =
heightTotal !== undefined ||
weight !== undefined ||
ageMin.trim() !== '' ||
ageMax.trim() !== '' ||
positions.length > 0 ||
bodyTypes.length > 0;
if (!hasTrait) {
setError('Pick at least one trait — height, weight, age, position, or body type.');
return;
}
onAdd({
height_in: heightTotal,
height_tolerance_in: heightTotal !== undefined ? 1 : undefined,
weight_lb: weight,
weight_tolerance_lb: weight !== undefined ? 10 : undefined,
age_min: ageMin.trim() ? parseInt(ageMin, 10) : undefined,
age_max: ageMax.trim() ? parseInt(ageMax, 10) : undefined,
positions: positions.length ? positions : undefined,
body_types: bodyTypes.length ? bodyTypes : undefined,
});
setPositions([]);
setBodyTypes([]);
setAgeMin('');
setAgeMax('');
};
return (
<div className="rounded-2xl border-2 border-amber-500/40 bg-gradient-to-br from-amber-950/30 to-zinc-900/80 p-5 space-y-4">
<div className="flex items-start gap-3">
<div className="p-2 rounded-xl bg-amber-500/20">
<EyeOff className="w-5 h-5 text-amber-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Who can&apos;t see me</h3>
<p className="text-sm text-zinc-400 mt-1 leading-relaxed">
People who match any rule below will <strong className="text-amber-200">not see you at all</strong> not on the map, not in chat, not in search. You still see everyone else (unless you block them).
</p>
</div>
</div>
<div className="flex gap-2 text-xs text-amber-200/80 bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-2">
<Info className="w-4 h-4 shrink-0 mt-0.5" />
<span>
Example: 5&apos;8&quot; and 220 lb hides you from guys around that size. Add multiple rules for different types you&apos;re not into.
</span>
</div>
{rules.length > 0 && (
<ul className="space-y-2">
{rules.map((rule) => (
<li
key={rule.id}
className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl bg-zinc-800/80 border border-zinc-700"
>
<span className="text-sm text-zinc-200">{buildHideRuleLabel(rule)}</span>
<button
type="button"
onClick={() => onRemove(rule.id)}
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10"
aria-label="Remove rule"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
<div className="space-y-3 pt-2 border-t border-zinc-700/80">
<p className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Add hide rule</p>
<div className="grid grid-cols-3 gap-2">
<label className="text-xs text-zinc-500 col-span-3">Height</label>
<input
type="number"
min={4}
max={7}
value={heightFt}
onChange={(e) => setHeightFt(e.target.value)}
placeholder="ft"
className="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
/>
<input
type="number"
min={0}
max={11}
value={heightIn}
onChange={(e) => setHeightIn(e.target.value)}
placeholder="in"
className="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
/>
<span className="self-center text-xs text-zinc-500">±1 in</span>
</div>
<div>
<label className="text-xs text-zinc-500">Weight (lb)</label>
<input
type="number"
min={90}
max={400}
value={weightLb}
onChange={(e) => setWeightLb(e.target.value)}
className="w-full mt-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
/>
<p className="text-[10px] text-zinc-600 mt-0.5">±10 lb tolerance</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-zinc-500">Age min</label>
<input
type="number"
value={ageMin}
onChange={(e) => setAgeMin(e.target.value)}
className="w-full mt-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
/>
</div>
<div>
<label className="text-xs text-zinc-500">Age max</label>
<input
type="number"
value={ageMax}
onChange={(e) => setAgeMax(e.target.value)}
className="w-full mt-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-zinc-500 mb-1.5 block">Position</label>
<div className="flex flex-wrap gap-1.5">
{POSITIONS.map((p) => (
<button
key={p}
type="button"
onClick={() => toggle(positions, p, setPositions)}
className={`px-2.5 py-1 rounded-full text-xs capitalize ${
positions.includes(p)
? 'bg-amber-500/30 text-amber-200 border border-amber-500/50'
: 'bg-zinc-800 text-zinc-400 border border-zinc-700'
}`}
>
{p}
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-zinc-500 mb-1.5 block">Body type</label>
<div className="flex flex-wrap gap-1.5">
{BODY_TYPE_OPTIONS.map((b) => (
<button
key={b}
type="button"
onClick={() => toggle(bodyTypes, b, setBodyTypes)}
className={`px-2.5 py-1 rounded-full text-xs capitalize ${
bodyTypes.includes(b)
? 'bg-amber-500/30 text-amber-200 border border-amber-500/50'
: 'bg-zinc-800 text-zinc-400 border border-zinc-700'
}`}
>
{b}
</button>
))}
</div>
</div>
<p className="text-xs text-zinc-500">
Preview: <span className="text-zinc-300">{preview}</span>
</p>
{error && <p className="text-xs text-red-400">{error}</p>}
<button
type="button"
onClick={handleAdd}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-amber-600 hover:bg-amber-500 text-white text-sm font-semibold"
>
<Plus className="w-4 h-4" />
Add hide rule
</button>
</div>
</div>
);
}

View File

@@ -15,10 +15,12 @@ import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests"; import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests";
import { InterestTicker } from "@/components/InterestTicker"; import { InterestTicker } from "@/components/InterestTicker";
import { InterestPreviewRail } from "@/components/InterestPreviewRail"; import { InterestPreviewRail } from "@/components/InterestPreviewRail";
import { useFeedPreferences, useAudienceView } from "@/components/Providers"; import { useFeedPreferences, useAudienceView, useOpenIdVerification, useAccess } from "@/components/Providers";
import { profileMatchesAudienceView } from "@/lib/audience"; import { profileMatchesAudienceView } from "@/lib/audience";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data"; import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import { useOpenIdVerification, useAccess } from "@/components/Providers"; import { useAuth } from "@/lib/auth/context";
import { usePrivacy } from "@/contexts/PrivacyContext";
import { filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
import { distanceMiles } from "@/lib/profile-stats"; import { distanceMiles } from "@/lib/profile-stats";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -52,7 +54,15 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const { capabilities, blurExplicit } = useAccess(); const { capabilities, blurExplicit } = useAccess();
const { feedInterests } = useFeedPreferences(); const { feedInterests } = useFeedPreferences();
const { audienceView } = useAudienceView(); const { audienceView } = useAudienceView();
const { user, profile: authProfile } = useAuth();
const { blockList } = usePrivacy();
const [profiles] = useState<Profile[]>(MOCK_PROFILES); const [profiles] = useState<Profile[]>(MOCK_PROFILES);
const viewer = useMemo(
() => (authProfile ? authProfile : guestViewerProfile()),
[authProfile]
);
const viewerId = user?.id ?? "guest";
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS); const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null); const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
@@ -141,7 +151,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
}, []); }, []);
const filteredProfiles = useMemo(() => { const filteredProfiles = useMemo(() => {
return profiles.filter((p) => { const base = profiles.filter((p) => {
if (filters.onlineOnly && p.status === "offline") return false; if (filters.onlineOnly && p.status === "offline") return false;
if (p.age && (p.age < filters.ageMin || p.age > filters.ageMax)) return false; if (p.age && (p.age < filters.ageMin || p.age > filters.ageMax)) return false;
if (filters.positions.length && p.position && !filters.positions.includes(p.position)) if (filters.positions.length && p.position && !filters.positions.includes(p.position))
@@ -155,17 +165,19 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
if (filters.status.length && !filters.status.includes(p.status)) return false; if (filters.status.length && !filters.status.includes(p.status)) return false;
return true; return true;
}); });
}, [profiles, filters, feedInterests, audienceView]); return filterVisibleProfiles(viewer, viewerId, base, blockList);
}, [profiles, filters, feedInterests, audienceView, viewer, viewerId, blockList]);
const interestMatches = useMemo(() => { const interestMatches = useMemo(() => {
if (!feedInterests.length) return []; if (!feedInterests.length) return [];
return profiles.filter( const base = profiles.filter(
(p) => (p) =>
p.status !== "offline" && p.status !== "offline" &&
profileMatchesAudienceView(p, audienceView) && profileMatchesAudienceView(p, audienceView) &&
profileMatchesInterests(p.kinks, feedInterests) profileMatchesInterests(p.kinks, feedInterests)
); );
}, [profiles, feedInterests, audienceView]); return filterVisibleProfiles(viewer, viewerId, base, blockList);
}, [profiles, feedInterests, audienceView, viewer, viewerId, blockList]);
useEffect(() => { useEffect(() => {
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length); onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);

View File

@@ -7,6 +7,7 @@ import { MobileHeader } from "@/components/MobileHeader";
import { IdVerificationModal } from "@/components/IdVerificationModal"; import { IdVerificationModal } from "@/components/IdVerificationModal";
import { SiteFooter } from "@/components/SiteFooter"; import { SiteFooter } from "@/components/SiteFooter";
import { AuthProvider, useAuth } from "@/lib/auth/context"; import { AuthProvider, useAuth } from "@/lib/auth/context";
import { PrivacyProvider } from "@/contexts/PrivacyContext";
import { import {
requiresIdVerification, requiresIdVerification,
getUserState, getUserState,
@@ -259,7 +260,9 @@ export function Providers({ children }: ProvidersProps) {
<> <>
{!verified && <AgeGate onVerified={() => setVerified(true)} />} {!verified && <AgeGate onVerified={() => setVerified(true)} />}
<AuthProvider> <AuthProvider>
<InnerProviders>{children}</InnerProviders> <PrivacyProvider>
<InnerProviders>{children}</InnerProviders>
</PrivacyProvider>
</AuthProvider> </AuthProvider>
</> </>
); );

View File

@@ -0,0 +1,155 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useAuth } from '@/lib/auth/context';
import {
addHideFromRule,
loadHideFromRules,
removeHideFromRule,
type HideFromRule,
} from '@/lib/privacy-rules';
import {
blockProfile as blockProfileLib,
loadBlockList,
unblockAll as unblockAllLib,
unblockByUsername as unblockByUsernameLib,
unblockProfile as unblockProfileLib,
type BlockEntry,
} from '@/lib/block-list';
import type { Profile } from '@/types';
interface PrivacyContextValue {
hideRules: HideFromRule[];
blockList: BlockEntry[];
addHideRule: (rule: Omit<HideFromRule, 'id' | 'created_at' | 'label'>) => void;
removeHideRule: (id: string) => void;
blockProfile: (profile: Profile) => void;
unblockProfile: (blockedUserId: string) => void;
unblockByUsername: (username: string) => boolean;
unblockAll: () => void;
isBlocked: (userId: string) => boolean;
refresh: () => void;
}
const PrivacyContext = createContext<PrivacyContextValue | null>(null);
export function PrivacyProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const userId = user?.id ?? null;
const [hideRules, setHideRules] = useState<HideFromRule[]>([]);
const [blockList, setBlockList] = useState<BlockEntry[]>([]);
const refresh = useCallback(() => {
setHideRules(loadHideFromRules(userId));
setBlockList(loadBlockList(userId));
}, [userId]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
const onHide = () => refresh();
const onBlock = () => refresh();
window.addEventListener('eg-hide-rules', onHide);
window.addEventListener('eg-block-list', onBlock);
return () => {
window.removeEventListener('eg-hide-rules', onHide);
window.removeEventListener('eg-block-list', onBlock);
};
}, [refresh]);
const addHideRule = useCallback(
(rule: Omit<HideFromRule, 'id' | 'created_at' | 'label'>) => {
const next = addHideFromRule(rule, userId);
setHideRules(next);
},
[userId]
);
const removeHideRule = useCallback(
(id: string) => {
const next = removeHideFromRule(id, userId);
setHideRules(next);
},
[userId]
);
const blockProfile = useCallback(
(profile: Profile) => {
const next = blockProfileLib(profile, userId);
setBlockList(next);
},
[userId]
);
const unblockProfile = useCallback(
(blockedUserId: string) => {
const next = unblockProfileLib(blockedUserId, userId);
setBlockList(next);
},
[userId]
);
const unblockByUsername = useCallback(
(username: string) => {
const { blocks, removed } = unblockByUsernameLib(username, userId);
if (removed) setBlockList(blocks);
return !!removed;
},
[userId]
);
const unblockAll = useCallback(() => {
setBlockList(unblockAllLib(userId));
}, [userId]);
const isBlocked = useCallback(
(targetId: string) => blockList.some((b) => b.id === targetId),
[blockList]
);
const value = useMemo(
() => ({
hideRules,
blockList,
addHideRule,
removeHideRule,
blockProfile,
unblockProfile,
unblockByUsername,
unblockAll,
isBlocked,
refresh,
}),
[
hideRules,
blockList,
addHideRule,
removeHideRule,
blockProfile,
unblockProfile,
unblockByUsername,
unblockAll,
isBlocked,
refresh,
]
);
return <PrivacyContext.Provider value={value}>{children}</PrivacyContext.Provider>;
}
export function usePrivacy() {
const ctx = useContext(PrivacyContext);
if (!ctx) throw new Error('usePrivacy must be used within PrivacyProvider');
return ctx;
}

View File

@@ -54,6 +54,9 @@ function mapDbProfile(row: Record<string, unknown>): Profile {
orientation: (row.orientation as string) ?? null, orientation: (row.orientation as string) ?? null,
community: (row.community as Profile["community"]) ?? null, community: (row.community as Profile["community"]) ?? null,
seeking: (row.seeking as Profile["seeking"]) ?? null, seeking: (row.seeking as Profile["seeking"]) ?? null,
height_in: (row.height_in as number) ?? null,
weight_lb: (row.weight_lb as number) ?? null,
body_type: (row.body_type as string) ?? null,
}; };
} }

112
src/lib/block-list.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { Profile } from "@/types";
const LS_BLOCKS = "eg_block_list";
const LS_BLOCKS_USER = (userId: string) => `eg_block_list_${userId}`;
export interface BlockEntry {
id: string;
username: string | null;
display_name: string | null;
avatar_url: string | null;
blocked_at: string;
}
export function profileToBlockEntry(p: Profile): BlockEntry {
return {
id: p.id,
username: p.username,
display_name: p.display_name,
avatar_url: p.avatar_url,
blocked_at: new Date().toISOString(),
};
}
export function loadBlockList(userId?: string | null): BlockEntry[] {
if (typeof window === "undefined") return [];
try {
const key = userId ? LS_BLOCKS_USER(userId) : LS_BLOCKS;
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw) as BlockEntry[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveBlockList(blocks: BlockEntry[], userId?: string | null): void {
if (typeof window === "undefined") return;
const key = userId ? LS_BLOCKS_USER(userId) : LS_BLOCKS;
localStorage.setItem(key, JSON.stringify(blocks));
window.dispatchEvent(new CustomEvent("eg-block-list", { detail: blocks }));
}
export function isBlocked(
profileId: string,
blocks: BlockEntry[]
): boolean {
return blocks.some((b) => b.id === profileId);
}
export function blockProfile(
profile: Profile,
userId?: string | null
): BlockEntry[] {
const current = loadBlockList(userId);
if (current.some((b) => b.id === profile.id)) return current;
const next = [profileToBlockEntry(profile), ...current];
saveBlockList(next, userId);
return next;
}
export function unblockProfile(
profileId: string,
userId?: string | null
): BlockEntry[] {
const next = loadBlockList(userId).filter((b) => b.id !== profileId);
saveBlockList(next, userId);
return next;
}
export function unblockByUsername(
query: string,
userId?: string | null
): { blocks: BlockEntry[]; removed: BlockEntry | null } {
const q = query.trim().toLowerCase();
if (!q) return { blocks: loadBlockList(userId), removed: null };
const current = loadBlockList(userId);
const match = current.find(
(b) =>
b.username?.toLowerCase() === q ||
b.display_name?.toLowerCase() === q
);
if (!match) return { blocks: current, removed: null };
const next = current.filter((b) => b.id !== match.id);
saveBlockList(next, userId);
return { blocks: next, removed: match };
}
export function unblockAll(userId?: string | null): BlockEntry[] {
saveBlockList([], userId);
return [];
}
export function searchBlockList(
blocks: BlockEntry[],
query: string
): BlockEntry[] {
const q = query.trim().toLowerCase();
if (!q) return blocks;
return blocks.filter(
(b) =>
b.username?.toLowerCase().includes(q) ||
b.display_name?.toLowerCase().includes(q) ||
b.id.toLowerCase().includes(q)
);
}
export function displayBlockedName(entry: BlockEntry): string {
if (entry.username) return `@${entry.username}`;
if (entry.display_name) return entry.display_name;
return `User ${entry.id.slice(0, 8)}`;
}

175
src/lib/privacy-rules.ts Normal file
View File

@@ -0,0 +1,175 @@
import type { AudienceCommunity, Position, Profile } from "@/types";
const LS_HIDE_RULES = "eg_hide_from_rules";
const LS_HIDE_RULES_USER = (userId: string) => `eg_hide_from_rules_${userId}`;
/** People matching ALL set traits in a rule cannot see you on the map, chat, or search. */
export interface HideFromRule {
id: string;
created_at: string;
/** Human-readable summary shown in the UI */
label: string;
height_in?: number | null;
height_tolerance_in?: number;
weight_lb?: number | null;
weight_tolerance_lb?: number;
age_min?: number | null;
age_max?: number | null;
positions?: Position[];
body_types?: string[];
kinks?: string[];
communities?: AudienceCommunity[];
}
export const BODY_TYPE_OPTIONS = [
"slim",
"fit",
"average",
"stocky",
"bear",
"muscular",
"chubby",
] as const;
export function formatHeight(inches: number): string {
const ft = Math.floor(inches / 12);
const inch = inches % 12;
return `${ft}'${inch}"`;
}
export function parseHeightFeetInches(feet: number, inches: number): number {
return Math.max(48, Math.min(96, feet * 12 + inches));
}
export function buildHideRuleLabel(draft: Partial<HideFromRule>): string {
const parts: string[] = [];
if (draft.height_in != null) {
const tol = draft.height_tolerance_in ?? 0;
parts.push(
tol > 0
? `${formatHeight(draft.height_in)}${tol}")`
: formatHeight(draft.height_in)
);
}
if (draft.weight_lb != null) {
const tol = draft.weight_tolerance_lb ?? 5;
parts.push(
tol > 0 ? `${draft.weight_lb} lb (±${tol})` : `${draft.weight_lb} lb`
);
}
if (draft.age_min != null || draft.age_max != null) {
const min = draft.age_min ?? 18;
const max = draft.age_max ?? 99;
parts.push(`age ${min}${max}`);
}
if (draft.positions?.length) parts.push(draft.positions.join("/"));
if (draft.body_types?.length) parts.push(draft.body_types.join(", "));
if (draft.kinks?.length) parts.push(draft.kinks.slice(0, 3).join(", "));
if (draft.communities?.length) parts.push(draft.communities.join(", "));
return parts.length ? parts.join(" · ") : "No traits selected";
}
export function ruleHasCriteria(rule: HideFromRule): boolean {
return (
rule.height_in != null ||
rule.weight_lb != null ||
rule.age_min != null ||
rule.age_max != null ||
(rule.positions?.length ?? 0) > 0 ||
(rule.body_types?.length ?? 0) > 0 ||
(rule.kinks?.length ?? 0) > 0 ||
(rule.communities?.length ?? 0) > 0
);
}
/** Viewer matches rule when every specified trait on the rule matches the viewer profile. */
export function viewerMatchesHideRule(viewer: Profile, rule: HideFromRule): boolean {
if (!ruleHasCriteria(rule)) return false;
if (rule.height_in != null) {
if (viewer.height_in == null) return false;
const tol = rule.height_tolerance_in ?? 0;
if (Math.abs(viewer.height_in - rule.height_in) > tol) return false;
}
if (rule.weight_lb != null) {
if (viewer.weight_lb == null) return false;
const tol = rule.weight_tolerance_lb ?? 5;
if (Math.abs(viewer.weight_lb - rule.weight_lb) > tol) return false;
}
if (rule.age_min != null || rule.age_max != null) {
if (viewer.age == null) return false;
const min = rule.age_min ?? 18;
const max = rule.age_max ?? 99;
if (viewer.age < min || viewer.age > max) return false;
}
if (rule.positions?.length) {
if (!viewer.position || !rule.positions.includes(viewer.position)) return false;
}
if (rule.body_types?.length) {
if (!viewer.body_type || !rule.body_types.includes(viewer.body_type)) return false;
}
if (rule.kinks?.length) {
if (!rule.kinks.some((k) => viewer.kinks.includes(k))) return false;
}
if (rule.communities?.length) {
const community = viewer.community ?? (viewer.orientation === "gay" ? "gay-men" : null);
if (!community || !rule.communities.includes(community)) return false;
}
return true;
}
export function viewerHiddenByRules(
viewer: Profile,
rules: HideFromRule[]
): boolean {
return rules.some((r) => viewerMatchesHideRule(viewer, r));
}
export function loadHideFromRules(userId?: string | null): HideFromRule[] {
if (typeof window === "undefined") return [];
try {
const key = userId ? LS_HIDE_RULES_USER(userId) : LS_HIDE_RULES;
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw) as HideFromRule[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveHideFromRules(rules: HideFromRule[], userId?: string | null): void {
if (typeof window === "undefined") return;
const key = userId ? LS_HIDE_RULES_USER(userId) : LS_HIDE_RULES;
localStorage.setItem(key, JSON.stringify(rules));
window.dispatchEvent(new CustomEvent("eg-hide-rules", { detail: rules }));
}
export function addHideFromRule(
draft: Omit<HideFromRule, "id" | "created_at" | "label"> & { label?: string },
userId?: string | null
): HideFromRule[] {
const label = draft.label ?? buildHideRuleLabel(draft);
const rule: HideFromRule = {
...draft,
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
label,
};
const next = [...loadHideFromRules(userId), rule];
saveHideFromRules(next, userId);
return next;
}
export function removeHideFromRule(id: string, userId?: string | null): HideFromRule[] {
const next = loadHideFromRules(userId).filter((r) => r.id !== id);
saveHideFromRules(next, userId);
return next;
}

121
src/lib/visibility.ts Normal file
View File

@@ -0,0 +1,121 @@
import type { Profile } from "@/types";
import type { BlockEntry } from "@/lib/block-list";
import { isBlocked, loadBlockList } from "@/lib/block-list";
import type { HideFromRule } from "@/lib/privacy-rules";
import { loadHideFromRules, viewerHiddenByRules } from "@/lib/privacy-rules";
export interface VisibilityContext {
/** The browsing user's profile (or guest stand-in). */
viewer: Profile | null;
viewerId: string | null;
/** My hide-from rules — people matching these cannot see me. */
myHideRules: HideFromRule[];
/** Profiles I have blocked. */
myBlockList: BlockEntry[];
}
/**
* Can `viewer` see `target` on map / chat / rails?
* - Block is mutual: either party blocked the other → hidden
* - Hide rules on target: if viewer matches target's hide rules, viewer cannot see target
*/
export function canViewerSeeTarget(
viewer: Profile | null,
viewerId: string | null,
target: Profile,
myHideRules: HideFromRule[],
myBlockList: BlockEntry[],
targetBlockList: BlockEntry[] = []
): boolean {
const effectiveViewerId = viewerId ?? viewer?.id ?? null;
if (effectiveViewerId === target.id) return true;
if (isBlocked(target.id, myBlockList)) return false;
if (effectiveViewerId && isBlocked(effectiveViewerId, targetBlockList)) return false;
if (viewer && viewerHiddenByRules(viewer, myHideRules)) return false;
return true;
}
/** Filter targets visible to the current viewer (blocks + per-target hide rules). */
export function filterVisibleProfiles(
viewer: Profile | null,
viewerId: string | null,
targets: Profile[],
myBlockList: BlockEntry[]
): Profile[] {
return targets.filter((target) => {
const targetRules = loadHideFromRules(target.id);
const targetBlocks = loadBlockList(target.id);
return canViewerSeeTarget(
viewer,
viewerId,
target,
targetRules,
myBlockList,
targetBlocks
);
});
}
/** Build a minimal guest viewer profile from local saved stats. */
export function guestViewerProfile(): Profile {
const now = new Date().toISOString();
let height_in: number | null = null;
let weight_lb: number | null = null;
let age: number | null = null;
let position: Profile["position"] = null;
let body_type: string | null = null;
let kinks: string[] = [];
let community: Profile["community"] = "gay-men";
try {
const raw = localStorage.getItem("eg_profile");
if (raw) {
const p = JSON.parse(raw) as Record<string, unknown>;
if (typeof p.height_in === "number") height_in = p.height_in;
else if (typeof p.heightIn === "number") height_in = p.heightIn;
if (typeof p.weight_lb === "number") weight_lb = p.weight_lb;
else if (typeof p.weightLb === "number") weight_lb = p.weightLb;
if (typeof p.age === "number") age = p.age;
if (typeof p.body_type === "string") body_type = p.body_type;
else if (typeof p.bodyType === "string") body_type = p.bodyType;
if (p.position) position = p.position as Profile["position"];
if (typeof p.body_type === "string") body_type = p.body_type;
if (Array.isArray(p.kinks)) kinks = p.kinks as string[];
if (p.community) community = p.community as Profile["community"];
}
} catch {
/* ignore */
}
return {
id: "guest",
username: null,
display_name: null,
age,
position,
bio: null,
avatar_url: null,
status: "online",
is_anonymous: true,
kinks,
sti_status: null,
on_prep: false,
last_active: now,
lat: null,
lng: null,
city: null,
state: null,
id_verified: false,
id_verified_at: null,
vanilla_mode: false,
created_at: now,
height_in,
weight_lb,
body_type,
community,
};
}

View File

@@ -0,0 +1,48 @@
-- ExposedGays privacy: trait hide rules + block list (cloud sync)
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS height_in integer,
ADD COLUMN IF NOT EXISTS weight_lb integer,
ADD COLUMN IF NOT EXISTS body_type text;
CREATE TABLE IF NOT EXISTS hide_from_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
label text NOT NULL DEFAULT '',
height_in integer,
height_tolerance_in integer DEFAULT 1,
weight_lb integer,
weight_tolerance_lb integer DEFAULT 10,
age_min integer,
age_max integer,
positions text[] DEFAULT '{}',
body_types text[] DEFAULT '{}',
kinks text[] DEFAULT '{}',
communities text[] DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_hide_from_rules_user ON hide_from_rules(user_id);
CREATE TABLE IF NOT EXISTS user_blocks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
blocker_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
blocked_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
blocked_username text,
blocked_display_name text,
blocked_avatar_url text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (blocker_id, blocked_id)
);
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocker ON user_blocks(blocker_id);
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_id);
ALTER TABLE hide_from_rules ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_blocks ENABLE ROW LEVEL SECURITY;
CREATE POLICY hide_from_rules_own ON hide_from_rules
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY user_blocks_own ON user_blocks
FOR ALL USING (auth.uid() = blocker_id);