diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 373385f..734ad03 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -25,6 +25,10 @@ import { IdVerificationModal } from "@/components/IdVerificationModal"; import { LocalGuidePanel } from "@/components/LocalGuidePanel"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; 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 { US_STATES, getUserState, @@ -39,6 +43,15 @@ export default function ProfilePage() { const { capabilities } = useAccess(); const { setFeedInterests } = useFeedPreferences(); const { audienceView, setAudienceView } = useAudienceView(); + const { + hideRules, + blockList, + addHideRule, + removeHideRule, + unblockProfile, + unblockByUsername, + unblockAll, + } = usePrivacy(); const [authOpen, setAuthOpen] = useState(false); const [idModalOpen, setIdModalOpen] = useState(false); const [isAnonymous, setIsAnonymous] = useState(true); @@ -54,6 +67,10 @@ export default function ProfilePage() { const [message, setMessage] = useState(""); const [community, setCommunity] = useState("gay-men"); const [seeking, setSeeking] = useState(null); + const [heightFt, setHeightFt] = useState(""); + const [heightIn, setHeightIn] = useState(""); + const [weightLb, setWeightLb] = useState(""); + const [bodyType, setBodyType] = useState(""); useEffect(() => { if (profile) { @@ -70,6 +87,12 @@ export default function ProfilePage() { setState(profile.state || getUserState() || ""); setCommunity(profile.community ?? resolveProfileCommunity(profile)); 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 { const local = localStorage.getItem("eg_profile"); setState(getUserState() || ""); @@ -86,6 +109,12 @@ export default function ProfilePage() { setStiStatus(p.stiStatus || ""); if (p.community) setCommunity(p.community); 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 { /* ignore */ } @@ -107,6 +136,12 @@ export default function ProfilePage() { 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 = { display_name: isAnonymous ? null : displayName || null, age: parseInt(age) || null, @@ -125,6 +160,9 @@ export default function ProfilePage() { city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale", lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"), lng: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373"), + height_in: heightTotal, + weight_lb: weight, + body_type: bodyType || null, }; if (user) { @@ -144,6 +182,9 @@ export default function ProfilePage() { isAnonymous: data.is_anonymous, community: data.community, 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."); @@ -212,11 +253,14 @@ export default function ProfilePage() { )} - + - Visibility + Privacy & visibility +

+ Control who can see you and who you never want to see again. +

- +
@@ -232,6 +276,19 @@ export default function ProfilePage() { />
)} + + + +
@@ -338,6 +395,62 @@ export default function ProfilePage() { /> +
+ +

+ Used for hide rules — people matching your traits won't see you if you block them by stats. +

+
+ setHeightFt(e.target.value)} + placeholder="ft" + className="h-11" + /> + setHeightIn(e.target.value)} + placeholder="in" + className="h-11" + /> +
+
+ +
+ + setWeightLb(e.target.value)} + placeholder="e.g. 180" + className="mt-1 h-11" + /> +
+ +
+ +
+ {BODY_TYPE_OPTIONS.map((b) => ( + setBodyType(bodyType === b ? "" : b)} + > + {b} + + ))} +
+
+
diff --git a/src/components/BlockListPanel.tsx b/src/components/BlockListPanel.tsx new file mode 100644 index 0000000..91d64da --- /dev/null +++ b/src/components/BlockListPanel.tsx @@ -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 ( +
+
+
+ +
+
+

Block list

+

+ Blocked people can't see you and you won't see them anywhere on ExposedGays. +

+
+
+ +
+ + 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" + /> +
+ +
+ 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()} + /> + +
+ + {message && ( +

+ {message} +

+ )} + + {filtered.length === 0 ? ( +

+ {blocks.length === 0 ? 'Nobody blocked yet.' : 'No matches for that search.'} +

+ ) : ( +
    + {filtered.map((entry) => ( +
  • +
    +

    {displayBlockedName(entry)}

    +

    + Blocked {new Date(entry.blocked_at).toLocaleDateString()} +

    +
    + +
  • + ))} +
+ )} + + {blocks.length > 0 && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/CruiserProfileView.tsx b/src/components/CruiserProfileView.tsx index 8dcd728..ce37194 100644 --- a/src/components/CruiserProfileView.tsx +++ b/src/components/CruiserProfileView.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Image from "next/image"; import { ArrowLeft, + Ban, ShieldCheck, Share2, Video, @@ -18,6 +19,7 @@ import { import { Button } from "@/components/ui/button"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { useAccess } from "@/components/Providers"; +import { usePrivacy } from "@/contexts/PrivacyContext"; import type { Profile } from "@/types"; import { formatStatsLine, formatDistance } from "@/lib/profile-stats"; import { timeAgo } from "@/lib/utils"; @@ -84,7 +86,9 @@ export function CruiserProfileView({ vanillaMode, }: CruiserProfileViewProps) { const { capabilities, blurExplicit } = useAccess(); + const { blockProfile, isBlocked, unblockProfile } = usePrivacy(); const [tab, setTab] = useState("about"); + const [blockMenuOpen, setBlockMenuOpen] = useState(false); const [activePhoto, setActivePhoto] = useState(0); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); @@ -121,9 +125,20 @@ export function CruiserProfileView({ const handleChat = () => { if (!capabilities.canSendMessages) return; + if (isBlocked(profile.id)) return; onChat(profile); }; + const handleBlock = () => { + if (isBlocked(profile.id)) { + unblockProfile(profile.id); + } else { + blockProfile(profile); + onClose(); + } + setBlockMenuOpen(false); + }; + const thumbs = photos.slice(0, 5); const openLightbox = (index: number) => { @@ -413,9 +428,28 @@ export function CruiserProfileView({ {/* Bottom bar */}
- +
+ + {blockMenuOpen && ( +
+ +
+ )} +
{timeAgo(profile.last_active)} {profile.distance_miles != null && ( diff --git a/src/components/HideFromTraitsPanel.tsx b/src/components/HideFromTraitsPanel.tsx new file mode 100644 index 0000000..ec9571e --- /dev/null +++ b/src/components/HideFromTraitsPanel.tsx @@ -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) => 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([]); + const [bodyTypes, setBodyTypes] = useState([]); + 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 ( +
+
+
+ +
+
+

Who can't see me

+

+ People who match any rule below will not see you at all — not on the map, not in chat, not in search. You still see everyone else (unless you block them). +

+
+
+ +
+ + + Example: 5'8" and 220 lb hides you from guys around that size. Add multiple rules for different types you're not into. + +
+ + {rules.length > 0 && ( +
    + {rules.map((rule) => ( +
  • + {buildHideRuleLabel(rule)} + +
  • + ))} +
+ )} + +
+

Add hide rule

+ +
+ + setHeightFt(e.target.value)} + placeholder="ft" + className="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm" + /> + setHeightIn(e.target.value)} + placeholder="in" + className="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm" + /> + ±1 in +
+ +
+ + 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" + /> +

±10 lb tolerance

+
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ +
+ {POSITIONS.map((p) => ( + + ))} +
+
+ +
+ +
+ {BODY_TYPE_OPTIONS.map((b) => ( + + ))} +
+
+ +

+ Preview: {preview} +

+ + {error &&

{error}

} + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/Map.tsx b/src/components/Map.tsx index c3402fb..d2658dc 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -15,10 +15,12 @@ import type { Profile, CruisingSpot, MapFilters, Position } from "@/types"; import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests"; import { InterestTicker } from "@/components/InterestTicker"; 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 { 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 { cn } from "@/lib/utils"; @@ -52,7 +54,15 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) { const { capabilities, blurExplicit } = useAccess(); const { feedInterests } = useFeedPreferences(); const { audienceView } = useAudienceView(); + const { user, profile: authProfile } = useAuth(); + const { blockList } = usePrivacy(); const [profiles] = useState(MOCK_PROFILES); + + const viewer = useMemo( + () => (authProfile ? authProfile : guestViewerProfile()), + [authProfile] + ); + const viewerId = user?.id ?? "guest"; const [spots] = useState(MOCK_SPOTS); const [selectedProfile, setSelectedProfile] = useState(null); const [showFilters, setShowFilters] = useState(false); @@ -141,7 +151,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) { }, []); const filteredProfiles = useMemo(() => { - return profiles.filter((p) => { + const base = profiles.filter((p) => { if (filters.onlineOnly && p.status === "offline") 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)) @@ -155,17 +165,19 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) { if (filters.status.length && !filters.status.includes(p.status)) return false; return true; }); - }, [profiles, filters, feedInterests, audienceView]); + return filterVisibleProfiles(viewer, viewerId, base, blockList); + }, [profiles, filters, feedInterests, audienceView, viewer, viewerId, blockList]); const interestMatches = useMemo(() => { if (!feedInterests.length) return []; - return profiles.filter( + const base = profiles.filter( (p) => p.status !== "offline" && profileMatchesAudienceView(p, audienceView) && profileMatchesInterests(p.kinks, feedInterests) ); - }, [profiles, feedInterests, audienceView]); + return filterVisibleProfiles(viewer, viewerId, base, blockList); + }, [profiles, feedInterests, audienceView, viewer, viewerId, blockList]); useEffect(() => { onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length); diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 1298c3a..e4b539b 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -7,6 +7,7 @@ import { MobileHeader } from "@/components/MobileHeader"; import { IdVerificationModal } from "@/components/IdVerificationModal"; import { SiteFooter } from "@/components/SiteFooter"; import { AuthProvider, useAuth } from "@/lib/auth/context"; +import { PrivacyProvider } from "@/contexts/PrivacyContext"; import { requiresIdVerification, getUserState, @@ -259,7 +260,9 @@ export function Providers({ children }: ProvidersProps) { <> {!verified && setVerified(true)} />} - {children} + + {children} + ); diff --git a/src/contexts/PrivacyContext.tsx b/src/contexts/PrivacyContext.tsx new file mode 100644 index 0000000..c11ec35 --- /dev/null +++ b/src/contexts/PrivacyContext.tsx @@ -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) => 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(null); + +export function PrivacyProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const userId = user?.id ?? null; + + const [hideRules, setHideRules] = useState([]); + const [blockList, setBlockList] = useState([]); + + 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) => { + 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 {children}; +} + +export function usePrivacy() { + const ctx = useContext(PrivacyContext); + if (!ctx) throw new Error('usePrivacy must be used within PrivacyProvider'); + return ctx; +} \ No newline at end of file diff --git a/src/lib/auth/context.tsx b/src/lib/auth/context.tsx index 1bfe97a..5a2df1d 100644 --- a/src/lib/auth/context.tsx +++ b/src/lib/auth/context.tsx @@ -54,6 +54,9 @@ function mapDbProfile(row: Record): Profile { orientation: (row.orientation as string) ?? null, community: (row.community as Profile["community"]) ?? 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, }; } diff --git a/src/lib/block-list.ts b/src/lib/block-list.ts new file mode 100644 index 0000000..a2f4b69 --- /dev/null +++ b/src/lib/block-list.ts @@ -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)}`; +} \ No newline at end of file diff --git a/src/lib/privacy-rules.ts b/src/lib/privacy-rules.ts new file mode 100644 index 0000000..f2f5cfb --- /dev/null +++ b/src/lib/privacy-rules.ts @@ -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): 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 & { 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; +} \ No newline at end of file diff --git a/src/lib/visibility.ts b/src/lib/visibility.ts new file mode 100644 index 0000000..16bae71 --- /dev/null +++ b/src/lib/visibility.ts @@ -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; + 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, + }; +} \ No newline at end of file diff --git a/supabase/migration_v5_privacy.sql b/supabase/migration_v5_privacy.sql new file mode 100644 index 0000000..bcc47f3 --- /dev/null +++ b/supabase/migration_v5_privacy.sql @@ -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); \ No newline at end of file