Compare commits

..

No commits in common. "main" and "maintenance/clean-up-messages" have entirely different histories.

20 changed files with 856 additions and 1360 deletions

View File

@ -39,8 +39,7 @@ dependencies = [
"Pillow", "Pillow",
"pillow-heif", "pillow-heif",
"IP2Location", "IP2Location",
"bleach", "bleach"
"sentry-sdk"
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@ -9,8 +9,6 @@ from snek.shell import Shell
from snek.app import Application from snek.app import Application
@click.group() @click.group()
def cli(): def cli():
pass pass
@ -124,12 +122,6 @@ def shell(db_path):
Shell(db_path).run() Shell(db_path).run()
def main(): def main():
try:
import sentry_sdk
sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1")
except ImportError:
print("Could not import sentry_sdk")
cli() cli()

View File

@ -6,7 +6,6 @@ import uuid
import signal import signal
from datetime import datetime from datetime import datetime
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import aiohttp_debugtoolbar
from snek import snode from snek import snode
from snek.view.threads import ThreadsView from snek.view.threads import ThreadsView
@ -32,7 +31,6 @@ from snek.sgit import GitApplication
from snek.sssh import start_ssh_server from snek.sssh import start_ssh_server
from snek.system import http from snek.system import http
from snek.system.cache import Cache 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.markdown import MarkdownExtension
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
from snek.system.profiler import profiler_handler from snek.system.profiler import profiler_handler
@ -129,7 +127,6 @@ async def trailing_slash_middleware(request, handler):
class Application(BaseApplication): class Application(BaseApplication):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
middlewares = [ middlewares = [
stats_middleware,
cors_middleware, cors_middleware,
web.normalize_path_middleware(merge_slashes=True), web.normalize_path_middleware(merge_slashes=True),
ip2location_middleware, ip2location_middleware,
@ -171,18 +168,10 @@ class Application(BaseApplication):
self.ip2location = IP2Location.IP2Location( self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN") 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.prepare_asyncio)
self.on_startup.append(self.start_user_availability_service) self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server) self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database) self.on_startup.append(self.prepare_database)
async def prepare_stats(self, app):
app['stats'] = create_stats_structure()
print("Stats prepared", flush=True)
@property @property
def uptime_seconds(self): def uptime_seconds(self):
@ -290,9 +279,9 @@ class Application(BaseApplication):
self.router.add_view("/login.json", LoginView) self.router.add_view("/login.json", LoginView)
self.router.add_view("/register.html", RegisterView) self.router.add_view("/register.html", RegisterView)
self.router.add_view("/register.json", RegisterView) self.router.add_view("/register.json", RegisterView)
# self.router.add_view("/drive/{rel_path:.*}", DriveView) self.router.add_view("/drive/{rel_path:.*}", DriveView)
## self.router.add_view("/drive.bin", UploadView) self.router.add_view("/drive.bin", UploadView)
# self.router.add_view("/drive.bin/{uid}.{ext}", UploadView) self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
self.router.add_view("/search-user.html", SearchUserView) self.router.add_view("/search-user.html", SearchUserView)
self.router.add_view("/search-user.json", SearchUserView) self.router.add_view("/search-user.json", SearchUserView)
self.router.add_view("/avatar/{uid}.svg", AvatarView) self.router.add_view("/avatar/{uid}.svg", AvatarView)
@ -300,26 +289,25 @@ class Application(BaseApplication):
self.router.add_get("/http-photo", self.handle_http_photo) self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView) self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView) self.router.add_get("/c/{channel:.*}", ChannelView)
#self.router.add_view( self.router.add_view(
# "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
#) )
#self.router.add_view( self.router.add_view(
# "/channel/{channel_uid}/drive.json", ChannelDriveApiView "/channel/{channel_uid}/drive.json", ChannelDriveApiView
#) )
self.router.add_view( self.router.add_view(
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView "/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
) )
self.router.add_view( self.router.add_view(
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView "/channel/attachment/{relative_url:.*}", ChannelAttachmentView
)# )
self.router.add_view("/channel/{channel}.html", WebView) self.router.add_view("/channel/{channel}.html", WebView)
self.router.add_view("/threads.html", ThreadsView) self.router.add_view("/threads.html", ThreadsView)
self.router.add_view("/terminal.ws", TerminalSocketView) self.router.add_view("/terminal.ws", TerminalSocketView)
self.router.add_view("/terminal.html", TerminalView) self.router.add_view("/terminal.html", TerminalView)
#self.router.add_view("/drive.json", DriveApiView) self.router.add_view("/drive.json", DriveApiView)
#self.router.add_view("/drive.html", DriveView) self.router.add_view("/drive.html", DriveView)
#self.router.add_view("/drive/{drive}.json", 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("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView) self.router.add_view("/user/{user}.html", UserView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView) self.router.add_view("/repository/{username}/{repository}", RepositoryView)
@ -499,7 +487,6 @@ class Application(BaseApplication):
raise raised_exception raise raised_exception
app = Application(db_path="sqlite:///snek.db") app = Application(db_path="sqlite:///snek.db")
#aiohttp_debugtoolbar.setup(app)
async def main(): async def main():

View File

@ -12,10 +12,6 @@ class ChannelModel(BaseModel):
index = ModelField(name="index", required=True, kind=int, value=1000) index = ModelField(name="index", required=True, kind=int, value=1000)
last_message_on = ModelField(name="last_message_on", required=False, kind=str) last_message_on = ModelField(name="last_message_on", required=False, kind=str)
history_start = ModelField(name="history_start", required=False, kind=str) history_start = ModelField(name="history_start", required=False, kind=str)
@property
def is_dm(self):
return 'dm' in self['tag'].lower()
async def get_last_message(self) -> ChannelMessageModel: async def get_last_message(self) -> ChannelMessageModel:
history_start_filter = "" history_start_filter = ""
@ -23,7 +19,7 @@ class ChannelModel(BaseModel):
history_start_filter = f" AND created_at > '{self['history_start']}' " history_start_filter = f" AND created_at > '{self['history_start']}' "
try: try:
async for model in self.app.services.channel_message.query( async for model in self.app.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY id DESC LIMIT 1", "SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY created_at DESC LIMIT 1",
{"channel_uid": self["uid"]}, {"channel_uid": self["uid"]},
): ):

View File

