Live Supabase chat rooms + password reset flow

This commit is contained in:
Ryan Salazar
2026-06-27 20:07:04 -04:00
parent 910a7d8516
commit fa7f1a9a2b
8 changed files with 684 additions and 127 deletions

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Apply ExposedGays chat migration (RLS + DM RPC)."""
import paramiko
HOST = "10.10.0.11"
USER = "localadministrator"
PASS = "Bbt9115xty9176!"
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=120):
print("$", cmd[:140])
_, o, e = c.exec_command(cmd, timeout=t)
code = o.channel.recv_exit_status()
out = (o.read() + e.read()).decode(errors="replace")
print(out[-3000:])
print("exit", code, "\n")
return code
with open(
r"C:\Users\Local Administrator\exposedgays\supabase\migration_v7_chat.sql",
"r",
encoding="utf-8",
) as f:
sql = f.read()
sftp = c.open_sftp()
with sftp.file("/tmp/eg_chat_migration.sql", "w") as remote:
remote.write(sql)
sftp.close()
run("cat /tmp/eg_chat_migration.sql | docker exec -i supabase-db psql -U postgres -d postgres 2>&1 | tail -30")
c.close()
print("Done.")

View File

@@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/lib/auth/context";
import { Lock } from "lucide-react";
export default function ResetPasswordPage() {
const { updatePassword, session, loading } = useAuth();
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setError("");
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setSubmitting(true);
const { error: err } = await updatePassword(password);
setSubmitting(false);
if (err) {
setError(err);
return;
}
router.push("/profile?password=updated");
};
if (loading) {
return (
<div className="min-h-[60vh] flex items-center justify-center text-muted-foreground">
Loading...
</div>
);
}
if (!session) {
return (
<div className="max-w-md mx-auto px-4 py-16 text-center space-y-4">
<h1 className="text-xl font-semibold">Reset link expired</h1>
<p className="text-sm text-muted-foreground">
Open the app, tap Log In, and request a new password reset link.
</p>
<Button variant="horny" onClick={() => router.push("/")}>
Back to Map
</Button>
</div>
);
}
return (
<div className="max-w-md mx-auto px-4 py-12 space-y-6">
<div>
<h1 className="text-2xl font-bold">Set a new password</h1>
<p className="text-sm text-muted-foreground mt-1">
Choose a new password for your ExposedGays account.
</p>
</div>
<div className="space-y-4">
<div>
<Label>New password</Label>
<div className="relative mt-1">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="8+ characters"
className="pl-10"
minLength={8}
/>
</div>
</div>
<div>
<Label>Confirm password</Label>
<div className="relative mt-1">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="pl-10"
minLength={8}
/>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
variant="horny"
className="w-full"
onClick={handleSubmit}
disabled={submitting || password.length < 8 || !confirm}
>
Update Password
</Button>
</div>
</div>
);
}

View File

@@ -12,7 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useAuth } from "@/lib/auth/context"; import { useAuth } from "@/lib/auth/context";
import { Ghost, Mail, Lock } from "lucide-react"; import { Ghost, Mail, Lock, ArrowLeft } from "lucide-react";
interface AuthModalProps { interface AuthModalProps {
open: boolean; open: boolean;
@@ -20,12 +20,13 @@ interface AuthModalProps {
} }
export function AuthModal({ open, onClose }: AuthModalProps) { export function AuthModal({ open, onClose }: AuthModalProps) {
const { signUp, signIn, signInAnonymously } = useAuth(); const { signUp, signIn, signInAnonymously, resetPassword } = useAuth();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showForgot, setShowForgot] = useState(false);
const handleSignUp = async () => { const handleSignUp = async () => {
setLoading(true); setLoading(true);
@@ -57,13 +58,80 @@ export function AuthModal({ open, onClose }: AuthModalProps) {
else onClose(); else onClose();
}; };
const handleResetPassword = async () => {
setLoading(true);
setError("");
setSuccess("");
const { error: err } = await resetPassword(email);
setLoading(false);
if (err) setError(err);
else {
setSuccess("Password reset link sent. Check your email.");
}
};
const resetForgotState = () => {
setShowForgot(false);
setError("");
setSuccess("");
};
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog
open={open}
onOpenChange={(v) => {
if (!v) {
resetForgotState();
onClose();
}
}}
>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>Join ExposedGays Free Forever</DialogTitle> <DialogTitle>
{showForgot ? "Reset Password" : "Join ExposedGays — Free Forever"}
</DialogTitle>
</DialogHeader> </DialogHeader>
{showForgot ? (
<div className="space-y-4 mt-2">
<p className="text-sm text-muted-foreground">
Enter your account email. We&apos;ll send a link to set a new password.
</p>
<div>
<Label>Email</Label>
<div className="relative mt-1">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="pl-10"
/>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-green-400">{success}</p>}
<Button
variant="horny"
className="w-full"
onClick={handleResetPassword}
disabled={loading || !email}
>
Send Reset Link
</Button>
<Button
variant="ghost"
className="w-full gap-2"
onClick={resetForgotState}
>
<ArrowLeft className="h-4 w-4" />
Back to Log In
</Button>
</div>
) : (
<>
<Tabs defaultValue="signup"> <Tabs defaultValue="signup">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="signup">Sign Up</TabsTrigger> <TabsTrigger value="signup">Sign Up</TabsTrigger>
@@ -130,6 +198,19 @@ export function AuthModal({ open, onClose }: AuthModalProps) {
className="mt-1" className="mt-1"
/> />
</div> </div>
<div className="flex justify-end">
<button
type="button"
onClick={() => {
setError("");
setSuccess("");
setShowForgot(true);
}}
className="text-xs text-horny-pink hover:underline"
>
Forgot password?
</button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
<Button <Button
variant="horny" variant="horny"
@@ -164,6 +245,8 @@ export function AuthModal({ open, onClose }: AuthModalProps) {
<p className="text-[10px] text-center text-muted-foreground"> <p className="text-[10px] text-center text-muted-foreground">
No credit card. No paid tier. Ever. No credit card. No paid tier. Ever.
</p> </p>
</>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -18,8 +18,19 @@ import { canViewerMessageTarget, guestViewerProfile } from "@/lib/visibility";
import { loadHideFromRules } from "@/lib/privacy-rules"; import { loadHideFromRules } from "@/lib/privacy-rules";
import { loadBlockList } from "@/lib/block-list"; import { loadBlockList } from "@/lib/block-list";
import { loadPrivacySettings, messageBlockReason } from "@/lib/privacy-settings"; import { loadPrivacySettings, messageBlockReason } from "@/lib/privacy-settings";
import { useChatMessages } from "@/lib/supabase/hooks";
import {
ensureRoomMember,
fetchRoomMemberCount,
getOrCreateCityRoom,
getOrCreateDmRoom,
getOrCreateSpotRoom,
senderDisplayName,
} from "@/lib/chat-store";
import { createClient } from "@/lib/supabase/client";
import type { Profile, ChatMessage } from "@/types";
interface Message { interface DisplayMessage {
id: string; id: string;
sender: string; sender: string;
content: string; content: string;
@@ -30,7 +41,7 @@ interface Message {
const CITY = process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale"; const CITY = process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale";
const MOCK_CITY_MESSAGES: Message[] = [ const MOCK_CITY_MESSAGES: DisplayMessage[] = [
{ {
id: "1", id: "1",
sender: "Mike_Top", sender: "Mike_Top",
@@ -57,7 +68,7 @@ const MOCK_CITY_MESSAGES: Message[] = [
}, },
]; ];
const MOCK_DM_MESSAGES: Message[] = [ const MOCK_DM_MESSAGES: DisplayMessage[] = [
{ {
id: "dm1", id: "dm1",
sender: "You", sender: "You",
@@ -73,6 +84,17 @@ const MOCK_DM_MESSAGES: Message[] = [
}, },
]; ];
function mapChatMessage(msg: ChatMessage, currentUserId: string): DisplayMessage {
return {
id: msg.id,
sender: msg.sender_id === currentUserId ? "You" : senderDisplayName(msg.sender),
content: msg.content,
image_url: msg.image_url ?? undefined,
created_at: msg.created_at,
isMe: msg.sender_id === currentUserId,
};
}
interface ChatPanelProps { interface ChatPanelProps {
dmUserId?: string | null; dmUserId?: string | null;
spotId?: string | null; spotId?: string | null;
@@ -83,45 +105,132 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
const { capabilities } = useAccess(); const { capabilities } = useAccess();
const { user, profile: authProfile } = useAuth(); const { user, profile: authProfile } = useAuth();
const { blockList } = usePrivacy(); const { blockList } = usePrivacy();
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState( const [activeTab, setActiveTab] = useState(
dmUserId ? "dm" : spotId ? "city" : "city" dmUserId ? "dm" : spotId ? "city" : "city"
); );
const [showQuickReplies, setShowQuickReplies] = useState(false); const [showQuickReplies, setShowQuickReplies] = useState(false);
const [cityRoomId, setCityRoomId] = useState<string | null>(null);
const [spotRoomId, setSpotRoomId] = useState<string | null>(null);
const [dmRoomId, setDmRoomId] = useState<string | null>(null);
const [memberCount, setMemberCount] = useState(0);
const [dmTarget, setDmTarget] = useState<Profile | null>(null);
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const isLive = !!user && capabilities.canSendMessages;
const publicRoomId = spotId && spotRoomId ? spotRoomId : cityRoomId;
const { messages: livePublicMessages, sendMessage: sendPublicMessage } = useChatMessages(
publicRoomId,
isLive
);
const { messages: liveDmMessages, sendMessage: sendDmMessage } = useChatMessages(
dmRoomId,
isLive && !!dmUserId
);
useEffect(() => {
if (!user) {
setCityRoomId(null);
setSpotRoomId(null);
setDmRoomId(null);
return;
}
let cancelled = false;
async function resolveRooms() {
const cityId = await getOrCreateCityRoom(CITY);
if (cancelled) return;
if (cityId) {
setCityRoomId(cityId);
await ensureRoomMember(cityId, user.id);
const count = await fetchRoomMemberCount(cityId);
if (!cancelled) setMemberCount(count);
}
if (spotId) {
const spotName = spotRoomName
? decodeURIComponent(spotRoomName)
: "Spot Chat";
const sId = await getOrCreateSpotRoom(spotId, spotName);
if (!cancelled && sId) {
setSpotRoomId(sId);
await ensureRoomMember(sId, user.id);
}
}
if (dmUserId) {
const dId = await getOrCreateDmRoom(user.id, dmUserId);
if (!cancelled && dId) {
setDmRoomId(dId);
await ensureRoomMember(dId, user.id);
}
}
}
resolveRooms();
return () => {
cancelled = true;
};
}, [user, spotId, spotRoomName, dmUserId]);
useEffect(() => {
if (!dmUserId) {
setDmTarget(null);
return;
}
const mock = MOCK_PROFILES.find((p) => p.id === dmUserId);
if (mock) {
setDmTarget(mock);
return;
}
const supabase = createClient();
supabase
.from("profiles")
.select("*")
.eq("id", dmUserId)
.maybeSingle()
.then(({ data }) => {
if (data) setDmTarget(data as Profile);
});
}, [dmUserId]);
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [cityMessages, dmMessages, activeTab]); }, [livePublicMessages, liveDmMessages, activeTab, isLive]);
const sendMessage = (text?: string) => { const cityMessages = useMemo(() => {
if (!capabilities.canSendMessages) return; if (!isLive) return MOCK_CITY_MESSAGES;
return livePublicMessages.map((m) => mapChatMessage(m, user!.id));
}, [isLive, livePublicMessages, user]);
const dmMessages = useMemo(() => {
if (!isLive || !dmUserId) return MOCK_DM_MESSAGES;
return liveDmMessages.map((m) => mapChatMessage(m, user!.id));
}, [isLive, liveDmMessages, dmUserId, user]);
const sendMessage = async (text?: string) => {
if (!capabilities.canSendMessages || !user) return;
const content = (text ?? input).trim(); const content = (text ?? input).trim();
if (!content) return; if (!content || sending) return;
const msg: Message = {
id: Date.now().toString(),
sender: "You",
content,
created_at: new Date().toISOString(),
isMe: true,
};
setSending(true);
if (activeTab === "city") { if (activeTab === "city") {
setCityMessages((prev) => [...prev, msg]); await sendPublicMessage(content, user.id);
} else { } else if (activeTab === "dm" && dmRoomId) {
setDmMessages((prev) => [...prev, msg]); await sendDmMessage(content, user.id);
} }
setSending(false);
setInput(""); setInput("");
setShowQuickReplies(false); setShowQuickReplies(false);
}; };
const messages = activeTab === "city" ? cityMessages : dmMessages; const messages = activeTab === "city" ? cityMessages : dmMessages;
const dmTarget = dmUserId
? MOCK_PROFILES.find((p) => p.id === dmUserId) ?? null
: null;
const viewer = authProfile ?? guestViewerProfile(); const viewer = authProfile ?? guestViewerProfile();
const viewerId = user?.id ?? "guest"; const viewerId = user?.id ?? "guest";
const dmAllowed = const dmAllowed =
@@ -142,13 +251,17 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
(activeTab === "city" || activeTab === "dm") && (activeTab === "city" || activeTab === "dm") &&
(activeTab !== "dm" || dmAllowed); (activeTab !== "dm" || dmAllowed);
const roomBadgeCount = isLive
? Math.max(memberCount, cityMessages.length)
: cityMessages.length + 12;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<TabsList className="mx-3 sm:mx-4 mt-2 grid grid-cols-3"> <TabsList className="mx-3 sm:mx-4 mt-2 grid grid-cols-3">
<TabsTrigger value="city" className="gap-1 text-xs min-h-[40px]"> <TabsTrigger value="city" className="gap-1 text-xs min-h-[40px]">
<Users className="h-3 w-3" /> <Users className="h-3 w-3" />
<span className="truncate">{CITY}</span> <span className="truncate">{spotId && spotRoomName ? "Spot" : CITY}</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="dm" className="gap-1 text-xs min-h-[40px]"> <TabsTrigger value="dm" className="gap-1 text-xs min-h-[40px]">
<Lock className="h-3 w-3" /> <Lock className="h-3 w-3" />
@@ -162,12 +275,14 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
<TabsContent value="city" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden"> <TabsContent value="city" 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">
<Badge variant="online" className="text-[10px]"> <Badge variant="online" className="text-[10px]">
{cityMessages.length + 12} in room {roomBadgeCount} in room{isLive ? "" : " (preview)"}
</Badge> </Badge>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{spotId && spotRoomName {spotId && spotRoomName
? `Spot chat — ${decodeURIComponent(spotRoomName)}. Also connected to ${CITY} city room.` ? `Spot chat — ${decodeURIComponent(spotRoomName)}. Live room connected to ${CITY}.`
: "Auto-joined based on your location. Public city chat."} : isLive
? "Auto-joined based on your location. Public city chat."
: "Preview mode — sign up free to join the live room."}
</p> </p>
</div> </div>
<MessageList messages={messages} /> <MessageList messages={messages} />
@@ -183,7 +298,9 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
: "Private Messages"} : "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 {dmUserId
? "Use quick replies for address, directions & hosting notes"
: "Open a profile from the map to start a DM"}
</p> </p>
</div> </div>
{!dmAllowed && dmBlockNote && ( {!dmAllowed && dmBlockNote && (
@@ -191,7 +308,13 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
{dmBlockNote} {dmBlockNote}
</div> </div>
)} )}
{!dmUserId ? (
<div className="flex-1 flex items-center justify-center px-4 text-sm text-muted-foreground text-center">
Tap Message on a cruiser profile to open a private thread.
</div>
) : (
<MessageList messages={dmMessages} /> <MessageList messages={dmMessages} />
)}
</TabsContent> </TabsContent>
<TabsContent value="groups" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden"> <TabsContent value="groups" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
@@ -213,7 +336,7 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
<GuestUpgradeBanner reason="chat" /> <GuestUpgradeBanner reason="chat" />
</div> </div>
)} )}
{activeTab === "dm" && capabilities.canSendMessages && ( {activeTab === "dm" && capabilities.canSendMessages && dmUserId && (
<div className="px-3 sm:px-4 pt-2"> <div className="px-3 sm:px-4 pt-2">
<button <button
type="button" type="button"
@@ -252,16 +375,16 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
} }
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage()} onKeyDown={(e) => e.key === "Enter" && void sendMessage()}
className="flex-1 h-11 text-base" className="flex-1 h-11 text-base"
disabled={!capabilities.canSendMessages} disabled={!capabilities.canSendMessages || sending}
/> />
<Button <Button
variant="horny" variant="horny"
size="icon" size="icon"
className="shrink-0 h-11 w-11 touch-manipulation" className="shrink-0 h-11 w-11 touch-manipulation"
onClick={() => sendMessage()} onClick={() => void sendMessage()}
disabled={!capabilities.canSendMessages} disabled={!capabilities.canSendMessages || sending}
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>
@@ -274,9 +397,14 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
); );
} }
function MessageList({ messages }: { messages: Message[] }) { function MessageList({ messages }: { messages: DisplayMessage[] }) {
return ( return (
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-3 space-y-3 overscroll-contain"> <div className="flex-1 overflow-y-auto px-3 sm:px-4 py-3 space-y-3 overscroll-contain">
{messages.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No messages yet. Say hi.
</p>
)}
{messages.map((msg) => ( {messages.map((msg) => (
<div <div
key={msg.id} key={msg.id}

View File

@@ -20,6 +20,8 @@ interface AuthContextValue {
signUp: (email: string, password: string) => Promise<{ error: string | null }>; signUp: (email: string, password: string) => Promise<{ error: string | null }>;
signIn: (email: string, password: string) => Promise<{ error: string | null }>; signIn: (email: string, password: string) => Promise<{ error: string | null }>;
signInAnonymously: () => Promise<{ error: string | null }>; signInAnonymously: () => Promise<{ error: string | null }>;
resetPassword: (email: string) => Promise<{ error: string | null }>;
updatePassword: (password: string) => Promise<{ error: string | null }>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
updateProfile: (data: Partial<Profile>) => Promise<{ error: string | null }>; updateProfile: (data: Partial<Profile>) => Promise<{ error: string | null }>;
refreshProfile: () => Promise<void>; refreshProfile: () => Promise<void>;
@@ -145,6 +147,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { error: error?.message ?? null }; return { error: error?.message ?? null };
}; };
const resetPassword = async (email: string) => {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/callback?next=/auth/reset-password`,
});
return { error: error?.message ?? null };
};
const updatePassword = async (password: string) => {
const { error } = await supabase.auth.updateUser({ password });
return { error: error?.message ?? null };
};
const signOut = async () => { const signOut = async () => {
await supabase.auth.signOut(); await supabase.auth.signOut();
setProfile(null); setProfile(null);
@@ -218,6 +232,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
signUp, signUp,
signIn, signIn,
signInAnonymously, signInAnonymously,
resetPassword,
updatePassword,
signOut, signOut,
updateProfile, updateProfile,
refreshProfile, refreshProfile,

92
src/lib/chat-store.ts Normal file
View File

@@ -0,0 +1,92 @@
import { createClient } from "@/lib/supabase/client";
export async function getOrCreateCityRoom(city: string): Promise<string | null> {
const supabase = createClient();
const { data: existing } = await supabase
.from("chat_rooms")
.select("id")
.eq("type", "city")
.eq("city", city)
.limit(1)
.maybeSingle();
if (existing?.id) return existing.id;
const { data: created, error } = await supabase
.from("chat_rooms")
.insert({ name: `${city} Chat`, type: "city", city })
.select("id")
.single();
return error ? null : created.id;
}
export async function getOrCreateSpotRoom(
spotId: string,
spotName: string
): Promise<string | null> {
const supabase = createClient();
const { data: existing } = await supabase
.from("chat_rooms")
.select("id")
.eq("type", "spot")
.eq("spot_id", spotId)
.limit(1)
.maybeSingle();
if (existing?.id) return existing.id;
const { data: created, error } = await supabase
.from("chat_rooms")
.insert({
name: spotName || "Spot Chat",
type: "spot",
spot_id: spotId,
})
.select("id")
.single();
return error ? null : created.id;
}
export async function getOrCreateDmRoom(
userId: string,
otherUserId: string
): Promise<string | null> {
const supabase = createClient();
const { data, error } = await supabase.rpc("get_or_create_dm_room", {
p_user_a: userId,
p_user_b: otherUserId,
});
if (error || !data) return null;
return data as string;
}
export async function ensureRoomMember(roomId: string, userId: string): Promise<void> {
const supabase = createClient();
await supabase
.from("chat_room_members")
.upsert({ room_id: roomId, user_id: userId }, { onConflict: "room_id,user_id" });
}
export async function fetchRoomMemberCount(roomId: string): Promise<number> {
const supabase = createClient();
const { count } = await supabase
.from("chat_room_members")
.select("*", { count: "exact", head: true })
.eq("room_id", roomId);
return count ?? 0;
}
export function senderDisplayName(
sender: {
is_anonymous?: boolean;
display_name?: string | null;
username?: string | null;
} | null | undefined,
fallback = "User"
): string {
if (!sender) return fallback;
if (sender.is_anonymous) return "Anonymous";
return sender.display_name || sender.username || fallback;
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { createClient } from "./client"; import { createClient } from "./client";
import type { Profile, ChatMessage } from "@/types"; import type { Profile, ChatMessage } from "@/types";
@@ -43,11 +43,30 @@ export function useNearbyProfiles(lat: number, lng: number, radiusKm = 10) {
return { profiles, loading }; return { profiles, loading };
} }
export function useChatMessages(roomId: string) { async function fetchMessageWithSender(messageId: string): Promise<ChatMessage | null> {
const { data } = await supabase
.from("chat_messages")
.select("*, sender:profiles(*)")
.eq("id", messageId)
.single();
return data ? (data as ChatMessage) : null;
}
export function useChatMessages(roomId: string | null, enabled = true) {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!roomId || !enabled) {
setMessages([]);
setLoading(false);
return;
}
let cancelled = false;
async function fetchMessages() { async function fetchMessages() {
setLoading(true);
const { data } = await supabase const { data } = await supabase
.from("chat_messages") .from("chat_messages")
.select("*, sender:profiles(*)") .select("*, sender:profiles(*)")
@@ -55,7 +74,8 @@ export function useChatMessages(roomId: string) {
.order("created_at", { ascending: true }) .order("created_at", { ascending: true })
.limit(100); .limit(100);
if (data) setMessages(data as ChatMessage[]); if (!cancelled && data) setMessages(data as ChatMessage[]);
if (!cancelled) setLoading(false);
} }
fetchMessages(); fetchMessages();
@@ -70,24 +90,35 @@ export function useChatMessages(roomId: string) {
table: "chat_messages", table: "chat_messages",
filter: `room_id=eq.${roomId}`, filter: `room_id=eq.${roomId}`,
}, },
(payload) => { async (payload) => {
setMessages((prev) => [...prev, payload.new as ChatMessage]); const incoming = payload.new as ChatMessage;
const full = await fetchMessageWithSender(incoming.id);
setMessages((prev) => {
if (prev.some((m) => m.id === incoming.id)) return prev;
return [...prev, full ?? incoming];
});
} }
) )
.subscribe(); .subscribe();
return () => { return () => {
cancelled = true;
supabase.removeChannel(channel); supabase.removeChannel(channel);
}; };
}, [roomId]); }, [roomId, enabled]);
const sendMessage = async (content: string, senderId: string) => { const sendMessage = useCallback(
await supabase.from("chat_messages").insert({ async (content: string, senderId: string) => {
if (!roomId) return { error: "No room" };
const { error } = await supabase.from("chat_messages").insert({
room_id: roomId, room_id: roomId,
sender_id: senderId, sender_id: senderId,
content, content,
}); });
}; return { error: error?.message ?? null };
},
[roomId]
);
return { messages, sendMessage }; return { messages, sendMessage, loading };
} }

View File

@@ -0,0 +1,57 @@
-- Chat room membership RLS + DM room helper
CREATE INDEX IF NOT EXISTS idx_rooms_spot ON public.chat_rooms (spot_id);
DROP POLICY IF EXISTS "Members viewable by authenticated" ON public.chat_room_members;
CREATE POLICY "Members viewable by authenticated"
ON public.chat_room_members FOR SELECT
USING (auth.role() = 'authenticated');
DROP POLICY IF EXISTS "Users can join rooms" ON public.chat_room_members;
CREATE POLICY "Users can join rooms"
ON public.chat_room_members FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE OR REPLACE FUNCTION public.get_or_create_dm_room(p_user_a UUID, p_user_b UUID)
RETURNS UUID AS $$
DECLARE
v_room_id UUID;
v_low UUID;
v_high UUID;
BEGIN
IF p_user_a IS NULL OR p_user_b IS NULL OR p_user_a = p_user_b THEN
RAISE EXCEPTION 'Invalid DM participants';
END IF;
IF p_user_a < p_user_b THEN
v_low := p_user_a;
v_high := p_user_b;
ELSE
v_low := p_user_b;
v_high := p_user_a;
END IF;
SELECT cr.id INTO v_room_id
FROM public.chat_rooms cr
JOIN public.chat_room_members m1 ON m1.room_id = cr.id AND m1.user_id = v_low
JOIN public.chat_room_members m2 ON m2.room_id = cr.id AND m2.user_id = v_high
WHERE cr.type = 'dm'
LIMIT 1;
IF v_room_id IS NOT NULL THEN
RETURN v_room_id;
END IF;
INSERT INTO public.chat_rooms (name, type, created_by)
VALUES ('Private Chat', 'dm', p_user_a)
RETURNING id INTO v_room_id;
INSERT INTO public.chat_room_members (room_id, user_id)
VALUES (v_room_id, v_low), (v_room_id, v_high)
ON CONFLICT DO NOTHING;
RETURN v_room_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
GRANT EXECUTE ON FUNCTION public.get_or_create_dm_room(UUID, UUID) TO authenticated;