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
4 changes: 0 additions & 4 deletions app/components/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -516,9 +516,7 @@ export default function Chat({ user }: { user: User }) {

return (
<div className="flex flex-col h-screen">
{/* ── Conversation bar: labels above icon rows so text + avatars align cleanly ── */}
<div className="flex items-stretch gap-3 px-3 py-2 border-b border-white/[0.06] bg-[#0a0a1a]/80 backdrop-blur-md flex-shrink-0">
{/* Global: pinned icons, label sits under the icon */}
<div className="flex-shrink-0 min-w-0">
<Conversations
conversations={globalConversations}
Expand All @@ -531,7 +529,6 @@ export default function Chat({ user }: { user: User }) {

<div className="w-px self-stretch bg-white/[0.08] flex-shrink-0 my-1" />

{/* DMs: scrollable list, labels sit under each icon */}
<div className="flex-1 min-w-0 overflow-x-auto">
<div className="flex gap-2 items-start">
<Conversations
Expand All @@ -544,7 +541,6 @@ export default function Chat({ user }: { user: User }) {
</div>
</div>

{/* + aligned with avatar row (bottom), not with section title */}
<button
onClick={() => setShowModal(true)}
className="flex-shrink-0 self-center w-8 h-8 rounded-full bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center hover:bg-indigo-500/25 transition"
Expand Down
261 changes: 232 additions & 29 deletions app/components/chat/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ 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 { createPortal } from "react-dom";
import Image from "next/image";
import { faFile } from "@fortawesome/free-solid-svg-icons";
import { faDownload, faFile, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Player from "./Player";

const RANK_BORDER: Record<string, { border: string; ring: string }> = {
"MISSION IMPOSSIBLE": {
Expand Down Expand Up @@ -42,6 +44,16 @@ export default function Messages({
badgesByUserId?: Record<string, { label: string; className: string }>;
}) {
const [showScrollBtn, setShowScrollBtn] = useState(false);
const [mediaViewer, setMediaViewer] = useState<{
type: "image" | "video";
url: string;
filename: string;
} | null>(null);
const [isMounted, setIsMounted] = useState(false);

useEffect(() => {
setIsMounted(true);
}, []);

useEffect(() => {
const container = document.getElementById("chat-container");
Expand All @@ -55,8 +67,88 @@ export default function Messages({
return () => container.removeEventListener("scroll", handleScroll);
}, []);

const handleDownloadMedia = async () => {
if (!mediaViewer) return;
try {
const res = await fetch(mediaViewer.url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = mediaViewer.filename || "media";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);
} catch (err) {
console.error("Media download failed:", err);
window.open(mediaViewer.url, "_blank", "noopener,noreferrer");
}
};

return (
<>
{isMounted &&
mediaViewer &&
createPortal(
<div
className="fixed inset-0 z-[9999] bg-black/85 backdrop-blur-sm flex items-center justify-center p-0 sm:p-4"
onClick={() => setMediaViewer(null)}
>
<div
className="w-screen h-[100dvh] sm:w-full sm:h-auto sm:max-w-5xl sm:max-h-[90vh] rounded-none sm:rounded-2xl border-0 sm:border border-white/10 bg-[#0d0d18]/95 shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div
className={`${
mediaViewer.type === "video"
? "h-[100dvh] sm:h-[90vh] p-0 bg-black"
: "h-[100dvh] sm:h-[90vh] p-0 bg-black/30"
} flex items-center justify-center`}
>
{mediaViewer.type === "image" ? (
<div className="relative h-full w-full flex items-center justify-center">
<img
src={mediaViewer.url}
alt={mediaViewer.filename}
className="h-full w-full object-contain"
/>
<div className="absolute top-3 right-3 z-30 flex items-center gap-2">
<button
type="button"
onClick={handleDownloadMedia}
className="w-8 h-8 rounded-md text-white/90 hover:text-white transition"
aria-label="Download media"
>
<FontAwesomeIcon icon={faDownload} className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => setMediaViewer(null)}
className="w-8 h-8 rounded-md text-white/90 hover:text-white transition"
aria-label="Close viewer"
>
<FontAwesomeIcon icon={faXmark} className="w-3 h-3" />
</button>
</div>
</div>
) : (
<Player
src={mediaViewer.url}
autoPlay={true}
immersive={true}
className="w-full h-full"
onDownload={handleDownloadMedia}
onClose={() => setMediaViewer(null)}
/>
)}
</div>
</div>
</div>,
document.body,
)}

<div
className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 space-y-1"
id="chat-container"
Expand Down Expand Up @@ -95,11 +187,11 @@ export default function Messages({
const badgeLabel = badge?.label ?? "NEWBIE";
const rankBorder = RANK_BORDER[badgeLabel] ?? DEFAULT_RANK_BORDER;

const badgePillClass = isSelf
? `bg-indigo-500/10 text-indigo-200 ring-1 ${rankBorder.border} ${rankBorder.ring}`
: badge?.className ?? "bg-white/[0.03] border-white/10 text-gray-400";
const badgePillClass =
badge?.className ??
`bg-white/[0.03] text-gray-300 ring-1 ${rankBorder.border} ${rankBorder.ring}`;

// long msg = avatar up a lil, no cap.
// long msg? nudge avatar up, ez.
const text = msg.text ?? "";
const hasMedia = !!msg.attachments?.length;
const isLongMessage = hasMedia || text.length >= 120 || text.includes("\n");
Expand Down Expand Up @@ -167,16 +259,25 @@ export default function Messages({
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
const codeText = String(children).replace(/\n$/, "");

if (!match) {
return (
<code
className="px-1 py-0.5 rounded bg-white/10 text-[0.85em]"
{...(props as Record<string, unknown>)}
>
{children}
</code>
);
}

return (
<SyntaxHighlighter
style={atomDark as { [key: string]: React.CSSProperties }}
language={match ? match[1] : "text"}
PreTag="pre"
className="rounded-xl text-xs border border-white/10 !bg-neutral-900/60"
{...(props as Record<string, unknown>)}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
<CodeBlock
code={codeText}
language={match[1]}
props={props as Record<string, unknown>}
/>
);
},
}}
Expand All @@ -190,7 +291,9 @@ export default function Messages({
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-1.5 space-y-1.5">
{msg.attachments.map((att, i) => (
<div key={i}>{getAttachments(att)}</div>
<div key={i}>
{getAttachments(att, (payload) => setMediaViewer(payload))}
</div>
))}
</div>
)}
Expand All @@ -206,27 +309,127 @@ export default function Messages({
);
}

function getAttachments(attachment: {
mimetype: string;
public_url: string;
filename: string;
function CodeBlock({
code,
language,
props,
}: {
code: string;
language: string;
props: Record<string, unknown>;
}) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch (err) {
console.error("Copy code failed:", err);
}
};

return (
<div className="relative group/code max-w-full">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-1 rounded-md border border-white/15 bg-black/45 text-gray-300 hover:text-white hover:bg-black/60 transition opacity-0 group-hover/code:opacity-100"
>
{copied ? "Copied" : "Copy"}
</button>
<SyntaxHighlighter
style={atomDark as { [key: string]: React.CSSProperties }}
language={language}
PreTag="pre"
wrapLongLines={true}
className="rounded-xl text-xs border border-white/10 !bg-neutral-900/60 max-w-full"
codeTagProps={{
style: {
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
},
}}
customStyle={{
width: "100%",
maxWidth: "100%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
overflowX: "hidden",
margin: 0,
}}
{...props}
>
{code}
</SyntaxHighlighter>
</div>
);
}

function getAttachments(
attachment: {
mimetype: string;
public_url: string;
filename: string;
},
onOpenMedia: (payload: { type: "image" | "video"; url: string; filename: string }) => void,
) {
switch (attachment.mimetype.split("/")[0]) {
case "image":
return (
<Image
src={attachment.public_url}
alt={attachment.filename}
className="max-w-full rounded-xl border border-white/10"
width={320}
height={240}
/>
<button
type="button"
onClick={() =>
onOpenMedia({
type: "image",
url: attachment.public_url,
filename: attachment.filename,
})
}
className="group relative block w-full max-w-[320px] sm:max-w-[380px] overflow-hidden rounded-xl border border-white/10 bg-black/30"
>
<Image
src={attachment.public_url}
alt={attachment.filename}
className="w-full h-auto rounded-xl transition-transform duration-200 group-hover:scale-[1.01]"
width={520}
height={320}
/>
<div className="absolute inset-x-0 bottom-0 px-2 py-1.5 bg-gradient-to-t from-black/70 to-transparent text-left">
<span className="text-[11px] text-gray-200">Tap to preview</span>
</div>
</button>
);
case "video":
return (
<video controls className="max-w-full rounded-xl border border-white/10">
<source src={attachment.public_url} type={attachment.mimetype} />
</video>
<button
type="button"
onClick={() =>
onOpenMedia({
type: "video",
url: attachment.public_url,
filename: attachment.filename,
})
}
className="group relative w-full max-w-full overflow-hidden rounded-xl border border-white/10 bg-black/35 text-left"
>
<video
muted
playsInline
className="w-full h-auto max-h-[420px] object-contain bg-black opacity-95 group-hover:opacity-100 transition"
>
<source src={attachment.public_url} type={attachment.mimetype} />
</video>
<div className="absolute inset-0 flex items-center justify-center">
<span className="px-3 py-1 rounded-full bg-black/60 border border-white/20 text-xs text-gray-100">
Play video
</span>
</div>
</button>
);
case "audio":
return (
Expand Down
Loading
Loading