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)