Compare commits
46 Commits
maintenanc
...
main
Author | SHA1 | Date | |
---|---|---|---|
fadc57a7c7 | |||
cca3946a35 | |||
18be3fdc19 | |||
939e63f244 | |||
b4c267d584 | |||
b9b31a494a | |||
b961954aa1 | |||
84287808c8 | |||
692272e3ca | |||
89d639e44e | |||
e62da0aef1 | |||
3759306e38 | |||
fcd91b4321 | |||
cf32a78ef5 | |||
7c43d957bc | |||
cc6a9ef9d3 | |||
1babfa0d64 | |||
ce940b39b8 | |||
6151fc1dac | |||
338bdb5932 | |||
bbcc845c26 | |||
1c080bc4be | |||
59b0494328 | |||
6337350b60 | |||
986acfac38 | |||
b27149b5ba | |||
eb1284060a | |||
4266ac1f12 | |||
6b4709d011 | |||
ef8d3068a8 | |||
![]() |
a23c14389b | ||
![]() |
6dfd8db0a6 | ||
abce2e03d1 | |||
54d7d5b74e | |||
17bb88050a | |||
![]() |
8c2e20dfe8 | ||
![]() |
3e2dd7ea04 | ||
![]() |
70eebefac7 | ||
![]() |
ac47d201d8 | ||
![]() |
11e19f48e8 | ||
![]() |
5ac49522d9 | ||
f9f1179db5 | |||
04527c286f | |||
e23d6571c8 | |||
0c331bbb93 | |||
a2d506cce9 |
@ -39,7 +39,8 @@ dependencies = [
|
||||
"Pillow",
|
||||
"pillow-heif",
|
||||
"IP2Location",
|
||||
"bleach"
|
||||
"bleach",
|
||||
"sentry-sdk"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
@ -9,6 +9,8 @@ from snek.shell import Shell
|
||||
from snek.app import Application
|
||||
|
||||
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
@ -122,6 +124,12 @@ def shell(db_path):
|
||||
Shell(db_path).run()
|
||||
|
||||
def main():
|
||||
try:
|
||||
import sentry_sdk
|
||||
sentry_sdk.init("https://ab6147c2f3354c819768c7e89455557b@gt.molodetz.nl/1")
|
||||
except ImportError:
|
||||
print("Could not import sentry_sdk")
|
||||
|
||||
cli()
|
||||
|
||||
|
||||
|
@ -6,6 +6,7 @@ import uuid
|
||||
import signal
|
||||
from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
import aiohttp_debugtoolbar
|
||||
|
||||
from snek import snode
|
||||
from snek.view.threads import ThreadsView
|
||||
@ -31,6 +32,7 @@ from snek.sgit import GitApplication
|
||||
from snek.sssh import start_ssh_server
|
||||
from snek.system import http
|
||||
from snek.system.cache import Cache
|
||||
from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler
|
||||
from snek.system.markdown import MarkdownExtension
|
||||
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
|
||||
from snek.system.profiler import profiler_handler
|
||||
@ -127,6 +129,7 @@ async def trailing_slash_middleware(request, handler):
|
||||
class Application(BaseApplication):
|
||||
def __init__(self, *args, **kwargs):
|
||||
middlewares = [
|
||||
stats_middleware,
|
||||
cors_middleware,
|
||||
web.normalize_path_middleware(merge_slashes=True),
|
||||
ip2location_middleware,
|
||||
@ -168,11 +171,19 @@ class Application(BaseApplication):
|
||||
self.ip2location = IP2Location.IP2Location(
|
||||
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
|
||||
)
|
||||
self.on_startup.append(self.prepare_stats)
|
||||
self.on_startup.append(self.prepare_asyncio)
|
||||
self.on_startup.append(self.start_user_availability_service)
|
||||
self.on_startup.append(self.start_ssh_server)
|
||||
self.on_startup.append(self.prepare_database)
|
||||
|
||||
|
||||
|
||||
async def prepare_stats(self, app):
|
||||
app['stats'] = create_stats_structure()
|
||||
print("Stats prepared", flush=True)
|
||||
|
||||
|
||||
@property
|
||||
def uptime_seconds(self):
|
||||
return (datetime.now() - self.time_start).total_seconds()
|
||||
@ -279,9 +290,9 @@ class Application(BaseApplication):
|
||||
self.router.add_view("/login.json", LoginView)
|
||||
self.router.add_view("/register.html", RegisterView)
|
||||
self.router.add_view("/register.json", RegisterView)
|
||||
self.router.add_view("/drive/{rel_path:.*}", DriveView)
|
||||
self.router.add_view("/drive.bin", UploadView)
|
||||
self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
|
||||
# self.router.add_view("/drive/{rel_path:.*}", DriveView)
|
||||
## self.router.add_view("/drive.bin", UploadView)
|
||||
# self.router.add_view("/drive.bin/{uid}.{ext}", UploadView)
|
||||
self.router.add_view("/search-user.html", SearchUserView)
|
||||
self.router.add_view("/search-user.json", SearchUserView)
|
||||
self.router.add_view("/avatar/{uid}.svg", AvatarView)
|
||||
@ -289,25 +300,26 @@ class Application(BaseApplication):
|
||||
self.router.add_get("/http-photo", self.handle_http_photo)
|
||||
self.router.add_get("/rpc.ws", RPCView)
|
||||
self.router.add_get("/c/{channel:.*}", ChannelView)
|
||||
self.router.add_view(
|
||||
"/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
|
||||
)
|
||||
self.router.add_view(
|
||||
"/channel/{channel_uid}/drive.json", ChannelDriveApiView
|
||||
)
|
||||
#self.router.add_view(
|
||||
# "/channel/{channel_uid}/attachment.bin", ChannelAttachmentView
|
||||
#)
|
||||
#self.router.add_view(
|
||||
# "/channel/{channel_uid}/drive.json", ChannelDriveApiView
|
||||
#)
|
||||
self.router.add_view(
|
||||
"/channel/{channel_uid}/attachment.sock", ChannelAttachmentUploadView
|
||||
)
|
||||
self.router.add_view(
|
||||
"/channel/attachment/{relative_url:.*}", ChannelAttachmentView
|
||||
)
|
||||
)#
|
||||
self.router.add_view("/channel/{channel}.html", WebView)
|
||||
self.router.add_view("/threads.html", ThreadsView)
|
||||
self.router.add_view("/terminal.ws", TerminalSocketView)
|
||||
self.router.add_view("/terminal.html", TerminalView)
|
||||
self.router.add_view("/drive.json", DriveApiView)
|
||||
self.router.add_view("/drive.html", DriveView)
|
||||
self.router.add_view("/drive/{drive}.json", DriveView)
|
||||
#self.router.add_view("/drive.json", DriveApiView)
|
||||
#self.router.add_view("/drive.html", DriveView)
|
||||
#self.router.add_view("/drive/{drive}.json", DriveView)
|
||||
self.router.add_get("/stats.html", stats_handler)
|
||||
self.router.add_view("/stats.json", StatsView)
|
||||
self.router.add_view("/user/{user}.html", UserView)
|
||||
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
||||
@ -487,6 +499,7 @@ class Application(BaseApplication):
|
||||
raise raised_exception
|
||||
|
||||
app = Application(db_path="sqlite:///snek.db")
|
||||
#aiohttp_debugtoolbar.setup(app)
|
||||
|
||||
|
||||
async def main():
|
||||
|
@ -13,13 +13,17 @@ class ChannelModel(BaseModel):
|
||||
last_message_on = ModelField(name="last_message_on", 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:
|
||||
history_start_filter = ""
|
||||
if self["history_start"]:
|
||||
history_start_filter = f" AND created_at > '{self['history_start']}' "
|
||||
try:
|
||||
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"]},
|
||||
):
|
||||
|
||||
|
@ -1,5 +1,20 @@
|
||||
from snek.system.service import BaseService
|
||||
from snek.system.template import whitelist_attributes
|
||||
from snek.system.template import sanitize_html
|
||||
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):
|
||||
@ -8,24 +23,45 @@ class ChannelMessageService(BaseService):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
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):
|
||||
args = {}
|
||||
async for message in self.find():
|
||||
updated_at = message["updated_at"]
|
||||
message["is_final"] = True
|
||||
html = message["html"]
|
||||
await self.save(message)
|
||||
for message in self.mapper.db["channel_message"].find():
|
||||
print(message)
|
||||
try:
|
||||
message = await self.get(uid=message["uid"])
|
||||
updated_at = message["updated_at"]
|
||||
message["is_final"] = True
|
||||
html = message["html"]
|
||||
await self.save(message)
|
||||
|
||||
self.mapper.db["channel_message"].upsert(
|
||||
{
|
||||
"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)
|
||||
|
||||
self.mapper.db["channel_message"].upsert(
|
||||
{
|
||||
"uid": message["uid"],
|
||||
"updated_at": updated_at,
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
if html != message["html"]:
|
||||
print("Reredefined message", message["uid"])
|
||||
|
||||
while True:
|
||||
changed = 0
|
||||
@ -61,10 +97,14 @@ class ChannelMessageService(BaseService):
|
||||
"color": user["color"],
|
||||
}
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
template = self.app.jinja2_env.get_template("message.html")
|
||||
model["html"] = template.render(**context)
|
||||
model["html"] = whitelist_attributes(model["html"])
|
||||
|
||||
context = json.loads(json.dumps(context, default=str))
|
||||
|
||||
|
||||
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:
|
||||
print(ex, flush=True)
|
||||
|
||||
@ -83,6 +123,8 @@ class ChannelMessageService(BaseService):
|
||||
["deleted_at"], unique=False
|
||||
)
|
||||
self._configured_indexes = True
|
||||
if model['is_final']:
|
||||
self.delete_executor(model['uid'])
|
||||
return model
|
||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||
|
||||
@ -91,9 +133,9 @@ class ChannelMessageService(BaseService):
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
if not message["html"].startswith("<chat-message"):
|
||||
await (await self.get(uid=message["uid"])).save()
|
||||
message["html"] = (await self.get(uid=message["uid"])).html
|
||||
#if not message["html"].startswith("<chat-message"):
|
||||
#message = await self.get(uid=message["uid"])
|
||||
#await self.save(message)
|
||||
|
||||
return {
|
||||
"uid": message["uid"],
|
||||
@ -119,10 +161,15 @@ class ChannelMessageService(BaseService):
|
||||
"color": user["color"],
|
||||
}
|
||||
)
|
||||
template = self.app.jinja2_env.get_template("message.html")
|
||||
model["html"] = template.render(**context)
|
||||
model["html"] = whitelist_attributes(model["html"])
|
||||
return await super().save(model)
|
||||
context = json.loads(json.dumps(context, default=str))
|
||||
loop = asyncio.get_event_loop()
|
||||
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'])
|
||||
|
||||
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):
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
|
@ -44,19 +44,29 @@ class SocketService(BaseService):
|
||||
|
||||
async def user_availability_service(self):
|
||||
logger.info("User availability update service started.")
|
||||
logger.debug("Entering the main loop.")
|
||||
while True:
|
||||
logger.info("Updating user availability...")
|
||||
logger.debug("Initializing users_updated list.")
|
||||
users_updated = []
|
||||
logger.debug("Iterating over sockets.")
|
||||
for s in self.sockets:
|
||||
logger.debug(f"Checking connection status for socket: {s}.")
|
||||
if not s.is_connected:
|
||||
logger.debug("Socket is not connected, continuing to next socket.")
|
||||
continue
|
||||
logger.debug(f"Checking if user {s.user} is already updated.")
|
||||
if s.user not in users_updated:
|
||||
logger.debug(f"Updating last_ping for user: {s.user}.")
|
||||
s.user["last_ping"] = now()
|
||||
logger.debug(f"Saving user {s.user} to the database.")
|
||||
await self.app.services.user.save(s.user)
|
||||
logger.debug(f"Adding user {s.user} to users_updated list.")
|
||||
users_updated.append(s.user)
|
||||
logger.info(
|
||||
f"Updated user availability for {len(users_updated)} online users."
|
||||
)
|
||||
logger.debug("Sleeping for 60 seconds before the next update.")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def add(self, ws, user_uid):
|
||||
@ -77,7 +87,7 @@ class SocketService(BaseService):
|
||||
|
||||
async def send_to_user(self, user_uid, message):
|
||||
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):
|
||||
count += 1
|
||||
return count
|
||||
|
@ -368,7 +368,7 @@ input[type="text"], .chat-input textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.message.switch-user + .message, .message.long-time + .message, .message:first-child {
|
||||
.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{
|
||||
.time {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
|
@ -1,226 +1,494 @@
|
||||
import { NjetComponent} from "/njet.js"
|
||||
import { NjetComponent } from "/njet.js"
|
||||
|
||||
class NjetEditor extends NjetComponent {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
class NjetEditor extends NjetComponent {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#editor {
|
||||
padding: 1rem;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
#command-line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 0.2rem 1rem;
|
||||
background: #333;
|
||||
color: #0f0;
|
||||
display: none;
|
||||
font-family: monospace;
|
||||
}
|
||||
`;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
this.editor = document.createElement('div');
|
||||
this.editor.id = 'editor';
|
||||
this.editor.contentEditable = true;
|
||||
this.editor.innerText = `Welcome to VimEditor Component
|
||||
#editor {
|
||||
padding: 1rem;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
height: calc(100% - 30px);
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-size: 14px;
|
||||
caret-color: #fff;
|
||||
}
|
||||
|
||||
#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.id = 'editor';
|
||||
this.editor.contentEditable = true;
|
||||
this.editor.spellcheck = false;
|
||||
this.editor.innerText = `Welcome to VimEditor Component
|
||||
Line 2 here
|
||||
Another line
|
||||
Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
|
||||
|
||||
this.cmdLine = document.createElement('div');
|
||||
this.cmdLine.id = 'command-line';
|
||||
this.shadowRoot.append(style, this.editor, this.cmdLine);
|
||||
this.cmdLine = document.createElement('div');
|
||||
this.cmdLine.id = 'command-line';
|
||||
|
||||
this.mode = 'normal'; // normal | insert | visual | command
|
||||
this.keyBuffer = '';
|
||||
this.lastDeletedLine = '';
|
||||
this.yankedLine = '';
|
||||
const cmdPrompt = document.createElement('span');
|
||||
cmdPrompt.textContent = ':';
|
||||
|
||||
this.editor.addEventListener('keydown', this.handleKeydown.bind(this));
|
||||
}
|
||||
this.cmdInput = document.createElement('input');
|
||||
this.cmdInput.id = 'command-input';
|
||||
this.cmdInput.type = 'text';
|
||||
|
||||
connectedCallback() {
|
||||
this.cmdLine.append(cmdPrompt, this.cmdInput);
|
||||
|
||||
this.statusBar = document.createElement('div');
|
||||
this.statusBar.id = 'status-bar';
|
||||
|
||||
this.modeIndicator = document.createElement('span');
|
||||
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.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCaretOffset() {
|
||||
let caretOffset = 0;
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return 0;
|
||||
updateVisualSelection() {
|
||||
if (this.mode !== 'visual') return;
|
||||
this.visualEndOffset = this.getCaretOffset();
|
||||
}
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const preCaretRange = range.cloneRange();
|
||||
preCaretRange.selectNodeContents(this.editor);
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
||||
caretOffset = preCaretRange.toString().length;
|
||||
return caretOffset;
|
||||
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;
|
||||
}
|
||||
|
||||
setCaretOffset(offset) {
|
||||
const range = document.createRange();
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
let currentOffset = 0;
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (currentOffset + node.length >= offset) {
|
||||
range.setStart(node, offset - currentOffset);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return;
|
||||
}
|
||||
currentOffset += node.length;
|
||||
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;
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
const key = e.key;
|
||||
if (this.mode === 'command') {
|
||||
return; // Command mode input is handled by cmdInput
|
||||
}
|
||||
|
||||
if (this.mode === 'insert') {
|
||||
if (key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.mode = 'normal';
|
||||
this.editor.blur();
|
||||
this.editor.focus();
|
||||
}
|
||||
return;
|
||||
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();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
this.yankedLine = sel.toString();
|
||||
}
|
||||
this.setMode('normal');
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
if (e.key === 'd' || e.key === 'x') {
|
||||
e.preventDefault();
|
||||
// Delete selected text
|
||||
const sel = this.shadowRoot.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
this.lastDeletedLine = sel.toString();
|
||||
document.execCommand('delete');
|
||||
}
|
||||
this.setMode('normal');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('njet-editor', NjetEditor);
|
||||
export {NjetEditor}
|
||||
// Normal mode handling
|
||||
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 }
|
||||
|
@ -3,8 +3,15 @@ export class EventHandler {
|
||||
this.subscribers = {};
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
addEventListener(type, handler, { once = false } = {}) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -12,4 +19,15 @@ export class EventHandler {
|
||||
if (this.subscribers[type])
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,30 +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.
|
||||
|
||||
// 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 {
|
||||
static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid'];
|
||||
|
||||
isVisible() {
|
||||
if (!this) return false;
|
||||
const rect = this.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
if (this._originalChildren === undefined) {
|
||||
const { color, user_nick, created_at, user_uid} = this.dataset;
|
||||
const { color, user_nick, created_at, user_uid } = this.dataset;
|
||||
this.classList.add('message');
|
||||
this.style.maxWidth = '100%';
|
||||
this._originalChildren = Array.from(this.children);
|
||||
|
||||
this.innerHTML = `
|
||||
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
|
||||
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
|
||||
@ -38,23 +73,30 @@ class MessageElement extends HTMLElement {
|
||||
<div class="text"></div>
|
||||
<div class="time no-select" data-created_at="${created_at || ''}">
|
||||
<span></span>
|
||||
<a href="#reply">reply</a></div>
|
||||
<a href="#reply">reply</a>
|
||||
</div>
|
||||
`;
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.messageDiv = this.querySelector('.text');
|
||||
|
||||
if (this._originalChildren && this._originalChildren.length > 0) {
|
||||
this._originalChildren.forEach(child => {
|
||||
this.messageDiv.appendChild(child);
|
||||
});
|
||||
this._originalChildren.forEach(child => {
|
||||
this.messageDiv.appendChild(child);
|
||||
});
|
||||
}
|
||||
|
||||
this.timeDiv = this.querySelector('.time span');
|
||||
this.replyDiv = this.querySelector('.time a');
|
||||
|
||||
this.replyDiv.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new ReplyEvent(this.messageDiv));
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.siblingGenerated && this.nextElementSibling) {
|
||||
this.siblingGenerated = true;
|
||||
// Sibling logic for user switches and long time gaps
|
||||
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) {
|
||||
this.siblingGenerated = this.nextElementSibling;
|
||||
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
|
||||
this.classList.add('switch-user');
|
||||
} else {
|
||||
@ -62,7 +104,7 @@ class MessageElement extends HTMLElement {
|
||||
const siblingTime = new Date(this.nextElementSibling.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');
|
||||
} else {
|
||||
this.classList.remove('long-time');
|
||||
@ -75,7 +117,7 @@ class MessageElement extends HTMLElement {
|
||||
|
||||
updateMessage(...messages) {
|
||||
if (this._originalChildren) {
|
||||
this.messageDiv.replaceChildren(...messages)
|
||||
this.messageDiv.replaceChildren(...messages);
|
||||
this._originalChildren = messages;
|
||||
}
|
||||
}
|
||||
@ -84,62 +126,70 @@ class MessageElement extends HTMLElement {
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
}
|
||||
|
||||
connectedMoveCallback() {
|
||||
}
|
||||
disconnectedCallback() {}
|
||||
connectedMoveCallback() {}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
this.updateUI()
|
||||
this.updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
class MessageList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
app.ws.addEventListener("update_message_text", (data) => {
|
||||
this.upsertMessage(data);
|
||||
});
|
||||
app.ws.addEventListener("set_typing", (data) => {
|
||||
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._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) => {
|
||||
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);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
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 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;';
|
||||
|
||||
@ -160,12 +210,11 @@ class MessageList extends HTMLElement {
|
||||
|
||||
overlay.appendChild(fullImg);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.addEventListener('click', () => {
|
||||
if (overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
// Optional: ESC key closes overlay
|
||||
// ESC to close
|
||||
const escListener = (evt) => {
|
||||
if (evt.key === 'Escape') {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
@ -173,9 +222,9 @@ class MessageList extends HTMLElement {
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escListener);
|
||||
})
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
isElementVisible(element) {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
@ -186,14 +235,16 @@ class MessageList extends HTMLElement {
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
isScrolledToBottom() {
|
||||
return this.isElementVisible(this.firstElementChild);
|
||||
return this.visibleSet.has(this.endOfMessages);
|
||||
}
|
||||
scrollToBottom(force = false, behavior= 'smooth') {
|
||||
if (force || this.isScrolledToBottom()) {
|
||||
this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
|
||||
|
||||
scrollToBottom(force = false, behavior = 'instant') {
|
||||
if (force || !this.isScrolledToBottom()) {
|
||||
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
|
||||
setTimeout(() => {
|
||||
this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
|
||||
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
@ -205,7 +256,9 @@ class MessageList extends HTMLElement {
|
||||
this.querySelectorAll('.avatar').forEach((el) => {
|
||||
const anchor = el.closest('a');
|
||||
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
|
||||
lastElement = el;
|
||||
if(!lastElement)
|
||||
lastElement = el;
|
||||
|
||||
}
|
||||
});
|
||||
if (lastElement) {
|
||||
@ -216,41 +269,48 @@ class MessageList extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateTimes() {
|
||||
this.visibleSet.forEach((messageElement) => {
|
||||
if (messageElement instanceof MessageElement) {
|
||||
messageElement.updateUI();
|
||||
}
|
||||
})
|
||||
if (messageElement instanceof MessageElement) {
|
||||
messageElement.updateUI();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
upsertMessage(data) {
|
||||
let message = this.messageMap.get(data.uid);
|
||||
const newMessage = !!message;
|
||||
if (message) {
|
||||
message.parentElement.removeChild(message);
|
||||
}
|
||||
if (message && (data.is_final || !data.message)) {
|
||||
//message.parentElement?.removeChild(message);
|
||||
// TO force insert
|
||||
//message = null;
|
||||
|
||||
if (!data.message) return
|
||||
}
|
||||
if(message && !data.message){
|
||||
message.parentElement?.removeChild(message);
|
||||
message = null;
|
||||
}
|
||||
if (!data.message) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
wrapper.innerHTML = data.html;
|
||||
|
||||
if (message) {
|
||||
message.updateMessage(...wrapper.firstElementChild._originalChildren);
|
||||
// If the old element is already custom, only update its message children
|
||||
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
|
||||
} else {
|
||||
message = wrapper.firstElementChild;
|
||||
this.messageMap.set(data.uid, message);
|
||||
this._observer.observe(message);
|
||||
// If not, insert the new one and observe
|
||||
message = wrapper.firstElementChild;
|
||||
this.messageMap.set(data.uid, message);
|
||||
this._observer.observe(message);
|
||||
this.endOfMessages.after(message);
|
||||
}
|
||||
|
||||
const scrolledToBottom = this.isScrolledToBottom();
|
||||
this.prepend(message);
|
||||
if (scrolledToBottom) this.scrollToBottom(true, !newMessage ? 'smooth' : 'auto');
|
||||
|
||||
if (scrolledToBottom) this.scrollToBottom(true);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("chat-message", MessageElement);
|
||||
customElements.define("message-list", MessageList);
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
class RestClient {
|
||||
constructor({ baseURL = '', headers = {} } = {}) {
|
||||
this.baseURL = baseURL;
|
||||
@ -210,27 +208,52 @@ class Njet extends HTMLElement {
|
||||
customElements.define(name, component);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
constructor(config) {
|
||||
super();
|
||||
// Store the config for use in render and other methods
|
||||
this.config = config || {};
|
||||
|
||||
if (!Njet._root) {
|
||||
Njet._root = this
|
||||
Njet._rest = new RestClient({ baseURL: '/' || null })
|
||||
}
|
||||
this.root._elements.push(this)
|
||||
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.initProps(config);
|
||||
//if (typeof this.config.construct === 'function')
|
||||
// this.config.construct.call(this)
|
||||
|
||||
// Call construct if defined
|
||||
if (typeof this.config.construct === 'function') {
|
||||
this.config.construct.call(this)
|
||||
}
|
||||
}
|
||||
|
||||
initProps(config) {
|
||||
const props = Object.keys(config)
|
||||
props.forEach(prop => {
|
||||
if (config[prop] !== undefined) {
|
||||
// Skip special properties that are handled separately
|
||||
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];
|
||||
} 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) {
|
||||
this.classList.add(...config.classes);
|
||||
}
|
||||
@ -342,7 +365,7 @@ class NjetDialog extends Component {
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.style.marginTop = '20px';
|
||||
buttonContainer.style.display = 'flex';
|
||||
buttonContainer.style.justifyContent = 'flenjet-end';
|
||||
buttonContainer.style.justifyContent = 'flex-end';
|
||||
buttonContainer.style.gap = '10px';
|
||||
if (secondaryButton) {
|
||||
const secondary = new NjetButton(secondaryButton);
|
||||
@ -372,8 +395,9 @@ class NjetWindow extends Component {
|
||||
header.textContent = title;
|
||||
this.appendChild(header);
|
||||
}
|
||||
this.config.items.forEach(item => this.appendChild(item));
|
||||
|
||||
if (this.config.items) {
|
||||
this.config.items.forEach(item => this.appendChild(item));
|
||||
}
|
||||
}
|
||||
|
||||
show(){
|
||||
@ -408,7 +432,8 @@ class NjetGrid extends Component {
|
||||
}
|
||||
}
|
||||
Njet.registerComponent('njet-grid', NjetGrid);
|
||||
/*
|
||||
|
||||
/* Example usage:
|
||||
const button = new NjetButton({
|
||||
classes: ['my-button'],
|
||||
text: 'Shared',
|
||||
@ -493,7 +518,7 @@ document.body.appendChild(dialog);
|
||||
*/
|
||||
|
||||
class NjetComponent extends Component {}
|
||||
const njet = Njet
|
||||
const njet = Njet
|
||||
njet.showDialog = function(args){
|
||||
const dialog = new NjetDialog(args)
|
||||
dialog.show()
|
||||
@ -545,15 +570,16 @@ njet.showWindow = function(args) {
|
||||
return w
|
||||
}
|
||||
njet.publish = function(event, data) {
|
||||
if (this.root._subscriptions[event]) {
|
||||
if (this.root && this.root._subscriptions && this.root._subscriptions[event]) {
|
||||
this.root._subscriptions[event].forEach(callback => callback(data))
|
||||
}
|
||||
}
|
||||
njet.subscribe = function(event, callback) {
|
||||
if (!this.root) return;
|
||||
if (!this.root._subscriptions[event]) {
|
||||
this.root._subscriptions[event] = []
|
||||
}
|
||||
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 };
|
||||
|
@ -142,10 +142,9 @@ export class Socket extends EventHandler {
|
||||
method,
|
||||
args,
|
||||
};
|
||||
const me = this;
|
||||
return new Promise((resolve) => {
|
||||
me.addEventListener(call.callId, (data) => resolve(data));
|
||||
me.sendJson(call);
|
||||
this.addEventListener(call.callId, (data) => resolve(data), { once: true});
|
||||
this.sendJson(call);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
129
src/snek/system/stats.py
Normal file
129
src/snek/system/stats.py
Normal file
@ -0,0 +1,129 @@
|
||||
import asyncio
|
||||
from aiohttp import web, WSMsgType
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections import defaultdict
|
||||
import html
|
||||
|
||||
def create_stats_structure():
|
||||
"""Creates the nested dictionary structure for storing statistics."""
|
||||
def nested_dd():
|
||||
return defaultdict(lambda: defaultdict(int))
|
||||
return defaultdict(nested_dd)
|
||||
|
||||
def get_time_keys(dt: datetime):
|
||||
"""Generates dictionary keys for different time granularities."""
|
||||
return {
|
||||
"hour": dt.strftime('%Y-%m-%d-%H'),
|
||||
"day": dt.strftime('%Y-%m-%d'),
|
||||
"week": dt.strftime('%Y-%W'), # Week number, Monday is first day
|
||||
"month": dt.strftime('%Y-%m'),
|
||||
}
|
||||
|
||||
def update_stats_counters(stats_dict: defaultdict, now: datetime):
|
||||
"""Increments the appropriate time-based counters in a stats dictionary."""
|
||||
keys = get_time_keys(now)
|
||||
stats_dict['by_hour'][keys['hour']] += 1
|
||||
stats_dict['by_day'][keys['day']] += 1
|
||||
stats_dict['by_week'][keys['week']] += 1
|
||||
stats_dict['by_month'][keys['month']] += 1
|
||||
|
||||
def generate_time_series_svg(title: str, data: list[tuple[str, int]], y_label: str) -> str:
|
||||
"""Generates a responsive SVG bar chart for time-series data."""
|
||||
if not data:
|
||||
return f"<h3>{html.escape(title)}</h3><p>No data yet.</p>"
|
||||
max_val = max(item[1] for item in data) if data else 1
|
||||
svg_height, svg_width = 250, 600
|
||||
bar_padding = 5
|
||||
bar_width = (svg_width - 50) / len(data) - bar_padding
|
||||
|
||||
bars = ""
|
||||
labels = ""
|
||||
for i, (key, val) in enumerate(data):
|
||||
bar_height = (val / max_val) * (svg_height - 50) if max_val > 0 else 0
|
||||
x = i * (bar_width + bar_padding) + 40
|
||||
y = svg_height - bar_height - 30
|
||||
|
||||
bars += f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" fill="#007BFF"><title>{html.escape(key)}: {val}</title></rect>'
|
||||
labels += f'<text x="{x + bar_width / 2}" y="{svg_height - 15}" font-size="11" text-anchor="middle">{html.escape(key)}</text>'
|
||||
|
||||
return f"""
|
||||
<h3>{html.escape(title)}</h3>
|
||||
<div style="border:1px solid #ccc; padding: 10px; border-radius: 5px;">
|
||||
<svg viewBox="0 0 {svg_width} {svg_height}" style="width:100%; height:auto;">
|
||||
<g>{bars}</g>
|
||||
<g>{labels}</g>
|
||||
<line x1="35" y1="10" x2="35" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
|
||||
<line x1="35" y1="{svg_height - 30}" x2="{svg_width - 10}" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
|
||||
<text x="5" y="{svg_height - 30}" font-size="12">0</text>
|
||||
<text x="5" y="20" font-size="12">{max_val}</text>
|
||||
</svg>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@web.middleware
|
||||
async def middleware(request, handler):
|
||||
"""Middleware to count all incoming HTTP requests."""
|
||||
# Avoid counting requests to the stats page itself
|
||||
if request.path.startswith('/stats.html'):
|
||||
return await handler(request)
|
||||
|
||||
update_stats_counters(request.app['stats']['http_requests'], datetime.now(timezone.utc))
|
||||
return await handler(request)
|
||||
|
||||
def update_websocket_stats(app):
|
||||
update_stats_counters(app['stats']['websocket_requests'], datetime.now(timezone.utc))
|
||||
|
||||
async def pipe_and_count_websocket(ws_from, ws_to, stats_dict):
|
||||
"""This function proxies WebSocket messages AND counts them."""
|
||||
async for msg in ws_from:
|
||||
# This is the key part for monitoring WebSockets
|
||||
update_stats_counters(stats_dict, datetime.now(timezone.utc))
|
||||
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
await ws_to.send_str(msg.data)
|
||||
elif msg.type == WSMsgType.BINARY:
|
||||
await ws_to.send_bytes(msg.data)
|
||||
elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR):
|
||||
await ws_to.close(code=ws_from.close_code)
|
||||
break
|
||||
|
||||
|
||||
async def stats_handler(request: web.Request):
|
||||
"""Handler to display the statistics dashboard."""
|
||||
stats = request.app['stats']
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Helper to prepare data for charts
|
||||
def get_data(source, period, count):
|
||||
data = []
|
||||
for i in range(count - 1, -1, -1):
|
||||
if period == 'hour':
|
||||
dt = now - timedelta(hours=i)
|
||||
key, label = dt.strftime('%Y-%m-%d-%H'), dt.strftime('%H:00')
|
||||
data.append((label, source['by_hour'].get(key, 0)))
|
||||
elif period == 'day':
|
||||
dt = now - timedelta(days=i)
|
||||
key, label = dt.strftime('%Y-%m-%d'), dt.strftime('%a')
|
||||
data.append((label, source['by_day'].get(key, 0)))
|
||||
return data
|
||||
|
||||
http_hourly = get_data(stats['http_requests'], 'hour', 24)
|
||||
ws_hourly = get_data(stats['ws_messages'], 'hour', 24)
|
||||
http_daily = get_data(stats['http_requests'], 'day', 7)
|
||||
ws_daily = get_data(stats['ws_messages'], 'day', 7)
|
||||
|
||||
body = f"""
|
||||
<html><head><title>App Stats</title><meta http-equiv="refresh" content="30"></head>
|
||||
<body>
|
||||
<h2>Application Dashboard</h2>
|
||||
<h3>Last 24 Hours</h3>
|
||||
{generate_time_series_svg("HTTP Requests", http_hourly, "Reqs/Hour")}
|
||||
{generate_time_series_svg("WebSocket Messages", ws_hourly, "Msgs/Hour")}
|
||||
<h3>Last 7 Days</h3>
|
||||
{generate_time_series_svg("HTTP Requests", http_daily, "Reqs/Day")}
|
||||
{generate_time_series_svg("WebSocket Messages", ws_daily, "Msgs/Day")}
|
||||
</body></html>
|
||||
"""
|
||||
return web.Response(text=body, content_type='text/html')
|
||||
|
@ -79,44 +79,38 @@ emoji.EMOJI_DATA[
|
||||
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
|
||||
|
||||
|
||||
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": [],
|
||||
}
|
||||
|
||||
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"]
|
||||
|
||||
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(
|
||||
value,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"],
|
||||
protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"],
|
||||
strip=True,
|
||||
)
|
||||
|
||||
@ -132,50 +126,8 @@ def set_link_target_blank(text):
|
||||
|
||||
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):
|
||||
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)
|
||||
return sanitize_html(html)
|
||||
|
||||
|
||||
def embed_youtube(text):
|
||||
|
@ -12,7 +12,7 @@ function showTerm(options){
|
||||
|
||||
|
||||
class StarField {
|
||||
constructor({ count = 200, container = document.body } = {}) {
|
||||
constructor({ count = 50, container = document.body } = {}) {
|
||||
this.container = container;
|
||||
this.starCount = count;
|
||||
this.stars = [];
|
||||
@ -567,7 +567,7 @@ const count = Array.from(messages).filter(el => el.textContent.trim() === text).
|
||||
|
||||
|
||||
|
||||
const starField = new StarField({starCount: 200});
|
||||
const starField = new StarField({starCount: 50});
|
||||
app.starField = starField;
|
||||
|
||||
class DemoSequence {
|
||||
|
@ -72,12 +72,13 @@ function throttle(fn, wait) {
|
||||
// --- Scroll: load extra messages, throttled ---
|
||||
let isLoadingExtra = false;
|
||||
async function loadExtra() {
|
||||
const firstMessage = messagesContainer.children[messagesContainer.children.length - 1];
|
||||
const firstMessage = messagesContainer.lastElementChild;
|
||||
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
|
||||
isLoadingExtra = true;
|
||||
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
|
||||
if (messages.length) {
|
||||
const frag = document.createDocumentFragment();
|
||||
messages.reverse();
|
||||
messages.forEach(msg => {
|
||||
const temp = document.createElement("div");
|
||||
temp.innerHTML = msg.html;
|
||||
@ -138,10 +139,16 @@ chatInputField.textarea.focus();
|
||||
|
||||
// --- Reply helper ---
|
||||
function replyMessage(message) {
|
||||
chatInputField.value = "```markdown\n> " + (message || '').split("\n").join("\n> ") + "\n```\n";
|
||||
chatInputField.value = "```markdown\n> " + (message || '').trim().split("\n").join("\n> ") + "\n```\n";
|
||||
chatInputField.textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
chatInputField.focus();
|
||||
}
|
||||
|
||||
messagesContainer.addEventListener("reply", (e) => {
|
||||
const messageText = e.replyText || e.messageTextTarget.textContent.trim();
|
||||
replyMessage(messageText);
|
||||
})
|
||||
|
||||
// --- Mention helpers ---
|
||||
function extractMentions(message) {
|
||||
return [...new Set(message.match(/@\w+/g) || [])];
|
||||
@ -215,7 +222,7 @@ document.addEventListener('keydown', function(event) {
|
||||
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
|
||||
if (gPressCount === 2) {
|
||||
gPressCount = 0;
|
||||
messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
|
||||
messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" });
|
||||
loadExtra();
|
||||
}
|
||||
}
|
||||
@ -254,7 +261,7 @@ function updateLayout(doScrollDown) {
|
||||
function isScrolledPastHalf() {
|
||||
let scrollTop = messagesContainer.scrollTop;
|
||||
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
|
||||
return scrollTop < scrollableHeight / 2;
|
||||
return Math.abs(scrollTop) > scrollableHeight / 2;
|
||||
}
|
||||
|
||||
// --- Initial layout update ---
|
||||
|
@ -29,7 +29,7 @@ class ChannelDriveApiView(DriveApiView):
|
||||
|
||||
class ChannelAttachmentView(BaseView):
|
||||
|
||||
login_required=True
|
||||
login_required=False
|
||||
|
||||
async def get(self):
|
||||
relative_path = self.request.match_info.get("relative_url")
|
||||
|
@ -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.
|
||||
|
||||
|
||||
from snek.system.stats import update_websocket_stats
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import random
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.model import now
|
||||
@ -507,7 +507,9 @@ class RPCView(BaseView):
|
||||
raise Exception("Method not found")
|
||||
success = True
|
||||
try:
|
||||
update_websocket_stats(self.app)
|
||||
result = await method(*args)
|
||||
update_websocket_stats(self.app)
|
||||
except Exception as ex:
|
||||
result = {"exception": str(ex), "traceback": traceback.format_exc()}
|
||||
success = False
|
||||
@ -527,8 +529,8 @@ class RPCView(BaseView):
|
||||
try:
|
||||
await self.ws.send_str(json.dumps(obj, default=str))
|
||||
except Exception as ex:
|
||||
print("THIS IS THE DeAL>",str(ex), flush=True)
|
||||
await self.services.socket.delete(self.ws)
|
||||
await self.ws.close()
|
||||
|
||||
async def get_online_users(self, channel_uid):
|
||||
self._require_login()
|
||||
@ -636,7 +638,7 @@ class RPCView(BaseView):
|
||||
try:
|
||||
await rpc(msg.json())
|
||||
except Exception as ex:
|
||||
print("Deleting socket", ex, flush=True)
|
||||
print("XXXXXXXXXX Deleting socket", ex, flush=True)
|
||||
logger.exception(ex)
|
||||
await self.services.socket.delete(ws)
|
||||
break
|
||||
|
@ -55,7 +55,7 @@ class WebView(BaseView):
|
||||
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
|
||||
)
|
||||
if not channel_member:
|
||||
if not channel["is_private"]:
|
||||
if not channel["is_private"] and not channel.is_dm:
|
||||
channel_member = await self.app.services.channel_member.create(
|
||||
channel_uid=channel["uid"],
|
||||
user_uid=self.session.get("uid"),
|
||||
@ -82,7 +82,6 @@ class WebView(BaseView):
|
||||
await self.app.services.notification.mark_as_read(
|
||||
self.session.get("uid"), message["uid"]
|
||||
)
|
||||
print(messages)
|
||||
name = await channel_member.get_name()
|
||||
return await self.render_template(
|
||||
"web.html",
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user