Add a check for the `snek-speaker` element's enabled state before calling `speak()`. When the speaker is disabled, fall back to playing the message sound instead of attempting speech synthesis. This prevents silent failures and ensures consistent audio feedback based on user preference.
328 lines
11 KiB
HTML
328 lines
11 KiB
HTML
{% extends "app.html" %}
|
|
|
|
{% block header_text %}<h2 style="color:#fff">{{ name }}</h2>{% endblock %}
|
|
|
|
{% block main %}
|
|
{% include "channel.html" %}
|
|
<section class="chat-area">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css">
|
|
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
|
|
|
|
<div id="terminal" class="hidden"></div>
|
|
<message-list class="chat-messages">
|
|
{% if not messages %}
|
|
|
|
<div>
|
|
<h1>Welcome to your new channel!</h1>
|
|
<p>This is the start of something great. Use the commands below to get started:</p>
|
|
<ul>
|
|
<li>Press <code>/invite</code> to invite someone.</li>
|
|
<li>Press <code>/online</code> to see who's currently online.</li>
|
|
<li>Press <code>/help</code> to view all available commands.</li>
|
|
</ul>
|
|
<p>Enjoy chatting!</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% for message in messages %}
|
|
{% autoescape false %}
|
|
{{ message.html }}
|
|
{% endautoescape %}
|
|
{% endfor %}
|
|
<div class="message-list-bottom"></div>
|
|
</message-list>
|
|
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
|
|
</section>
|
|
{% include "dialog_container.html" %}
|
|
{% include "dialog_help.html" %}
|
|
{% include "dialog_online.html" %}
|
|
<script type="module">
|
|
import { app } from "/app.js";
|
|
import { Schedule } from "/schedule.js";
|
|
|
|
// --- Cache selectors ---
|
|
const chatInputField = document.querySelector("chat-input");
|
|
const messagesContainer = document.querySelector(".chat-messages");
|
|
const chatArea = document.querySelector(".chat-area");
|
|
const channelUid = "{{ channel.uid.value }}";
|
|
const username = "{{ user.username.value }}";
|
|
// --- Command completions ---
|
|
chatInputField.autoCompletions = {
|
|
"/online": showOnline,
|
|
"/clear": () => { messagesContainer.innerHTML = ''; },
|
|
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
|
|
"/help": showHelp,
|
|
"/container": async() =>{
|
|
containerDialog.openWithStatus()
|
|
}
|
|
};
|
|
|
|
// --- Throttle utility ---
|
|
function throttle(fn, wait) {
|
|
let last = 0;
|
|
return function(...args) {
|
|
const now = Date.now();
|
|
if (now - last >= wait) {
|
|
last = now;
|
|
fn.apply(this, args);
|
|
}
|
|
};
|
|
}
|
|
|
|
// --- Scroll: load extra messages, throttled ---
|
|
let isLoadingExtra = false;
|
|
async function loadExtra() {
|
|
const firstMessage = messagesContainer.querySelector(".message:first-child");
|
|
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
|
|
isLoadingExtra = true;
|
|
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
|
|
if (messages.length) {
|
|
const frag = document.createDocumentFragment();
|
|
messages.forEach(msg => {
|
|
const temp = document.createElement("div");
|
|
temp.innerHTML = msg.html;
|
|
frag.appendChild(temp.firstChild);
|
|
});
|
|
firstMessage.parentNode.insertBefore(frag, firstMessage);
|
|
updateLayout(false);
|
|
}
|
|
isLoadingExtra = false;
|
|
}
|
|
messagesContainer.addEventListener("scroll", throttle(loadExtra, 200));
|
|
|
|
// --- Only update visible times ---
|
|
function updateTimes() {
|
|
const containers = messagesContainer.querySelectorAll(".time");
|
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
containers.forEach(container => {
|
|
const rect = container.getBoundingClientRect();
|
|
if (rect.top >= 0 && rect.bottom <= viewportHeight) {
|
|
const messageDiv = container.closest('.message');
|
|
let text = messageDiv.querySelector(".text").innerText;
|
|
const time = document.createElement("span");
|
|
time.innerText = app.timeDescription(container.dataset.created_at);
|
|
messageDiv.querySelector(".text").querySelectorAll("img").forEach(img => {
|
|
text += " " + img.src
|
|
})
|
|
|
|
|
|
|
|
container.replaceChildren(time);
|
|
const reply = document.createElement("a");
|
|
reply.innerText = " reply";
|
|
reply.href = "#reply";
|
|
container.appendChild(reply);
|
|
reply.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
replyMessage(text);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
setInterval(() => requestIdleCallback(updateTimes), 30000);
|
|
|
|
// --- Paste & drag/drop uploads ---
|
|
const textBox = chatInputField.textarea;
|
|
textBox.addEventListener("paste", async (e) => {
|
|
try {
|
|
const clipboardItems = await navigator.clipboard.read();
|
|
const dt = new DataTransfer();
|
|
for (const item of clipboardItems) {
|
|
for (const type of item.types.filter(t => !t.startsWith('text/'))) {
|
|
const blob = await item.getType(type);
|
|
dt.items.add(new File([blob], "image.png", { type }));
|
|
}
|
|
}
|
|
if (dt.items.length > 0) {
|
|
const uploadButton = chatInputField.uploadButton;
|
|
const input = uploadButton.shadowRoot.querySelector('.file-input');
|
|
input.files = dt.files;
|
|
await uploadButton.uploadFiles();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to read clipboard contents: ", error);
|
|
}
|
|
});
|
|
chatArea.addEventListener("drop", async (e) => {
|
|
e.preventDefault();
|
|
const dt = e.dataTransfer;
|
|
if (dt.items.length > 0) {
|
|
const uploadButton = chatInputField.uploadButton;
|
|
const input = uploadButton.shadowRoot.querySelector('.file-input');
|
|
input.files = dt.files;
|
|
await uploadButton.uploadFiles();
|
|
}
|
|
});
|
|
chatArea.addEventListener("dragover", e => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "link";
|
|
});
|
|
|
|
// --- Focus input on load ---
|
|
chatInputField.textarea.focus();
|
|
|
|
// --- Reply helper ---
|
|
function replyMessage(message) {
|
|
chatInputField.value = "```markdown\n> " + (message || '') + "\n```\n";
|
|
chatInputField.focus();
|
|
}
|
|
|
|
// --- Mention helpers ---
|
|
function extractMentions(message) {
|
|
return [...new Set(message.match(/@\w+/g) || [])];
|
|
}
|
|
function isMentionToMe(message) {
|
|
return message.toLowerCase().includes('@' + username.toLowerCase());
|
|
}
|
|
function isMentionForSomeoneElse(message) {
|
|
const mentions = extractMentions(message);
|
|
return mentions.length > 0 && !mentions.includes('@' + username);
|
|
}
|
|
|
|
// --- WebSocket events ---
|
|
app.ws.addEventListener("refresh", (data) => {
|
|
app.starField.showNotify(data.message);
|
|
setTimeout(() => window.location.reload(), 4000);
|
|
});
|
|
app.ws.addEventListener("stars_render", (data)=>{
|
|
app.starField.renderWord(data.message, { rainbow: true, resolution: 8 });
|
|
setTimeout(() => app.starField.shuffleAll(5000), 10000);
|
|
})
|
|
app.ws.addEventListener("deployed", (data) => {
|
|
app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 });
|
|
setTimeout(() => app.starField.shuffleAll(5000), 10000);
|
|
});
|
|
app.ws.addEventListener("starfield.render_word", (data) => {
|
|
app.starField.renderWord(data.word, data);
|
|
});
|
|
|
|
// --- Channel message event ---
|
|
app.addEventListener("channel-message", (data) => {
|
|
|
|
let display = data.text && data.text.trim() ? 'block' : 'none';
|
|
|
|
if (data.channel_uid !== channelUid) {
|
|
if (!isMentionForSomeoneElse(data.message)) {
|
|
channelSidebar.notify(data);
|
|
app.playSound("messageOtherChannel");
|
|
}
|
|
return;
|
|
}
|
|
if (data.username !== username) {
|
|
if (isMentionToMe(data.message)) {
|
|
app.playSound("mention");
|
|
} else if (!isMentionForSomeoneElse(data.message)) {
|
|
if(data.is_final){
|
|
const speaker = document.querySelector("snek-speaker");
|
|
if(speaker.enabled){
|
|
speaker.speak(data.message);
|
|
}else{
|
|
app.playSound("message");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const lastElement = messagesContainer.querySelector(".message-list-bottom");
|
|
const doScrollDown = messagesContainer.isScrolledToBottom();
|
|
|
|
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
|
|
if (oldMessage) {
|
|
oldMessage.remove();
|
|
}
|
|
|
|
const message = document.createElement("div");
|
|
|
|
|
|
message.innerHTML = data.html;
|
|
message.style.display = display;
|
|
messagesContainer.insertBefore(message.firstChild, lastElement);
|
|
updateLayout(doScrollDown);
|
|
setTimeout(() => updateLayout(doScrollDown), 1000);
|
|
app.rpc.markAsRead(channelUid);
|
|
});
|
|
|
|
// --- Keyboard shortcuts ---
|
|
let escPressed = false, gPressCount = 0, keyTimeout;
|
|
document.addEventListener('keydown', function(event) {
|
|
if (event.key === 'Escape') {
|
|
gPressCount = 0;
|
|
document.activeElement.blur();
|
|
}
|
|
if (event.key === 'g' && !chatInputField.isActive()) {
|
|
gPressCount++;
|
|
clearTimeout(keyTimeout);
|
|
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
|
|
if (gPressCount === 2) {
|
|
gPressCount = 0;
|
|
messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
|
|
loadExtra();
|
|
}
|
|
}
|
|
if(event.key == 'i' && !chatInputField.isActive()) {
|
|
event.preventDefault();
|
|
chatInputField.focus();
|
|
}
|
|
if(event.key == 'r' && !chatInputField.isActive()) {
|
|
event.preventDefault();
|
|
const replyLinks = document.querySelectorAll('.time a')
|
|
if(replyLinks.length){
|
|
const replyLink = replyLinks[replyLinks.length - 1]
|
|
replyLink.click()
|
|
}
|
|
}
|
|
if (event.shiftKey && event.key === 'G') {
|
|
if (!chatInputField.isActive()) {
|
|
event.preventDefault();
|
|
updateLayout(true);
|
|
setTimeout(() => chatInputField.focus(), 500);
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Image click-to-zoom (delegated) ---
|
|
// --- Layout update ---
|
|
function updateLayout(doScrollDown) {
|
|
updateTimes();
|
|
let previousUser = null, previousDate = null;
|
|
messagesContainer.querySelectorAll(".message").forEach((message) => {
|
|
if (previousUser !== message.dataset.user_uid) {
|
|
message.classList.add("switch-user");
|
|
previousUser = message.dataset.user_uid;
|
|
previousDate = new Date(message.dataset.created_at);
|
|
} else {
|
|
message.classList.remove("switch-user");
|
|
if (!previousDate) {
|
|
previousDate = new Date(message.dataset.created_at);
|
|
} else {
|
|
const currentDate = new Date(message.dataset.created_at);
|
|
if (currentDate.getTime() - previousDate.getTime() > 1000 * 60 * 20) {
|
|
message.classList.add("long-time");
|
|
} else {
|
|
message.classList.remove("long-time");
|
|
}
|
|
previousDate = currentDate;
|
|
}
|
|
}
|
|
});
|
|
if (doScrollDown) messagesContainer.scrollToBottom?.();
|
|
}
|
|
|
|
// --- Utility: check if element is visible ---
|
|
|
|
|
|
// --- Utility: check if scrolled past half ---
|
|
function isScrolledPastHalf() {
|
|
let scrollTop = messagesContainer.scrollTop;
|
|
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
|
|
return scrollTop < scrollableHeight / 2;
|
|
}
|
|
|
|
// --- Initial layout update ---
|
|
updateLayout(true);
|
|
|
|
|
|
|
|
</script>
|
|
{% endblock %}
|