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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ next-env.d.ts
# email previews (development only)
/email-previews/
AGENTS.md
CLAUDE.local.md

# Local Netlify folder
.netlify
Expand Down
8 changes: 4 additions & 4 deletions src/components/tickets/ChannelPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ export default function ChannelPreview({
const hasUnread = unread > 0;

return (
// Use <a> instead of <button> because we need buttons inside and buttons cannot have other buttons inside them
<a
// Not <button> or <a>: this holds a button, and message previews can contain links
<div
onClick={() => setActiveChannel?.(channel)}
className={`flex flex-col p-3 rounded-lg my-1 relative ${
isActive ? "bg-blue-light" : "bg-white"
Expand All @@ -59,7 +59,7 @@ export default function ChannelPreview({

{/* Bottom Row */}
<div className="flex justify-between items-center text-sm text-gray-600">
<div className="flex-1 min-w-0 max-w-[170px] truncate text-ellipsis">
<div className="flex-1 min-w-0 max-w-[170px] truncate text-ellipsis [&_a]:pointer-events-none">
{latestMessagePreview == "Nothing yet..." ? "" : latestMessagePreview}
</div>
<div className="flex items-center gap-2">
Expand All @@ -71,6 +71,6 @@ export default function ChannelPreview({
</div>
</div>
</div>
</a>
</div>
);
}
140 changes: 140 additions & 0 deletions src/components/tickets/TicketAttachmentsButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useChannelStateContext } from "stream-chat-react";
import { isScrapedContent, type LocalMessage } from "stream-chat";
import { Paperclip, FileText, LinkSimple } from "@phosphor-icons/react";
import Portal from "../baseTable/Portal";

const MAX_HISTORY_PAGES = 20;
const HISTORY_PAGE_SIZE = 100;

export default function TicketAttachmentsButton() {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const { channel } = useChannelStateContext();
const [messages, setMessages] = useState<LocalMessage[]>(
() => channel.state.messages
);
const backfilledChannelsRef = useRef(new Set<string>());

useEffect(() => {
setMessages([...channel.state.messages]);

const refresh = () => setMessages([...channel.state.messages]);
channel.on("message.new", refresh);
channel.on("message.updated", refresh);
channel.on("message.deleted", refresh);

let cancelled = false;

const backfillHistory = async () => {
const cid = channel.cid;
if (backfilledChannelsRef.current.has(cid)) return;

for (let page = 0; page < MAX_HISTORY_PAGES; page++) {
if (cancelled) return;
if (!channel.state.messagePagination.hasPrev) break;

const oldestId = channel.state.messages[0]?.id;
try {
await channel.query({
messages: {
limit: HISTORY_PAGE_SIZE,
...(oldestId ? { id_lt: oldestId } : {}),
},
});
} catch (error) {
console.error("Failed to backfill ticket message history:", error);
break;
}

if (cancelled) return;
setMessages([...channel.state.messages]);
}

if (!cancelled) backfilledChannelsRef.current.add(cid);
};

backfillHistory();

return () => {
cancelled = true;
channel.off("message.new", refresh);
channel.off("message.updated", refresh);
channel.off("message.deleted", refresh);
};
}, [channel]);

const attachments = useMemo(() => {
return (messages ?? [])
.filter((message) => !message.deleted_at)
.flatMap((message) => message.attachments ?? [])
.filter(
(attachment) =>
isScrapedContent(attachment) ||
attachment.image_url ||
attachment.asset_url
);
}, [messages]);

return (
<div className="relative">
<button
ref={buttonRef}
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
className="flex items-center gap-1 px-2 py-1 rounded-full hover:bg-sunken hover:shadow-md transition-all duration-150 text-sm"
>
<Paperclip size={18} />
Attachments
</button>
<Portal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
triggerRef={buttonRef}
position="bottom-left"
className="w-72 max-h-96 overflow-y-auto rounded-md bg-white shadow-lg ring-1 ring-black/5 p-2"
>
{attachments.length === 0 ? (
<p className="text-sm text-gray-primary/70 px-2 py-3">
No attachments yet.
</p>
) : (
attachments.map((attachment, i) => (
<a
key={i}
href={
isScrapedContent(attachment)
? attachment.title_link || attachment.og_scrape_url
: (attachment.asset_url ?? attachment.image_url)
}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-2 py-2 rounded hover:bg-gray-100"
>
{attachment.image_url || attachment.thumb_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachment.thumb_url ?? attachment.image_url}
alt={attachment.title ?? attachment.fallback ?? "attachment"}
className="w-8 h-8 object-cover rounded shrink-0"
/>
) : isScrapedContent(attachment) ? (
<LinkSimple size={20} className="shrink-0" />
) : (
<FileText size={20} className="shrink-0" />
)}
<span className="text-sm truncate">
{attachment.title ??
attachment.fallback ??
attachment.og_scrape_url ??
"Untitled file"}
</span>
</a>
))
)}
</Portal>
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/tickets/TicketChannelHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ExtraChannelData } from "@/types/api/streamio.types";
import { useChannelStateContext } from "stream-chat-react";
import ChannelOptionsButton from "./ChannelOptionsButton";
import TicketAttachmentsButton from "./TicketAttachmentsButton";
import { useSession } from "next-auth/react";
import { isStaff } from "@/lib/userUtils";

Expand Down Expand Up @@ -29,6 +30,7 @@ export default function TicketChannelHeader() {
>
{data.closed ? "Closed" : "Open"}
</span>
<TicketAttachmentsButton />
</div>
{!data.closed && isStaffUser && (
<ChannelOptionsButton channel={channel} />
Expand Down
Loading