rspace-online/lib/folk-chat.ts

359 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 300px;
min-height: 400px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #f97316;
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.chat-container {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
}
.messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.message {
background: #f1f5f9;
border-radius: 8px;
padding: 8px 12px;
max-width: 85%;
}
.message.own {
background: #f97316;
color: white;
align-self: flex-end;
}
.message-header {
display: flex;
justify-content: space-between;
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
}
.message-content {
font-size: 14px;
line-height: 1.4;
}
.input-container {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid #e2e8f0;
}
.message-input {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
outline: none;
}
.message-input:focus {
border-color: #f97316;
}
.send-btn {
background: #f97316;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
}
.send-btn:hover {
background: #ea580c;
}
.username-prompt {
padding: 12px;
text-align: center;
}
.username-input {
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
outline: none;
margin-bottom: 8px;
width: 100%;
box-sizing: border-box;
}
.username-btn {
background: #f97316;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
}
`;
interface ChatMessage {
id: string;
userName: string;
content: string;
timestamp: number;
}
declare global {
interface HTMLElementTagNameMap {
"folk-chat": FolkChat;
}
}
export class FolkChat extends FolkShape {
static override tagName = "folk-chat";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#roomId = "default-room";
#userName = "";
#messages: ChatMessage[] = [];
#messagesEl: HTMLElement | null = null;
get roomId() {
return this.#roomId;
}
set roomId(value: string) {
this.#roomId = value;
this.requestUpdate("roomId");
}
get userName() {
return this.#userName;
}
set userName(value: string) {
this.#userName = value;
localStorage.setItem("folk-chat-username", value);
this.requestUpdate("userName");
}
get messages() {
return this.#messages;
}
override createRenderRoot() {
const root = super.createRenderRoot();
// Try to restore username from localStorage
this.#userName = localStorage.getItem("folk-chat-username") || "";
this.#roomId = this.getAttribute("room-id") || "default-room";
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>💬</span>
<span>Chat</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="chat-container">
<div class="messages"></div>
<div class="input-container">
<input type="text" class="message-input" placeholder="Type a message..." />
<button class="send-btn">Send</button>
</div>
</div>
<div class="username-prompt" style="display: none;">
<p>Enter your name to join the chat:</p>
<input type="text" class="username-input" placeholder="Your name..." />
<button class="username-btn">Join Chat</button>
</div>
`;
// Replace the container div (slot's parent) with our wrapper
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
// Get element references
this.#messagesEl = wrapper.querySelector(".messages");
const chatContainer = wrapper.querySelector(".chat-container") as HTMLElement;
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
const messageInput = wrapper.querySelector(".message-input") as HTMLInputElement;
const sendBtn = wrapper.querySelector(".send-btn") as HTMLButtonElement;
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Show username prompt if not set
if (!this.#userName) {
chatContainer.style.display = "none";
usernamePrompt.style.display = "block";
}
// Username submit
const submitUsername = () => {
const name = usernameInput.value.trim();
if (name) {
this.userName = name;
chatContainer.style.display = "flex";
usernamePrompt.style.display = "none";
}
};
usernameBtn.addEventListener("click", submitUsername);
usernameInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") submitUsername();
});
// Send message
const sendMessage = () => {
const content = messageInput.value.trim();
if (content && this.#userName) {
this.addMessage({
id: crypto.randomUUID(),
userName: this.#userName,
content,
timestamp: Date.now(),
});
messageInput.value = "";
}
};
sendBtn.addEventListener("click", sendMessage);
messageInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") sendMessage();
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
addMessage(message: ChatMessage) {
this.#messages.push(message);
this.#renderMessages();
this.dispatchEvent(
new CustomEvent("message", {
detail: { message },
bubbles: true,
})
);
}
#renderMessages() {
if (!this.#messagesEl) return;
this.#messagesEl.innerHTML = this.#messages
.map((msg) => {
const isOwn = msg.userName === this.#userName;
const time = new Date(msg.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return `
<div class="message ${isOwn ? "own" : ""}">
<div class="message-header">
<span class="username">${msg.userName}</span>
<span class="time">${time}</span>
</div>
<div class="message-content">${this.#escapeHtml(msg.content)}</div>
</div>
`;
})
.join("");
// Scroll to bottom
this.#messagesEl.scrollTop = this.#messagesEl.scrollHeight;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-chat",
roomId: this.roomId,
messages: this.messages,
};
}
}