@ -1,20 +1,5 @@
from snek.system.service import BaseService from snek.system.service import BaseService
from snek.system.template import sanitize_html from snek.system.template import whitelist_attributes
import time
import asyncio
from concurrent.futures import ProcessPoolExecutor
import json
from jinja2 import Environment, FileSystemLoader
global jinja2_env
import pathlib
template_path = pathlib.Path(__file__).parent.parent.joinpath("templates")
def render(context):
template =jinja2_env.get_template("message.html")
return sanitize_html(template.render(**context))
class ChannelMessageService(BaseService): class ChannelMessageService(BaseService):
@ -23,46 +8,25 @@ class ChannelMessageService(BaseService):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._configured_indexes = False self._configured_indexes = False
self._executor_pools = {}
global jinja2_env
jinja2_env = self.app.jinja2_env
self._max_workers = 1
def get_or_create_executor(self, uid):
if not uid in self._executor_pools:
self._executor_pools[uid] = ProcessPoolExecutor(max_workers=5)
return self._executor_pools[uid]
def delete_executor(self, uid):
if uid in self._executor_pools:
self._executor_pools[uid].shutdown()
del self._executor_pools[uid]
async def maintenance(self): async def maintenance(self):
args = {} args = {}
for message in self.mapper.db["channel_message"].find(): async for message in self.find():
print(message) updated_at = message["updated_at"]
try: message["is_final"] = True
message = await self.get(uid=message["uid"]) html = message["html"]
updated_at = message["updated_at"] await self.save(message)
message["is_final"] = True
html = message["html"] self.mapper.db["channel_message"].upsert(
await self.save(message) {
"uid": message["uid"],
"updated_at": updated_at,
},
["uid"],
)
if html != message["html"]:
print("Reredefined message", message["uid"])
self.mapper.db["channel_message"].upsert(
{
"uid": message["uid"],
"updated_at": updated_at,
},
["uid"],
)
if html != message["html"]:
print("Reredefined message", message["uid"])
except Exception as ex:
time.sleep(0.1)
print(ex, flush=True)
while True: while True:
changed = 0 changed = 0
async for message in self.find(is_final=False): async for message in self.find(is_final=False):
@ -97,14 +61,10 @@ class ChannelMessageService(BaseService):
"color": user["color"], "color": user["color"],
} }
) )
loop = asyncio.get_event_loop()
try: try:
template = self.app.jinja2_env.get_template("message.html")
context = json.loads(json.dumps(context, default=str)) model["html"] = template.render(**context)
model["html"] = whitelist_attributes(model["html"])
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render,context)
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html'])
except Exception as ex: except Exception as ex:
print(ex, flush=True) print(ex, flush=True)
@ -123,8 +83,6 @@ class ChannelMessageService(BaseService):
["deleted_at"], unique=False ["deleted_at"], unique=False
) )
self._configured_indexes = True self._configured_indexes = True
if model['is_final']:
self.delete_executor(model['uid'])
return model return model
raise Exception(f"Failed to create channel message: {model.errors}.") raise Exception(f"Failed to create channel message: {model.errors}.")
@ -133,10 +91,10 @@ class ChannelMessageService(BaseService):
if not user: if not user:
return {} return {}
#if not message["html"].startswith("<chat-message"): if not message["html"].startswith("<chat-message"):
#message = await self.get(uid=message["uid"]) await (await self.get(uid=message["uid"])).save()
#await self.save(message) message["html"] = (await self.get(uid=message["uid"])).html
return { return {
"uid": message["uid"], "uid": message["uid"],
"color": user["color"], "color": user["color"],
@ -161,15 +119,10 @@ class ChannelMessageService(BaseService):
"color": user["color"], "color": user["color"],
} }
) )
context = json.loads(json.dumps(context, default=str)) template = self.app.jinja2_env.get_template("message.html")
loop = asyncio.get_event_loop() model["html"] = template.render(**context)
model["html"] = await loop.run_in_executor(self.get_or_create_executor(model["uid"]), render, context) model["html"] = whitelist_attributes(model["html"])
#model['html'] = await loop.run_in_executor(self.get_or_create_executor(user["uid"]), sanitize_html,model['html']) return await super().save(model)
result = await super().save(model)
if model['is_final']:
self.delete_executor(model['uid'])
return result
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30): async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
channel = await self.services.channel.get(uid=channel_uid) channel = await self.services.channel.get(uid=channel_uid)

View File

@ -44,29 +44,19 @@ class SocketService(BaseService):
async def user_availability_service(self): async def user_availability_service(self):
logger.info("User availability update service started.") logger.info("User availability update service started.")
logger.debug("Entering the main loop.")
while True: while True:
logger.info("Updating user availability...") logger.info("Updating user availability...")
logger.debug("Initializing users_updated list.")
users_updated = [] users_updated = []
logger.debug("Iterating over sockets.")
for s in self.sockets: for s in self.sockets:
logger.debug(f"Checking connection status for socket: {s}.")
if not s.is_connected: if not s.is_connected:
logger.debug("Socket is not connected, continuing to next socket.")
continue continue
logger.debug(f"Checking if user {s.user} is already updated.")
if s.user not in users_updated: if s.user not in users_updated:
logger.debug(f"Updating last_ping for user: {s.user}.")
s.user["last_ping"] = now() s.user["last_ping"] = now()
logger.debug(f"Saving user {s.user} to the database.")
await self.app.services.user.save(s.user) await self.app.services.user.save(s.user)
logger.debug(f"Adding user {s.user} to users_updated list.")
users_updated.append(s.user) users_updated.append(s.user)
logger.info( logger.info(
f"Updated user availability for {len(users_updated)} online users." f"Updated user availability for {len(users_updated)} online users."
) )
logger.debug("Sleeping for 60 seconds before the next update.")
await asyncio.sleep(60) await asyncio.sleep(60)
async def add(self, ws, user_uid): async def add(self, ws, user_uid):
@ -87,7 +77,7 @@ class SocketService(BaseService):
async def send_to_user(self, user_uid, message): async def send_to_user(self, user_uid, message):
count = 0 count = 0
for s in list(self.users.get(user_uid, [])): for s in self.users.get(user_uid, []):
if await s.send_json(message): if await s.send_json(message):
count += 1 count += 1
return count return count

View File

