Live Supabase chat rooms + password reset flow
This commit is contained in:
37
deploy/apply-chat-migration.py
Normal file
37
deploy/apply-chat-migration.py
Normal 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.")
|
||||||
113
src/app/auth/reset-password/page.tsx
Normal file
113
src/app/auth/reset-password/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
92
src/lib/chat-store.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
57
supabase/migration_v7_chat.sql
Normal file
57
supabase/migration_v7_chat.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user