Files
snek/src/snek/templates/web.html
T
BordedDev 530f9ab27c feat: show reply time indicator when message gap exceeds 20 minutes
Add logic to detect time gaps between consecutive messages from the same user. When the difference between two messages' timestamps exceeds 20 minutes, the later message receives a `long-time` CSS class, causing its timestamp to be displayed persistently alongside the existing `switch-user` rule. The CSS selector `.message:has(+ .message.long-time)` ensures the preceding message's time remains visible when followed by a long-delayed reply.
2025-05-21 22:19:39 +00:00

344 lines
11 KiB
HTML

{% extends "app.html" %}
{% block header_text %}<h2 style="color:#fff">{{ name }}</h2>{% endblock %}
{% block main %}
<section class="chat-area">
<message-list class="chat-messages">
{% for message in messages %}
{% autoescape false %}
{{ message.html }}
{% endautoescape %}
{% endfor %}
</message-list>
<chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input>
</section>
{% include "dialog_help.html" %}
{% include "dialog_online.html" %}
<script type="module">
import { app } from "/app.js";
import { Schedule } from "/schedule.js";
const channelUid = "{{ channel.uid.value }}";
const chatInputField = document.querySelector("chat-input");
chatInputField.autoCompletions = {
"/online": () =>{
showOnline();
},
"/clear": () => {
document.querySelector(".chat-messages").innerHTML = '';
},
"/live": () =>{
chatInputField.liveType = !chatInputField.liveType
},
"/help": () => {
showHelp();
}
}
const textBox = document.querySelector("chat-input").textarea
textBox.addEventListener("paste", async (e) => {
try {
const clipboardItems = await navigator.clipboard.read();
// DataTransfer needs to be used to modify the files list of the input
const dt = new DataTransfer();
for (const clipboardItem of clipboardItems) {
const fileTypes = clipboardItem.types.filter(type => !type.startsWith('text/'))
for (const fileType of fileTypes) {
const blob = await clipboardItem.getType(fileType);
// Do something with the image blob.
dt.items.add(new File([blob], "image.png", { type: fileType }));
}
}
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);
}
});
const chatInput = document.querySelector(".chat-area")
chatInput.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();
}
})
chatInput.addEventListener("dragover", async (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "link";
})
chatInputField.textarea.focus();
function replyMessage(message) {
const field = chatInputField
field.value = "```markdown\n> " + (message || '') + "\n```\n";
field.focus();
}
function updateTimes() {
document.querySelectorAll(".time").forEach((container) => {
const messageDiv = container.closest('.message');
const userNick = messageDiv.dataset.user_nick;
const text = messageDiv.querySelector(".text").innerText;
const time = document.createElement("span");
time.innerText = app.timeDescription(container.dataset.created_at);
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);
})
});
}
function isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
const messagesContainer = document.querySelector(".chat-messages");
function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
if (scrollTop < scrollableHeight / 2) {
return true;
}
return false;
}
let isLoadingExtra = false;
async function loadExtra() {
const firstMessage = messagesContainer.querySelector(".message:first-child");
if (isLoadingExtra) {
return;
}
if (!isScrolledPastHalf()) {
return;
}
isLoadingExtra = true;
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
messages.forEach((message) => {
firstMessage.insertAdjacentHTML("beforebegin", message.html);
})
updateLayout(false);
isLoadingExtra = false;
}
messagesContainer.addEventListener("scroll", () => {
loadExtra();
});
let lastMessage
function updateLayout(doScrollDown) {
const messagesContainer = document.querySelector(".chat-messages");
updateTimes();
let previousUser = null;
let previousDate = null;
document.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;
}
}
});
lastMessage = messagesContainer.querySelector(".message:last-child");
if (doScrollDown) {
lastMessage?.scrollIntoView({ block: "end", inline: "nearest" });
chatInputField.scrollIntoView({ block: "end", inline: "nearest" });
}
}
setInterval(updateTimes, 30000);
function isMentionToMe(message){
const mentionText = '@{{ user.username.value }}';
return message.toLowerCase().includes(mentionText);
}
function extractMentions(message) {
return [...new Set(message.match(/@\w+/g) || [])];
}
function isMentionForSomeoneElse(message){
const mentions = extractMentions(message);
const mentionText = '@{{ user.username.value }}';
return mentions.length > 0 && mentions.indexOf(mentionText) == -1;
}
app.addEventListener("channel-message", (data) => {
let display = 'block';
if(!data.text || !data.text.trim()){
display = "none";
}
if (data.channel_uid !== channelUid) {
if(!isMentionForSomeoneElse(data.message)){
channelSidebar.notify(data);
app.playSound("messageOtherChannel");
}
return;
}
if (data.username !== "{{ user.username.value }}") {
if(isMentionToMe(data.message)){
app.playSound("mention");
}else if (!isMentionForSomeoneElse(data.message)){
app.playSound("message");
}
}
const messagesContainer = document.querySelector(".chat-messages");
lastMessage = messagesContainer.querySelector(".message:last-child");
const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);
const message = document.createElement("div");
message.innerHTML = data.html;
message.style.display = display
document.querySelector(".chat-messages").appendChild(message.firstChild);
updateLayout(doScrollDownBecauseLastMessageIsVisible);
setTimeout(() => {
updateLayout(doScrollDownBecauseLastMessageIsVisible)
}, 1000);
app.rpc.markAsRead(channelUid);
});
let escPressed = false;
let gPressCount = 0;
let keyTimeout;
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
escPressed = true;
gPressCount = 0;
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => {
escPressed = false;
}, 300);
}
if (event.key === 'G' && escPressed) {
gPressCount++;
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => {
gPressCount = 0;
}, 300);
if (gPressCount === 2) {
gPressCount = 0;
escPressed = false;
messagesContainer.querySelector(".message:last-child").scrollIntoView({ block: "end", inline: "nearest" });
setTimeout(() => {
chatInputField.focus();
},500)
}
}
if (event.shiftKey && event.key === 'G') {
if(chatInputField.isActive()){
updateLayout(true);
setTimeout(() => {
chatInputField.focus();
},500)
}
}
});
messagesContainer.addEventListener('click', (e) => {
if(e.target.tagName != 'IMG')
return
const img = e.target
if(e.target.classList.contains('avatar')){
return
}
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0,0,0,0.9)';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.zIndex = 9999;
const fullImg = document.createElement('img');
const urlObj = new URL(img.src);
urlObj.search = ''
fullImg.src = urlObj.toString();
fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => {
document.body.removeChild(overlay);
});
});
updateLayout(true);
</script>
{% endblock %}