@ -368,7 +368,7 @@ input[type="text"], .chat-input textarea {
} }
} }
.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{ .message.switch-user + .message, .message.long-time + .message, .message:first-child {
.time { .time {
display: block; display: block;
opacity: 1; opacity: 1;

View File

@ -1,494 +1,226 @@
import { NjetComponent } from "/njet.js" import { NjetComponent} from "/njet.js"
class NjetEditor extends NjetComponent { class NjetEditor extends NjetComponent {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: 'open' });
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
:host { #editor {
display: block; padding: 1rem;
position: relative; outline: none;
height: 100%; white-space: pre-wrap;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; line-height: 1.5;
} height: 100%;
overflow-y: auto;
#editor { background: #1e1e1e;
padding: 1rem; color: #d4d4d4;
outline: none; }
white-space: pre-wrap; #command-line {
line-height: 1.5; position: absolute;
height: calc(100% - 30px); bottom: 0;
overflow-y: auto; left: 0;
background: #1e1e1e; width: 100%;
color: #d4d4d4; padding: 0.2rem 1rem;
font-size: 14px; background: #333;
caret-color: #fff; color: #0f0;
} display: none;
font-family: monospace;
#editor.insert-mode { }
caret-color: #4ec9b0; `;
}
#editor.visual-mode {
caret-color: #c586c0;
}
#editor::selection {
background: #264f78;
}
#status-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: #007acc;
color: #fff;
display: flex;
align-items: center;
padding: 0 1rem;
font-size: 12px;
font-weight: 500;
}
#mode-indicator {
text-transform: uppercase;
margin-right: 20px;
font-weight: bold;
}
#command-line {
position: absolute;
bottom: 30px;
left: 0;
width: 100%;
padding: 0.3rem 1rem;
background: #2d2d2d;
color: #d4d4d4;
display: none;
font-family: inherit;
border-top: 1px solid #3e3e3e;
font-size: 14px;
}
#command-input {
background: transparent;
border: none;
color: inherit;
outline: none;
font-family: inherit;
font-size: inherit;
width: calc(100% - 20px);
}
.visual-selection {
background: #264f78 !important;
}
`;
this.editor = document.createElement('div'); this.editor = document.createElement('div');
this.editor.id = 'editor'; this.editor.id = 'editor';
this.editor.contentEditable = true; this.editor.contentEditable = true;
this.editor.spellcheck = false; this.editor.innerText = `Welcome to VimEditor Component
this.editor.innerText = `Welcome to VimEditor Component
Line 2 here Line 2 here
Another line Another line
Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`; Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
this.cmdLine = document.createElement('div'); this.cmdLine = document.createElement('div');
this.cmdLine.id = 'command-line'; this.cmdLine.id = 'command-line';
this.shadowRoot.append(style, this.editor, this.cmdLine);
const cmdPrompt = document.createElement('span');
cmdPrompt.textContent = ':';
this.cmdInput = document.createElement('input');
this.cmdInput.id = 'command-input';
this.cmdInput.type = 'text';
this.cmdLine.append(cmdPrompt, this.cmdInput);
this.statusBar = document.createElement('div'); this.mode = 'normal'; // normal | insert | visual | command
this.statusBar.id = 'status-bar'; this.keyBuffer = '';
this.lastDeletedLine = '';
this.modeIndicator = document.createElement('span'); this.yankedLine = '';
this.modeIndicator.id = 'mode-indicator';
this.modeIndicator.textContent = 'NORMAL';
this.statusBar.appendChild(this.modeIndicator);
this.shadowRoot.append(style, this.editor, this.cmdLine, this.statusBar); this.editor.addEventListener('keydown', this.handleKeydown.bind(this));
}
this.mode = 'normal'; connectedCallback() {
this.keyBuffer = '';
this.lastDeletedLine = '';
this.yankedLine = '';
this.visualStartOffset = null;
this.visualEndOffset = null;
// Bind event handlers
this.handleKeydown = this.handleKeydown.bind(this);
this.handleCmdKeydown = this.handleCmdKeydown.bind(this);
this.updateVisualSelection = this.updateVisualSelection.bind(this);
this.editor.addEventListener('keydown', this.handleKeydown);
this.cmdInput.addEventListener('keydown', this.handleCmdKeydown);
this.editor.addEventListener('beforeinput', this.handleBeforeInput.bind(this));
}
connectedCallback() {
this.editor.focus();
}
setMode(mode) {
this.mode = mode;
this.modeIndicator.textContent = mode.toUpperCase();
// Update editor classes
this.editor.classList.remove('insert-mode', 'visual-mode', 'normal-mode');
this.editor.classList.add(`${mode}-mode`);
if (mode === 'visual') {
this.visualStartOffset = this.getCaretOffset();
this.editor.addEventListener('selectionchange', this.updateVisualSelection);
} else {
this.clearVisualSelection();
this.editor.removeEventListener('selectionchange', this.updateVisualSelection);
}
if (mode === 'command') {
this.cmdLine.style.display = 'block';
this.cmdInput.value = '';
this.cmdInput.focus();
} else {
this.cmdLine.style.display = 'none';
if (mode !== 'insert') {
// Keep focus on editor for all non-insert modes
this.editor.focus(); this.editor.focus();
} }
}
}
updateVisualSelection() { getCaretOffset() {
if (this.mode !== 'visual') return; let caretOffset = 0;
this.visualEndOffset = this.getCaretOffset();
}
clearVisualSelection() {
const sel = this.shadowRoot.getSelection();
if (sel) sel.removeAllRanges();
this.visualStartOffset = null;
this.visualEndOffset = null;
}
getCaretOffset() {
const sel = this.shadowRoot.getSelection();
if (!sel || sel.rangeCount === 0) return 0;
const range = sel.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(this.editor);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
setCaretOffset(offset) {
const textContent = this.editor.innerText;
offset = Math.max(0, Math.min(offset, textContent.length));
const range = document.createRange();
const sel = this.shadowRoot.getSelection();
const walker = document.createTreeWalker(
this.editor,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let node;
while ((node = walker.nextNode())) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= offset) {
range.setStart(node, offset - currentOffset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return;
}
currentOffset += nodeLength;
}
// If we couldn't find the position, set to end
if (this.editor.lastChild) {
range.selectNodeContents(this.editor.lastChild);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
}
handleBeforeInput(e) {
if (this.mode !== 'insert') {
e.preventDefault();
}
}
handleCmdKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
this.executeCommand(this.cmdInput.value);
this.setMode('normal');
} else if (e.key === 'Escape') {
e.preventDefault();
this.setMode('normal');
}
}
executeCommand(cmd) {
const trimmedCmd = cmd.trim();
// Handle basic vim commands
if (trimmedCmd === 'w' || trimmedCmd === 'write') {
console.log('Save command (not implemented)');
} else if (trimmedCmd === 'q' || trimmedCmd === 'quit') {
console.log('Quit command (not implemented)');
} else if (trimmedCmd === 'wq' || trimmedCmd === 'x') {
console.log('Save and quit command (not implemented)');
} else if (/^\d+$/.test(trimmedCmd)) {
// Go to line number
const lineNum = parseInt(trimmedCmd, 10) - 1;
this.goToLine(lineNum);
}
}
goToLine(lineNum) {
const lines = this.editor.innerText.split('\n');
if (lineNum < 0 || lineNum >= lines.length) return;
let offset = 0;
for (let i = 0; i < lineNum; i++) {
offset += lines[i].length + 1;
}
this.setCaretOffset(offset);
}
getCurrentLineInfo() {
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
let charCount = 0;
for (let i = 0; i < lines.length; i++) {
if (caretPos <= charCount + lines[i].length) {
return {
lineIndex: i,
lines: lines,
lineStartOffset: charCount,
positionInLine: caretPos - charCount
};
}
charCount += lines[i].length + 1;
}
return {
lineIndex: lines.length - 1,
lines: lines,
lineStartOffset: charCount - lines[lines.length - 1].length - 1,
positionInLine: 0
};
}
handleKeydown(e) {
if (this.mode === 'insert') {
if (e.key === 'Escape') {
e.preventDefault();
this.setMode('normal');
// Move cursor one position left (vim behavior)
const offset = this.getCaretOffset();
if (offset > 0) {
this.setCaretOffset(offset - 1);
}
}
return;
}
if (this.mode === 'command') {
return; // Command mode input is handled by cmdInput
}
if (this.mode === 'visual') {
if (e.key === 'Escape') {
e.preventDefault();
this.setMode('normal');
return;
}
// Allow movement in visual mode
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
return; // Let default behavior handle selection
}
if (e.key === 'y') {
e.preventDefault();
// Yank selected text
const sel = this.shadowRoot.getSelection(); const sel = this.shadowRoot.getSelection();
if (sel && sel.rangeCount > 0) { if (!sel || sel.rangeCount === 0) return 0;
this.yankedLine = sel.toString();
} const range = sel.getRangeAt(0);
this.setMode('normal'); const preCaretRange = range.cloneRange();
return; preCaretRange.selectNodeContents(this.editor);
preCaretRange.setEnd(range.endContainer, range.endOffset);
caretOffset = preCaretRange.toString().length;
return caretOffset;
} }
if (e.key === 'd' || e.key === 'x') { setCaretOffset(offset) {
e.preventDefault(); const range = document.createRange();
// Delete selected text
const sel = this.shadowRoot.getSelection(); const sel = this.shadowRoot.getSelection();
if (sel && sel.rangeCount > 0) { const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false);
this.lastDeletedLine = sel.toString();
document.execCommand('delete');
}
this.setMode('normal');
return;
}
}
// Normal mode handling let currentOffset = 0;
e.preventDefault(); let node;
while ((node = walker.nextNode())) {
// Special keys that should be handled immediately if (currentOffset + node.length >= offset) {
if (e.key === 'Escape') { range.setStart(node, offset - currentOffset);
this.keyBuffer = ''; range.collapse(true);
this.setMode('normal'); sel.removeAllRanges();
return; sel.addRange(range);
} return;
// Build key buffer for commands
this.keyBuffer += e.key;
const lineInfo = this.getCurrentLineInfo();
const { lineIndex, lines, lineStartOffset, positionInLine } = lineInfo;
// Process commands
switch (this.keyBuffer) {
case 'i':
this.keyBuffer = '';
this.setMode('insert');
break;
case 'a':
this.keyBuffer = '';
this.setCaretOffset(this.getCaretOffset() + 1);
this.setMode('insert');
break;
case 'v':
this.keyBuffer = '';
this.setMode('visual');
break;
case ':':
this.keyBuffer = '';
this.setMode('command');
break;
case 'yy':
this.keyBuffer = '';
this.yankedLine = lines[lineIndex];
break;
case 'dd':
this.keyBuffer = '';
this.lastDeletedLine = lines[lineIndex];
lines.splice(lineIndex, 1);
if (lines.length === 0) lines.push('');
this.editor.innerText = lines.join('\n');
this.setCaretOffset(lineStartOffset);
break;
case 'p':
this.keyBuffer = '';
const lineToPaste = this.yankedLine || this.lastDeletedLine;
if (lineToPaste) {
lines.splice(lineIndex + 1, 0, lineToPaste);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(lineStartOffset + lines[lineIndex].length + 1);
}
break;
case '0':
this.keyBuffer = '';
this.setCaretOffset(lineStartOffset);
break;
case '$':
this.keyBuffer = '';
this.setCaretOffset(lineStartOffset + lines[lineIndex].length);
break;
case 'gg':
this.keyBuffer = '';
this.setCaretOffset(0);
break;
case 'G':
this.keyBuffer = '';
this.setCaretOffset(this.editor.innerText.length);
break;
case 'h':
case 'ArrowLeft':
this.keyBuffer = '';
const currentOffset = this.getCaretOffset();
if (currentOffset > 0) {
this.setCaretOffset(currentOffset - 1);
}
break;
case 'l':
case 'ArrowRight':
this.keyBuffer = '';
this.setCaretOffset(this.getCaretOffset() + 1);
break;
case 'j':
case 'ArrowDown':
this.keyBuffer = '';
if (lineIndex < lines.length - 1) {
const nextLineStart = lineStartOffset + lines[lineIndex].length + 1;
const nextLineLength = lines[lineIndex + 1].length;
const newPosition = Math.min(positionInLine, nextLineLength);
this.setCaretOffset(nextLineStart + newPosition);
}
break;
case 'k':
case 'ArrowUp':
this.keyBuffer = '';
if (lineIndex > 0) {
let prevLineStart = 0;
for (let i = 0; i < lineIndex - 1; i++) {
prevLineStart += lines[i].length + 1;
} }
const prevLineLength = lines[lineIndex - 1].length; currentOffset += node.length;
const newPosition = Math.min(positionInLine, prevLineLength);
this.setCaretOffset(prevLineStart + newPosition);
} }
break; }
default: handleKeydown(e) {
// Clear buffer if it gets too long or contains invalid sequences const key = e.key;
if (this.keyBuffer.length > 2 ||
(this.keyBuffer.length === 2 && !['dd', 'yy', 'gg'].includes(this.keyBuffer))) { if (this.mode === 'insert') {
this.keyBuffer = ''; if (key === 'Escape') {
e.preventDefault();
this.mode = 'normal';
this.editor.blur();
this.editor.focus();
}
return;
} }
break;
if (this.mode === 'command') {
if (key === 'Enter' || key === 'Escape') {
e.preventDefault();
this.cmdLine.style.display = 'none';
this.mode = 'normal';
this.keyBuffer = '';
}
return;
}
if (this.mode === 'visual') {
if (key === 'Escape') {
e.preventDefault();
this.mode = 'normal';
}
return;
}
// Handle normal mode
this.keyBuffer += key;
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
let charCount = 0, lineIdx = 0;
for (let i = 0; i < lines.length; i++) {
if (caretPos <= charCount + lines[i].length) {
lineIdx = i;
break;
}
charCount += lines[i].length + 1;
}
const offsetToLine = idx =>
text.split('\n').slice(0, idx).reduce((acc, l) => acc + l.length + 1, 0);
switch (this.keyBuffer) {
case 'i':
e.preventDefault();
this.mode = 'insert';
this.keyBuffer = '';
break;
case 'v':
e.preventDefault();
this.mode = 'visual';
this.keyBuffer = '';
break;
case ':':
e.preventDefault();
this.mode = 'command';
this.cmdLine.style.display = 'block';
this.cmdLine.textContent = ':';
this.keyBuffer = '';
break;
case 'yy':
e.preventDefault();
this.yankedLine = lines[lineIdx];
this.keyBuffer = '';
break;
case 'dd':
e.preventDefault();
this.lastDeletedLine = lines[lineIdx];
lines.splice(lineIdx, 1);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break;
case 'p':
e.preventDefault();
const lineToPaste = this.yankedLine || this.lastDeletedLine;
if (lineToPaste) {
lines.splice(lineIdx + 1, 0, lineToPaste);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(offsetToLine(lineIdx + 1));
}
this.keyBuffer = '';
break;
case '0':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break;
case '$':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx) + lines[lineIdx].length);
this.keyBuffer = '';
break;
case 'gg':
e.preventDefault();
this.setCaretOffset(0);
this.keyBuffer = '';
break;
case 'G':
e.preventDefault();
this.setCaretOffset(text.length);
this.keyBuffer = '';
break;
case 'Escape':
e.preventDefault();
this.mode = 'normal';
this.keyBuffer = '';
this.cmdLine.style.display = 'none';
break;
default:
// allow up to 2 chars for combos
if (this.keyBuffer.length > 2) this.keyBuffer = '';
break;
}
}
} }
}
}
customElements.define('njet-editor', NjetEditor); customElements.define('njet-editor', NjetEditor);
export { NjetEditor } export {NjetEditor}

