Author SHA1 Message Date
BordedDev 8c2e20dfe8 Fix reply text 2025-07-20 03:38:18 +02:00
BordedDev 3e2dd7ea04 Moved replay to custom event 2025-07-20 01:11:05 +02:00
BordedDev 70eebefac7 Fixed upsert error when typing 2025-07-19 23:31:23 +02:00
BordedDev ac47d201d8 Fixed scrolled to bottom check 2025-07-19 00:13:06 +02:00
BordedDev 11e19f48e8 Fix g scroll 2025-07-19 00:00:29 +02:00
BordedDev 5ac49522d9 Fixed scrolling behavior, reply, cross channel messages 2025-07-18 23:57:41 +02:00
retoor f9f1179db5 Updated channel message. 2025-07-18 23:20:46 +02:00
retoor 04527c286f Updated save. 2025-07-18 17:49:01 +02:00
retoor e23d6571c8 Updated save. 2025-07-18 17:44:45 +02:00
retoor 0c331bbb93 Update. 2025-07-18 00:43:40 +02:00
retoor a2d506cce9 Merge pull request 'maintenance/clean-up-messages' (#65) from BordedDev/snek:maintenance/clean-up-messages into main
Reviewed-on: retoor/snek#65
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-18 00:17:33 +02:00
BordedDev 3a4cf93bcc Fixed some scrolling issues when opening terminal 2025-07-17 23:21:15 +02:00
BordedDev 29b9fce07d Cleaned up message rendering a bit 2025-07-17 02:22:47 +02:00
11 changed files with 479 additions and 181 deletions
+9
View File
@@ -31,6 +31,7 @@ from snek.sgit import GitApplication
from snek.sssh import start_ssh_server
from snek.system import http
from snek.system.cache import Cache
from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler
from snek.system.markdown import MarkdownExtension
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.profiler import profiler_handler
@@ -127,6 +128,7 @@ async def trailing_slash_middleware(request, handler):
class Application(BaseApplication):
def __init__(self, *args, **kwargs):
middlewares = [
stats_middleware,
cors_middleware,
web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware,
@@ -168,11 +170,17 @@ class Application(BaseApplication):
self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
)
self.on_startup.append(self.prepare_stats)
self.on_startup.append(self.prepare_asyncio)
self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database)
async def prepare_stats(self, app):
app['stats'] = create_stats_structure()
print("Stats prepared", flush=True)
@property
def uptime_seconds(self):
return (datetime.now() - self.time_start).total_seconds()
@@ -308,6 +316,7 @@ class Application(BaseApplication):
self.router.add_view("/drive.json", DriveApiView)
self.router.add_view("/drive.html", DriveView)
self.router.add_view("/drive/{drive}.json", DriveView)
self.router.add_get("/stats.html", stats_handler)
self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
+28 -12
View File
@@ -1,24 +1,26 @@
from snek.system.service import BaseService
from snek.system.template import whitelist_attributes
import time
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._configured_indexes = False
async def maintenance(self):
args = {}
async for message in self.find():
for message in self.mapper.db["channel_message"].find():
print(message)
try:
message = await self.get(uid=message["uid"])
updated_at = message["updated_at"]
message["is_final"] = True
html = message["html"]
await self.save(message)
self.mapper.db['channel_message'].upsert(
self.mapper.db["channel_message"].upsert(
{
"uid": message["uid"],
"updated_at": updated_at,
@@ -28,6 +30,11 @@ class ChannelMessageService(BaseService):
if html != message["html"]:
print("Reredefined message", message["uid"])
except Exception as ex:
time.sleep(0.1)
print(ex, flush=True)
while True:
changed = 0
async for message in self.find(is_final=False):
@@ -41,7 +48,6 @@ class ChannelMessageService(BaseService):
if not changed:
break
async def create(self, channel_uid, user_uid, message, is_final=True):
model = await self.new()
@@ -72,12 +78,18 @@ class ChannelMessageService(BaseService):
if await super().save(model):
if not self._configured_indexes:
if not self.mapper.db["channel_message"].has_index(['is_final','user_uid','channel_uid']):
self.mapper.db["channel_message"].create_index(['is_final','user_uid','channel_uid'], unique=False)
if not self.mapper.db["channel_message"].has_index(['uid']):
self.mapper.db["channel_message"].create_index(['uid'], unique=True)
if not self.mapper.db["channel_message"].has_index(['deleted_at']):
self.mapper.db["channel_message"].create_index(['deleted_at'], unique=False)
if not self.mapper.db["channel_message"].has_index(
["is_final", "user_uid", "channel_uid"]
):
self.mapper.db["channel_message"].create_index(
["is_final", "user_uid", "channel_uid"], unique=False
)
if not self.mapper.db["channel_message"].has_index(["uid"]):
self.mapper.db["channel_message"].create_index(["uid"], unique=True)
if not self.mapper.db["channel_message"].has_index(["deleted_at"]):
self.mapper.db["channel_message"].create_index(
["deleted_at"], unique=False
)
self._configured_indexes = True
return model
raise Exception(f"Failed to create channel message: {model.errors}.")
@@ -86,6 +98,11 @@ class ChannelMessageService(BaseService):
user = await self.services.user.get(uid=message["user_uid"])
if not user:
return {}
#if not message["html"].startswith("<chat-message"):
#message = await self.get(uid=message["uid"])
#await self.save(message)
return {
"uid": message["uid"],
"color": user["color"],
@@ -115,7 +132,6 @@ class ChannelMessageService(BaseService):
model["html"] = whitelist_attributes(model["html"])
return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
+2 -2
View File
@@ -144,7 +144,7 @@ footer {
.chat-messages {
display: flex;
flex-direction: column;
flex-direction: column-reverse;
}
.container {
@@ -368,7 +368,7 @@ input[type="text"], .chat-input textarea {
}
}
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{
.time {
display: block;
opacity: 1;
+5 -3
View File
@@ -298,7 +298,9 @@ textToLeetAdvanced(text) {
this.appendChild(this.uploadButton);
this.textarea.addEventListener("blur", () => {
this.updateFromInput("");
this.updateFromInput(this.value, true).then(
this.updateFromInput("")
)
});
this.subscribe("file-uploads-done", (data)=>{
@@ -417,7 +419,7 @@ textToLeetAdvanced(text) {
updateFromInput(value) {
updateFromInput(value, isFinal = false) {
this.value = value;
@@ -425,7 +427,7 @@ textToLeetAdvanced(text) {
if (this.liveType && value[0] !== "/") {
const messageText = this.replaceMentionsWithAuthors(value);
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType);
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
return this.messageUid;
}
}
+247 -52
View File
@@ -5,48 +5,235 @@
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
import { app } from "../app.js";
import {app} from "./app.js";
const LONG_TIME = 1000 * 60 * 20
export class ReplyEvent extends Event {
constructor(messageTextTarget) {
super('reply', { bubbles: true, composed: true });
this.messageTextTarget = messageTextTarget;
const newMessage = messageTextTarget.cloneNode(true);
newMessage.style.maxHeight = "0"
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
newMessage.querySelectorAll('.embed-url-link').forEach(link => {
link.remove()
})
newMessage.querySelectorAll('picture').forEach(picture => {
const img = picture.querySelector('img');
if (img) {
picture.replaceWith(img);
}
})
newMessage.querySelectorAll('img').forEach(img => {
const src = img.src || img.currentSrc;
img.replaceWith(document.createTextNode(src));
})
newMessage.querySelectorAll('iframe').forEach(iframe => {
const src = iframe.src || iframe.currentSrc;
iframe.replaceWith(document.createTextNode(src));
})
newMessage.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('href');
const text = a.innerText || a.textContent;
if (text === href || text === '') {
a.replaceWith(document.createTextNode(href));
} else {
a.replaceWith(document.createTextNode(`[${text}](${href})`));
}
})
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
newMessage.remove()
}
}
class MessageElement extends HTMLElement {
// static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid'];
isVisible() {
if (!this) return false;
const rect = this.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
updateUI() {
if (this._originalChildren === undefined) {
const { color, user_nick, created_at, user_uid} = this.dataset;
this.classList.add('message');
this.style.maxWidth = '100%';
this._originalChildren = Array.from(this.children);
this.innerHTML = `
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
</a>
<div class="message-content">
<div class="author" style="color: ${color || ''};">${user_nick || ''}</div>
<div class="text"></div>
<div class="time no-select" data-created_at="${created_at || ''}">
<span></span>
<a href="#reply">reply</a></div>
</div>
`;
this.messageDiv = this.querySelector('.text');
if (this._originalChildren && this._originalChildren.length > 0) {
this._originalChildren.forEach(child => {
this.messageDiv.appendChild(child);
});
}
this.timeDiv = this.querySelector('.time span');
this.replyDiv = this.querySelector('.time a');
this.replyDiv.addEventListener('click', (e) => {
e.preventDefault();
this.dispatchEvent(new ReplyEvent(this.messageDiv));
})
}
if (!this.siblingGenerated && this.nextElementSibling) {
this.siblingGenerated = true;
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
this.classList.add('switch-user');
} else {
this.classList.remove('switch-user');
const siblingTime = new Date(this.nextElementSibling.dataset.created_at);
const currentTime = new Date(this.dataset.created_at);
if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) {
this.classList.add('long-time');
} else {
this.classList.remove('long-time');
}
}
}
this.timeDiv.innerText = app.timeDescription(this.dataset.created_at);
}
updateMessage(...messages) {
if (this._originalChildren) {
this.messageDiv.replaceChildren(...messages)
this._originalChildren = messages;
}
}
connectedCallback() {
this.updateUI();
}
disconnectedCallback() {
}
connectedMoveCallback() {
}
attributeChangedCallback(name, oldValue, newValue) {
this.updateUI()
}
}
class MessageList extends HTMLElement {
constructor() {
super();
app.ws.addEventListener("update_message_text", (data) => {
this.updateMessageText(data.uid, data);
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid,data.color);
});
this.items = [];
this.messageMap = new Map();
this.visibleSet = new Set();
this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.visibleSet.add(entry.target);
const messageElement = entry.target;
if (messageElement instanceof MessageElement) {
messageElement.updateUI();
}
} else {
this.visibleSet.delete(entry.target);
}
});
}, {
root: this,
threshold: 0,
})
for(const c of this.children) {
this._observer.observe(c);
if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c);
}
}
this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages);
this.scrollToBottom(true);
}
connectedCallback() {
const messagesContainer = this
messagesContainer.addEventListener('click', (e) => {
this.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
const img = e.target;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:pointer;';
const urlObj = new URL(img.currentSrc || img.src)
urlObj.searchParams.delete("width");
urlObj.searchParams.delete("height");
const urlObj = new URL(img.currentSrc || img.src, window.location.origin);
urlObj.searchParams.delete('width');
urlObj.searchParams.delete('height');
const fullImg = document.createElement('img');
fullImg.src = urlObj.toString();
fullImg.alt = img.alt;
fullImg.alt = img.alt || '';
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
fullImg.style.boxShadow = '0 0 32px #000';
fullImg.style.borderRadius = '8px';
fullImg.style.background = '#222';
fullImg.style.objectFit = 'contain';
fullImg.loading = 'lazy';
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay));
overlay.addEventListener('click', () => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
});
// Optional: ESC key closes overlay
const escListener = (evt) => {
if (evt.key === 'Escape') {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
document.removeEventListener('keydown', escListener);
}
};
document.addEventListener('keydown', escListener);
})
}
isElementVisible(element) {
if (!element) return false;
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
@@ -56,61 +243,69 @@ class MessageList extends HTMLElement {
);
}
isScrolledToBottom() {
return this.isElementVisible(this.querySelector(".message-list-bottom"));
return this.isElementVisible(this.endOfMessages);
}
scrollToBottom(force) {
//this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView();
this.querySelector(".message-list-bottom").scrollIntoView();
scrollToBottom(force = false, behavior= 'instant') {
if (force || !this.isScrolledToBottom()) {
this.firstElementChild.scrollIntoView({ behavior, block: 'end' });
setTimeout(() => {
this.firstElementChild.scrollIntoView({ behavior, block: 'end' });
}, 200);
}
}
// this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView();
},200)
}
updateMessageText(uid, message) {
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
if (!messageDiv) {
return;
}
const scrollToBottom = this.isScrolledToBottom();
const receivedHtml = document.createElement("div");
receivedHtml.innerHTML = message.html;
const html = receivedHtml.querySelector(".text").innerHTML;
const textElement = messageDiv.querySelector(".text");
textElement.innerHTML = html;
textElement.style.display = message.text == "" ? "none" : "block";
if(scrollToBottom)
this.scrollToBottom(true)
}
triggerGlow(uid, color) {
app.starField.glowColor(color)
if (!uid || !color) return;
app.starField.glowColor(color);
let lastElement = null;
this.querySelectorAll(".avatar").forEach((el) => {
const div = el.closest("a");
if (el.href.indexOf(uid) != -1) {
this.querySelectorAll('.avatar').forEach((el) => {
const anchor = el.closest('a');
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
lastElement = el;
}
});
if (lastElement) {
lastElement.classList.add("glow");
lastElement.classList.add('glow');
setTimeout(() => {
lastElement.classList.remove("glow");
lastElement.classList.remove('glow');
}, 1000);
}
}
set data(items) {
this.items = items;
this.render();
}
render() {
this.innerHTML = "";
//this.insertAdjacentHTML("beforeend", html);
updateTimes() {
this.visibleSet.forEach((messageElement) => {
if (messageElement instanceof MessageElement) {
messageElement.updateUI();
}
})
}
upsertMessage(data) {
let message = this.messageMap.get(data.uid);
if (message) {
message.parentElement?.removeChild(message);
}
if (!data.message) return
const wrapper = document.createElement("div");
wrapper.innerHTML = data.html;
if (message) {
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
} else {
message = wrapper.firstElementChild;
this.messageMap.set(data.uid, message);
this._observer.observe(message);
}
const scrolledToBottom = this.isScrolledToBottom();
this.prepend(message);
if (scrolledToBottom) this.scrollToBottom(true);
}
}
customElements.define("chat-message", MessageElement);
customElements.define("message-list", MessageList);
+129
View File
@@ -0,0 +1,129 @@
import asyncio
from aiohttp import web, WSMsgType
from datetime import datetime, timedelta, timezone
from collections import defaultdict
import html
def create_stats_structure():
"""Creates the nested dictionary structure for storing statistics."""
def nested_dd():
return defaultdict(lambda: defaultdict(int))
return defaultdict(nested_dd)
def get_time_keys(dt: datetime):
"""Generates dictionary keys for different time granularities."""
return {
"hour": dt.strftime('%Y-%m-%d-%H'),
"day": dt.strftime('%Y-%m-%d'),
"week": dt.strftime('%Y-%W'), # Week number, Monday is first day
"month": dt.strftime('%Y-%m'),
}
def update_stats_counters(stats_dict: defaultdict, now: datetime):
"""Increments the appropriate time-based counters in a stats dictionary."""
keys = get_time_keys(now)
stats_dict['by_hour'][keys['hour']] += 1
stats_dict['by_day'][keys['day']] += 1
stats_dict['by_week'][keys['week']] += 1
stats_dict['by_month'][keys['month']] += 1
def generate_time_series_svg(title: str, data: list[tuple[str, int]], y_label: str) -> str:
"""Generates a responsive SVG bar chart for time-series data."""
if not data:
return f"<h3>{html.escape(title)}</h3><p>No data yet.</p>"
max_val = max(item[1] for item in data) if data else 1
svg_height, svg_width = 250, 600
bar_padding = 5
bar_width = (svg_width - 50) / len(data) - bar_padding
bars = ""
labels = ""
for i, (key, val) in enumerate(data):
bar_height = (val / max_val) * (svg_height - 50) if max_val > 0 else 0
x = i * (bar_width + bar_padding) + 40
y = svg_height - bar_height - 30
bars += f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" fill="#007BFF"><title>{html.escape(key)}: {val}</title></rect>'
labels += f'<text x="{x + bar_width / 2}" y="{svg_height - 15}" font-size="11" text-anchor="middle">{html.escape(key)}</text>'
return f"""
<h3>{html.escape(title)}</h3>
<div style="border:1px solid #ccc; padding: 10px; border-radius: 5px;">
<svg viewBox="0 0 {svg_width} {svg_height}" style="width:100%; height:auto;">
<g>{bars}</g>
<g>{labels}</g>
<line x1="35" y1="10" x2="35" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
<line x1="35" y1="{svg_height - 30}" x2="{svg_width - 10}" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
<text x="5" y="{svg_height - 30}" font-size="12">0</text>
<text x="5" y="20" font-size="12">{max_val}</text>
</svg>
</div>
"""
@web.middleware
async def middleware(request, handler):
"""Middleware to count all incoming HTTP requests."""
# Avoid counting requests to the stats page itself
if request.path.startswith('/stats.html'):
return await handler(request)
update_stats_counters(request.app['stats']['http_requests'], datetime.now(timezone.utc))
return await handler(request)
def update_websocket_stats(app):
update_stats_counters(app['stats']['websocket_requests'], datetime.now(timezone.utc))
async def pipe_and_count_websocket(ws_from, ws_to, stats_dict):
"""This function proxies WebSocket messages AND counts them."""
async for msg in ws_from:
# This is the key part for monitoring WebSockets
update_stats_counters(stats_dict, datetime.now(timezone.utc))
if msg.type == WSMsgType.TEXT:
await ws_to.send_str(msg.data)
elif msg.type == WSMsgType.BINARY:
await ws_to.send_bytes(msg.data)
elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR):
await ws_to.close(code=ws_from.close_code)
break
async def stats_handler(request: web.Request):
"""Handler to display the statistics dashboard."""
stats = request.app['stats']
now = datetime.now(timezone.utc)
# Helper to prepare data for charts
def get_data(source, period, count):
data = []
for i in range(count - 1, -1, -1):
if period == 'hour':
dt = now - timedelta(hours=i)
key, label = dt.strftime('%Y-%m-%d-%H'), dt.strftime('%H:00')
data.append((label, source['by_hour'].get(key, 0)))
elif period == 'day':
dt = now - timedelta(days=i)
key, label = dt.strftime('%Y-%m-%d'), dt.strftime('%a')
data.append((label, source['by_day'].get(key, 0)))
return data
http_hourly = get_data(stats['http_requests'], 'hour', 24)
ws_hourly = get_data(stats['ws_messages'], 'hour', 24)
http_daily = get_data(stats['http_requests'], 'day', 7)
ws_daily = get_data(stats['ws_messages'], 'day', 7)
body = f"""
<html><head><title>App Stats</title><meta http-equiv="refresh" content="30"></head>
<body>
<h2>Application Dashboard</h2>
<h3>Last 24 Hours</h3>
{generate_time_series_svg("HTTP Requests", http_hourly, "Reqs/Hour")}
{generate_time_series_svg("WebSocket Messages", ws_hourly, "Msgs/Hour")}
<h3>Last 7 Days</h3>
{generate_time_series_svg("HTTP Requests", http_daily, "Reqs/Day")}
{generate_time_series_svg("WebSocket Messages", ws_daily, "Msgs/Day")}
</body></html>
"""
return web.Response(text=body, content_type='text/html')
+1 -1
View File
@@ -115,7 +115,7 @@ app.starField.renderWord("H4x0r 1337")
{% endif %}
document.addEventListener("keydown", async() => {
document.addEventListener("keydown", async(event) => {
if(prevKey == "Escape"){
document.querySelector("chat-input").querySelector("textarea").value = "";
}
+1 -1
View File
@@ -1 +1 @@
<div style="max-width:100%;" data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" class="message"><a class="avatar" style="background-color: {{color}}; color: black;" href="/user/{{user_uid}}.html"><img class="avatar-img" width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></a><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class="time no-select" data-created_at="{{created_at}}"></div></div></div>
<chat-message data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</chat-message>
+15 -69
View File
@@ -25,12 +25,11 @@
</div>
{% endif %}
{% for message in messages %}
{% for message in messages|reverse %}
{% 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>
@@ -73,18 +72,19 @@ function throttle(fn, wait) {
// --- Scroll: load extra messages, throttled ---
let isLoadingExtra = false;
async function loadExtra() {
const firstMessage = messagesContainer.querySelector(".message:first-child");
const firstMessage = messagesContainer.lastElementChild;
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.reverse();
messages.forEach(msg => {
const temp = document.createElement("div");
temp.innerHTML = msg.html;
frag.appendChild(temp.firstChild);
});
firstMessage.parentNode.insertBefore(frag, firstMessage);
messagesContainer.appendChild(frag);
updateLayout(false);
}
isLoadingExtra = false;
@@ -93,32 +93,7 @@ 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);
});
}
});
messagesContainer.updateTimes();
}
setInterval(() => requestIdleCallback(updateTimes), 30000);
@@ -164,10 +139,16 @@ chatInputField.textarea.focus();
// --- Reply helper ---
function replyMessage(message) {
chatInputField.value = "```markdown\n> " + (message || '') + "\n```\n";
chatInputField.value = "```markdown\n> " + (message || '').trim().split("\n").join("\n> ") + "\n```\n";
chatInputField.textarea.dispatchEvent(new Event('change', { bubbles: true }));
chatInputField.focus();
}
messagesContainer.addEventListener("reply", (e) => {
const messageText = e.replyText || e.messageTextTarget.textContent.trim();
replyMessage(messageText);
})
// --- Mention helpers ---
function extractMentions(message) {
return [...new Set(message.match(/@\w+/g) || [])];
@@ -223,22 +204,8 @@ app.addEventListener("channel-message", (data) => {
}
}
}
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);
messagesContainer.upsertMessage(data)
app.rpc.markAsRead(channelUid);
});
@@ -255,7 +222,7 @@ document.addEventListener('keydown', function(event) {
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
if (gPressCount === 2) {
gPressCount = 0;
messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" });
loadExtra();
}
}
@@ -284,27 +251,6 @@ document.addEventListener('keydown', function(event) {
// --- 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?.();
}
@@ -315,7 +261,7 @@ function updateLayout(doScrollDown) {
function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
return scrollTop < scrollableHeight / 2;
return Math.abs(scrollTop) > scrollableHeight / 2;
}
// --- Initial layout update ---
+3 -1
View File
@@ -6,7 +6,7 @@
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
from snek.system.stats import update_websocket_stats
import asyncio
import json
import logging
@@ -507,7 +507,9 @@ class RPCView(BaseView):
raise Exception("Method not found")
success = True
try:
update_websocket_stats(self.app)
result = await method(*args)
update_websocket_stats(self.app)
except Exception as ex:
result = {"exception": str(ex), "traceback": traceback.format_exc()}
success = False
-1
View File
@@ -82,7 +82,6 @@ class WebView(BaseView):
await self.app.services.notification.mark_as_read(
self.session.get("uid"), message["uid"]
)
print(messages)
name = await channel_member.get_name()
return await self.render_template(
"web.html",