Skip to content

Commit 30a8ac9

Browse files
authored
Merge pull request #357 from GTBitsOfGood/attachments-tab-in-support-tickets
created TicketAttachmentsButton.tsx and modified TicketChannelHeader.tsx
2 parents bdaa66a + 0818985 commit 30a8ac9

4 files changed

Lines changed: 147 additions & 4 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ next-env.d.ts
4646
# email previews (development only)
4747
/email-previews/
4848
AGENTS.md
49+
CLAUDE.local.md
4950

5051
# Local Netlify folder
5152
.netlify

‎src/components/tickets/ChannelPreview.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ export default function ChannelPreview({
3333
const hasUnread = unread > 0;
3434

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

6060
{/* Bottom Row */}
6161
<div className="flex justify-between items-center text-sm text-gray-600">
62-
<div className="flex-1 min-w-0 max-w-[170px] truncate text-ellipsis">
62+
<div className="flex-1 min-w-0 max-w-[170px] truncate text-ellipsis [&_a]:pointer-events-none">
6363
{latestMessagePreview == "Nothing yet..." ? "" : latestMessagePreview}
6464
</div>
6565
<div className="flex items-center gap-2">
@@ -71,6 +71,6 @@ export default function ChannelPreview({
7171
</div>
7272
</div>
7373
</div>
74-
</a>
74+
</div>
7575
);
7676
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { useEffect, useMemo, useRef, useState } from "react";
2+
import { useChannelStateContext } from "stream-chat-react";
3+
import { isScrapedContent, type LocalMessage } from "stream-chat";
4+
import { Paperclip, FileText, LinkSimple } from "@phosphor-icons/react";
5+
import Portal from "../baseTable/Portal";
6+
7+
const MAX_HISTORY_PAGES = 20;
8+
const HISTORY_PAGE_SIZE = 100;
9+
10+
export default function TicketAttachmentsButton() {
11+
const [isOpen, setIsOpen] = useState(false);
12+
const buttonRef = useRef<HTMLButtonElement>(null);
13+
const { channel } = useChannelStateContext();
14+
const [messages, setMessages] = useState<LocalMessage[]>(
15+
() => channel.state.messages
16+
);
17+
const backfilledChannelsRef = useRef(new Set<string>());
18+
19+
useEffect(() => {
20+
setMessages([...channel.state.messages]);
21+
22+
const refresh = () => setMessages([...channel.state.messages]);
23+
channel.on("message.new", refresh);
24+
channel.on("message.updated", refresh);
25+
channel.on("message.deleted", refresh);
26+
27+
let cancelled = false;
28+
29+
const backfillHistory = async () => {
30+
const cid = channel.cid;
31+
if (backfilledChannelsRef.current.has(cid)) return;
32+
33+
for (let page = 0; page < MAX_HISTORY_PAGES; page++) {
34+
if (cancelled) return;
35+
if (!channel.state.messagePagination.hasPrev) break;
36+
37+
const oldestId = channel.state.messages[0]?.id;
38+
try {
39+
await channel.query({
40+
messages: {
41+
limit: HISTORY_PAGE_SIZE,
42+
...(oldestId ? { id_lt: oldestId } : {}),
43+
},
44+
});
45+
} catch (error) {
46+
console.error("Failed to backfill ticket message history:", error);
47+
break;
48+
}
49+
50+
if (cancelled) return;
51+
setMessages([...channel.state.messages]);
52+
}
53+
54+
if (!cancelled) backfilledChannelsRef.current.add(cid);
55+
};
56+
57+
backfillHistory();
58+
59+
return () => {
60+
cancelled = true;
61+
channel.off("message.new", refresh);
62+
channel.off("message.updated", refresh);
63+
channel.off("message.deleted", refresh);
64+
};
65+
}, [channel]);
66+
67+
const attachments = useMemo(() => {
68+
return (messages ?? [])
69+
.filter((message) => !message.deleted_at)
70+
.flatMap((message) => message.attachments ?? [])
71+
.filter(
72+
(attachment) =>
73+
isScrapedContent(attachment) ||
74+
attachment.image_url ||
75+
attachment.asset_url
76+
);
77+
}, [messages]);
78+
79+
return (
80+
<div className="relative">
81+
<button
82+
ref={buttonRef}
83+
onClick={(e) => {
84+
e.stopPropagation();
85+
setIsOpen(!isOpen);
86+
}}
87+
className="flex items-center gap-1 px-2 py-1 rounded-full hover:bg-sunken hover:shadow-md transition-all duration-150 text-sm"
88+
>
89+
<Paperclip size={18} />
90+
Attachments
91+
</button>
92+
<Portal
93+
isOpen={isOpen}
94+
onClose={() => setIsOpen(false)}
95+
triggerRef={buttonRef}
96+
position="bottom-left"
97+
className="w-72 max-h-96 overflow-y-auto rounded-md bg-white shadow-lg ring-1 ring-black/5 p-2"
98+
>
99+
{attachments.length === 0 ? (
100+
<p className="text-sm text-gray-primary/70 px-2 py-3">
101+
No attachments yet.
102+
</p>
103+
) : (
104+
attachments.map((attachment, i) => (
105+
<a
106+
key={i}
107+
href={
108+
isScrapedContent(attachment)
109+
? attachment.title_link || attachment.og_scrape_url
110+
: (attachment.asset_url ?? attachment.image_url)
111+
}
112+
target="_blank"
113+
rel="noopener noreferrer"
114+
className="flex items-center gap-2 px-2 py-2 rounded hover:bg-gray-100"
115+
>
116+
{attachment.image_url || attachment.thumb_url ? (
117+
// eslint-disable-next-line @next/next/no-img-element
118+
<img
119+
src={attachment.thumb_url ?? attachment.image_url}
120+
alt={attachment.title ?? attachment.fallback ?? "attachment"}
121+
className="w-8 h-8 object-cover rounded shrink-0"
122+
/>
123+
) : isScrapedContent(attachment) ? (
124+
<LinkSimple size={20} className="shrink-0" />
125+
) : (
126+
<FileText size={20} className="shrink-0" />
127+
)}
128+
<span className="text-sm truncate">
129+
{attachment.title ??
130+
attachment.fallback ??
131+
attachment.og_scrape_url ??
132+
"Untitled file"}
133+
</span>
134+
</a>
135+
))
136+
)}
137+
</Portal>
138+
</div>
139+
);
140+
}

‎src/components/tickets/TicketChannelHeader.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ExtraChannelData } from "@/types/api/streamio.types";
22
import { useChannelStateContext } from "stream-chat-react";
33
import ChannelOptionsButton from "./ChannelOptionsButton";
4+
import TicketAttachmentsButton from "./TicketAttachmentsButton";
45
import { useSession } from "next-auth/react";
56
import { isStaff } from "@/lib/userUtils";
67

@@ -29,6 +30,7 @@ export default function TicketChannelHeader() {
2930
>
3031
{data.closed ? "Closed" : "Open"}
3132
</span>
33+
<TicketAttachmentsButton />
3234
</div>
3335
{!data.closed && isStaffUser && (
3436
<ChannelOptionsButton channel={channel} />

0 commit comments

Comments
 (0)