View File

@ -3,15 +3,8 @@ export class EventHandler {
this.subscribers = {}; this.subscribers = {};
} }
addEventListener(type, handler, { once = false } = {}) { addEventListener(type, handler) {
if (!this.subscribers[type]) this.subscribers[type] = []; if (!this.subscribers[type]) this.subscribers[type] = [];
if (once) {
const originalHandler = handler;
handler = (...args) => {
originalHandler(...args);
this.removeEventListener(type, handler);
};
}
this.subscribers[type].push(handler); this.subscribers[type].push(handler);
} }
@ -19,15 +12,4 @@ export class EventHandler {
if (this.subscribers[type]) if (this.subscribers[type])
this.subscribers[type].forEach((handler) => handler(...data)); this.subscribers[type].forEach((handler) => handler(...data));
} }
removeEventListener(type, handler) {
if (!this.subscribers[type]) return;
this.subscribers[type] = this.subscribers[type].filter(
(h) => h !== handler
);
if (this.subscribers[type].length === 0) {
delete this.subscribers[type];
}
}
} }

View File

@ -5,65 +5,30 @@
// 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. // 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. // 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; const LONG_TIME = 1000 * 60 * 20
export class ReplyEvent extends Event {
constructor(messageTextTarget) {
super('reply', { bubbles: true, composed: true });
this.messageTextTarget = messageTextTarget;
// Clone and sanitize message node to text-only reply
const newMessage = messageTextTarget.cloneNode(true);
newMessage.style.maxHeight = "0";
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
// Remove all .embed-url-link
newMessage.querySelectorAll('.embed-url-link').forEach(link => link.remove());
// Replace <picture> with their <img>
newMessage.querySelectorAll('picture').forEach(picture => {
const img = picture.querySelector('img');
if (img) picture.replaceWith(img);
});
// Replace <img> with just their src
newMessage.querySelectorAll('img').forEach(img => {
const src = img.src || img.currentSrc;
img.replaceWith(document.createTextNode(src));
});
// Replace <iframe> with their src
newMessage.querySelectorAll('iframe').forEach(iframe => {
const src = iframe.src || iframe.currentSrc;
iframe.replaceWith(document.createTextNode(src));
});
// Replace <a> with href or markdown
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 { 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() { updateUI() {
if (this._originalChildren === undefined) { if (this._originalChildren === undefined) {
const { color, user_nick, created_at, user_uid } = this.dataset; const { color, user_nick, created_at, user_uid} = this.dataset;
this.classList.add('message'); this.classList.add('message');
this.style.maxWidth = '100%'; this.style.maxWidth = '100%';
this._originalChildren = Array.from(this.children); this._originalChildren = Array.from(this.children);
this.innerHTML = ` this.innerHTML = `
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html"> <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"> <img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
@ -73,30 +38,23 @@ class MessageElement extends HTMLElement {
<div class="text"></div> <div class="text"></div>
<div class="time no-select" data-created_at="${created_at || ''}"> <div class="time no-select" data-created_at="${created_at || ''}">
<span></span> <span></span>
<a href="#reply">reply</a> <a href="#reply">reply</a></div>
</div> </div>
</div> `;
`;
this.messageDiv = this.querySelector('.text'); this.messageDiv = this.querySelector('.text');
if (this._originalChildren && this._originalChildren.length > 0) { if (this._originalChildren && this._originalChildren.length > 0) {
this._originalChildren.forEach(child => { this._originalChildren.forEach(child => {
this.messageDiv.appendChild(child); this.messageDiv.appendChild(child);
}); });
} }
this.timeDiv = this.querySelector('.time span'); 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));
});
} }
// Sibling logic for user switches and long time gaps if (!this.siblingGenerated && this.nextElementSibling) {
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) { this.siblingGenerated = true;
this.siblingGenerated = this.nextElementSibling;
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) { if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
this.classList.add('switch-user'); this.classList.add('switch-user');
} else { } else {
@ -104,7 +62,7 @@ class MessageElement extends HTMLElement {
const siblingTime = new Date(this.nextElementSibling.dataset.created_at); const siblingTime = new Date(this.nextElementSibling.dataset.created_at);
const currentTime = new Date(this.dataset.created_at); const currentTime = new Date(this.dataset.created_at);
if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) { if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) {
this.classList.add('long-time'); this.classList.add('long-time');
} else { } else {
this.classList.remove('long-time'); this.classList.remove('long-time');
@ -117,7 +75,7 @@ class MessageElement extends HTMLElement {
updateMessage(...messages) { updateMessage(...messages) {
if (this._originalChildren) { if (this._originalChildren) {
this.messageDiv.replaceChildren(...messages); this.messageDiv.replaceChildren(...messages)
this._originalChildren = messages; this._originalChildren = messages;
} }
} }
@ -126,70 +84,62 @@ class MessageElement extends HTMLElement {
this.updateUI(); this.updateUI();
} }
disconnectedCallback() {} disconnectedCallback() {
connectedMoveCallback() {} }
connectedMoveCallback() {
}
attributeChangedCallback(name, oldValue, newValue) { attributeChangedCallback(name, oldValue, newValue) {
this.updateUI(); this.updateUI()
} }
} }
class MessageList extends HTMLElement { class MessageList extends HTMLElement {
constructor() { constructor() {
super(); super();
this.messageMap = new Map();
this.visibleSet = new Set();
this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.visibleSet.add(entry.target);
if (entry.target instanceof MessageElement) {
entry.target.updateUI();
}
} else {
this.visibleSet.delete(entry.target);
}
});
}, {
root: this,
threshold: 0,
});
// End-of-messages marker
this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages);
// Observe existing children and index by uid
for (const c of this.children) {
this._observer.observe(c);
if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c);
}
}
// Wire up socket events
app.ws.addEventListener("update_message_text", (data) => { app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) { this.upsertMessage(data);
this.upsertMessage(data);
}
}); });
app.ws.addEventListener("set_typing", (data) => { app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid, data.color); this.triggerGlow(data.user_uid,data.color);
}); });
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);
}
});
console.log(this.visibleSet);
}, {
root: this,
threshold: 0.1
})
for(const c of this.children) {
this._observer.observe(c);
if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c);
}
}
this.scrollToBottom(true); this.scrollToBottom(true);
} }
connectedCallback() { connectedCallback() {
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
if ( if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
e.target.tagName !== 'IMG' ||
e.target.classList.contains('avatar-img')
) return;
const img = e.target; const img = e.target;
const overlay = document.createElement('div'); 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;cursor:pointer;'; 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;';
@ -210,11 +160,12 @@ class MessageList extends HTMLElement {
overlay.appendChild(fullImg); overlay.appendChild(fullImg);
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.addEventListener('click', () => { overlay.addEventListener('click', () => {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay); if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}); });
// ESC to close // Optional: ESC key closes overlay
const escListener = (evt) => { const escListener = (evt) => {
if (evt.key === 'Escape') { if (evt.key === 'Escape') {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay); if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
@ -222,9 +173,9 @@ class MessageList extends HTMLElement {
} }
}; };
document.addEventListener('keydown', escListener); document.addEventListener('keydown', escListener);
}); })
}
}
isElementVisible(element) { isElementVisible(element) {
if (!element) return false; if (!element) return false;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@ -235,16 +186,14 @@ class MessageList extends HTMLElement {
rect.right <= (window.innerWidth || document.documentElement.clientWidth) rect.right <= (window.innerWidth || document.documentElement.clientWidth)
); );
} }
isScrolledToBottom() { isScrolledToBottom() {
return this.visibleSet.has(this.endOfMessages); return this.isElementVisible(this.firstElementChild);
} }
scrollToBottom(force = false, behavior= 'smooth') {
scrollToBottom(force = false, behavior = 'instant') { if (force || this.isScrolledToBottom()) {
if (force || !this.isScrolledToBottom()) { this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
setTimeout(() => { setTimeout(() => {
this.endOfMessages.scrollIntoView({ behavior, block: 'end' }); this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
}, 200); }, 200);
} }
} }
@ -256,9 +205,7 @@ class MessageList extends HTMLElement {
this.querySelectorAll('.avatar').forEach((el) => { this.querySelectorAll('.avatar').forEach((el) => {
const anchor = el.closest('a'); const anchor = el.closest('a');
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) { if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
if(!lastElement) lastElement = el;
lastElement = el;
} }
}); });
if (lastElement) { if (lastElement) {
@ -269,48 +216,41 @@ class MessageList extends HTMLElement {
} }
} }
updateTimes() { updateTimes() {
this.visibleSet.forEach((messageElement) => { this.visibleSet.forEach((messageElement) => {
if (messageElement instanceof MessageElement) { if (messageElement instanceof MessageElement) {
messageElement.updateUI(); messageElement.updateUI();
} }
}); })
} }
upsertMessage(data) { upsertMessage(data) {
let message = this.messageMap.get(data.uid); let message = this.messageMap.get(data.uid);
if (message && (data.is_final || !data.message)) { const newMessage = !!message;
//message.parentElement?.removeChild(message); if (message) {
// TO force insert message.parentElement.removeChild(message);
//message = null;
} }
if(message && !data.message){
message.parentElement?.removeChild(message); if (!data.message) return
message = null;
}
if (!data.message) return;
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.innerHTML = data.html; wrapper.innerHTML = data.html;
if (message) { if (message) {
// If the old element is already custom, only update its message children message.updateMessage(...wrapper.firstElementChild._originalChildren);
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
} else { } else {
// If not, insert the new one and observe message = wrapper.firstElementChild;
message = wrapper.firstElementChild; this.messageMap.set(data.uid, message);
this.messageMap.set(data.uid, message); this._observer.observe(message);
this._observer.observe(message);
this.endOfMessages.after(message);
} }
const scrolledToBottom = this.isScrolledToBottom(); const scrolledToBottom = this.isScrolledToBottom();
this.prepend(message);
if (scrolledToBottom) this.scrollToBottom(true); if (scrolledToBottom) this.scrollToBottom(true, !newMessage ? 'smooth' : 'auto');
} }
} }
customElements.define("chat-message", MessageElement); customElements.define("chat-message", MessageElement);
customElements.define("message-list", MessageList); customElements.define("message-list", MessageList);

