init
This commit is contained in:
619
chat/chat.js
Normal file
619
chat/chat.js
Normal file
@@ -0,0 +1,619 @@
|
||||
import server from "/@server/server.js"
|
||||
|
||||
css(`
|
||||
chat-, chat- * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
chat- {
|
||||
font-family: 'Arial';
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
chat-::-webkit-scrollbar { display: none; }
|
||||
chat- input::placeholder {
|
||||
color: var(--headertext);
|
||||
opacity: 0.35;
|
||||
}
|
||||
`)
|
||||
|
||||
class Chat extends Shadow {
|
||||
selectedChatId = null
|
||||
searchText = ""
|
||||
searchOpen = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.currentUser = { id: 1, name: "You" }
|
||||
|
||||
const ago = (h) => new Date(Date.now() - h * 3600000)
|
||||
const msg = (senderId, senderName, text, hoursAgo) => ({ id: Math.random(), senderId, senderName, text, sentAt: ago(hoursAgo) })
|
||||
|
||||
this.chats = [
|
||||
{
|
||||
id: 1, type: "dm", name: "Sarah McIntyre",
|
||||
members: ["Sarah McIntyre"], memberCount: 2, unread: 2,
|
||||
lastMessage: { senderId: 2, senderName: "Sarah McIntyre", text: "Can you review the PR when you get a chance?", sentAt: ago(0.3) },
|
||||
messages: [
|
||||
msg(1, "You", "Hey Sarah, did you see the new design mockups?", 24),
|
||||
msg(2, "Sarah McIntyre", "Just looked — they're really clean. I love the new sidebar.", 23.5),
|
||||
msg(1, "You", "Agreed. Alex did a great job.", 23.4),
|
||||
msg(2, "Sarah McIntyre", "Are we going to ship this week or wait for the backend?", 5),
|
||||
msg(1, "You", "Let's aim for Thursday. I'll sync with Marcus.", 4.8),
|
||||
msg(2, "Sarah McIntyre", "Sounds good 👍", 4.7),
|
||||
msg(2, "Sarah McIntyre", "Can you review the PR when you get a chance?", 0.3),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2, type: "dm", name: "Marcus Webb",
|
||||
members: ["Marcus Webb"], memberCount: 2, unread: 0,
|
||||
lastMessage: { senderId: 1, senderName: "You", text: "I'll send over the specs by EOD", sentAt: ago(1.5) },
|
||||
messages: [
|
||||
msg(3, "Marcus Webb", "Hey, the API endpoint is returning 500s on staging.", 3),
|
||||
msg(1, "You", "Oh no — is it the auth middleware again?", 2.9),
|
||||
msg(3, "Marcus Webb", "Yep. Same issue as last week.", 2.8),
|
||||
msg(1, "You", "I'll patch it now. Give me 20 mins.", 2.75),
|
||||
msg(3, "Marcus Webb", "Thanks, no rush.", 2.7),
|
||||
msg(1, "You", "Fixed. Can you redeploy and check?", 2.2),
|
||||
msg(3, "Marcus Webb", "All green 🎉 Thanks!", 2.1),
|
||||
msg(1, "You", "I'll send over the specs by EOD", 1.5),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3, type: "dm", name: "Priya Anand",
|
||||
members: ["Priya Anand"], memberCount: 2, unread: 0,
|
||||
lastMessage: { senderId: 4, senderName: "Priya Anand", text: "See you at the standup!", sentAt: ago(18) },
|
||||
messages: [
|
||||
msg(4, "Priya Anand", "Quick question — what's the launch date for v2?", 20),
|
||||
msg(1, "You", "Still TBD, but we're targeting end of May.", 19.8),
|
||||
msg(4, "Priya Anand", "Got it. I'll update the roadmap doc.", 19.5),
|
||||
msg(1, "You", "Perfect, thanks Priya.", 19.4),
|
||||
msg(4, "Priya Anand", "See you at the standup!", 18),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4, type: "group", name: "Product Team",
|
||||
members: ["Sarah McIntyre", "Marcus Webb", "Priya Anand", "You"], memberCount: 4, unread: 5,
|
||||
lastMessage: { senderId: 2, senderName: "Sarah McIntyre", text: "I've updated the Figma file with the new flows", sentAt: ago(0.15) },
|
||||
messages: [
|
||||
msg(3, "Marcus Webb", "Morning everyone! API docs are updated.", 8),
|
||||
msg(4, "Priya Anand", "Nice work Marcus 🙌", 7.9),
|
||||
msg(1, "You", "I'll start on the integration tests today.", 7.8),
|
||||
msg(2, "Sarah McIntyre", "Great. I'm finishing up the onboarding screens.", 7.5),
|
||||
msg(4, "Priya Anand", "Can we do a quick sync at 2pm?", 2),
|
||||
msg(1, "You", "Works for me.", 1.95),
|
||||
msg(3, "Marcus Webb", "Same", 1.9),
|
||||
msg(2, "Sarah McIntyre", "I'll send the invite.", 1.85),
|
||||
msg(2, "Sarah McIntyre", "I've updated the Figma file with the new flows", 0.15),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5, type: "group", name: "Design Review",
|
||||
members: ["Sarah McIntyre", "You", "Jordan Kim"], memberCount: 3, unread: 0,
|
||||
lastMessage: { senderId: 5, senderName: "Jordan Kim", text: "The contrast on mobile looks off — can we bump it?", sentAt: ago(26) },
|
||||
messages: [
|
||||
msg(5, "Jordan Kim", "Hey, sharing the first round of designs for the settings page.", 30),
|
||||
msg(2, "Sarah McIntyre", "These look great! Love the card layout.", 29.5),
|
||||
msg(1, "You", "Agreed. One thought — the spacing on the form feels a bit tight.", 29),
|
||||
msg(5, "Jordan Kim", "Good call. I'll loosen it up.", 28.8),
|
||||
msg(2, "Sarah McIntyre", "Also maybe we increase the font size slightly?", 28),
|
||||
msg(5, "Jordan Kim", "The contrast on mobile looks off — can we bump it?", 26),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6, type: "channel", name: "general",
|
||||
members: ["Sarah McIntyre", "Marcus Webb", "Priya Anand", "Jordan Kim", "You"], memberCount: 24, unread: 11,
|
||||
lastMessage: { senderId: 3, senderName: "Marcus Webb", text: "Just pushed the hotfix to production", sentAt: ago(0.08) },
|
||||
messages: [
|
||||
msg(4, "Priya Anand", "Good morning team! Reminder: all-hands is Thursday at 10am.", 9),
|
||||
msg(2, "Sarah McIntyre", "Thanks for the reminder!", 8.9),
|
||||
msg(5, "Jordan Kim", "Will there be a recording for those in other time zones?", 8.7),
|
||||
msg(4, "Priya Anand", "Yes — I'll post the link in #announcements after.", 8.6),
|
||||
msg(1, "You", "Thanks Priya 🙏", 8.5),
|
||||
msg(3, "Marcus Webb", "Staging is back up btw, had a brief outage this morning.", 4),
|
||||
msg(2, "Sarah McIntyre", "Oh I didn't even notice, nice quick fix!", 3.8),
|
||||
msg(3, "Marcus Webb", "Just pushed the hotfix to production", 0.08),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7, type: "channel", name: "engineering",
|
||||
members: ["Marcus Webb", "You"], memberCount: 8, unread: 0,
|
||||
lastMessage: { senderId: 1, senderName: "You", text: "PR is up: #247 — adds rate limiting to the auth routes", sentAt: ago(3) },
|
||||
messages: [
|
||||
msg(3, "Marcus Webb", "Heads up: I'm updating the CI pipeline today. Builds might be slow for a bit.", 5),
|
||||
msg(1, "You", "Noted, thanks for the warning.", 4.9),
|
||||
msg(3, "Marcus Webb", "Back to normal now.", 4),
|
||||
msg(1, "You", "PR is up: #247 — adds rate limiting to the auth routes", 3),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8, type: "announcement", name: "Announcements",
|
||||
members: [], memberCount: 24, unread: 1,
|
||||
lastMessage: { senderId: 4, senderName: "Priya Anand", text: "Q2 planning kick-off is next Monday at 9am.", sentAt: ago(12) },
|
||||
messages: [
|
||||
msg(4, "Priya Anand", "Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer.", 72),
|
||||
msg(4, "Priya Anand", "Reminder: expense reports for March are due this Friday.", 48),
|
||||
msg(4, "Priya Anand", "Q2 planning kick-off is next Monday at 9am. Please come prepared with your team's priorities.", 12),
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
get selectedChat() {
|
||||
return this.chats.find(c => c.id === this.selectedChatId) || null
|
||||
}
|
||||
|
||||
get filteredChats() {
|
||||
if (!this.searchText) return this.chats
|
||||
const q = this.searchText.toLowerCase()
|
||||
return this.chats.filter(c => c.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
if (this.selectedChatId === null) {
|
||||
this.renderList()
|
||||
} else {
|
||||
this.renderThread()
|
||||
}
|
||||
})
|
||||
.height(100, pct)
|
||||
.width(100, pct)
|
||||
.overflow("hidden")
|
||||
}
|
||||
|
||||
// ── List view ────────────────────────────────────────────────────────────
|
||||
|
||||
renderList() {
|
||||
VStack(() => {
|
||||
this.renderListHeader()
|
||||
this.renderChatList()
|
||||
})
|
||||
.height(100, pct)
|
||||
.width(100, vw)
|
||||
.overflow("hidden")
|
||||
}
|
||||
|
||||
renderListHeader() {
|
||||
VStack(() => {
|
||||
|
||||
// Search bar
|
||||
if (this.searchOpen) {
|
||||
HStack(() => {
|
||||
p("🔍").margin(0).fontSize(0.78, em).opacity(0.4).flexShrink(0)
|
||||
input()
|
||||
.attr({ type: "text", placeholder: "Search…", autofocus: "true" })
|
||||
.flex(1).border("none").outline("none")
|
||||
.background("transparent")
|
||||
.color("var(--headertext)").fontSize(0.9, em)
|
||||
.onInput((e) => { this.searchText = e.target.value; this.rerender() })
|
||||
})
|
||||
.gap(0.5, em)
|
||||
.paddingHorizontal(0.85, em).paddingVertical(0.55, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.6, em)
|
||||
.alignItems("center")
|
||||
.marginHorizontal(1.1, em)
|
||||
.marginBottom(0.5, em)
|
||||
}
|
||||
})
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
renderChatList() {
|
||||
const chats = this.filteredChats
|
||||
const groups = {
|
||||
dms: chats.filter(c => c.type === "dm"),
|
||||
groups: chats.filter(c => c.type === "group"),
|
||||
channels: chats.filter(c => c.type === "channel" || c.type === "announcement"),
|
||||
}
|
||||
|
||||
VStack(() => {
|
||||
if (chats.length === 0) {
|
||||
VStack(() => {
|
||||
p("No results").margin(0).fontSize(0.88, em)
|
||||
.color("var(--headertext)").opacity(0.35).textAlign("center")
|
||||
}).flex(1).justifyContent("center").alignItems("center")
|
||||
} else {
|
||||
if (groups.dms.length > 0) {
|
||||
this.sectionLabel("DIRECT MESSAGES")
|
||||
groups.dms.forEach(c => this.renderRow(c))
|
||||
}
|
||||
if (groups.groups.length > 0) {
|
||||
this.sectionLabel("GROUPS")
|
||||
groups.groups.forEach(c => this.renderRow(c))
|
||||
}
|
||||
if (groups.channels.length > 0) {
|
||||
this.sectionLabel("CHANNELS")
|
||||
groups.channels.forEach(c => this.renderRow(c))
|
||||
}
|
||||
}
|
||||
})
|
||||
.flex(1).overflowY("auto")
|
||||
.paddingBottom(2, em)
|
||||
}
|
||||
|
||||
sectionLabel(text) {
|
||||
p(text)
|
||||
.margin(0).marginTop(0.85, em).marginBottom(0.2, em)
|
||||
.paddingHorizontal(1.1, em)
|
||||
.fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em")
|
||||
.color("var(--headertext)").opacity(0.35)
|
||||
}
|
||||
|
||||
renderRow(chat) {
|
||||
const hasUnread = chat.unread > 0
|
||||
HStack(() => {
|
||||
this.renderIcon(chat)
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
p(chat.name)
|
||||
.margin(0).fontSize(0.95, em)
|
||||
.fontWeight(hasUnread ? "700" : "500")
|
||||
.color("var(--headertext)")
|
||||
.flex(1).minWidth(0)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
|
||||
p(this.formatTime(chat.lastMessage?.sentAt))
|
||||
.margin(0).fontSize(0.7, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(hasUnread ? 0.7 : 0.35)
|
||||
.fontWeight(hasUnread ? "600" : "400")
|
||||
.flexShrink(0)
|
||||
})
|
||||
.alignItems("center").width(100, pct)
|
||||
|
||||
HStack(() => {
|
||||
p(this.previewText(chat))
|
||||
.margin(0).fontSize(0.82, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(hasUnread ? 0.65 : 0.38)
|
||||
.fontWeight(hasUnread ? "500" : "400")
|
||||
.flex(1).minWidth(0)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
|
||||
if (hasUnread) {
|
||||
p(chat.unread > 99 ? "99+" : String(chat.unread))
|
||||
.margin(0)
|
||||
.paddingHorizontal(0.45, em).paddingVertical(0.1, em)
|
||||
.background("var(--quillred)").color("white")
|
||||
.fontSize(0.65, em).fontWeight("700")
|
||||
.borderRadius(100, px)
|
||||
.minWidth(1.2, em).textAlign("center")
|
||||
.flexShrink(0)
|
||||
}
|
||||
})
|
||||
.alignItems("center").width(100, pct).marginTop(0.2, em)
|
||||
})
|
||||
.flex(1).minWidth(0)
|
||||
})
|
||||
.gap(0.75, em)
|
||||
.paddingHorizontal(1.1, em).paddingVertical(0.8, em)
|
||||
.alignItems("center")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.onTouch((start) => {
|
||||
if (!start) {
|
||||
this.selectedChatId = chat.id
|
||||
chat.unread = 0
|
||||
this.rerender()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Thread view ──────────────────────────────────────────────────────────
|
||||
|
||||
renderThread() {
|
||||
const chat = this.selectedChat
|
||||
if (!chat) return
|
||||
|
||||
VStack(() => {
|
||||
this.renderThreadHeader(chat)
|
||||
this.renderMessages(chat)
|
||||
this.renderComposer(chat)
|
||||
})
|
||||
.height(100, pct)
|
||||
.width(100, pct)
|
||||
.overflow("hidden")
|
||||
}
|
||||
|
||||
renderThreadHeader(chat) {
|
||||
HStack(() => {
|
||||
// Back button
|
||||
p("‹")
|
||||
.margin(0).fontSize(1.8, em).lineHeight("1")
|
||||
.color("var(--headertext)").paddingRight(0.5, em)
|
||||
.cursor("pointer")
|
||||
.onTouch((start) => {
|
||||
if (!start) {
|
||||
this.selectedChatId = null
|
||||
this.rerender()
|
||||
}
|
||||
})
|
||||
|
||||
this.renderHeaderIcon(chat)
|
||||
|
||||
VStack(() => {
|
||||
p(chat.type === "channel" ? "#" + chat.name : chat.name)
|
||||
.margin(0).fontSize(0.95, em).fontWeight("700")
|
||||
.color("var(--headertext)")
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
|
||||
p(this.headerSubtitle(chat))
|
||||
.margin(0).marginTop(0.06, em)
|
||||
.fontSize(0.7, em).color("var(--headertext)").opacity(0.4)
|
||||
})
|
||||
.flex(1).minWidth(0)
|
||||
})
|
||||
.gap(0.6, em)
|
||||
.paddingHorizontal(1.1, em).paddingVertical(0.85, em)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
renderMessages(chat) {
|
||||
const messages = chat.messages || []
|
||||
const grouped = this.groupByDate(messages)
|
||||
|
||||
VStack(() => {
|
||||
if (messages.length === 0) {
|
||||
VStack(() => {
|
||||
p("No messages yet. Say hello!")
|
||||
.margin(0).fontSize(0.88, em)
|
||||
.color("var(--headertext)").opacity(0.3)
|
||||
}).flex(1).justifyContent("center").alignItems("center")
|
||||
} else {
|
||||
grouped.forEach(({ label, messages: dayMsgs }) => {
|
||||
// Date separator
|
||||
HStack(() => {
|
||||
VStack(() => {}).flex(1).height(1, px).background("var(--divider)")
|
||||
p(label)
|
||||
.margin(0).marginHorizontal(0.75, em)
|
||||
.fontSize(0.65, em).fontWeight("600")
|
||||
.color("var(--headertext)").opacity(0.38).whiteSpace("nowrap")
|
||||
VStack(() => {}).flex(1).height(1, px).background("var(--divider)")
|
||||
})
|
||||
.alignItems("center")
|
||||
.paddingHorizontal(1.1, em).paddingVertical(0.75, em)
|
||||
.flexShrink(0)
|
||||
|
||||
const runs = this.groupIntoRuns(dayMsgs)
|
||||
runs.forEach(run => this.renderRun(run, chat))
|
||||
})
|
||||
}
|
||||
})
|
||||
.flex(1).overflowY("auto")
|
||||
.paddingBottom(0.5, em)
|
||||
.width(100, pct).boxSizing("border-box")
|
||||
.onAppear(function () { this.scrollTop = this.scrollHeight })
|
||||
}
|
||||
|
||||
renderRun(run, chat) {
|
||||
const isMe = run.senderId === this.currentUser.id
|
||||
const showSenderName = !isMe && chat.type !== "dm"
|
||||
|
||||
VStack(() => {
|
||||
if (showSenderName) {
|
||||
HStack(() => {
|
||||
p(run.senderName)
|
||||
.margin(0).fontSize(0.75, em).fontWeight("600")
|
||||
.color(this.avatarColor(run.senderName))
|
||||
p(this.formatMsgTime(run.messages[0].sentAt))
|
||||
.margin(0).fontSize(0.68, em)
|
||||
.color("var(--headertext)").opacity(0.32)
|
||||
})
|
||||
.gap(0.5, em).alignItems("baseline").marginBottom(0.2, em)
|
||||
}
|
||||
|
||||
run.messages.forEach((msg, i) => {
|
||||
this.renderBubble(msg, isMe, i === run.messages.length - 1)
|
||||
})
|
||||
})
|
||||
.paddingHorizontal(1.1, em)
|
||||
.paddingTop(0.55, em).paddingBottom(0.08, em)
|
||||
.alignItems(isMe ? "flex-end" : "flex-start")
|
||||
.width(100, pct).boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
renderBubble(msg, isMe, showTime) {
|
||||
VStack(() => {
|
||||
VStack(() => {
|
||||
p(msg.text)
|
||||
.margin(0).fontSize(0.92, em).lineHeight("1.5")
|
||||
.color(isMe ? "white" : "var(--headertext)")
|
||||
.whiteSpace("pre-wrap").wordBreak("break-word")
|
||||
})
|
||||
.paddingHorizontal(0.9, em).paddingVertical(0.6, em)
|
||||
.background(isMe ? "var(--quillred)" : "var(--darkaccent)")
|
||||
.borderRadius(isMe ? "1em 1em 0.25em 1em" : "1em 1em 1em 0.25em")
|
||||
.maxWidth("75vw").boxSizing("border-box")
|
||||
|
||||
if (showTime) {
|
||||
p(this.formatMsgTime(msg.sentAt))
|
||||
.margin(0).marginTop(0.22, em)
|
||||
.fontSize(0.62, em).color("var(--headertext)").opacity(0.32)
|
||||
.alignSelf(isMe ? "flex-end" : "flex-start")
|
||||
}
|
||||
})
|
||||
.alignItems(isMe ? "flex-end" : "flex-start")
|
||||
.marginBottom(0.2, em)
|
||||
}
|
||||
|
||||
renderComposer(chat) {
|
||||
HStack(() => {
|
||||
HStack(() => {
|
||||
input()
|
||||
.attr({ type: "text", placeholder: `Message…`, id: `composer-${chat.id}` })
|
||||
.flex(1).border("none").outline("none")
|
||||
.background("transparent")
|
||||
.color("var(--headertext)").fontSize(0.95, em)
|
||||
})
|
||||
.flex(1)
|
||||
.paddingHorizontal(1, em).paddingVertical(0.7, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(1.5, em)
|
||||
.alignItems("center")
|
||||
|
||||
button("↑")
|
||||
.paddingHorizontal(0.85, em).paddingVertical(0.7, em)
|
||||
.background("var(--quillred)").color("white")
|
||||
.border("none").borderRadius(50, pct)
|
||||
.fontSize(1.05, em).fontWeight("700")
|
||||
.cursor("pointer").flexShrink(0)
|
||||
.onClick((done) => { if (done) this.sendMessage(chat) })
|
||||
})
|
||||
.gap(0.55, em)
|
||||
.paddingHorizontal(1.1, em).paddingVertical(0.75, em)
|
||||
.borderTop("1px solid var(--divider)")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
sendMessage(chat) {
|
||||
const input = this.$(`#composer-${chat.id}`)
|
||||
if (!input) return
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
|
||||
chat.messages.push({
|
||||
id: Date.now(),
|
||||
senderId: this.currentUser.id,
|
||||
senderName: this.currentUser.name,
|
||||
text,
|
||||
sentAt: new Date()
|
||||
})
|
||||
chat.lastMessage = { senderId: this.currentUser.id, senderName: this.currentUser.name, text, sentAt: new Date() }
|
||||
chat.unread = 0
|
||||
input.value = ""
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
// ── Shared icon rendering ─────────────────────────────────────────────────
|
||||
|
||||
renderIcon(chat, size = 2.5) {
|
||||
if (chat.type === "channel") {
|
||||
VStack(() => {
|
||||
p("#").margin(0).fontSize(1.05, em).fontWeight("700").color("white").lineHeight("1")
|
||||
})
|
||||
.width(size, em).height(size, em).borderRadius(0.45, em)
|
||||
.background("#5865f2").justifyContent("center").alignItems("center").flexShrink(0)
|
||||
} else if (chat.type === "announcement") {
|
||||
VStack(() => {
|
||||
p("📣").margin(0).fontSize(0.9, em).lineHeight("1")
|
||||
})
|
||||
.width(size, em).height(size, em).borderRadius(0.45, em)
|
||||
.background("#f59e0b").justifyContent("center").alignItems("center").flexShrink(0)
|
||||
} else if (chat.type === "group") {
|
||||
const s = size * 0.76
|
||||
ZStack(() => {
|
||||
VStack(() => {
|
||||
p((chat.members[1] || "B")[0].toUpperCase())
|
||||
.margin(0).fontSize(0.6, em).fontWeight("700").color("white")
|
||||
})
|
||||
.width(s, em).height(s, em).borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.members[1] || "B"))
|
||||
.justifyContent("center").alignItems("center")
|
||||
.position("absolute").bottom(0).right(0)
|
||||
|
||||
VStack(() => {
|
||||
p((chat.members[0] || "A")[0].toUpperCase())
|
||||
.margin(0).fontSize(0.6, em).fontWeight("700").color("white")
|
||||
})
|
||||
.width(s, em).height(s, em).borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.members[0] || "A"))
|
||||
.justifyContent("center").alignItems("center")
|
||||
.position("absolute").top(0).left(0)
|
||||
})
|
||||
.width(size, em).height(size, em).position("relative").flexShrink(0)
|
||||
} else {
|
||||
VStack(() => {
|
||||
p(chat.name[0].toUpperCase())
|
||||
.margin(0).fontSize(0.9, em).fontWeight("700").color("white").lineHeight("1")
|
||||
})
|
||||
.width(size, em).height(size, em).borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.name))
|
||||
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||
}
|
||||
}
|
||||
|
||||
renderHeaderIcon(chat) {
|
||||
this.renderIcon(chat, 2.2)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
previewText(chat) {
|
||||
const msg = chat.lastMessage
|
||||
if (!msg) return "No messages yet"
|
||||
const sender = chat.type === "dm" ? "" : (msg.senderName?.split(" ")[0] + ": ")
|
||||
return sender + msg.text
|
||||
}
|
||||
|
||||
headerSubtitle(chat) {
|
||||
if (chat.type === "channel" || chat.type === "announcement") return `${chat.memberCount} members`
|
||||
if (chat.type === "group") return chat.members?.join(", ") || ""
|
||||
return "Active recently"
|
||||
}
|
||||
|
||||
formatTime(date) {
|
||||
if (!date) return ""
|
||||
const d = new Date(date), now = new Date()
|
||||
const diffDays = Math.floor((now - d) / 86400000)
|
||||
if (diffDays === 0) return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return d.toLocaleDateString([], { weekday: "short" })
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
formatMsgTime(date) {
|
||||
return new Date(date).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
}
|
||||
|
||||
groupByDate(messages) {
|
||||
const map = new Map()
|
||||
messages.forEach(msg => {
|
||||
const key = new Date(msg.sentAt).toDateString()
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key).push(msg)
|
||||
})
|
||||
return Array.from(map.entries()).map(([key, messages]) => ({
|
||||
label: this.dateSeparatorLabel(new Date(key)),
|
||||
messages
|
||||
}))
|
||||
}
|
||||
|
||||
groupIntoRuns(messages) {
|
||||
const runs = []
|
||||
messages.forEach(msg => {
|
||||
const last = runs[runs.length - 1]
|
||||
if (last && last.senderId === msg.senderId) {
|
||||
last.messages.push(msg)
|
||||
} else {
|
||||
runs.push({ senderId: msg.senderId, senderName: msg.senderName, messages: [msg] })
|
||||
}
|
||||
})
|
||||
return runs
|
||||
}
|
||||
|
||||
dateSeparatorLabel(date) {
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now - date) / 86400000)
|
||||
if (diffDays === 0) return "Today"
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return date.toLocaleDateString([], { weekday: "long" })
|
||||
return date.toLocaleDateString([], { month: "long", day: "numeric", year: "numeric" })
|
||||
}
|
||||
|
||||
avatarColor(name) {
|
||||
const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]
|
||||
let hash = 0
|
||||
for (let i = 0; i < (name || "").length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
}
|
||||
|
||||
register(Chat)
|
||||
317
chat/desktop/DesktopChatSidebar.js
Normal file
317
chat/desktop/DesktopChatSidebar.js
Normal file
@@ -0,0 +1,317 @@
|
||||
class DesktopChatSidebar extends Shadow {
|
||||
constructor(chats, selectedId, onSelect) {
|
||||
super()
|
||||
this.chats = chats
|
||||
this.selectedId = selectedId
|
||||
this.onSelect = onSelect
|
||||
this.searchText = ""
|
||||
}
|
||||
|
||||
get filtered() {
|
||||
const q = this.searchText.toLowerCase();
|
||||
if (!q) return this.chats;
|
||||
return this.chats.filter(c => c.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
// ── Header ────────────────────────────────────────────────
|
||||
VStack(() => {
|
||||
|
||||
// Search
|
||||
HStack(() => {
|
||||
p("🔍")
|
||||
.margin(0)
|
||||
.fontSize(0.78, em)
|
||||
.opacity(0.4)
|
||||
.flexShrink(0)
|
||||
input()
|
||||
.attr({ type: "text", placeholder: "Search…" })
|
||||
.flex(1)
|
||||
.border("none")
|
||||
.outline("none")
|
||||
.background("transparent")
|
||||
.color("var(--headertext)")
|
||||
.fontSize(0.85, em)
|
||||
.onInput((e) => {
|
||||
this.searchText = e.target.value;
|
||||
this.rerender();
|
||||
})
|
||||
})
|
||||
.gap(0.5, em)
|
||||
.paddingHorizontal(0.75, em)
|
||||
.paddingVertical(0.52, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.5, em)
|
||||
.alignItems("center")
|
||||
.marginTop(0.75, em)
|
||||
})
|
||||
.paddingHorizontal(1, em)
|
||||
.paddingTop(1.1, em)
|
||||
.paddingBottom(0.75, em)
|
||||
.flexShrink(0)
|
||||
|
||||
// ── Chat list ─────────────────────────────────────────────
|
||||
VStack(() => {
|
||||
const groups = this.groupChats(this.filtered);
|
||||
|
||||
if (groups.dms.length > 0) {
|
||||
this.sectionLabel("DIRECT MESSAGES")
|
||||
groups.dms.forEach(c => this.renderRow(c))
|
||||
}
|
||||
if (groups.groups.length > 0) {
|
||||
this.sectionLabel("GROUPS")
|
||||
groups.groups.forEach(c => this.renderRow(c))
|
||||
}
|
||||
if (groups.channels.length > 0) {
|
||||
this.sectionLabel("CHANNELS")
|
||||
groups.channels.forEach(c => this.renderRow(c))
|
||||
}
|
||||
|
||||
if (this.filtered.length === 0) {
|
||||
p("No results")
|
||||
.margin(0)
|
||||
.marginTop(2, em)
|
||||
.fontSize(0.85, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.35)
|
||||
.textAlign("center")
|
||||
.width(100, pct)
|
||||
}
|
||||
})
|
||||
.gap(0)
|
||||
.flex(1)
|
||||
.overflowY("auto")
|
||||
.paddingBottom(1, em)
|
||||
})
|
||||
.height(100, pct)
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
}
|
||||
|
||||
groupChats(chats) {
|
||||
return {
|
||||
dms: chats.filter(c => c.type === "dm"),
|
||||
groups: chats.filter(c => c.type === "group"),
|
||||
channels: chats.filter(c => c.type === "channel" || c.type === "announcement"),
|
||||
};
|
||||
}
|
||||
|
||||
sectionLabel(text) {
|
||||
p(text)
|
||||
.margin(0)
|
||||
.marginTop(0.85, em)
|
||||
.marginBottom(0.2, em)
|
||||
.paddingHorizontal(1.1, em)
|
||||
.fontSize(0.62, em)
|
||||
.fontWeight("700")
|
||||
.letterSpacing("0.07em")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.35)
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
renderRow(chat) {
|
||||
const self = this
|
||||
const isSelected = chat.id === this.selectedId;
|
||||
const hasUnread = chat.unread > 0;
|
||||
|
||||
HStack(() => {
|
||||
// Avatar / icon
|
||||
this.renderIcon(chat)
|
||||
|
||||
// Name + preview
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
p(chat.name)
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.fontWeight(hasUnread ? "700" : "500")
|
||||
.color("var(--headertext)")
|
||||
.flex(1)
|
||||
.minWidth(0)
|
||||
.overflow("hidden")
|
||||
.whiteSpace("nowrap")
|
||||
.textOverflow("ellipsis")
|
||||
|
||||
p(this.formatTime(chat.lastMessage?.sentAt))
|
||||
.margin(0)
|
||||
.fontSize(0.68, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(hasUnread ? 0.7 : 0.35)
|
||||
.flexShrink(0)
|
||||
.fontWeight(hasUnread ? "600" : "400")
|
||||
})
|
||||
.alignItems("center")
|
||||
.width(100, pct)
|
||||
|
||||
HStack(() => {
|
||||
p(this.previewText(chat))
|
||||
.margin(0)
|
||||
.fontSize(0.78, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(hasUnread ? 0.65 : 0.38)
|
||||
.flex(1)
|
||||
.minWidth(0)
|
||||
.overflow("hidden")
|
||||
.whiteSpace("nowrap")
|
||||
.textOverflow("ellipsis")
|
||||
.fontWeight(hasUnread ? "500" : "400")
|
||||
|
||||
if (hasUnread) {
|
||||
p(chat.unread > 99 ? "99+" : String(chat.unread))
|
||||
.margin(0)
|
||||
.paddingHorizontal(0.45, em)
|
||||
.paddingVertical(0.1, em)
|
||||
.background("var(--quillred)")
|
||||
.color("white")
|
||||
.fontSize(0.65, em)
|
||||
.fontWeight("700")
|
||||
.borderRadius(100, px)
|
||||
.minWidth(1.2, em)
|
||||
.textAlign("center")
|
||||
.flexShrink(0)
|
||||
}
|
||||
})
|
||||
.alignItems("center")
|
||||
.width(100, pct)
|
||||
.marginTop(0.18, em)
|
||||
})
|
||||
.flex(1)
|
||||
.minWidth(0)
|
||||
})
|
||||
.gap(0.65, em)
|
||||
.paddingHorizontal(1, em)
|
||||
.paddingVertical(0.6, em)
|
||||
.marginHorizontal(0.4, em)
|
||||
.borderRadius(0.55, em)
|
||||
.background(isSelected ? "var(--app)" : "transparent")
|
||||
.cursor("pointer")
|
||||
.alignItems("center")
|
||||
.width("calc(100% - 0.8em)")
|
||||
.boxSizing("border-box")
|
||||
.onClick(function(done){ if(done){ self.onSelect(chat.id) } })
|
||||
}
|
||||
|
||||
renderIcon(chat) {
|
||||
if (chat.type === "channel") {
|
||||
VStack(() => {
|
||||
p("#")
|
||||
.margin(0)
|
||||
.fontSize(1.05, em)
|
||||
.fontWeight("700")
|
||||
.color("white")
|
||||
.lineHeight("1")
|
||||
})
|
||||
.width(2.35, em)
|
||||
.height(2.35, em)
|
||||
.borderRadius(0.45, em)
|
||||
.background("#5865f2")
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
} else if (chat.type === "announcement") {
|
||||
VStack(() => {
|
||||
p("📣")
|
||||
.margin(0)
|
||||
.fontSize(0.9, em)
|
||||
.lineHeight("1")
|
||||
})
|
||||
.width(2.35, em)
|
||||
.height(2.35, em)
|
||||
.borderRadius(0.45, em)
|
||||
.background("#f59e0b")
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
} else if (chat.type === "group") {
|
||||
// Stacked initials for group
|
||||
ZStack(() => {
|
||||
// Back circle
|
||||
VStack(() => {
|
||||
p(chat.members[1] ? chat.members[1][0].toUpperCase() : "?")
|
||||
.margin(0)
|
||||
.fontSize(0.6, em)
|
||||
.fontWeight("700")
|
||||
.color("white")
|
||||
})
|
||||
.width(1.8, em)
|
||||
.height(1.8, em)
|
||||
.borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.members[1] || "B"))
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.position("absolute")
|
||||
.bottom(0).right(0)
|
||||
.boxSizing("border-box")
|
||||
|
||||
// Front circle
|
||||
VStack(() => {
|
||||
p(chat.members[0] ? chat.members[0][0].toUpperCase() : "?")
|
||||
.margin(0)
|
||||
.fontSize(0.6, em)
|
||||
.fontWeight("700")
|
||||
.color("white")
|
||||
})
|
||||
.width(1.8, em)
|
||||
.height(1.8, em)
|
||||
.borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.members[0] || "A"))
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.position("absolute")
|
||||
.top(0).left(0)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
.width(2.35, em)
|
||||
.height(2.35, em)
|
||||
.position("relative")
|
||||
.flexShrink(0)
|
||||
} else {
|
||||
// DM — single avatar
|
||||
VStack(() => {
|
||||
p(chat.name[0].toUpperCase())
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.fontWeight("700")
|
||||
.color("white")
|
||||
.lineHeight("1")
|
||||
})
|
||||
.width(2.35, em)
|
||||
.height(2.35, em)
|
||||
.borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.name))
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
}
|
||||
}
|
||||
|
||||
previewText(chat) {
|
||||
const msg = chat.lastMessage;
|
||||
if (!msg) return "No messages yet";
|
||||
const sender = chat.type === "dm" ? "" : (msg.senderName?.split(" ")[0] + ": ");
|
||||
return sender + msg.text;
|
||||
}
|
||||
|
||||
formatTime(date) {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - d) / 86400000);
|
||||
if (diffDays === 0) return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
if (diffDays === 1) return "Yesterday";
|
||||
if (diffDays < 7) return d.toLocaleDateString([], { weekday: "short" });
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
avatarColor(name) {
|
||||
const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
}
|
||||
|
||||
register(DesktopChatSidebar)
|
||||
387
chat/desktop/DesktopChatThread.js
Normal file
387
chat/desktop/DesktopChatThread.js
Normal file
@@ -0,0 +1,387 @@
|
||||
class DesktopChatThread extends Shadow {
|
||||
constructor(chat, currentUser) {
|
||||
super()
|
||||
this.chat = chat
|
||||
this.currentUser = currentUser
|
||||
this.draftText = ""
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.chat) {
|
||||
VStack(() => {
|
||||
p("💬")
|
||||
.margin(0)
|
||||
.fontSize(2.8, em)
|
||||
.opacity(0.18)
|
||||
p("Select a conversation")
|
||||
.margin(0)
|
||||
.marginTop(0.75, em)
|
||||
.fontSize(0.92, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.32)
|
||||
})
|
||||
.flex(1)
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.height(100, pct)
|
||||
return;
|
||||
}
|
||||
|
||||
VStack(() => {
|
||||
this.renderHeader()
|
||||
this.renderMessages()
|
||||
this.renderComposer()
|
||||
})
|
||||
.height(100, pct)
|
||||
.width(100, pct)
|
||||
.overflow("hidden")
|
||||
}
|
||||
|
||||
renderHeader() {
|
||||
HStack(() => {
|
||||
// Chat icon (same logic as sidebar, compact)
|
||||
this.renderHeaderIcon()
|
||||
|
||||
VStack(() => {
|
||||
p(this.chat.name)
|
||||
.margin(0)
|
||||
.fontSize(0.95, em)
|
||||
.fontWeight("700")
|
||||
.color("var(--headertext)")
|
||||
|
||||
p(this.headerSubtitle())
|
||||
.margin(0)
|
||||
.marginTop(0.08, em)
|
||||
.fontSize(0.72, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.42)
|
||||
})
|
||||
.flex(1)
|
||||
.minWidth(0)
|
||||
})
|
||||
.gap(0.75, em)
|
||||
.paddingHorizontal(1.4, em)
|
||||
.paddingVertical(0.85, em)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
renderHeaderIcon() {
|
||||
const chat = this.chat;
|
||||
|
||||
if (chat.type === "channel") {
|
||||
VStack(() => {
|
||||
p("#")
|
||||
.margin(0)
|
||||
.fontSize(1.05, em)
|
||||
.fontWeight("700")
|
||||
.color("white")
|
||||
.lineHeight("1")
|
||||
})
|
||||
.width(2.2, em).height(2.2, em)
|
||||
.borderRadius(0.45, em)
|
||||
.background("#5865f2")
|
||||
.justifyContent("center").alignItems("center")
|
||||
.flexShrink(0)
|
||||
} else if (chat.type === "announcement") {
|
||||
VStack(() => {
|
||||
p("📣").margin(0).fontSize(0.88, em).lineHeight("1")
|
||||
})
|
||||
.width(2.2, em).height(2.2, em)
|
||||
.borderRadius(0.45, em)
|
||||
.background("#f59e0b")
|
||||
.justifyContent("center").alignItems("center")
|
||||
.flexShrink(0)
|
||||
} else if (chat.type === "group") {
|
||||
ZStack(() => {
|
||||
VStack(() => {
|
||||
p(chat.members[1]?.[0]?.toUpperCase() || "?")
|
||||
.margin(0).fontSize(0.55, em).fontWeight("700").color("white")
|
||||
})
|
||||
.width(1.65, em).height(1.65, em).borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.members[1] || "B"))
|
||||
.justifyContent("center").alignItems("center")
|
||||
.position("absolute").bottom(0).right(0)
|
||||
.boxSizing("border-box")
|
||||
|
||||
VStack(() => {
|
||||
p(chat.members[0]?.[0]?.toUpperCase() || "?")
|
||||
.margin(0).fontSize(0.55, em).fontWeight("700").color("white")
|
||||
})
|
||||
.width(1.65, em).height(1.65, em).borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.members[0] || "A"))
|
||||
.justifyContent("center").alignItems("center")
|
||||
.position("absolute").top(0).left(0)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
.width(2.2, em).height(2.2, em).position("relative").flexShrink(0)
|
||||
} else {
|
||||
VStack(() => {
|
||||
p(chat.name[0].toUpperCase())
|
||||
.margin(0).fontSize(0.85, em).fontWeight("700").color("white").lineHeight("1")
|
||||
})
|
||||
.width(2.2, em).height(2.2, em).borderRadius(50, pct)
|
||||
.background(this.avatarColor(chat.name))
|
||||
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||
}
|
||||
}
|
||||
|
||||
headerSubtitle() {
|
||||
const { type, members } = this.chat;
|
||||
if (type === "channel" || type === "announcement") {
|
||||
return `${this.chat.memberCount || members?.length || 0} members`;
|
||||
}
|
||||
if (type === "group") {
|
||||
return members?.join(", ") || "";
|
||||
}
|
||||
return "Active recently";
|
||||
}
|
||||
|
||||
renderMessages() {
|
||||
const messages = this.chat.messages || [];
|
||||
const grouped = this.groupByDate(messages);
|
||||
|
||||
VStack(() => {
|
||||
if (messages.length === 0) {
|
||||
VStack(() => {
|
||||
p("No messages yet. Say hello!")
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.3)
|
||||
})
|
||||
.flex(1)
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
} else {
|
||||
grouped.forEach(({ label, messages: dayMsgs }) => {
|
||||
// Date separator
|
||||
HStack(() => {
|
||||
VStack(() => {}).flex(1).height(1, px).background("var(--divider)")
|
||||
p(label)
|
||||
.margin(0)
|
||||
.marginHorizontal(0.85, em)
|
||||
.fontSize(0.68, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.38)
|
||||
.whiteSpace("nowrap")
|
||||
VStack(() => {}).flex(1).height(1, px).background("var(--divider)")
|
||||
})
|
||||
.alignItems("center")
|
||||
.paddingHorizontal(1.4, em)
|
||||
.paddingVertical(0.85, em)
|
||||
.flexShrink(0)
|
||||
|
||||
// Render message runs (consecutive messages from same sender)
|
||||
const runs = this.groupIntoRuns(dayMsgs);
|
||||
runs.forEach(run => this.renderRun(run))
|
||||
})
|
||||
}
|
||||
})
|
||||
.flex(1)
|
||||
.overflowY("auto")
|
||||
.paddingBottom(0.5, em)
|
||||
.gap(0)
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.attr({ id: `thread-${this.chat.id}` })
|
||||
.onAppear(function () { this.scrollTop = this.scrollHeight; })
|
||||
}
|
||||
|
||||
renderRun(run) {
|
||||
const isMe = run.senderId === this.currentUser.id;
|
||||
const showAvatar = !isMe && this.chat.type !== "dm";
|
||||
|
||||
VStack(() => {
|
||||
// Sender name + time (only at run start, non-DM)
|
||||
if (!isMe && this.chat.type !== "dm") {
|
||||
HStack(() => {
|
||||
p(run.senderName)
|
||||
.margin(0)
|
||||
.fontSize(0.75, em)
|
||||
.fontWeight("600")
|
||||
.color(this.avatarColor(run.senderName))
|
||||
|
||||
p(this.formatMsgTime(run.messages[0].sentAt))
|
||||
.margin(0)
|
||||
.fontSize(0.68, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.32)
|
||||
})
|
||||
.gap(0.55, em)
|
||||
.alignItems("baseline")
|
||||
.marginBottom(0.2, em)
|
||||
}
|
||||
|
||||
// Bubble stack
|
||||
run.messages.forEach((msg, i) => {
|
||||
const isLast = i === run.messages.length - 1;
|
||||
this.renderBubble(msg, isMe, isLast)
|
||||
})
|
||||
})
|
||||
.paddingHorizontal(1.4, em)
|
||||
.paddingTop(0.55, em)
|
||||
.paddingBottom(0.08, em)
|
||||
.alignItems(isMe ? "flex-end" : "flex-start")
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
renderBubble(msg, isMe, showTime) {
|
||||
VStack(() => {
|
||||
VStack(() => {
|
||||
p(msg.text)
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.color(isMe ? "white" : "var(--headertext)")
|
||||
.lineHeight("1.5")
|
||||
.whiteSpace("pre-wrap")
|
||||
.wordBreak("break-word")
|
||||
})
|
||||
.paddingHorizontal(0.85, em)
|
||||
.paddingVertical(0.55, em)
|
||||
.background(isMe ? "var(--quillred)" : "var(--darkaccent)")
|
||||
.borderRadius(isMe ? "1em 1em 0.25em 1em" : "1em 1em 1em 0.25em")
|
||||
.maxWidth(32, em)
|
||||
.boxSizing("border-box")
|
||||
|
||||
if (showTime) {
|
||||
p(this.formatMsgTime(msg.sentAt))
|
||||
.margin(0)
|
||||
.marginTop(0.22, em)
|
||||
.fontSize(0.65, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.32)
|
||||
.alignSelf(isMe ? "flex-end" : "flex-start")
|
||||
}
|
||||
})
|
||||
.alignItems(isMe ? "flex-end" : "flex-start")
|
||||
.marginBottom(0.2, em)
|
||||
}
|
||||
|
||||
renderComposer() {
|
||||
const self = this
|
||||
HStack(() => {
|
||||
// Input
|
||||
HStack(() => {
|
||||
input()
|
||||
.attr({ type: "text", placeholder: `Message ${this.chat.name}…`, id: `composer-${this.chat.id}` })
|
||||
.flex(1)
|
||||
.border("none")
|
||||
.outline("none")
|
||||
.background("transparent")
|
||||
.color("var(--headertext)")
|
||||
.fontSize(0.9, em)
|
||||
.onKeyDown((e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
})
|
||||
})
|
||||
.flex(1)
|
||||
.paddingHorizontal(1, em)
|
||||
.paddingVertical(0.62, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.65, em)
|
||||
.alignItems("center")
|
||||
|
||||
// Send button
|
||||
button("↑")
|
||||
.paddingHorizontal(0.75, em)
|
||||
.paddingVertical(0.62, em)
|
||||
.background("var(--quillred)")
|
||||
.color("white")
|
||||
.border("none")
|
||||
.borderRadius(0.65, em)
|
||||
.fontSize(1, em)
|
||||
.fontWeight("700")
|
||||
.cursor("pointer")
|
||||
.flexShrink(0)
|
||||
.onClick(function(done){ if(done){ self.sendMessage() } })
|
||||
})
|
||||
.gap(0.55, em)
|
||||
.paddingHorizontal(1.4, em)
|
||||
.paddingVertical(0.9, em)
|
||||
.borderTop("1px solid var(--divider)")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
sendMessage() {
|
||||
const input = this.$(`#composer-${this.chat.id}`);
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
this.chat.messages.push({
|
||||
id: Date.now(),
|
||||
senderId: this.currentUser.id,
|
||||
senderName: this.currentUser.name,
|
||||
text,
|
||||
sentAt: new Date()
|
||||
});
|
||||
this.chat.lastMessage = {
|
||||
senderId: this.currentUser.id,
|
||||
senderName: this.currentUser.name,
|
||||
text,
|
||||
sentAt: new Date()
|
||||
};
|
||||
this.chat.unread = 0;
|
||||
input.value = "";
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
groupByDate(messages) {
|
||||
const map = new Map();
|
||||
messages.forEach(msg => {
|
||||
const key = new Date(msg.sentAt).toDateString();
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(msg);
|
||||
});
|
||||
|
||||
return Array.from(map.entries()).map(([key, messages]) => ({
|
||||
label: this.dateSeparatorLabel(new Date(key)),
|
||||
messages
|
||||
}));
|
||||
}
|
||||
|
||||
groupIntoRuns(messages) {
|
||||
const runs = [];
|
||||
messages.forEach(msg => {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.senderId === msg.senderId) {
|
||||
last.messages.push(msg);
|
||||
} else {
|
||||
runs.push({ senderId: msg.senderId, senderName: msg.senderName, messages: [msg] });
|
||||
}
|
||||
});
|
||||
return runs;
|
||||
}
|
||||
|
||||
dateSeparatorLabel(date) {
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - date) / 86400000);
|
||||
if (diffDays === 0) return "Today";
|
||||
if (diffDays === 1) return "Yesterday";
|
||||
if (diffDays < 7) return date.toLocaleDateString([], { weekday: "long" });
|
||||
return date.toLocaleDateString([], { month: "long", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
formatMsgTime(date) {
|
||||
return new Date(date).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
avatarColor(name) {
|
||||
const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < (name || "").length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
}
|
||||
|
||||
register(DesktopChatThread)
|
||||
213
chat/desktop/chat.js
Normal file
213
chat/desktop/chat.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import "./DesktopChatSidebar.js"
|
||||
import "./DesktopChatThread.js"
|
||||
|
||||
css(`
|
||||
chat- {
|
||||
font-family: 'Arial';
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
chat- input::placeholder {
|
||||
color: var(--headertext);
|
||||
opacity: 0.35;
|
||||
}
|
||||
`)
|
||||
|
||||
class Chat extends Shadow {
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.currentUser = { id: 1, name: "You" }
|
||||
this.selectedChatId = null
|
||||
|
||||
const ago = (h) => new Date(Date.now() - h * 3600000);
|
||||
const msg = (senderId, senderName, text, hoursAgo) => ({ id: Math.random(), senderId, senderName, text, sentAt: ago(hoursAgo) });
|
||||
|
||||
this.chats = [
|
||||
// ── Direct messages ───────────────────────────────────────
|
||||
{
|
||||
id: 1,
|
||||
type: "dm",
|
||||
name: "Sarah McIntyre",
|
||||
members: ["Sarah McIntyre"],
|
||||
memberCount: 2,
|
||||
unread: 2,
|
||||
lastMessage: { senderId: 2, senderName: "Sarah McIntyre", text: "Can you review the PR when you get a chance?", sentAt: ago(0.3) },
|
||||
messages: [
|
||||
msg(1, "You", "Hey Sarah, did you see the new design mockups?", 24),
|
||||
msg(2, "Sarah McIntyre", "Just looked — they're really clean. I love the new sidebar.", 23.5),
|
||||
msg(1, "You", "Agreed. Alex did a great job.", 23.4),
|
||||
msg(2, "Sarah McIntyre", "Are we going to ship this week or wait for the backend?", 5),
|
||||
msg(1, "You", "Let's aim for Thursday. I'll sync with Marcus.", 4.8),
|
||||
msg(2, "Sarah McIntyre", "Sounds good 👍", 4.7),
|
||||
msg(2, "Sarah McIntyre", "Can you review the PR when you get a chance?", 0.3),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "dm",
|
||||
name: "Marcus Webb",
|
||||
members: ["Marcus Webb"],
|
||||
memberCount: 2,
|
||||
unread: 0,
|
||||
lastMessage: { senderId: 1, senderName: "You", text: "I'll send over the specs by EOD", sentAt: ago(1.5) },
|
||||
messages: [
|
||||
msg(3, "Marcus Webb", "Hey, the API endpoint is returning 500s on staging.", 3),
|
||||
msg(1, "You", "Oh no — is it the auth middleware again?", 2.9),
|
||||
msg(3, "Marcus Webb", "Yep. Same issue as last week.", 2.8),
|
||||
msg(1, "You", "I'll patch it now. Give me 20 mins.", 2.75),
|
||||
msg(3, "Marcus Webb", "Thanks, no rush.", 2.7),
|
||||
msg(1, "You", "Fixed. Can you redeploy and check?", 2.2),
|
||||
msg(3, "Marcus Webb", "All green 🎉 Thanks!", 2.1),
|
||||
msg(1, "You", "I'll send over the specs by EOD", 1.5),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "dm",
|
||||
name: "Priya Anand",
|
||||
members: ["Priya Anand"],
|
||||
memberCount: 2,
|
||||
unread: 0,
|
||||
lastMessage: { senderId: 4, senderName: "Priya Anand", text: "See you at the standup!", sentAt: ago(18) },
|
||||
messages: [
|
||||
msg(4, "Priya Anand", "Quick question — what's the launch date for v2?", 20),
|
||||
msg(1, "You", "Still TBD, but we're targeting end of May.", 19.8),
|
||||
msg(4, "Priya Anand", "Got it. I'll update the roadmap doc.", 19.5),
|
||||
msg(1, "You", "Perfect, thanks Priya.", 19.4),
|
||||
msg(4, "Priya Anand", "See you at the standup!", 18),
|
||||
]
|
||||
},
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────
|
||||
{
|
||||
id: 4,
|
||||
type: "group",
|
||||
name: "Product Team",
|
||||
members: ["Sarah McIntyre", "Marcus Webb", "Priya Anand", "You"],
|
||||
memberCount: 4,
|
||||
unread: 5,
|
||||
lastMessage: { senderId: 2, senderName: "Sarah McIntyre", text: "I've updated the Figma file with the new flows", sentAt: ago(0.15) },
|
||||
messages: [
|
||||
msg(3, "Marcus Webb", "Morning everyone! API docs are updated.", 8),
|
||||
msg(4, "Priya Anand", "Nice work Marcus 🙌", 7.9),
|
||||
msg(1, "You", "I'll start on the integration tests today.", 7.8),
|
||||
msg(2, "Sarah McIntyre", "Great. I'm finishing up the onboarding screens.", 7.5),
|
||||
msg(4, "Priya Anand", "Can we do a quick sync at 2pm?", 2),
|
||||
msg(1, "You", "Works for me.", 1.95),
|
||||
msg(3, "Marcus Webb", "Same", 1.9),
|
||||
msg(2, "Sarah McIntyre", "I'll send the invite.", 1.85),
|
||||
msg(2, "Sarah McIntyre", "I've updated the Figma file with the new flows", 0.15),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: "group",
|
||||
name: "Design Review",
|
||||
members: ["Sarah McIntyre", "You", "Jordan Kim"],
|
||||
memberCount: 3,
|
||||
unread: 0,
|
||||
lastMessage: { senderId: 5, senderName: "Jordan Kim", text: "The contrast on mobile looks off — can we bump it?", sentAt: ago(26) },
|
||||
messages: [
|
||||
msg(5, "Jordan Kim", "Hey, sharing the first round of designs for the settings page.", 30),
|
||||
msg(2, "Sarah McIntyre", "These look great! Love the card layout.", 29.5),
|
||||
msg(1, "You", "Agreed. One thought — the spacing on the form feels a bit tight.", 29),
|
||||
msg(5, "Jordan Kim", "Good call. I'll loosen it up.", 28.8),
|
||||
msg(2, "Sarah McIntyre", "Also maybe we increase the font size slightly?", 28),
|
||||
msg(5, "Jordan Kim", "The contrast on mobile looks off — can we bump it?", 26),
|
||||
]
|
||||
},
|
||||
|
||||
// ── Channels ──────────────────────────────────────────────
|
||||
{
|
||||
id: 6,
|
||||
type: "channel",
|
||||
name: "general",
|
||||
members: ["Sarah McIntyre", "Marcus Webb", "Priya Anand", "Jordan Kim", "You"],
|
||||
memberCount: 24,
|
||||
unread: 11,
|
||||
lastMessage: { senderId: 3, senderName: "Marcus Webb", text: "Just pushed the hotfix to production", sentAt: ago(0.08) },
|
||||
messages: [
|
||||
msg(4, "Priya Anand", "Good morning team! Reminder: all-hands is Thursday at 10am.", 9),
|
||||
msg(2, "Sarah McIntyre", "Thanks for the reminder!", 8.9),
|
||||
msg(5, "Jordan Kim", "Will there be a recording for those in other time zones?", 8.7),
|
||||
msg(4, "Priya Anand", "Yes — I'll post the link in #announcements after.", 8.6),
|
||||
msg(1, "You", "Thanks Priya 🙏", 8.5),
|
||||
msg(3, "Marcus Webb", "Staging is back up btw, had a brief outage this morning.", 4),
|
||||
msg(2, "Sarah McIntyre", "Oh I didn't even notice, nice quick fix!", 3.8),
|
||||
msg(3, "Marcus Webb", "Just pushed the hotfix to production", 0.08),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
type: "channel",
|
||||
name: "engineering",
|
||||
members: ["Marcus Webb", "You"],
|
||||
memberCount: 8,
|
||||
unread: 0,
|
||||
lastMessage: { senderId: 1, senderName: "You", text: "PR is up: #247 — adds rate limiting to the auth routes", sentAt: ago(3) },
|
||||
messages: [
|
||||
msg(3, "Marcus Webb", "Heads up: I'm updating the CI pipeline today. Builds might be slow for a bit.", 5),
|
||||
msg(1, "You", "Noted, thanks for the warning.", 4.9),
|
||||
msg(3, "Marcus Webb", "Back to normal now.", 4),
|
||||
msg(1, "You", "PR is up: #247 — adds rate limiting to the auth routes", 3),
|
||||
]
|
||||
},
|
||||
|
||||
// ── Announcements ─────────────────────────────────────────
|
||||
{
|
||||
id: 8,
|
||||
type: "announcement",
|
||||
name: "Announcements",
|
||||
members: [],
|
||||
memberCount: 24,
|
||||
unread: 1,
|
||||
lastMessage: { senderId: 4, senderName: "Priya Anand", text: "Q2 planning kick-off is next Monday at 9am. Please come prepared with your team's priorities.", sentAt: ago(12) },
|
||||
messages: [
|
||||
msg(4, "Priya Anand", "Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer and will be working closely with Sarah.", 72),
|
||||
msg(4, "Priya Anand", "Reminder: expense reports for March are due this Friday.", 48),
|
||||
msg(4, "Priya Anand", "Q2 planning kick-off is next Monday at 9am. Please come prepared with your team's priorities.", 12),
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
this.selectedChatId = this.chats[0].id
|
||||
}
|
||||
|
||||
get selectedChat() {
|
||||
return this.chats.find(c => c.id === this.selectedChatId) || null;
|
||||
}
|
||||
|
||||
render() {
|
||||
HStack(() => {
|
||||
// Left sidebar
|
||||
VStack(() => {
|
||||
DesktopChatSidebar(this.chats, this.selectedChatId, (id) => {
|
||||
this.selectedChatId = id;
|
||||
const chat = this.chats.find(c => c.id === id);
|
||||
if (chat) chat.unread = 0;
|
||||
this.rerender();
|
||||
})
|
||||
})
|
||||
.width(280, px)
|
||||
.minWidth(240, px)
|
||||
.height(100, pct)
|
||||
.borderRight("1px solid var(--divider)")
|
||||
.flexShrink(0)
|
||||
.overflow("hidden")
|
||||
|
||||
// Right thread
|
||||
VStack(() => {
|
||||
DesktopChatThread(this.selectedChat, this.currentUser)
|
||||
})
|
||||
.flex(1)
|
||||
.height(100, pct)
|
||||
.overflow("hidden")
|
||||
})
|
||||
.height(100, pct)
|
||||
.width(100, pct)
|
||||
.overflow("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
register(Chat)
|
||||
3
chat/icons/chat.svg
Normal file
3
chat/icons/chat.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="155" height="155" viewBox="0 0 155 155" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M76.471 0.0125703C75.8033 0.019182 75.1291 0.0324015 74.4614 0.0588431C54.9548 0.78599 36.4448 8.86384 22.636 22.6725H22.6426H22.6228C-1.51899 46.8397 -6.91899 83.9712 9.23007 113.995L2.76513 149.155V149.149C2.6197 149.929 2.86429 150.735 3.42616 151.297C3.98142 151.859 4.78129 152.11 5.56128 151.971L40.7417 145.5C70.7657 161.642 107.897 156.228 132.064 132.087C162.261 101.891 162.261 52.8625 132.064 22.6658C117.317 7.91809 97.2682 -0.225398 76.4771 0.00474876L76.471 0.0125703ZM76.5239 4.85142C96.0187 4.62667 114.804 12.2748 128.627 26.0977C156.979 54.4498 156.979 100.332 128.627 128.684C105.768 151.523 70.601 156.495 42.3148 140.874H42.3215C42.3148 140.874 42.3016 140.874 42.295 140.867C42.0438 140.735 41.7794 140.649 41.5018 140.616C41.4555 140.603 41.4158 140.596 41.3696 140.596C41.1514 140.57 40.9267 140.576 40.7151 140.616L8.12561 146.592L14.1213 114.029C14.2337 113.414 14.1081 112.78 13.7643 112.258C-1.74371 83.9985 3.24727 48.923 26.0398 26.1046C38.9829 13.1615 56.3346 5.59272 74.6262 4.90566C75.2542 4.87922 75.8888 4.866 76.5168 4.85939L76.5239 4.85142ZM38.448 62.1428C37.8068 62.1362 37.1855 62.394 36.7294 62.8502C36.2733 63.3063 36.0221 63.921 36.0221 64.5688C36.0221 65.21 36.2799 65.8248 36.736 66.2809C37.1921 66.7304 37.8069 66.9816 38.4481 66.9816H116.253C117.582 66.9816 118.659 65.8975 118.666 64.5687C118.666 63.9275 118.415 63.3128 117.959 62.8567C117.509 62.4006 116.894 62.1428 116.253 62.1428L38.448 62.1428ZM38.448 87.7907C37.8068 87.7841 37.1855 88.0419 36.7294 88.498C36.2733 88.9541 36.0221 89.5689 36.0221 90.2167C36.0287 90.8579 36.2865 91.4727 36.736 91.9222C37.1921 92.3717 37.8069 92.6229 38.4481 92.6229H84.6146C85.9433 92.6229 87.0208 91.5454 87.0208 90.2166C87.0274 89.5754 86.7762 88.9541 86.3201 88.498C85.8706 88.0485 85.2558 87.7907 84.6146 87.7907L38.448 87.7907Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
3
chat/icons/chatlight.svg
Normal file
3
chat/icons/chatlight.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="155" height="155" viewBox="0 0 155 155" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M76.471 0.0125703C75.8033 0.019182 75.1291 0.0324015 74.4614 0.0588431C54.9548 0.78599 36.4448 8.86384 22.636 22.6725H22.6426H22.6228C-1.51899 46.8397 -6.91899 83.9712 9.23007 113.995L2.76513 149.155V149.149C2.6197 149.929 2.86429 150.735 3.42616 151.297C3.98142 151.859 4.78129 152.11 5.56128 151.971L40.7417 145.5C70.7657 161.642 107.897 156.228 132.064 132.087C162.261 101.891 162.261 52.8625 132.064 22.6658C117.317 7.91809 97.2682 -0.225398 76.4771 0.00474876L76.471 0.0125703ZM76.5239 4.85142C96.0187 4.62667 114.804 12.2748 128.627 26.0977C156.979 54.4498 156.979 100.332 128.627 128.684C105.768 151.523 70.601 156.495 42.3148 140.874H42.3215C42.3148 140.874 42.3016 140.874 42.295 140.867C42.0438 140.735 41.7794 140.649 41.5018 140.616C41.4555 140.603 41.4158 140.596 41.3696 140.596C41.1514 140.57 40.9267 140.576 40.7151 140.616L8.12561 146.592L14.1213 114.029C14.2337 113.414 14.1081 112.78 13.7643 112.258C-1.74371 83.9985 3.24727 48.923 26.0398 26.1046C38.9829 13.1615 56.3346 5.59272 74.6262 4.90566C75.2542 4.87922 75.8888 4.866 76.5168 4.85939L76.5239 4.85142ZM38.448 62.1428C37.8068 62.1362 37.1855 62.394 36.7294 62.8502C36.2733 63.3063 36.0221 63.921 36.0221 64.5688C36.0221 65.21 36.2799 65.8248 36.736 66.2809C37.1921 66.7304 37.8069 66.9816 38.4481 66.9816H116.253C117.582 66.9816 118.659 65.8975 118.666 64.5687C118.666 63.9275 118.415 63.3128 117.959 62.8567C117.509 62.4006 116.894 62.1428 116.253 62.1428L38.448 62.1428ZM38.448 87.7907C37.8068 87.7841 37.1855 88.0419 36.7294 88.498C36.2733 88.9541 36.0221 89.5689 36.0221 90.2167C36.0287 90.8579 36.2865 91.4727 36.736 91.9222C37.1921 92.3717 37.8069 92.6229 38.4481 92.6229H84.6146C85.9433 92.6229 87.0208 91.5454 87.0208 90.2166C87.0274 89.5754 86.7762 88.9541 86.3201 88.498C85.8706 88.0485 85.2558 87.7907 84.6146 87.7907L38.448 87.7907Z" fill="#FFE9C8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
Reference in New Issue
Block a user