.....
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||
CornerDownRight, Forward, Pencil, Download,
|
||||
} from "lucide-react"
|
||||
@@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { toast } from "sonner"
|
||||
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
||||
import data from "@emoji-mart/data"
|
||||
import Picker from "@emoji-mart/react"
|
||||
|
||||
@@ -124,6 +125,7 @@ export default function ChatsPage() {
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
|
||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
@@ -642,12 +644,9 @@ export default function ChatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
||||
<Phone className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
@@ -995,6 +994,8 @@ export default function ChatsPage() {
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get("session")?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No session" }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ token })
|
||||
}
|
||||
@@ -13,10 +13,11 @@ export async function GET() {
|
||||
c.id,
|
||||
c.updated_at,
|
||||
cp_me.last_read_at,
|
||||
u.id AS other_user_id,
|
||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||
u.email AS other_user_email,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
u.id AS other_user_id,
|
||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||
u.email AS other_user_email,
|
||||
u.phone AS other_user_phone,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
||||
@@ -39,6 +40,7 @@ export async function GET() {
|
||||
id: row.other_user_id,
|
||||
name: row.other_user_name,
|
||||
email: row.other_user_email,
|
||||
phone: row.other_user_phone || "",
|
||||
avatar: avatarSvgUrl(row.other_user_name),
|
||||
},
|
||||
lastMessage: row.last_message || "",
|
||||
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
|
||||
const otherUser = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, phone, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
@@ -105,6 +107,7 @@ export async function POST(request: NextRequest) {
|
||||
id: other.id,
|
||||
name: other.name,
|
||||
email: other.email,
|
||||
phone: other.phone || "",
|
||||
avatar: avatarSvgUrl(other.name),
|
||||
},
|
||||
lastMessage: "",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import crypto from "crypto"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { phone, token: clientToken } = await request.json()
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
||||
await query(
|
||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||
[token, phone],
|
||||
)
|
||||
|
||||
const origin = request.headers.get("origin") || "http://localhost:3000"
|
||||
const inviteUrl = `${origin}/join/${token}`
|
||||
|
||||
return NextResponse.json({ token, inviteUrl })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const phone = request.nextUrl.searchParams.get("phone")
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT id, username, first_name, last_name, phone, avatar_url
|
||||
FROM users
|
||||
WHERE phone = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[phone],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ found: false })
|
||||
}
|
||||
|
||||
const user = result.rows[0]
|
||||
return NextResponse.json({
|
||||
found: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar_url,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Lookup failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, use, useCallback } from "react"
|
||||
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
|
||||
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
||||
|
||||
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
|
||||
const { roomId } = use(params)
|
||||
|
||||
const {
|
||||
callState,
|
||||
isMuted,
|
||||
isSpeakerOn,
|
||||
callDuration,
|
||||
error,
|
||||
isReady,
|
||||
participants,
|
||||
joinRoom,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
formatDuration,
|
||||
} = useWebRTCCall({ anonymous: true })
|
||||
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [joined, setJoined] = useState(false)
|
||||
const [micError, setMicError] = useState<string | null>(null)
|
||||
const [connectionTimeout, setConnectionTimeout] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) return
|
||||
const timer = setTimeout(() => {
|
||||
if (!isReady) setConnectionTimeout(true)
|
||||
}, 10000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isReady])
|
||||
|
||||
const handleJoin = useCallback(async () => {
|
||||
if (!displayName.trim()) return
|
||||
setMicError(null)
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
joinRoom(roomId, displayName.trim() || "Anonymous")
|
||||
setJoined(true)
|
||||
} catch {
|
||||
setMicError("Microphone access is required to join the call. Please allow microphone access in your browser settings.")
|
||||
}
|
||||
}, [displayName, roomId, joinRoom])
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
endCall()
|
||||
}, [endCall])
|
||||
|
||||
if (!joined) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{connectionTimeout ? (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
|
||||
</>
|
||||
) : !isReady ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
|
||||
<p className="text-[#888888] text-sm">Connecting to call...</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
|
||||
{micError && (
|
||||
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
||||
placeholder="Enter your name"
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={!displayName.trim()}
|
||||
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Join Call
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{callState === "calling" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "waiting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||
{participants.length > 0 && (
|
||||
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "participant_joined" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "connecting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "connected" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
||||
{formatDuration(callDuration)}
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={toggleSpeaker}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(callState === "ended" || callState === "idle") && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
|
||||
{callDuration > 0 && (
|
||||
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { query } from "@/lib/db"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
function ErrorPage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
{message}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function JoinPage({ params }: Props) {
|
||||
const { token } = await params
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
if (UUID_REGEX.test(token)) {
|
||||
redirect(`/call/${token}`)
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
|
||||
[token],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return <ErrorPage message="This call link is invalid." />
|
||||
}
|
||||
|
||||
const invite = result.rows[0]
|
||||
|
||||
if (new Date(invite.expires_at) <= new Date()) {
|
||||
return <ErrorPage message="This call has expired." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
You're Invited!
|
||||
</h1>
|
||||
<p className="text-[#888888] text-sm mb-6">
|
||||
Someone wants to connect with you on our platform.
|
||||
</p>
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
||||
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] mb-6">
|
||||
Create an account to start making free voice calls to this person and others on the platform.
|
||||
</p>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
||||
>
|
||||
Create Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { COMPANY_NAME } from "@/lib/constants"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
|
||||
@@ -14,7 +14,8 @@ const waves = [
|
||||
]
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
@@ -215,7 +216,7 @@ export default function LoginPage() {
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/dashboard")
|
||||
window.location.href = redirectTo
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || "Invalid email or password.")
|
||||
|
||||
Reference in New Issue
Block a user