Add anonymous and no-photo privacy toggles for visibility and DMs
This commit is contained in:
28
deploy/check-deploy-err.py
Normal file
28
deploy/check-deploy-err.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect("10.10.0.10", username="localadministrator", password="Bbt9115xty9176!", timeout=20, allow_agent=False, look_for_keys=False)
|
||||||
|
|
||||||
|
_, o, e = c.exec_command(
|
||||||
|
"docker exec coolify-db psql -U coolify -d coolify -t -A -c "
|
||||||
|
'"SELECT id,status FROM application_deployment_queues ORDER BY id DESC LIMIT 3;"',
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
o.channel.recv_exit_status()
|
||||||
|
print((o.read() + e.read()).decode())
|
||||||
|
|
||||||
|
_, o, e = c.exec_command(
|
||||||
|
"docker exec coolify-db psql -U coolify -d coolify -t -A -c "
|
||||||
|
'"SELECT logs FROM application_deployment_queues ORDER BY id DESC LIMIT 1;"',
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
o.channel.recv_exit_status()
|
||||||
|
raw = (o.read() + e.read()).decode()
|
||||||
|
for needle in ["Failed to compile", "Type error", "error TS", "Module not found", "Cannot find", "Error:"]:
|
||||||
|
idx = raw.rfind(needle)
|
||||||
|
if idx >= 0:
|
||||||
|
print("\n===", needle, "===\n")
|
||||||
|
print(raw[idx : idx + 3500])
|
||||||
|
c.close()
|
||||||
43
deploy/redeploy-poll.py
Normal file
43
deploy/redeploy-poll.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
|
||||||
|
HOST = "10.10.0.10"
|
||||||
|
USER = "localadministrator"
|
||||||
|
PASS = "Bbt9115xty9176!"
|
||||||
|
APP = "ira4bw4nhbvm87o7s3fgqu6v"
|
||||||
|
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect(HOST, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False)
|
||||||
|
|
||||||
|
def run(cmd, t=300):
|
||||||
|
print("$", cmd[:160])
|
||||||
|
_, o, e = c.exec_command(cmd, timeout=t)
|
||||||
|
code = o.channel.recv_exit_status()
|
||||||
|
out = (o.read() + e.read()).decode(errors="replace")
|
||||||
|
print(out[-4000:])
|
||||||
|
print("exit", code, "\n")
|
||||||
|
return out, code
|
||||||
|
|
||||||
|
run("cd /tmp/exposedgays && git fetch origin && git reset --hard origin/main && git log -1 --oneline")
|
||||||
|
run(
|
||||||
|
"TOKEN=$(cat /home/localadministrator/.coolify-token | tail -1); "
|
||||||
|
f"curl -sS -X POST 'http://127.0.0.1:8000/api/v1/deploy?uuid={APP}&force_rebuild=true' "
|
||||||
|
'-H "Authorization: Bearer $TOKEN"'
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in range(180):
|
||||||
|
out, _ = run("docker exec coolify php artisan queue:work --queue=high,default --once --timeout=900 2>&1", t=920)
|
||||||
|
if "ApplicationDeploymentJob" in out:
|
||||||
|
if "DONE" in out or "finished" in out.lower():
|
||||||
|
print("=== DEPLOY SUCCESS ===")
|
||||||
|
break
|
||||||
|
if "FAIL" in out:
|
||||||
|
print("=== DEPLOY FAILED ===")
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
run('docker exec coolify-db psql -U coolify -d coolify -t -c "SELECT id,status FROM application_deployment_queues ORDER BY id DESC LIMIT 1;"')
|
||||||
|
run("curl -sS https://exposedgays.com/ 2>&1 | head -c 400")
|
||||||
|
c.close()
|
||||||
@@ -28,6 +28,7 @@ import { useAccess, useFeedPreferences, useAudienceView } from "@/components/Pro
|
|||||||
import { usePrivacy } from "@/contexts/PrivacyContext";
|
import { usePrivacy } from "@/contexts/PrivacyContext";
|
||||||
import HideFromTraitsPanel from "@/components/HideFromTraitsPanel";
|
import HideFromTraitsPanel from "@/components/HideFromTraitsPanel";
|
||||||
import BlockListPanel from "@/components/BlockListPanel";
|
import BlockListPanel from "@/components/BlockListPanel";
|
||||||
|
import PrivacyAudienceToggles from "@/components/PrivacyAudienceToggles";
|
||||||
import { BODY_TYPE_OPTIONS } from "@/lib/privacy-rules";
|
import { BODY_TYPE_OPTIONS } from "@/lib/privacy-rules";
|
||||||
import {
|
import {
|
||||||
US_STATES,
|
US_STATES,
|
||||||
@@ -51,6 +52,8 @@ export default function ProfilePage() {
|
|||||||
unblockProfile,
|
unblockProfile,
|
||||||
unblockByUsername,
|
unblockByUsername,
|
||||||
unblockAll,
|
unblockAll,
|
||||||
|
privacySettings,
|
||||||
|
setPrivacySettings,
|
||||||
} = usePrivacy();
|
} = usePrivacy();
|
||||||
const [authOpen, setAuthOpen] = useState(false);
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
const [idModalOpen, setIdModalOpen] = useState(false);
|
const [idModalOpen, setIdModalOpen] = useState(false);
|
||||||
@@ -277,6 +280,11 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PrivacyAudienceToggles
|
||||||
|
settings={privacySettings}
|
||||||
|
onChange={setPrivacySettings}
|
||||||
|
/>
|
||||||
|
|
||||||
<HideFromTraitsPanel
|
<HideFromTraitsPanel
|
||||||
rules={hideRules}
|
rules={hideRules}
|
||||||
onAdd={addHideRule}
|
onAdd={addHideRule}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
|
|||||||
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
|
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
|
||||||
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
import { useAccess } from "@/components/Providers";
|
import { useAccess } from "@/components/Providers";
|
||||||
|
import { useAuth } from "@/lib/auth/context";
|
||||||
|
import { usePrivacy } from "@/contexts/PrivacyContext";
|
||||||
|
import { MOCK_PROFILES } from "@/lib/mock-data";
|
||||||
|
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";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,6 +79,8 @@ interface ChatPanelProps {
|
|||||||
|
|
||||||
export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
||||||
const { capabilities } = useAccess();
|
const { capabilities } = useAccess();
|
||||||
|
const { user, profile: authProfile } = useAuth();
|
||||||
|
const { blockList } = usePrivacy();
|
||||||
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
|
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
|
||||||
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
|
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@@ -105,7 +114,29 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const messages = activeTab === "city" ? cityMessages : dmMessages;
|
const messages = activeTab === "city" ? cityMessages : dmMessages;
|
||||||
const showComposer = activeTab === "city" || activeTab === "dm";
|
|
||||||
|
const dmTarget = dmUserId
|
||||||
|
? MOCK_PROFILES.find((p) => p.id === dmUserId) ?? null
|
||||||
|
: null;
|
||||||
|
const viewer = authProfile ?? guestViewerProfile();
|
||||||
|
const viewerId = user?.id ?? "guest";
|
||||||
|
const dmAllowed =
|
||||||
|
!dmTarget ||
|
||||||
|
canViewerMessageTarget(
|
||||||
|
viewer,
|
||||||
|
viewerId,
|
||||||
|
dmTarget,
|
||||||
|
loadHideFromRules(dmTarget.id),
|
||||||
|
blockList,
|
||||||
|
loadBlockList(dmTarget.id)
|
||||||
|
);
|
||||||
|
const dmBlockNote = dmTarget
|
||||||
|
? messageBlockReason(viewer, loadPrivacySettings(dmTarget.id))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const showComposer =
|
||||||
|
(activeTab === "city" || activeTab === "dm") &&
|
||||||
|
(activeTab !== "dm" || dmAllowed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -139,12 +170,21 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
|||||||
<TabsContent value="dm" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
|
<TabsContent value="dm" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
|
||||||
<div className="px-3 sm:px-4 py-2 border-b border-border">
|
<div className="px-3 sm:px-4 py-2 border-b border-border">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{dmUserId ? `Chat with User #${dmUserId}` : "Private Messages"}
|
{dmTarget
|
||||||
|
? dmTarget.display_name || dmTarget.username || `User #${dmUserId}`
|
||||||
|
: dmUserId
|
||||||
|
? `Chat with User #${dmUserId}`
|
||||||
|
: "Private Messages"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Use quick replies for address, directions & hosting notes
|
Use quick replies for address, directions & hosting notes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!dmAllowed && dmBlockNote && (
|
||||||
|
<div className="mx-3 sm:mx-4 mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||||
|
{dmBlockNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<MessageList messages={dmMessages} />
|
<MessageList messages={dmMessages} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ 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 { 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 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";
|
||||||
@@ -86,7 +91,8 @@ export function CruiserProfileView({
|
|||||||
vanillaMode,
|
vanillaMode,
|
||||||
}: CruiserProfileViewProps) {
|
}: CruiserProfileViewProps) {
|
||||||
const { capabilities, blurExplicit } = useAccess();
|
const { capabilities, blurExplicit } = useAccess();
|
||||||
const { blockProfile, isBlocked, unblockProfile } = usePrivacy();
|
const { user, profile: authProfile } = useAuth();
|
||||||
|
const { blockProfile, isBlocked, unblockProfile, blockList } = usePrivacy();
|
||||||
const [tab, setTab] = useState<Tab>("about");
|
const [tab, setTab] = useState<Tab>("about");
|
||||||
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
|
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
|
||||||
const [activePhoto, setActivePhoto] = useState(0);
|
const [activePhoto, setActivePhoto] = useState(0);
|
||||||
@@ -123,9 +129,25 @@ export function CruiserProfileView({
|
|||||||
const shouldBlurPhoto = (explicit: boolean) =>
|
const shouldBlurPhoto = (explicit: boolean) =>
|
||||||
vanillaMode || blurExplicit || (explicit && !capabilities.canViewExplicit);
|
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 = () => {
|
const handleChat = () => {
|
||||||
if (!capabilities.canSendMessages) return;
|
if (!capabilities.canSendMessages) return;
|
||||||
if (isBlocked(profile.id)) return;
|
if (isBlocked(profile.id)) return;
|
||||||
|
if (!canMessage) return;
|
||||||
onChat(profile);
|
onChat(profile);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -464,8 +486,14 @@ export function CruiserProfileView({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="rounded-full h-11 w-11 shrink-0"
|
className="rounded-full h-11 w-11 shrink-0"
|
||||||
onClick={handleChat}
|
onClick={handleChat}
|
||||||
disabled={!capabilities.canSendMessages}
|
disabled={!capabilities.canSendMessages || !canMessage || isBlocked(profile.id)}
|
||||||
title={capabilities.canSendMessages ? "Chat" : "Sign up to chat"}
|
title={
|
||||||
|
!capabilities.canSendMessages
|
||||||
|
? "Sign up to chat"
|
||||||
|
: !canMessage && messageBlockedNote
|
||||||
|
? messageBlockedNote
|
||||||
|
: "Chat"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-5 w-5" />
|
<MessageCircle className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import { profileMatchesAudienceView } from "@/lib/audience";
|
|||||||
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
|
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
|
||||||
import { useAuth } from "@/lib/auth/context";
|
import { useAuth } from "@/lib/auth/context";
|
||||||
import { usePrivacy } from "@/contexts/PrivacyContext";
|
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 { distanceMiles } from "@/lib/profile-stats";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -183,10 +185,22 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
|
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
|
||||||
}, [filteredProfiles, onActiveCountChange]);
|
}, [filteredProfiles, onActiveCountChange]);
|
||||||
|
|
||||||
const handleChat = useCallback((profile: Profile) => {
|
const handleChat = useCallback(
|
||||||
if (!capabilities.canSendMessages) return;
|
(profile: Profile) => {
|
||||||
window.location.href = `/chat?user=${profile.id}`;
|
if (!capabilities.canSendMessages) return;
|
||||||
}, [capabilities.canSendMessages]);
|
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) => {
|
const togglePosition = (pos: Position) => {
|
||||||
setFilters((f) => ({
|
setFilters((f) => ({
|
||||||
|
|||||||
96
src/components/PrivacyAudienceToggles.tsx
Normal file
96
src/components/PrivacyAudienceToggles.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-xl border border-zinc-700/80 bg-zinc-900/50 p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{title}</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1 leading-relaxed">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 py-1">
|
||||||
|
<Label className="text-sm text-zinc-300 font-normal">{hideLabel}</Label>
|
||||||
|
<Switch checked={hideOn} onCheckedChange={onHideChange} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 py-1 border-t border-zinc-800 pt-3">
|
||||||
|
<Label className="text-sm text-zinc-300 font-normal">{messageLabel}</Label>
|
||||||
|
<Switch checked={messagesOn} onCheckedChange={onMessagesChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrivacyAudienceToggles({
|
||||||
|
settings,
|
||||||
|
onChange,
|
||||||
|
}: PrivacyAudienceTogglesProps) {
|
||||||
|
const patch = (partial: Partial<PrivacySettings>) =>
|
||||||
|
onChange({ ...settings, ...partial });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Audience filters
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ToggleRow
|
||||||
|
title="Anonymous profiles"
|
||||||
|
description="Browsing anonymously or without a display name. You can block visibility and messages separately."
|
||||||
|
hideLabel="Hide me from anonymous users"
|
||||||
|
messageLabel="Block messages from anonymous users"
|
||||||
|
hideOn={settings.hide_from_anonymous}
|
||||||
|
messagesOn={settings.block_messages_from_anonymous}
|
||||||
|
onHideChange={(v) => patch({ hide_from_anonymous: v })}
|
||||||
|
onMessagesChange={(v) => patch({ block_messages_from_anonymous: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 text-xs text-zinc-500 px-1">
|
||||||
|
<Ghost className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span>Applies to guests and anyone with "Browse anonymously" turned on.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToggleRow
|
||||||
|
title="Profiles without photos"
|
||||||
|
description="No profile photo and no gallery uploads. Separate from anonymous — someone can be named but photo-less."
|
||||||
|
hideLabel="Hide me from users without photos"
|
||||||
|
messageLabel="Block messages from users without photos"
|
||||||
|
hideOn={settings.hide_from_no_photo}
|
||||||
|
messagesOn={settings.block_messages_from_no_photo}
|
||||||
|
onHideChange={(v) => patch({ hide_from_no_photo: v })}
|
||||||
|
onMessagesChange={(v) => patch({ block_messages_from_no_photo: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 text-xs text-zinc-500 px-1">
|
||||||
|
<CameraOff className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span>Upload at least one photo in your gallery so others know you're not in this group.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,10 +25,18 @@ import {
|
|||||||
type BlockEntry,
|
type BlockEntry,
|
||||||
} from '@/lib/block-list';
|
} from '@/lib/block-list';
|
||||||
import type { Profile } from '@/types';
|
import type { Profile } from '@/types';
|
||||||
|
import {
|
||||||
|
DEFAULT_PRIVACY_SETTINGS,
|
||||||
|
loadPrivacySettings,
|
||||||
|
savePrivacySettings,
|
||||||
|
type PrivacySettings,
|
||||||
|
} from '@/lib/privacy-settings';
|
||||||
|
|
||||||
interface PrivacyContextValue {
|
interface PrivacyContextValue {
|
||||||
hideRules: HideFromRule[];
|
hideRules: HideFromRule[];
|
||||||
blockList: BlockEntry[];
|
blockList: BlockEntry[];
|
||||||
|
privacySettings: PrivacySettings;
|
||||||
|
setPrivacySettings: (settings: PrivacySettings) => void;
|
||||||
addHideRule: (rule: Omit<HideFromRule, 'id' | 'created_at' | 'label'>) => void;
|
addHideRule: (rule: Omit<HideFromRule, 'id' | 'created_at' | 'label'>) => void;
|
||||||
removeHideRule: (id: string) => void;
|
removeHideRule: (id: string) => void;
|
||||||
blockProfile: (profile: Profile) => void;
|
blockProfile: (profile: Profile) => void;
|
||||||
@@ -47,10 +55,14 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const [hideRules, setHideRules] = useState<HideFromRule[]>([]);
|
const [hideRules, setHideRules] = useState<HideFromRule[]>([]);
|
||||||
const [blockList, setBlockList] = useState<BlockEntry[]>([]);
|
const [blockList, setBlockList] = useState<BlockEntry[]>([]);
|
||||||
|
const [privacySettings, setPrivacySettingsState] = useState<PrivacySettings>(
|
||||||
|
DEFAULT_PRIVACY_SETTINGS
|
||||||
|
);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setHideRules(loadHideFromRules(userId));
|
setHideRules(loadHideFromRules(userId));
|
||||||
setBlockList(loadBlockList(userId));
|
setBlockList(loadBlockList(userId));
|
||||||
|
setPrivacySettingsState(loadPrivacySettings(userId));
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,14 +72,25 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHide = () => refresh();
|
const onHide = () => refresh();
|
||||||
const onBlock = () => refresh();
|
const onBlock = () => refresh();
|
||||||
|
const onSettings = () => refresh();
|
||||||
window.addEventListener('eg-hide-rules', onHide);
|
window.addEventListener('eg-hide-rules', onHide);
|
||||||
window.addEventListener('eg-block-list', onBlock);
|
window.addEventListener('eg-block-list', onBlock);
|
||||||
|
window.addEventListener('eg-privacy-settings', onSettings);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('eg-hide-rules', onHide);
|
window.removeEventListener('eg-hide-rules', onHide);
|
||||||
window.removeEventListener('eg-block-list', onBlock);
|
window.removeEventListener('eg-block-list', onBlock);
|
||||||
|
window.removeEventListener('eg-privacy-settings', onSettings);
|
||||||
};
|
};
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
const setPrivacySettings = useCallback(
|
||||||
|
(settings: PrivacySettings) => {
|
||||||
|
savePrivacySettings(settings, userId);
|
||||||
|
setPrivacySettingsState(settings);
|
||||||
|
},
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
const addHideRule = useCallback(
|
const addHideRule = useCallback(
|
||||||
(rule: Omit<HideFromRule, 'id' | 'created_at' | 'label'>) => {
|
(rule: Omit<HideFromRule, 'id' | 'created_at' | 'label'>) => {
|
||||||
const next = addHideFromRule(rule, userId);
|
const next = addHideFromRule(rule, userId);
|
||||||
@@ -122,6 +145,8 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
|
|||||||
() => ({
|
() => ({
|
||||||
hideRules,
|
hideRules,
|
||||||
blockList,
|
blockList,
|
||||||
|
privacySettings,
|
||||||
|
setPrivacySettings,
|
||||||
addHideRule,
|
addHideRule,
|
||||||
removeHideRule,
|
removeHideRule,
|
||||||
blockProfile,
|
blockProfile,
|
||||||
@@ -134,6 +159,8 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
|
|||||||
[
|
[
|
||||||
hideRules,
|
hideRules,
|
||||||
blockList,
|
blockList,
|
||||||
|
privacySettings,
|
||||||
|
setPrivacySettings,
|
||||||
addHideRule,
|
addHideRule,
|
||||||
removeHideRule,
|
removeHideRule,
|
||||||
blockProfile,
|
blockProfile,
|
||||||
|
|||||||
91
src/lib/privacy-settings.ts
Normal file
91
src/lib/privacy-settings.ts
Normal file
@@ -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<PrivacySettings>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@ import type { BlockEntry } from "@/lib/block-list";
|
|||||||
import { isBlocked, loadBlockList } from "@/lib/block-list";
|
import { isBlocked, loadBlockList } from "@/lib/block-list";
|
||||||
import type { HideFromRule } from "@/lib/privacy-rules";
|
import type { HideFromRule } from "@/lib/privacy-rules";
|
||||||
import { loadHideFromRules, viewerHiddenByRules } from "@/lib/privacy-rules";
|
import { loadHideFromRules, viewerHiddenByRules } from "@/lib/privacy-rules";
|
||||||
|
import {
|
||||||
|
loadPrivacySettings,
|
||||||
|
viewerHiddenByPrivacySettings,
|
||||||
|
viewerMessagesBlockedByPrivacySettings,
|
||||||
|
} from "@/lib/privacy-settings";
|
||||||
|
|
||||||
export interface VisibilityContext {
|
export interface VisibilityContext {
|
||||||
/** The browsing user's profile (or guest stand-in). */
|
/** The browsing user's profile (or guest stand-in). */
|
||||||
@@ -36,6 +41,28 @@ export function canViewerSeeTarget(
|
|||||||
|
|
||||||
if (viewer && viewerHiddenByRules(viewer, myHideRules)) return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
ALTER TABLE profiles
|
ALTER TABLE profiles
|
||||||
ADD COLUMN IF NOT EXISTS height_in integer,
|
ADD COLUMN IF NOT EXISTS height_in integer,
|
||||||
ADD COLUMN IF NOT EXISTS weight_lb 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 (
|
CREATE TABLE IF NOT EXISTS hide_from_rules (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
Reference in New Issue
Block a user