Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
NODE_ENV=development
NEXT_PUBLIC_NODE_ENV=development

NEXT_PUBLIC_SUPABASE_BUCKET_NAME=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=

Expand Down
159 changes: 140 additions & 19 deletions app/components/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ import { useEffect, useRef, useState } from "react";
import { RealtimeChannel, User } from "@supabase/supabase-js";
import { createClient } from "../lib/supabase/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPaperPlane, faPlus } from "@fortawesome/free-solid-svg-icons";
import {
faFile,
faPaperPlane,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
import Conversations from "./chat/Conversations";
import Messages from "./chat/Messages";
import Image from "next/image";
import { toast } from "react-toastify";

export interface Conversation {
id: string;
Expand All @@ -19,6 +25,12 @@ export interface Message {
conversation_id: string;
sender_id: string;
text: string;
attachments: {
filename: string;
mimetype: string;
filesize: number;
public_url: string;
}[];
created_at: string;
}

Expand All @@ -39,6 +51,8 @@ export interface ConversationParticipantRow {
conversation: ConversationParticipant[];
}

type Attachment = File;

const supabase = createClient();

export default function Chat({ user }: { user: User }) {
Expand All @@ -54,6 +68,9 @@ export default function Chat({ user }: { user: User }) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const [badWords, setBadWords] = useState<string[]>([]);
const creatingRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || "";

useEffect(() => {
fetch(
Expand All @@ -66,13 +83,6 @@ export default function Chat({ user }: { user: User }) {
});
}, []);

const sanitizeInput = (input: string) => {
if (!badWords.length) return input;

const filter = new RegExp(`\\b(${badWords.join("|")})\\b`, "gi");
return input.replace(filter, "*-?;[]");
};

useEffect(() => {
if (textareaRef.current) {
const el = textareaRef.current;
Expand Down Expand Up @@ -207,6 +217,7 @@ export default function Chat({ user }: { user: User }) {
conversation_id: payload.new.conversation_id,
sender_id: payload.new.sender_id,
text: payload.new.text,
attachments: payload.new.attachments,
created_at: payload.new.created_at,
},
]);
Expand All @@ -226,7 +237,7 @@ export default function Chat({ user }: { user: User }) {
.eq("conversation_id", conversationId)
.order("created_at", { ascending: true });
if (data) {
setMessages(data);
setMessages(data as Message[]);
setTimeout(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, 100);
Expand Down Expand Up @@ -261,6 +272,24 @@ export default function Chat({ user }: { user: User }) {
fetchUsers();
}, [showModal, user.id]);

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;

setAttachments((prev) => [...prev, ...files]);
};

const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};

const sanitizeInput = (input: string) => {
if (!badWords.length) return input;

const filter = new RegExp(`\\b(${badWords.join("|")})\\b`, "gi");
return input.replace(filter, "*-?;[]");
};

const createConversation = async (otherUser: ChatUser) => {
if (creatingRef.current) return;
creatingRef.current = true;
Expand Down Expand Up @@ -320,19 +349,62 @@ export default function Chat({ user }: { user: User }) {
};

const sendMessage = async () => {
if (!input.trim() || !conversationId) return;
if ((!input.trim() && attachments.length === 0) || !conversationId) return;

try {
const uploadedAttachments = await Promise.all(
attachments.map(async (file) => {
if (!bucketName || bucketName.length === 0) {
toast.error("Storage bucket is not configured.");
return null;
}
if (file.size > 10 * 1024 * 1024) {
toast.error(`${file.name} is too large. Max size is 10MB.`);
return null;
}

const filePath = `messages/${conversationId}/${Date.now()}-${file.name}`;

const { error: uploadError } = await supabase.storage
.from(bucketName)
.upload(filePath, file);

if (uploadError) {
console.error("Upload error:", uploadError);
return null;
}

const { data } = supabase.storage
.from(bucketName)
.getPublicUrl(filePath);

await supabase.from("messages").insert({
conversation_id: conversationId,
sender_id: user.id,
text: sanitizeInput(input.slice(0, 1000)), // limit to 1000 chars
});
return {
filename: file.name,
mimetype: file.type,
filesize: file.size,
public_url: data.publicUrl,
};
}),
);

setTimeout(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, 100);
const validAttachments = uploadedAttachments.filter(Boolean);

setInput("");
await supabase.from("messages").insert({
conversation_id: conversationId,
sender_id: user.id,
text: sanitizeInput(input.slice(0, 1000)),
attachments: validAttachments,
});

setTimeout(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, 100);

setInput("");
setAttachments([]);
} catch (err) {
console.error("Send message error:", err);
}
};

return (
Expand Down Expand Up @@ -368,7 +440,48 @@ export default function Chat({ user }: { user: User }) {
/>

<div className="p-4 border-t border-neutral-700">
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((file, index) => (
<div
key={index}
className="bg-neutral-700 text-sm px-3 py-1 rounded flex items-center gap-2"
>
{file.type.startsWith("image/") ? (
<Image
src={URL.createObjectURL(file)}
alt={file.name}
width={20}
height={20}
className="w-6 h-6 rounded object-cover"
/>
) : (
<FontAwesomeIcon
icon={faFile}
className="w-4 h-4 text-gray-400"
/>
)}
<span className="truncate max-w-[120px]">{file.name}</span>
<button
onClick={() => removeAttachment(index)}
className="text-red-400 hover:text-red-300"
>
</button>
</div>
))}
</div>
)}

<div className="bg-neutral-800 rounded flex">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
/>

<textarea
ref={textareaRef}
value={input}
Expand All @@ -387,6 +500,14 @@ export default function Chat({ user }: { user: User }) {
maxHeight: "calc(1.5rem * 6)",
}}
/>

<button onClick={() => fileInputRef.current?.click()}>
<FontAwesomeIcon
icon={faFile}
className="text-gray-500 hover:text-gray-300 transition mx-2"
/>
</button>

<button
onClick={sendMessage}
className="bg-indigo-500 px-4 rounded max-h-12"
Expand Down
58 changes: 58 additions & 0 deletions app/components/chat/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { Conversation, Message } from "../Chat";
import { timeAgo } from "@/app/utils/time";
import { useEffect, useState } from "react";
import Image from "next/image";
import { faFile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";

export default function Messages({
messages,
Expand Down Expand Up @@ -127,6 +130,16 @@ export default function Messages({
>
{msg.text}
</ReactMarkdown>

{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2">
{msg.attachments.map((attachment, idx) => (
<div key={idx} className="mb-2">
{getAttachments(attachment)}
</div>
))}
</div>
)}
</div>

<div className="text-muted text-xs mt-1">
Expand All @@ -141,3 +154,48 @@ export default function Messages({
</>
);
}

function getAttachments(attachment: {
mimetype: string;
public_url: string;
filename: string;
}) {
switch (attachment.mimetype.split("/")[0]) {
case "image":
return (
<Image
src={attachment.public_url}
alt={attachment.filename}
className="max-w-full rounded"
width={400}
height={300}
/>
);
case "video":
return (
<video controls className="max-w-full rounded">
<source src={attachment.public_url} type={attachment.mimetype} />
Your browser does not support the video tag.
</video>
);
case "audio":
return (
<audio controls className="w-full">
<source src={attachment.public_url} type={attachment.mimetype} />
Your browser does not support the audio element.
</audio>
);
default:
return (
<a
href={attachment.public_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
<FontAwesomeIcon icon={faFile} className="mr-1" />
{attachment.filename}
</a>
);
}
}
6 changes: 6 additions & 0 deletions app/supabase-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,19 @@ export type Database = {
Row: {
conversation_id: string
email: string | null
type: string
user_id: string
}
Insert: {
conversation_id: string
email?: string | null
type?: string
user_id: string
}
Update: {
conversation_id?: string
email?: string | null
type?: string
user_id?: string
}
Relationships: [
Expand Down Expand Up @@ -125,20 +128,23 @@ export type Database = {
}
messages: {
Row: {
attachments: Json
conversation_id: string
created_at: string
id: string
sender_id: string
text: string
}
Insert: {
attachments?: Json
conversation_id: string
created_at?: string
id?: string
sender_id: string
text: string
}
Update: {
attachments?: Json
conversation_id?: string
created_at?: string
id?: string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ALTER TABLE messages
ADD COLUMN attachments JSONB NOT NULL DEFAULT '[]'::jsonb;

-- allow authenticated users to upload
CREATE POLICY "Allow uploads by owner"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = owner);
Loading