@@ -139,12 +170,21 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
- {dmUserId ? `Chat with User #${dmUserId}` : "Private Messages"}
+ {dmTarget
+ ? dmTarget.display_name || dmTarget.username || `User #${dmUserId}`
+ : dmUserId
+ ? `Chat with User #${dmUserId}`
+ : "Private Messages"}
Use quick replies for address, directions & hosting notes
+ {!dmAllowed && dmBlockNote && (
+
+ {dmBlockNote}
+
+ )}
diff --git a/src/components/CruiserProfileView.tsx b/src/components/CruiserProfileView.tsx
index ce37194..1e22115 100644
--- a/src/components/CruiserProfileView.tsx
+++ b/src/components/CruiserProfileView.tsx
@@ -20,6 +20,11 @@ import { Button } from "@/components/ui/button";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { useAccess } from "@/components/Providers";
import { usePrivacy } from "@/contexts/PrivacyContext";
+import { useAuth } from "@/lib/auth/context";
+import { canViewerMessageTarget, guestViewerProfile } from "@/lib/visibility";
+import { loadHideFromRules } from "@/lib/privacy-rules";
+import { loadBlockList } from "@/lib/block-list";
+import { loadPrivacySettings, messageBlockReason } from "@/lib/privacy-settings";
import type { Profile } from "@/types";
import { formatStatsLine, formatDistance } from "@/lib/profile-stats";
import { timeAgo } from "@/lib/utils";
@@ -86,7 +91,8 @@ export function CruiserProfileView({
vanillaMode,
}: CruiserProfileViewProps) {
const { capabilities, blurExplicit } = useAccess();
- const { blockProfile, isBlocked, unblockProfile } = usePrivacy();
+ const { user, profile: authProfile } = useAuth();
+ const { blockProfile, isBlocked, unblockProfile, blockList } = usePrivacy();
const [tab, setTab] = useState
("about");
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
const [activePhoto, setActivePhoto] = useState(0);
@@ -123,9 +129,25 @@ export function CruiserProfileView({
const shouldBlurPhoto = (explicit: boolean) =>
vanillaMode || blurExplicit || (explicit && !capabilities.canViewExplicit);
+ const viewer = authProfile ?? guestViewerProfile();
+ const viewerId = user?.id ?? "guest";
+ const canMessage = canViewerMessageTarget(
+ viewer,
+ viewerId,
+ profile,
+ loadHideFromRules(profile.id),
+ blockList,
+ loadBlockList(profile.id)
+ );
+ const messageBlockedNote = messageBlockReason(
+ viewer,
+ loadPrivacySettings(profile.id)
+ );
+
const handleChat = () => {
if (!capabilities.canSendMessages) return;
if (isBlocked(profile.id)) return;
+ if (!canMessage) return;
onChat(profile);
};
@@ -464,8 +486,14 @@ export function CruiserProfileView({
size="icon"
className="rounded-full h-11 w-11 shrink-0"
onClick={handleChat}
- disabled={!capabilities.canSendMessages}
- title={capabilities.canSendMessages ? "Chat" : "Sign up to chat"}
+ disabled={!capabilities.canSendMessages || !canMessage || isBlocked(profile.id)}
+ title={
+ !capabilities.canSendMessages
+ ? "Sign up to chat"
+ : !canMessage && messageBlockedNote
+ ? messageBlockedNote
+ : "Chat"
+ }
>
diff --git a/src/components/Map.tsx b/src/components/Map.tsx
index d2658dc..63cc3cd 100644
--- a/src/components/Map.tsx
+++ b/src/components/Map.tsx
@@ -20,7 +20,9 @@ import { profileMatchesAudienceView } from "@/lib/audience";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import { useAuth } from "@/lib/auth/context";
import { usePrivacy } from "@/contexts/PrivacyContext";
-import { filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
+import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
+import { loadHideFromRules } from "@/lib/privacy-rules";
+import { loadBlockList } from "@/lib/block-list";
import { distanceMiles } from "@/lib/profile-stats";
import { cn } from "@/lib/utils";
@@ -183,10 +185,22 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
}, [filteredProfiles, onActiveCountChange]);
- const handleChat = useCallback((profile: Profile) => {
- if (!capabilities.canSendMessages) return;
- window.location.href = `/chat?user=${profile.id}`;
- }, [capabilities.canSendMessages]);
+ const handleChat = useCallback(
+ (profile: Profile) => {
+ if (!capabilities.canSendMessages) return;
+ const allowed = canViewerMessageTarget(
+ viewer,
+ viewerId,
+ profile,
+ loadHideFromRules(profile.id),
+ blockList,
+ loadBlockList(profile.id)
+ );
+ if (!allowed) return;
+ window.location.href = `/chat?user=${profile.id}`;
+ },
+ [capabilities.canSendMessages, viewer, viewerId, blockList]
+ );
const togglePosition = (pos: Position) => {
setFilters((f) => ({
diff --git a/src/components/PrivacyAudienceToggles.tsx b/src/components/PrivacyAudienceToggles.tsx
new file mode 100644
index 0000000..6b1484f
--- /dev/null
+++ b/src/components/PrivacyAudienceToggles.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { Ghost, CameraOff } from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import { Label } from '@/components/ui/label';
+import type { PrivacySettings } from '@/lib/privacy-settings';
+
+interface PrivacyAudienceTogglesProps {
+ settings: PrivacySettings;
+ onChange: (settings: PrivacySettings) => void;
+}
+
+function ToggleRow({
+ title,
+ description,
+ hideLabel,
+ messageLabel,
+ hideOn,
+ messagesOn,
+ onHideChange,
+ onMessagesChange,
+}: {
+ title: string;
+ description: string;
+ hideLabel: string;
+ messageLabel: string;
+ hideOn: boolean;
+ messagesOn: boolean;
+ onHideChange: (v: boolean) => void;
+ onMessagesChange: (v: boolean) => void;
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function PrivacyAudienceToggles({
+ settings,
+ onChange,
+}: PrivacyAudienceTogglesProps) {
+ const patch = (partial: Partial) =>
+ onChange({ ...settings, ...partial });
+
+ return (
+
+
+ Audience filters
+
+
+
patch({ hide_from_anonymous: v })}
+ onMessagesChange={(v) => patch({ block_messages_from_anonymous: v })}
+ />
+
+
+
+ Applies to guests and anyone with "Browse anonymously" turned on.
+
+
+ patch({ hide_from_no_photo: v })}
+ onMessagesChange={(v) => patch({ block_messages_from_no_photo: v })}
+ />
+
+
+
+ Upload at least one photo in your gallery so others know you're not in this group.
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/contexts/PrivacyContext.tsx b/src/contexts/PrivacyContext.tsx
index c11ec35..e31fd42 100644
--- a/src/contexts/PrivacyContext.tsx
+++ b/src/contexts/PrivacyContext.tsx
@@ -25,10 +25,18 @@ import {
type BlockEntry,
} from '@/lib/block-list';
import type { Profile } from '@/types';
+import {
+ DEFAULT_PRIVACY_SETTINGS,
+ loadPrivacySettings,
+ savePrivacySettings,
+ type PrivacySettings,
+} from '@/lib/privacy-settings';
interface PrivacyContextValue {
hideRules: HideFromRule[];
blockList: BlockEntry[];
+ privacySettings: PrivacySettings;
+ setPrivacySettings: (settings: PrivacySettings) => void;
addHideRule: (rule: Omit) => void;
removeHideRule: (id: string) => void;
blockProfile: (profile: Profile) => void;
@@ -47,10 +55,14 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
const [hideRules, setHideRules] = useState([]);
const [blockList, setBlockList] = useState([]);
+ const [privacySettings, setPrivacySettingsState] = useState(
+ DEFAULT_PRIVACY_SETTINGS
+ );
const refresh = useCallback(() => {
setHideRules(loadHideFromRules(userId));
setBlockList(loadBlockList(userId));
+ setPrivacySettingsState(loadPrivacySettings(userId));
}, [userId]);
useEffect(() => {
@@ -60,14 +72,25 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const onHide = () => refresh();
const onBlock = () => refresh();
+ const onSettings = () => refresh();
window.addEventListener('eg-hide-rules', onHide);
window.addEventListener('eg-block-list', onBlock);
+ window.addEventListener('eg-privacy-settings', onSettings);
return () => {
window.removeEventListener('eg-hide-rules', onHide);
window.removeEventListener('eg-block-list', onBlock);
+ window.removeEventListener('eg-privacy-settings', onSettings);
};
}, [refresh]);
+ const setPrivacySettings = useCallback(
+ (settings: PrivacySettings) => {
+ savePrivacySettings(settings, userId);
+ setPrivacySettingsState(settings);
+ },
+ [userId]
+ );
+
const addHideRule = useCallback(
(rule: Omit) => {
const next = addHideFromRule(rule, userId);
@@ -122,6 +145,8 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
() => ({
hideRules,
blockList,
+ privacySettings,
+ setPrivacySettings,
addHideRule,
removeHideRule,
blockProfile,
@@ -134,6 +159,8 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
[
hideRules,
blockList,
+ privacySettings,
+ setPrivacySettings,
addHideRule,
removeHideRule,
blockProfile,
diff --git a/src/lib/privacy-settings.ts b/src/lib/privacy-settings.ts
new file mode 100644
index 0000000..960724b
--- /dev/null
+++ b/src/lib/privacy-settings.ts
@@ -0,0 +1,91 @@
+import type { Profile } from '@/types';
+
+const LS_SETTINGS = 'eg_privacy_settings';
+const LS_SETTINGS_USER = (userId: string) => `eg_privacy_settings_${userId}`;
+
+export interface PrivacySettings {
+ /** Anonymous / unnamed browsers cannot see you on map, search, or rails. */
+ hide_from_anonymous: boolean;
+ /** Anonymous users cannot send you DMs. */
+ block_messages_from_anonymous: boolean;
+ /** Profiles with no real photo cannot see you. */
+ hide_from_no_photo: boolean;
+ /** Profiles with no real photo cannot message you. */
+ block_messages_from_no_photo: boolean;
+}
+
+export const DEFAULT_PRIVACY_SETTINGS: PrivacySettings = {
+ hide_from_anonymous: false,
+ block_messages_from_anonymous: false,
+ hide_from_no_photo: false,
+ block_messages_from_no_photo: false,
+};
+
+const PLACEHOLDER_HINTS = ['placehold.co', 'text=?', 'text=%3f', 'text=%3F'];
+
+export function profileHasPhoto(profile: Profile): boolean {
+ if (profile.gallery?.some((g) => g.url?.trim())) return true;
+ const url = profile.avatar_url?.trim();
+ if (!url) return false;
+ const lower = url.toLowerCase();
+ return !PLACEHOLDER_HINTS.some((hint) => lower.includes(hint));
+}
+
+/** Guest browse + profiles marked anonymous. */
+export function isAnonymousProfile(profile: Profile): boolean {
+ return profile.id === 'guest' || profile.is_anonymous;
+}
+
+export function loadPrivacySettings(userId?: string | null): PrivacySettings {
+ if (typeof window === 'undefined') return { ...DEFAULT_PRIVACY_SETTINGS };
+ try {
+ const key = userId ? LS_SETTINGS_USER(userId) : LS_SETTINGS;
+ const raw = localStorage.getItem(key);
+ if (!raw) return { ...DEFAULT_PRIVACY_SETTINGS };
+ const parsed = JSON.parse(raw) as Partial;
+ return { ...DEFAULT_PRIVACY_SETTINGS, ...parsed };
+ } catch {
+ return { ...DEFAULT_PRIVACY_SETTINGS };
+ }
+}
+
+export function savePrivacySettings(
+ settings: PrivacySettings,
+ userId?: string | null
+): void {
+ if (typeof window === 'undefined') return;
+ const key = userId ? LS_SETTINGS_USER(userId) : LS_SETTINGS;
+ localStorage.setItem(key, JSON.stringify(settings));
+ window.dispatchEvent(new CustomEvent('eg-privacy-settings', { detail: settings }));
+}
+
+export function viewerHiddenByPrivacySettings(
+ viewer: Profile,
+ settings: PrivacySettings
+): boolean {
+ if (settings.hide_from_anonymous && isAnonymousProfile(viewer)) return true;
+ if (settings.hide_from_no_photo && !profileHasPhoto(viewer)) return true;
+ return false;
+}
+
+export function viewerMessagesBlockedByPrivacySettings(
+ viewer: Profile,
+ settings: PrivacySettings
+): boolean {
+ if (settings.block_messages_from_anonymous && isAnonymousProfile(viewer)) return true;
+ if (settings.block_messages_from_no_photo && !profileHasPhoto(viewer)) return true;
+ return false;
+}
+
+export function messageBlockReason(
+ viewer: Profile,
+ settings: PrivacySettings
+): string | null {
+ if (settings.block_messages_from_anonymous && isAnonymousProfile(viewer)) {
+ return 'This cruiser does not accept messages from anonymous profiles.';
+ }
+ if (settings.block_messages_from_no_photo && !profileHasPhoto(viewer)) {
+ return 'This cruiser only accepts messages from profiles with photos.';
+ }
+ return null;
+}
\ No newline at end of file
diff --git a/src/lib/visibility.ts b/src/lib/visibility.ts
index 16bae71..1e4a867 100644
--- a/src/lib/visibility.ts
+++ b/src/lib/visibility.ts
@@ -3,6 +3,11 @@ 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";
+import {
+ loadPrivacySettings,
+ viewerHiddenByPrivacySettings,
+ viewerMessagesBlockedByPrivacySettings,
+} from "@/lib/privacy-settings";
export interface VisibilityContext {
/** The browsing user's profile (or guest stand-in). */
@@ -36,6 +41,28 @@ export function canViewerSeeTarget(
if (viewer && viewerHiddenByRules(viewer, myHideRules)) return false;
+ const targetPrivacy = loadPrivacySettings(target.id);
+ if (viewer && viewerHiddenByPrivacySettings(viewer, targetPrivacy)) return false;
+
+ return true;
+}
+
+/** Can `viewer` open a DM thread with `target`? */
+export function canViewerMessageTarget(
+ viewer: Profile | null,
+ viewerId: string | null,
+ target: Profile,
+ myHideRules: HideFromRule[],
+ myBlockList: BlockEntry[],
+ targetBlockList: BlockEntry[] = []
+): boolean {
+ if (!canViewerSeeTarget(viewer, viewerId, target, myHideRules, myBlockList, targetBlockList)) {
+ return false;
+ }
+ if (viewer) {
+ const targetPrivacy = loadPrivacySettings(target.id);
+ if (viewerMessagesBlockedByPrivacySettings(viewer, targetPrivacy)) return false;
+ }
return true;
}
diff --git a/supabase/migration_v5_privacy.sql b/supabase/migration_v5_privacy.sql
index bcc47f3..c11fce0 100644
--- a/supabase/migration_v5_privacy.sql
+++ b/supabase/migration_v5_privacy.sql
@@ -3,7 +3,11 @@
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;
+ ADD COLUMN IF NOT EXISTS body_type text,
+ ADD COLUMN IF NOT EXISTS hide_from_anonymous boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS block_messages_from_anonymous boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS hide_from_no_photo boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS block_messages_from_no_photo boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS hide_from_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),