This commit is contained in:
metacryst
2026-04-28 20:05:00 -05:00
commit 0d6c7683ff
123 changed files with 20922 additions and 0 deletions

View 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)