View File

@ -1,3 +1,5 @@
class RestClient { class RestClient {
constructor({ baseURL = '', headers = {} } = {}) { constructor({ baseURL = '', headers = {} } = {}) {
this.baseURL = baseURL; this.baseURL = baseURL;
@ -208,52 +210,27 @@ class Njet extends HTMLElement {
customElements.define(name, component); customElements.define(name, component);
} }
constructor(config) { constructor() {
super(); super();
// Store the config for use in render and other methods
this.config = config || {};
if (!Njet._root) { if (!Njet._root) {
Njet._root = this Njet._root = this
Njet._rest = new RestClient({ baseURL: '/' || null }) Njet._rest = new RestClient({ baseURL: '/' || null })
} }
this.root._elements.push(this) this.root._elements.push(this)
this.classList.add('njet'); this.classList.add('njet');
// Initialize properties from config before rendering
this.initProps(this.config);
// Call render after properties are initialized
this.render.call(this); this.render.call(this);
//this.initProps(config);
// Call construct if defined //if (typeof this.config.construct === 'function')
if (typeof this.config.construct === 'function') { // this.config.construct.call(this)
this.config.construct.call(this)
}
} }
initProps(config) { initProps(config) {
const props = Object.keys(config) const props = Object.keys(config)
props.forEach(prop => { props.forEach(prop => {
// Skip special properties that are handled separately if (config[prop] !== undefined) {
if (['construct', 'items', 'classes'].includes(prop)) {
return;
}
// Check if there's a setter for this property
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), prop);
if (descriptor && descriptor.set) {
// Use the setter
this[prop] = config[prop]; this[prop] = config[prop];
} else if (prop in this) {
// Property exists, set it directly
this[prop] = config[prop];
} else {
// Set as attribute for unknown properties
this.setAttribute(prop, config[prop]);
} }
}); });
if (config.classes) { if (config.classes) {
this.classList.add(...config.classes); this.classList.add(...config.classes);
} }
@ -365,7 +342,7 @@ class NjetDialog extends Component {
const buttonContainer = document.createElement('div'); const buttonContainer = document.createElement('div');
buttonContainer.style.marginTop = '20px'; buttonContainer.style.marginTop = '20px';
buttonContainer.style.display = 'flex'; buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'flex-end'; buttonContainer.style.justifyContent = 'flenjet-end';
buttonContainer.style.gap = '10px'; buttonContainer.style.gap = '10px';
if (secondaryButton) { if (secondaryButton) {
const secondary = new NjetButton(secondaryButton); const secondary = new NjetButton(secondaryButton);
@ -395,9 +372,8 @@ class NjetWindow extends Component {
header.textContent = title; header.textContent = title;
this.appendChild(header); this.appendChild(header);
} }
if (this.config.items) { this.config.items.forEach(item => this.appendChild(item));
this.config.items.forEach(item => this.appendChild(item));
}
} }
show(){ show(){
@ -432,8 +408,7 @@ class NjetGrid extends Component {
} }
} }
Njet.registerComponent('njet-grid', NjetGrid); Njet.registerComponent('njet-grid', NjetGrid);
/*
/* Example usage:
const button = new NjetButton({ const button = new NjetButton({
classes: ['my-button'], classes: ['my-button'],
text: 'Shared', text: 'Shared',
@ -518,7 +493,7 @@ document.body.appendChild(dialog);
*/ */
class NjetComponent extends Component {} class NjetComponent extends Component {}
const njet = Njet const njet = Njet
njet.showDialog = function(args){ njet.showDialog = function(args){
const dialog = new NjetDialog(args) const dialog = new NjetDialog(args)
dialog.show() dialog.show()
@ -570,16 +545,15 @@ njet.showWindow = function(args) {
return w return w
} }
njet.publish = function(event, data) { njet.publish = function(event, data) {
if (this.root && this.root._subscriptions && this.root._subscriptions[event]) { if (this.root._subscriptions[event]) {
this.root._subscriptions[event].forEach(callback => callback(data)) this.root._subscriptions[event].forEach(callback => callback(data))
} }
} }
njet.subscribe = function(event, callback) { njet.subscribe = function(event, callback) {
if (!this.root) return;
if (!this.root._subscriptions[event]) { if (!this.root._subscriptions[event]) {
this.root._subscriptions[event] = [] this.root._subscriptions[event] = []
} }
this.root._subscriptions[event].push(callback) this.root._subscriptions[event].push(callback)
} }
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow, eventBus }; export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow,eventBus };

