Author SHA1 Message Date
retoor b9b31a494a Update. 2025-08-31 03:41:41 +02:00
retoor b961954aa1 Update. 2025-08-31 03:27:47 +02:00
retoor 84287808c8 Update. 2025-08-31 03:25:26 +02:00
retoor 692272e3ca Fixes. 2025-08-31 03:22:00 +02:00
retoor 89d639e44e New editor. 2025-08-06 15:33:13 +02:00
retoor e62da0aef1 Bugfix. 2025-08-06 14:18:11 +02:00
retoor 3759306e38 Update sentry. 2025-08-06 11:51:58 +02:00
retoor fcd91b4321 Deleted security. 2025-08-03 03:31:08 +02:00
retoor cf32a78ef5 Removed interval. 2025-08-01 02:06:20 +02:00
retoor 7c43d957bc Randomized skeep. 2025-08-01 01:59:23 +02:00
retoor cc6a9ef9d3 Randomized skeep. 2025-08-01 01:57:18 +02:00
retoor 1babfa0d64 Optional replace.: 2025-07-30 14:49:16 +02:00
retoor ce940b39b8 Optional replace.: 2025-07-30 14:46:10 +02:00
retoor 6151fc1dac Optional replace.: 2025-07-30 14:44:25 +02:00
retoor 338bdb5932 Optional replace.: 2025-07-30 14:41:23 +02:00
retoor bbcc845c26 Optional replace.: 2025-07-30 14:36:56 +02:00
retoor 1c080bc4be Big webdav change. 2025-07-30 03:25:36 +02:00
retoor 59b0494328 Update. 2025-07-30 02:51:26 +02:00
retoor 6337350b60 Update. 2025-07-26 23:56:24 +02:00
retoor 986acfac38 Update. 2025-07-25 17:17:45 +02:00
retoor b27149b5ba Update. 2025-07-25 17:10:03 +02:00
retoor eb1284060a Update attributes. 2025-07-25 17:06:15 +02:00
retoor 4266ac1f12 Less stars 2025-07-25 17:06:15 +02:00
retoor 6b4709d011 Merge pull request 'Add support for once event listeners and improve event removal' (#67) from BordedDev/snek:feat/once-event-listener into main
Reviewed-on: retoor/snek#67
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-24 23:36:50 +02:00
retoor ef8d3068a8 Merge pull request 'Fix some message rendering' (#68) from BordedDev/snek:bugfix/message-list-rendering into main
Reviewed-on: retoor/snek#68
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-07-24 23:35:49 +02:00
BordedDev 6dfd8db0a6 Add support for once event listeners and improve event removal 2025-07-24 15:59:17 +02:00
15 changed files with 1046 additions and 733 deletions
+2 -1
View File
@@ -39,7 +39,8 @@ dependencies = [
"Pillow", "Pillow",
"pillow-heif", "pillow-heif",
"IP2Location", "IP2Location",
"bleach" "bleach",
"sentry-sdk"
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+8
View File
@@ -9,6 +9,8 @@ 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
@@ -122,6 +124,12 @@ 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()
+17 -13
View File
@@ -6,6 +6,7 @@ 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
@@ -176,6 +177,8 @@ class Application(BaseApplication):
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): async def prepare_stats(self, app):
app['stats'] = create_stats_structure() app['stats'] = create_stats_structure()
print("Stats prepared", flush=True) print("Stats prepared", flush=True)
@@ -287,9 +290,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)
@@ -297,25 +300,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_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)
@@ -496,6 +499,7 @@ 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():
+1 -1
View File
@@ -23,7 +23,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 created_at DESC LIMIT 1", "SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY id DESC LIMIT 1",
{"channel_uid": self["uid"]}, {"channel_uid": self["uid"]},
): ):
+13 -5
View File
@@ -1,6 +1,11 @@
from snek.system.service import BaseService from snek.system.service import BaseService
from snek.system.template import whitelist_attributes from snek.system.template import sanitize_html
import time import time
import asyncio
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=50)
class ChannelMessageService(BaseService): class ChannelMessageService(BaseService):
mapper_name = "channel_message" mapper_name = "channel_message"
@@ -69,10 +74,11 @@ class ChannelMessageService(BaseService):
"color": user["color"], "color": user["color"],
} }
) )
context['message'] = whitelist_attributes(context['message']) loop = asyncio.get_event_loop()
try: try:
template = self.app.jinja2_env.get_template("message.html") template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context) model["html"] = await loop.run_in_executor(executor, lambda: template.render(**context))
model['html'] = await loop.run_in_executor(executor, lambda:sanitize_html(model['html']))
except Exception as ex: except Exception as ex:
print(ex, flush=True) print(ex, flush=True)
@@ -118,7 +124,6 @@ class ChannelMessageService(BaseService):
async def save(self, model): async def save(self, model):
context = {} context = {}
context.update(model.record) context.update(model.record)
context['message'] = whitelist_attributes(context['message'])
user = await self.app.services.user.get(model["user_uid"]) user = await self.app.services.user.get(model["user_uid"])
context.update( context.update(
{ {
@@ -129,7 +134,10 @@ class ChannelMessageService(BaseService):
} }
) )
template = self.app.jinja2_env.get_template("message.html") template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
loop = asyncio.get_event_loop()
model["html"] = await loop.run_in_executor(executor, lambda: template.render(**context))
model['html'] = await loop.run_in_executor(executor, lambda: sanitize_html(model['html']))
return await super().save(model) return await super().save(model)
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):
+11 -1
View File
@@ -44,19 +44,29 @@ 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):
@@ -77,7 +87,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 self.users.get(user_uid, []): for s in list(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
+468 -200
View File
@@ -1,226 +1,494 @@
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 = `
#editor { :host {
padding: 1rem; display: block;
outline: none; position: relative;
white-space: pre-wrap; height: 100%;
line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
height: 100%; }
overflow-y: auto;
background: #1e1e1e; #editor {
color: #d4d4d4; padding: 1rem;
} outline: none;
#command-line { white-space: pre-wrap;
position: absolute; line-height: 1.5;
bottom: 0; height: calc(100% - 30px);
left: 0; overflow-y: auto;
width: 100%; background: #1e1e1e;
padding: 0.2rem 1rem; color: #d4d4d4;
background: #333; font-size: 14px;
color: #0f0; caret-color: #fff;
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.innerText = `Welcome to VimEditor Component this.editor.spellcheck = false;
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.mode = 'normal'; // normal | insert | visual | command this.statusBar = document.createElement('div');
this.keyBuffer = ''; this.statusBar.id = 'status-bar';
this.lastDeletedLine = '';
this.yankedLine = ''; this.modeIndicator = document.createElement('span');
this.modeIndicator.id = 'mode-indicator';
this.modeIndicator.textContent = 'NORMAL';
this.statusBar.appendChild(this.modeIndicator);
this.editor.addEventListener('keydown', this.handleKeydown.bind(this)); this.shadowRoot.append(style, this.editor, this.cmdLine, this.statusBar);
}
connectedCallback() { this.mode = 'normal';
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();
} }
}
}
getCaretOffset() { updateVisualSelection() {
let caretOffset = 0; if (this.mode !== 'visual') return;
const sel = this.shadowRoot.getSelection(); this.visualEndOffset = this.getCaretOffset();
if (!sel || sel.rangeCount === 0) return 0; }
const range = sel.getRangeAt(0); clearVisualSelection() {
const preCaretRange = range.cloneRange(); const sel = this.shadowRoot.getSelection();
preCaretRange.selectNodeContents(this.editor); if (sel) sel.removeAllRanges();
preCaretRange.setEnd(range.endContainer, range.endOffset); this.visualStartOffset = null;
caretOffset = preCaretRange.toString().length; this.visualEndOffset = null;
return caretOffset; }
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);
}
}
setCaretOffset(offset) { handleBeforeInput(e) {
const range = document.createRange(); if (this.mode !== 'insert') {
const sel = this.shadowRoot.getSelection(); e.preventDefault();
const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false); }
}
let currentOffset = 0; handleCmdKeydown(e) {
let node; if (e.key === 'Enter') {
while ((node = walker.nextNode())) { e.preventDefault();
if (currentOffset + node.length >= offset) { this.executeCommand(this.cmdInput.value);
range.setStart(node, offset - currentOffset); this.setMode('normal');
range.collapse(true); } else if (e.key === 'Escape') {
sel.removeAllRanges(); e.preventDefault();
sel.addRange(range); this.setMode('normal');
return; }
} }
currentOffset += node.length;
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;
}
handleKeydown(e) { if (this.mode === 'command') {
const key = e.key; return; // Command mode input is handled by cmdInput
}
if (this.mode === 'insert') { if (this.mode === 'visual') {
if (key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
this.mode = 'normal'; this.setMode('normal');
this.editor.blur(); return;
this.editor.focus(); }
}
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();
if (sel && sel.rangeCount > 0) {
this.yankedLine = sel.toString();
} }
this.setMode('normal');
if (this.mode === 'command') { return;
if (key === 'Enter' || key === 'Escape') { }
e.preventDefault();
this.cmdLine.style.display = 'none'; if (e.key === 'd' || e.key === 'x') {
this.mode = 'normal'; e.preventDefault();
this.keyBuffer = ''; // Delete selected text
} const sel = this.shadowRoot.getSelection();
return; if (sel && sel.rangeCount > 0) {
} this.lastDeletedLine = sel.toString();
document.execCommand('delete');
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;
} }
this.setMode('normal');
return;
} }
} }
customElements.define('njet-editor', NjetEditor); // Normal mode handling
export {NjetEditor} e.preventDefault();
// Special keys that should be handled immediately
if (e.key === 'Escape') {
this.keyBuffer = '';
this.setMode('normal');
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;
const newPosition = Math.min(positionInLine, prevLineLength);
this.setCaretOffset(prevLineStart + newPosition);
}
break;
default:
// Clear buffer if it gets too long or contains invalid sequences
if (this.keyBuffer.length > 2 ||
(this.keyBuffer.length === 2 && !['dd', 'yy', 'gg'].includes(this.keyBuffer))) {
this.keyBuffer = '';
}
break;
}
}
}
customElements.define('njet-editor', NjetEditor);
export { NjetEditor }
+19 -1
View File
@@ -3,8 +3,15 @@ export class EventHandler {
this.subscribers = {}; this.subscribers = {};
} }
addEventListener(type, handler) { addEventListener(type, handler, { once = false } = {}) {
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);
} }
@@ -12,4 +19,15 @@ 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];
}
}
} }
+103 -93
View File
@@ -5,64 +5,65 @@
// 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 { export class ReplyEvent extends Event {
constructor(messageTextTarget) { constructor(messageTextTarget) {
super('reply', { bubbles: true, composed: true }); super('reply', { bubbles: true, composed: true });
this.messageTextTarget = messageTextTarget; this.messageTextTarget = messageTextTarget;
const newMessage = messageTextTarget.cloneNode(true); // Clone and sanitize message node to text-only reply
newMessage.style.maxHeight = "0" const newMessage = messageTextTarget.cloneNode(true);
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget); newMessage.style.maxHeight = "0";
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
newMessage.querySelectorAll('.embed-url-link').forEach(link => { // Remove all .embed-url-link
link.remove() newMessage.querySelectorAll('.embed-url-link').forEach(link => link.remove());
})
newMessage.querySelectorAll('picture').forEach(picture => { // Replace <picture> with their <img>
const img = picture.querySelector('img'); newMessage.querySelectorAll('picture').forEach(picture => {
if (img) { const img = picture.querySelector('img');
picture.replaceWith(img); if (img) picture.replaceWith(img);
} });
})
newMessage.querySelectorAll('img').forEach(img => { // Replace <img> with just their src
const src = img.src || img.currentSrc; newMessage.querySelectorAll('img').forEach(img => {
img.replaceWith(document.createTextNode(src)); const src = img.src || img.currentSrc;
}) img.replaceWith(document.createTextNode(src));
});
newMessage.querySelectorAll('iframe').forEach(iframe => { // Replace <iframe> with their src
const src = iframe.src || iframe.currentSrc; newMessage.querySelectorAll('iframe').forEach(iframe => {
iframe.replaceWith(document.createTextNode(src)); const src = iframe.src || iframe.currentSrc;
}) iframe.replaceWith(document.createTextNode(src));
});
newMessage.querySelectorAll('a').forEach(a => { // Replace <a> with href or markdown
const href = a.getAttribute('href'); newMessage.querySelectorAll('a').forEach(a => {
const text = a.innerText || a.textContent; const href = a.getAttribute('href');
if (text === href || text === '') { const text = a.innerText || a.textContent;
a.replaceWith(document.createTextNode(href)); if (text === href || text === '') {
} else { a.replaceWith(document.createTextNode(href));
a.replaceWith(document.createTextNode(`[${text}](${href})`)); } else {
} a.replaceWith(document.createTextNode(`[${text}](${href})`));
}) }
});
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim(); this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
newMessage.remove() 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'];
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">
@@ -72,27 +73,28 @@ 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></div> <a href="#reply">reply</a>
</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 = this.querySelector('.time a');
this.replyDiv.addEventListener('click', (e) => { this.replyDiv.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new ReplyEvent(this.messageDiv)); this.dispatchEvent(new ReplyEvent(this.messageDiv));
}) });
} }
// Sibling logic for user switches and long time gaps
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) { if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) {
this.siblingGenerated = this.nextElementSibling; this.siblingGenerated = this.nextElementSibling;
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) { if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
@@ -102,7 +104,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');
@@ -115,7 +117,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;
} }
} }
@@ -124,38 +126,26 @@ 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();
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid,data.color);
});
this.messageMap = new Map(); this.messageMap = new Map();
this.visibleSet = new Set(); this.visibleSet = new Set();
this._observer = new IntersectionObserver((entries) => { this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.visibleSet.add(entry.target); this.visibleSet.add(entry.target);
const messageElement = entry.target; if (entry.target instanceof MessageElement) {
if (messageElement instanceof MessageElement) { entry.target.updateUI();
messageElement.updateUI();
} }
} else { } else {
this.visibleSet.delete(entry.target); this.visibleSet.delete(entry.target);
@@ -164,28 +154,42 @@ class MessageList extends HTMLElement {
}, { }, {
root: this, root: this,
threshold: 0, threshold: 0,
}) });
// End-of-messages marker
this.endOfMessages = document.createElement('div'); this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom'); this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages); this.prepend(this.endOfMessages);
for(const c of this.children) { // Observe existing children and index by uid
for (const c of this.children) {
this._observer.observe(c); this._observer.observe(c);
if (c instanceof MessageElement) { if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c); this.messageMap.set(c.dataset.uid, c);
} }
} }
// Wire up socket events
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid, data.color);
});
this.scrollToBottom(true); this.scrollToBottom(true);
} }
connectedCallback() { connectedCallback() {
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return; if (
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;';
@@ -206,12 +210,11 @@ 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) { if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
overlay.parentNode.removeChild(overlay);
}
}); });
// Optional: ESC key closes overlay // ESC to close
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);
@@ -219,8 +222,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();
@@ -231,10 +235,12 @@ 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.visibleSet.has(this.endOfMessages);
} }
scrollToBottom(force = false, behavior= 'instant') {
scrollToBottom(force = false, behavior = 'instant') {
if (force || !this.isScrolledToBottom()) { if (force || !this.isScrolledToBottom()) {
this.endOfMessages.scrollIntoView({ behavior, block: 'end' }); this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
setTimeout(() => { setTimeout(() => {
@@ -261,40 +267,44 @@ 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) { if (message && (data.is_final || !data.message)) {
message.parentElement?.removeChild(message); message.parentElement?.removeChild(message);
// TO force insert
message = null;
} }
if (!data.message) return;
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 || wrapper.firstElementChild.children)); 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.endOfMessages.after(message);
if (scrolledToBottom) this.scrollToBottom(true); if (scrolledToBottom) this.scrollToBottom(true);
} }
} }
customElements.define("chat-message", MessageElement); customElements.define("chat-message", MessageElement);
customElements.define("message-list", MessageList); customElements.define("message-list", MessageList);
+2 -3
View File
@@ -142,10 +142,9 @@ export class Socket extends EventHandler {
method, method,
args, args,
}; };
const me = this;
return new Promise((resolve) => { return new Promise((resolve) => {
me.addEventListener(call.callId, (data) => resolve(data)); this.addEventListener(call.callId, (data) => resolve(data), { once: true});
me.sendJson(call); this.sendJson(call);
}); });
} }
} }
+26
View File
@@ -82,6 +82,32 @@ emoji.EMOJI_DATA[
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"] ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["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"], protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"],
+2 -2
View File
@@ -12,7 +12,7 @@ function showTerm(options){
class StarField { class StarField {
constructor({ count = 100, container = document.body } = {}) { constructor({ count = 50, 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: 100}); const starField = new StarField({starCount: 50});
app.starField = starField; app.starField = starField;
class DemoSequence { class DemoSequence {
+1 -1
View File
@@ -29,7 +29,7 @@ class ChannelDriveApiView(DriveApiView):
class ChannelAttachmentView(BaseView): class ChannelAttachmentView(BaseView):
login_required=True login_required=False
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")
+3 -3
View File
@@ -11,7 +11,7 @@ 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
@@ -529,8 +529,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 +638,7 @@ class RPCView(BaseView):
try: try:
await rpc(msg.json()) await rpc(msg.json())
except Exception as ex: except Exception as ex:
print("Deleting socket", ex, flush=True) print("XXXXXXXXXX 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
+370 -409
View File
File diff suppressed because it is too large Load Diff