View File

@ -142,9 +142,10 @@ export class Socket extends EventHandler {
method, method,
args, args,
}; };
const me = this;
return new Promise((resolve) => { return new Promise((resolve) => {
this.addEventListener(call.callId, (data) => resolve(data), { once: true}); me.addEventListener(call.callId, (data) => resolve(data));
this.sendJson(call); me.sendJson(call);
}); });
} }
} }

View File

@ -1,129 +0,0 @@
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')

View File

@ -79,38 +79,44 @@ emoji.EMOJI_DATA[
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]} ] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"] ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [
"img",
"video",
"audio",
"source",
"iframe",
"picture",
"span",
]
ALLOWED_ATTRIBUTES = {
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
"img": ["src", "alt", "title", "width", "height"],
"a": ["href", "title", "target", "rel", "referrerpolicy", "class"],
"iframe": [
"src",
"width",
"height",
"frameborder",
"allow",
"allowfullscreen",
"title",
"referrerpolicy",
"style",
],
"video": ["src", "controls", "width", "height"],
"audio": ["src", "controls"],
"source": ["src", "type"],
"span": ["class"],
"picture": [],
}
def sanitize_html(value): def sanitize_html(value):
soup = BeautifulSoup(value, 'html.parser')
for script in soup.find_all('script'):
script.decompose()
#for iframe in soup.find_all('iframe'):
#iframe.decompose()
for tag in soup.find_all(['object', 'embed']):
tag.decompose()
for tag in soup.find_all():
event_attributes = ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']
for attr in event_attributes:
if attr in tag.attrs:
del tag[attr]
for img in soup.find_all('img'):
if 'onerror' in img.attrs:
img.decompose()
return soup.prettify()
def sanitize_html2(value):
return bleach.clean( return bleach.clean(
value, value,
protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"], tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"],
strip=True, strip=True,
) )
@ -126,8 +132,50 @@ def set_link_target_blank(text):
return str(soup) return str(soup)
SAFE_ATTRIBUTES = {
"href",
"src",
"alt",
"title",
"width",
"height",
"style",
"id",
"class",
"rel",
"type",
"name",
"value",
"placeholder",
"aria-hidden",
"aria-label",
"srcset",
"target",
"rel",
"referrerpolicy",
"controls",
"frameborder",
"allow",
"allowfullscreen",
"referrerpolicy",
}
def whitelist_attributes(html): def whitelist_attributes(html):
return sanitize_html(html) soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all():
if hasattr(tag, "attrs"):
if tag.name in ["script", "form", "input"]:
tag.replace_with("")
continue
attrs = dict(tag.attrs)
for attr in list(attrs):
# Check if attribute is in the safe list or is a data-* attribute
if not (attr in SAFE_ATTRIBUTES or attr.startswith("data-")):
del tag.attrs[attr]
return str(soup)
def embed_youtube(text): def embed_youtube(text):

View File

@ -12,7 +12,7 @@ function showTerm(options){
class StarField { class StarField {
constructor({ count = 50, container = document.body } = {}) { constructor({ count = 200, container = document.body } = {}) {
this.container = container; this.container = container;
this.starCount = count; this.starCount = count;
this.stars = []; this.stars = [];
@ -567,7 +567,7 @@ const count = Array.from(messages).filter(el => el.textContent.trim() === text).
const starField = new StarField({starCount: 50}); const starField = new StarField({starCount: 200});
app.starField = starField; app.starField = starField;
class DemoSequence { class DemoSequence {

View File

@ -72,13 +72,12 @@ function throttle(fn, wait) {
// --- Scroll: load extra messages, throttled --- // --- Scroll: load extra messages, throttled ---
let isLoadingExtra = false; let isLoadingExtra = false;
async function loadExtra() { async function loadExtra() {
const firstMessage = messagesContainer.lastElementChild; const firstMessage = messagesContainer.children[messagesContainer.children.length - 1];
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return; if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
isLoadingExtra = true; isLoadingExtra = true;
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at); const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
if (messages.length) { if (messages.length) {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
messages.reverse();
messages.forEach(msg => { messages.forEach(msg => {
const temp = document.createElement("div"); const temp = document.createElement("div");
temp.innerHTML = msg.html; temp.innerHTML = msg.html;
@ -139,16 +138,10 @@ chatInputField.textarea.focus();
// --- Reply helper --- // --- Reply helper ---
function replyMessage(message) { function replyMessage(message) {
chatInputField.value = "```markdown\n> " + (message || '').trim().split("\n").join("\n> ") + "\n```\n"; chatInputField.value = "```markdown\n> " + (message || '').split("\n").join("\n> ") + "\n```\n";
chatInputField.textarea.dispatchEvent(new Event('change', { bubbles: true }));
chatInputField.focus(); chatInputField.focus();
} }
messagesContainer.addEventListener("reply", (e) => {
const messageText = e.replyText || e.messageTextTarget.textContent.trim();
replyMessage(messageText);
})
// --- Mention helpers --- // --- Mention helpers ---
function extractMentions(message) { function extractMentions(message) {
return [...new Set(message.match(/@\w+/g) || [])]; return [...new Set(message.match(/@\w+/g) || [])];
@ -222,7 +215,7 @@ document.addEventListener('keydown', function(event) {
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300); keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
if (gPressCount === 2) { if (gPressCount === 2) {
gPressCount = 0; gPressCount = 0;
messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" }); messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
loadExtra(); loadExtra();
} }
} }
@ -261,7 +254,7 @@ function updateLayout(doScrollDown) {
function isScrolledPastHalf() { function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop; let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight; let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
return Math.abs(scrollTop) > scrollableHeight / 2; return scrollTop < scrollableHeight / 2;
} }
// --- Initial layout update --- // --- Initial layout update ---

View File

@ -29,7 +29,7 @@ class ChannelDriveApiView(DriveApiView):
class ChannelAttachmentView(BaseView): class ChannelAttachmentView(BaseView):
login_required=False login_required=True
async def get(self): async def get(self):
relative_path = self.request.match_info.get("relative_url") relative_path = self.request.match_info.get("relative_url")

View File

@ -6,12 +6,12 @@
# 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. # 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 asyncio
import json import json
import logging import logging
import traceback import traceback
import random
from aiohttp import web from aiohttp import web
from snek.system.model import now from snek.system.model import now
@ -305,7 +305,7 @@ class RPCView(BaseView):
async def send_message(self, channel_uid, message, is_final=True): async def send_message(self, channel_uid, message, is_final=True):
self._require_login() self._require_login()
message = message.strip() message = message.strip()
if not is_final: if not is_final:
@ -507,9 +507,7 @@ class RPCView(BaseView):
raise Exception("Method not found") raise Exception("Method not found")
success = True success = True
try: try:
update_websocket_stats(self.app)
result = await method(*args) result = await method(*args)
update_websocket_stats(self.app)
except Exception as ex: except Exception as ex:
result = {"exception": str(ex), "traceback": traceback.format_exc()} result = {"exception": str(ex), "traceback": traceback.format_exc()}
success = False success = False
@ -529,8 +527,8 @@ class RPCView(BaseView):
try: try:
await self.ws.send_str(json.dumps(obj, default=str)) await self.ws.send_str(json.dumps(obj, default=str))
except Exception as ex: except Exception as ex:
print("THIS IS THE DeAL>",str(ex), flush=True)
await self.services.socket.delete(self.ws) await self.services.socket.delete(self.ws)
await self.ws.close()
async def get_online_users(self, channel_uid): async def get_online_users(self, channel_uid):
self._require_login() self._require_login()
@ -638,7 +636,7 @@ class RPCView(BaseView):
try: try:
await rpc(msg.json()) await rpc(msg.json())
except Exception as ex: except Exception as ex:
print("XXXXXXXXXX Deleting socket", ex, flush=True) print("Deleting socket", ex, flush=True)
logger.exception(ex) logger.exception(ex)
await self.services.socket.delete(ws) await self.services.socket.delete(ws)
break break

View File

@ -55,7 +55,7 @@ class WebView(BaseView):
user_uid=self.session.get("uid"), channel_uid=channel["uid"] user_uid=self.session.get("uid"), channel_uid=channel["uid"]
) )
if not channel_member: if not channel_member:
if not channel["is_private"] and not channel.is_dm: if not channel["is_private"]:
channel_member = await self.app.services.channel_member.create( channel_member = await self.app.services.channel_member.create(
channel_uid=channel["uid"], channel_uid=channel["uid"],
user_uid=self.session.get("uid"), user_uid=self.session.get("uid"),
@ -82,6 +82,7 @@ class WebView(BaseView):
await self.app.services.notification.mark_as_read( await self.app.services.notification.mark_as_read(
self.session.get("uid"), message["uid"] self.session.get("uid"), message["uid"]
) )
print(messages)
name = await channel_member.get_name() name = await channel_member.get_name()
return await self.render_template( return await self.render_template(
"web.html", "web.html",

File diff suppressed because it is too large Load Diff