forked from retoor/snek
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51b4898078 | ||
|
|
40f9646251 | ||
|
|
5e99e894e9 | ||
|
|
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 | ||
|
|
3a4cf93bcc | ||
|
|
29b9fce07d |
+3
-1
@@ -39,7 +39,9 @@ dependencies = [
|
|||||||
"Pillow",
|
"Pillow",
|
||||||
"pillow-heif",
|
"pillow-heif",
|
||||||
"IP2Location",
|
"IP2Location",
|
||||||
"bleach"
|
"bleach",
|
||||||
|
"sentry-sdk",
|
||||||
|
"aiosqlite"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-22
@@ -9,7 +9,7 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from snek import snode
|
from snek import snode
|
||||||
from snek.view.threads import ThreadsView
|
from snek.view.threads import ThreadsView
|
||||||
|
from snek.system.ads import AsyncDataSet
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
@@ -31,6 +31,7 @@ from snek.sgit import GitApplication
|
|||||||
from snek.sssh import start_ssh_server
|
from snek.sssh import start_ssh_server
|
||||||
from snek.system import http
|
from snek.system import http
|
||||||
from snek.system.cache import Cache
|
from snek.system.cache import Cache
|
||||||
|
from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler
|
||||||
from snek.system.markdown import MarkdownExtension
|
from snek.system.markdown import MarkdownExtension
|
||||||
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
|
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
|
||||||
from snek.system.profiler import profiler_handler
|
from snek.system.profiler import profiler_handler
|
||||||
@@ -106,7 +107,7 @@ async def ip2location_middleware(request, handler):
|
|||||||
user["city"]
|
user["city"]
|
||||||
if user["city"] != location.city:
|
if user["city"] != location.city:
|
||||||
user["country_long"] = location.country
|
user["country_long"] = location.country
|
||||||
user["country_short"] = locaion.country_short
|
user["country_short"] = location.country_short
|
||||||
user["city"] = location.city
|
user["city"] = location.city
|
||||||
user["region"] = location.region
|
user["region"] = location.region
|
||||||
user["latitude"] = location.latitude
|
user["latitude"] = location.latitude
|
||||||
@@ -127,6 +128,7 @@ async def trailing_slash_middleware(request, handler):
|
|||||||
class Application(BaseApplication):
|
class Application(BaseApplication):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
middlewares = [
|
middlewares = [
|
||||||
|
stats_middleware,
|
||||||
cors_middleware,
|
cors_middleware,
|
||||||
web.normalize_path_middleware(merge_slashes=True),
|
web.normalize_path_middleware(merge_slashes=True),
|
||||||
ip2location_middleware,
|
ip2location_middleware,
|
||||||
@@ -140,6 +142,7 @@ class Application(BaseApplication):
|
|||||||
client_max_size=1024 * 1024 * 1024 * 5 * args,
|
client_max_size=1024 * 1024 * 1024 * 5 * args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
self.db = AsyncDataSet(kwargs["db_path"].replace("sqlite:///", ""))
|
||||||
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
session_setup(self, EncryptedCookieStorage(SESSION_KEY))
|
||||||
self.tasks = asyncio.Queue()
|
self.tasks = asyncio.Queue()
|
||||||
self._middlewares.append(session_middleware)
|
self._middlewares.append(session_middleware)
|
||||||
@@ -162,16 +165,22 @@ class Application(BaseApplication):
|
|||||||
self.mappers = get_mappers(app=self)
|
self.mappers = get_mappers(app=self)
|
||||||
self.broadcast_service = None
|
self.broadcast_service = None
|
||||||
self.user_availability_service_task = None
|
self.user_availability_service_task = None
|
||||||
|
|
||||||
self.setup_router()
|
self.setup_router()
|
||||||
base_path = pathlib.Path(__file__).parent
|
base_path = pathlib.Path(__file__).parent
|
||||||
self.ip2location = IP2Location.IP2Location(
|
self.ip2location = IP2Location.IP2Location(
|
||||||
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
|
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
|
||||||
)
|
)
|
||||||
|
self.on_startup.append(self.prepare_stats)
|
||||||
self.on_startup.append(self.prepare_asyncio)
|
self.on_startup.append(self.prepare_asyncio)
|
||||||
self.on_startup.append(self.start_user_availability_service)
|
self.on_startup.append(self.start_user_availability_service)
|
||||||
self.on_startup.append(self.start_ssh_server)
|
self.on_startup.append(self.start_ssh_server)
|
||||||
self.on_startup.append(self.prepare_database)
|
#self.on_startup.append(self.prepare_database)
|
||||||
|
|
||||||
|
async def prepare_stats(self, app):
|
||||||
|
app['stats'] = create_stats_structure()
|
||||||
|
print("Stats prepared", flush=True)
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uptime_seconds(self):
|
def uptime_seconds(self):
|
||||||
@@ -234,21 +243,11 @@ class Application(BaseApplication):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print(ex)
|
print(ex)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def prepare_database(self, app):
|
async def prepare_database(self, app):
|
||||||
self.db.query("PRAGMA journal_mode=WAL")
|
await self.db.query_raw("PRAGMA journal_mode=WAL")
|
||||||
self.db.query("PRAGMA syncnorm=off")
|
await self.db.query_raw("PRAGMA syncnorm=off")
|
||||||
|
|
||||||
try:
|
|
||||||
if not self.db["user"].has_index("username"):
|
|
||||||
self.db["user"].create_index("username", unique=True)
|
|
||||||
if not self.db["channel_member"].has_index(["channel_uid", "user_uid"]):
|
|
||||||
self.db["channel_member"].create_index(["channel_uid", "user_uid"])
|
|
||||||
if not self.db["channel_message"].has_index(["channel_uid", "user_uid"]):
|
|
||||||
self.db["channel_message"].create_index(["channel_uid", "user_uid"])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await self.services.drive.prepare_all()
|
await self.services.drive.prepare_all()
|
||||||
self.loop.create_task(self.task_runner())
|
self.loop.create_task(self.task_runner())
|
||||||
@@ -308,6 +307,7 @@ class Application(BaseApplication):
|
|||||||
self.router.add_view("/drive.json", DriveApiView)
|
self.router.add_view("/drive.json", DriveApiView)
|
||||||
self.router.add_view("/drive.html", DriveView)
|
self.router.add_view("/drive.html", DriveView)
|
||||||
self.router.add_view("/drive/{drive}.json", DriveView)
|
self.router.add_view("/drive/{drive}.json", DriveView)
|
||||||
|
self.router.add_get("/stats.html", stats_handler)
|
||||||
self.router.add_view("/stats.json", StatsView)
|
self.router.add_view("/stats.json", StatsView)
|
||||||
self.router.add_view("/user/{user}.html", UserView)
|
self.router.add_view("/user/{user}.html", UserView)
|
||||||
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
||||||
@@ -412,11 +412,11 @@ class Application(BaseApplication):
|
|||||||
self.jinja2_env.loader = await self.get_user_template_loader(
|
self.jinja2_env.loader = await self.get_user_template_loader(
|
||||||
request.session.get("uid")
|
request.session.get("uid")
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context["nonce"] = request['csp_nonce']
|
context["nonce"] = request['csp_nonce']
|
||||||
except:
|
except:
|
||||||
context['nonce'] = '?'
|
context['nonce'] = '?'
|
||||||
|
|
||||||
rendered = await super().render_template(template, request, context)
|
rendered = await super().render_template(template, request, context)
|
||||||
|
|
||||||
@@ -451,7 +451,7 @@ class Application(BaseApplication):
|
|||||||
|
|
||||||
async def get_user_template_loader(self, uid=None):
|
async def get_user_template_loader(self, uid=None):
|
||||||
template_paths = []
|
template_paths = []
|
||||||
for admin_uid in self.services.user.get_admin_uids():
|
for admin_uid in await self.services.user.get_admin_uids():
|
||||||
user_template_path = await self.services.user.get_template_path(admin_uid)
|
user_template_path = await self.services.user.get_template_path(admin_uid)
|
||||||
if user_template_path:
|
if user_template_path:
|
||||||
template_paths.append(user_template_path)
|
template_paths.append(user_template_path)
|
||||||
@@ -463,7 +463,7 @@ class Application(BaseApplication):
|
|||||||
|
|
||||||
template_paths.append(self.template_path)
|
template_paths.append(self.template_path)
|
||||||
return FileSystemLoader(template_paths)
|
return FileSystemLoader(template_paths)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def no_save(self):
|
async def no_save(self):
|
||||||
stats = {
|
stats = {
|
||||||
@@ -478,7 +478,7 @@ class Application(BaseApplication):
|
|||||||
self.services.channel_message.mapper.save = patched_save
|
self.services.channel_message.mapper.save = patched_save
|
||||||
raised_exception = None
|
raised_exception = None
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raised_exception = ex
|
raised_exception = ex
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ class UserMapper(BaseMapper):
|
|||||||
table_name = "user"
|
table_name = "user"
|
||||||
model_class = UserModel
|
model_class = UserModel
|
||||||
|
|
||||||
def get_admin_uids(self):
|
async def get_admin_uids(self):
|
||||||
try:
|
try:
|
||||||
return [
|
return [
|
||||||
user["uid"]
|
user["uid"]
|
||||||
for user in self.db.query(
|
for user in await self.db.query(
|
||||||
"SELECT uid FROM user WHERE is_admin = :is_admin",
|
"SELECT uid FROM user WHERE is_admin = :is_admin",
|
||||||
{"is_admin": True},
|
{"is_admin": True},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class ChannelModel(BaseModel):
|
|||||||
index = ModelField(name="index", required=True, kind=int, value=1000)
|
index = ModelField(name="index", required=True, kind=int, value=1000)
|
||||||
last_message_on = ModelField(name="last_message_on", required=False, kind=str)
|
last_message_on = ModelField(name="last_message_on", required=False, kind=str)
|
||||||
history_start = ModelField(name="history_start", required=False, kind=str)
|
history_start = ModelField(name="history_start", required=False, kind=str)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dm(self):
|
||||||
|
return 'dm' in self['tag'].lower()
|
||||||
|
|
||||||
async def get_last_message(self) -> ChannelMessageModel:
|
async def get_last_message(self) -> ChannelMessageModel:
|
||||||
history_start_filter = ""
|
history_start_filter = ""
|
||||||
|
|||||||
@@ -1,33 +1,40 @@
|
|||||||
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
|
||||||
|
|
||||||
class ChannelMessageService(BaseService):
|
class ChannelMessageService(BaseService):
|
||||||
mapper_name = "channel_message"
|
mapper_name = "channel_message"
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._configured_indexes = False
|
self._configured_indexes = False
|
||||||
|
|
||||||
async def maintenance(self):
|
async def maintenance(self):
|
||||||
args = {}
|
args = {}
|
||||||
async for message in self.find():
|
for message in self.mapper.db["channel_message"].find():
|
||||||
updated_at = message["updated_at"]
|
print(message)
|
||||||
message["is_final"] = True
|
try:
|
||||||
html = message["html"]
|
message = await self.get(uid=message["uid"])
|
||||||
await self.save(message)
|
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:
|
while True:
|
||||||
changed = 0
|
changed = 0
|
||||||
async for message in self.find(is_final=False):
|
async for message in self.find(is_final=False):
|
||||||
@@ -41,7 +48,6 @@ class ChannelMessageService(BaseService):
|
|||||||
if not changed:
|
if not changed:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
async def create(self, channel_uid, user_uid, message, is_final=True):
|
async def create(self, channel_uid, user_uid, message, is_final=True):
|
||||||
model = await self.new()
|
model = await self.new()
|
||||||
|
|
||||||
@@ -66,19 +72,25 @@ class ChannelMessageService(BaseService):
|
|||||||
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"] = template.render(**context)
|
||||||
model["html"] = whitelist_attributes(model["html"])
|
model['html'] = sanitize_html(model['html'])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print(ex, flush=True)
|
print(ex, flush=True)
|
||||||
|
|
||||||
if await super().save(model):
|
if await super().save(model):
|
||||||
if not self._configured_indexes:
|
if not self._configured_indexes:
|
||||||
if not self.mapper.db["channel_message"].has_index(['is_final','user_uid','channel_uid']):
|
if not self.mapper.db["channel_message"].has_index(
|
||||||
self.mapper.db["channel_message"].create_index(['is_final','user_uid','channel_uid'], unique=False)
|
["is_final", "user_uid", "channel_uid"]
|
||||||
if not self.mapper.db["channel_message"].has_index(['uid']):
|
):
|
||||||
self.mapper.db["channel_message"].create_index(['uid'], unique=True)
|
self.mapper.db["channel_message"].create_index(
|
||||||
if not self.mapper.db["channel_message"].has_index(['deleted_at']):
|
["is_final", "user_uid", "channel_uid"], unique=False
|
||||||
self.mapper.db["channel_message"].create_index(['deleted_at'], unique=False)
|
)
|
||||||
self._configured_indexes = True
|
if not self.mapper.db["channel_message"].has_index(["uid"]):
|
||||||
|
self.mapper.db["channel_message"].create_index(["uid"], unique=True)
|
||||||
|
if not self.mapper.db["channel_message"].has_index(["deleted_at"]):
|
||||||
|
self.mapper.db["channel_message"].create_index(
|
||||||
|
["deleted_at"], unique=False
|
||||||
|
)
|
||||||
|
self._configured_indexes = True
|
||||||
return model
|
return model
|
||||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||||
|
|
||||||
@@ -86,6 +98,11 @@ class ChannelMessageService(BaseService):
|
|||||||
user = await self.services.user.get(uid=message["user_uid"])
|
user = await self.services.user.get(uid=message["user_uid"])
|
||||||
if not user:
|
if not user:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
#if not message["html"].startswith("<chat-message"):
|
||||||
|
#message = await self.get(uid=message["uid"])
|
||||||
|
#await self.save(message)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"uid": message["uid"],
|
"uid": message["uid"],
|
||||||
"color": user["color"],
|
"color": user["color"],
|
||||||
@@ -112,17 +129,16 @@ 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)
|
model["html"] = template.render(**context)
|
||||||
model["html"] = whitelist_attributes(model["html"])
|
model['html'] = 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):
|
||||||
channel = await self.services.channel.get(uid=channel_uid)
|
channel = await self.services.channel.get(uid=channel_uid)
|
||||||
if not channel:
|
if not channel:
|
||||||
return []
|
return []
|
||||||
history_start_filter = ""
|
history_start_filter = ""
|
||||||
if channel["history_start"]:
|
if channel["history_start"]:
|
||||||
history_start_filter = f" AND created_at > '{channel['history_start']}'"
|
history_start_filter = f" AND created_at > '{channel['history_start']}'"
|
||||||
results = []
|
results = []
|
||||||
offset = page * page_size
|
offset = page * page_size
|
||||||
try:
|
try:
|
||||||
@@ -140,22 +156,22 @@ class ChannelMessageService(BaseService):
|
|||||||
elif page > 0:
|
elif page > 0:
|
||||||
async for model in self.query(
|
async for model in self.query(
|
||||||
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size",
|
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp {history_start_filter} ORDER BY created_at DESC LIMIT :page_size",
|
||||||
{
|
*{
|
||||||
"channel_uid": channel_uid,
|
"channel_uid": channel_uid,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
},
|
}.values(),
|
||||||
):
|
):
|
||||||
results.append(model)
|
results.append(model)
|
||||||
else:
|
else:
|
||||||
async for model in self.query(
|
async for model in self.query(
|
||||||
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
|
f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid {history_start_filter} ORDER BY created_at DESC LIMIT :page_size OFFSET :offset",
|
||||||
{
|
*{
|
||||||
"channel_uid": channel_uid,
|
"channel_uid": channel_uid,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
},
|
}.values(),
|
||||||
):
|
):
|
||||||
results.append(model)
|
results.append(model)
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class SocketService(BaseService):
|
|||||||
|
|
||||||
async def send_to_user(self, user_uid, message):
|
async def send_to_user(self, user_uid, message):
|
||||||
count = 0
|
count = 0
|
||||||
for s in 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
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ class UserService(BaseService):
|
|||||||
model.username.value = username
|
model.username.value = username
|
||||||
model.password.value = await security.hash(password)
|
model.password.value = await security.hash(password)
|
||||||
if await self.save(model):
|
if await self.save(model):
|
||||||
|
for x in range(10):
|
||||||
|
print("Jazeker!!!")
|
||||||
if model:
|
if model:
|
||||||
channel = await self.services.channel.ensure_public_channel(
|
channel = await self.services.channel.ensure_public_channel(
|
||||||
model["uid"]
|
model["uid"]
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ class UserPropertyService(BaseService):
|
|||||||
mapper_name = "user_property"
|
mapper_name = "user_property"
|
||||||
|
|
||||||
async def set(self, user_uid, name, value):
|
async def set(self, user_uid, name, value):
|
||||||
self.mapper.db["user_property"].upsert(
|
self.mapper.db.upsert(
|
||||||
|
"user_property",
|
||||||
{
|
{
|
||||||
"user_uid": user_uid,
|
"user_uid": user_uid,
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ footer {
|
|||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -368,7 +368,7 @@ input[type="text"], .chat-input textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
|
.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{
|
||||||
.time {
|
.time {
|
||||||
display: block;
|
display: block;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -298,7 +298,9 @@ textToLeetAdvanced(text) {
|
|||||||
this.appendChild(this.uploadButton);
|
this.appendChild(this.uploadButton);
|
||||||
|
|
||||||
this.textarea.addEventListener("blur", () => {
|
this.textarea.addEventListener("blur", () => {
|
||||||
this.updateFromInput("");
|
this.updateFromInput(this.value, true).then(
|
||||||
|
this.updateFromInput("")
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
this.subscribe("file-uploads-done", (data)=>{
|
this.subscribe("file-uploads-done", (data)=>{
|
||||||
@@ -417,7 +419,7 @@ textToLeetAdvanced(text) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
updateFromInput(value) {
|
updateFromInput(value, isFinal = false) {
|
||||||
|
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
|
||||||
@@ -425,7 +427,7 @@ textToLeetAdvanced(text) {
|
|||||||
|
|
||||||
if (this.liveType && value[0] !== "/") {
|
if (this.liveType && value[0] !== "/") {
|
||||||
const messageText = this.replaceMentionsWithAuthors(value);
|
const messageText = this.replaceMentionsWithAuthors(value);
|
||||||
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType);
|
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
|
||||||
return this.messageUid;
|
return this.messageUid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+468
-200
@@ -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 }
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+262
-68
@@ -5,112 +5,306 @@
|
|||||||
// 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;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
updateUI() {
|
||||||
|
if (this._originalChildren === undefined) {
|
||||||
|
const { color, user_nick, created_at, user_uid } = this.dataset;
|
||||||
|
this.classList.add('message');
|
||||||
|
this.style.maxWidth = '100%';
|
||||||
|
this._originalChildren = Array.from(this.children);
|
||||||
|
|
||||||
|
this.innerHTML = `
|
||||||
|
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
|
||||||
|
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
|
||||||
|
</a>
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="author" style="color: ${color || ''};">${user_nick || ''}</div>
|
||||||
|
<div class="text"></div>
|
||||||
|
<div class="time no-select" data-created_at="${created_at || ''}">
|
||||||
|
<span></span>
|
||||||
|
<a href="#reply">reply</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.messageDiv = this.querySelector('.text');
|
||||||
|
if (this._originalChildren && this._originalChildren.length > 0) {
|
||||||
|
this._originalChildren.forEach(child => {
|
||||||
|
this.messageDiv.appendChild(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeDiv = this.querySelector('.time span');
|
||||||
|
this.replyDiv = this.querySelector('.time a');
|
||||||
|
|
||||||
|
this.replyDiv.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dispatchEvent(new ReplyEvent(this.messageDiv));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
this.classList.remove('switch-user');
|
||||||
|
const siblingTime = new Date(this.nextElementSibling.dataset.created_at);
|
||||||
|
const currentTime = new Date(this.dataset.created_at);
|
||||||
|
|
||||||
|
if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) {
|
||||||
|
this.classList.add('long-time');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('long-time');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeDiv.innerText = app.timeDescription(this.dataset.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMessage(...messages) {
|
||||||
|
if (this._originalChildren) {
|
||||||
|
this.messageDiv.replaceChildren(...messages);
|
||||||
|
this._originalChildren = messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {}
|
||||||
|
connectedMoveCallback() {}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MessageList extends HTMLElement {
|
class MessageList extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
this.messageMap = new Map();
|
||||||
|
this.visibleSet = new Set();
|
||||||
|
|
||||||
|
this._observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
this.visibleSet.add(entry.target);
|
||||||
|
if (entry.target instanceof MessageElement) {
|
||||||
|
entry.target.updateUI();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.visibleSet.delete(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
root: this,
|
||||||
|
threshold: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// End-of-messages marker
|
||||||
|
this.endOfMessages = document.createElement('div');
|
||||||
|
this.endOfMessages.classList.add('message-list-bottom');
|
||||||
|
this.prepend(this.endOfMessages);
|
||||||
|
|
||||||
|
// Observe existing children and index by uid
|
||||||
|
for (const c of this.children) {
|
||||||
|
this._observer.observe(c);
|
||||||
|
if (c instanceof MessageElement) {
|
||||||
|
this.messageMap.set(c.dataset.uid, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up socket events
|
||||||
app.ws.addEventListener("update_message_text", (data) => {
|
app.ws.addEventListener("update_message_text", (data) => {
|
||||||
this.updateMessageText(data.uid, data);
|
if (this.messageMap.has(data.uid)) {
|
||||||
|
this.upsertMessage(data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
app.ws.addEventListener("set_typing", (data) => {
|
app.ws.addEventListener("set_typing", (data) => {
|
||||||
this.triggerGlow(data.user_uid,data.color);
|
this.triggerGlow(data.user_uid, data.color);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.items = [];
|
this.scrollToBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const messagesContainer = this
|
this.addEventListener('click', (e) => {
|
||||||
messagesContainer.addEventListener('click', (e) => {
|
if (
|
||||||
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
|
e.target.tagName !== 'IMG' ||
|
||||||
|
e.target.classList.contains('avatar-img')
|
||||||
|
) return;
|
||||||
|
|
||||||
const img = e.target;
|
const img = e.target;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:pointer;';
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
const urlObj = new URL(img.currentSrc || img.src, window.location.origin);
|
||||||
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;'
|
urlObj.searchParams.delete('width');
|
||||||
|
urlObj.searchParams.delete('height');
|
||||||
|
|
||||||
const urlObj = new URL(img.currentSrc || img.src)
|
const fullImg = document.createElement('img');
|
||||||
urlObj.searchParams.delete("width");
|
fullImg.src = urlObj.toString();
|
||||||
urlObj.searchParams.delete("height");
|
fullImg.alt = img.alt || '';
|
||||||
|
fullImg.style.maxWidth = '90%';
|
||||||
|
fullImg.style.maxHeight = '90%';
|
||||||
|
fullImg.style.boxShadow = '0 0 32px #000';
|
||||||
|
fullImg.style.borderRadius = '8px';
|
||||||
|
fullImg.style.background = '#222';
|
||||||
|
fullImg.style.objectFit = 'contain';
|
||||||
|
fullImg.loading = 'lazy';
|
||||||
|
|
||||||
const fullImg = document.createElement('img');
|
overlay.appendChild(fullImg);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
fullImg.src = urlObj.toString();
|
|
||||||
fullImg.alt = img.alt;
|
|
||||||
fullImg.style.maxWidth = '90%';
|
|
||||||
fullImg.style.maxHeight = '90%';
|
|
||||||
|
|
||||||
overlay.appendChild(fullImg);
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
overlay.addEventListener('click', () => document.body.removeChild(overlay));
|
|
||||||
})
|
|
||||||
|
|
||||||
|
overlay.addEventListener('click', () => {
|
||||||
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
|
});
|
||||||
|
// ESC to close
|
||||||
|
const escListener = (evt) => {
|
||||||
|
if (evt.key === 'Escape') {
|
||||||
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
|
document.removeEventListener('keydown', escListener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escListener);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isElementVisible(element) {
|
isElementVisible(element) {
|
||||||
|
if (!element) return false;
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
return (
|
return (
|
||||||
rect.top >= 0 &&
|
rect.top >= 0 &&
|
||||||
rect.left >= 0 &&
|
rect.left >= 0 &&
|
||||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
isScrolledToBottom() {
|
isScrolledToBottom() {
|
||||||
return this.isElementVisible(this.querySelector(".message-list-bottom"));
|
return this.visibleSet.has(this.endOfMessages);
|
||||||
}
|
}
|
||||||
scrollToBottom(force) {
|
|
||||||
//this.scrollTop = this.scrollHeight;
|
|
||||||
|
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
scrollToBottom(force = false, behavior = 'instant') {
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
if (force || !this.isScrolledToBottom()) {
|
||||||
|
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
|
||||||
// this.scrollTop = this.scrollHeight;
|
}, 200);
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
|
||||||
},200)
|
|
||||||
}
|
|
||||||
updateMessageText(uid, message) {
|
|
||||||
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
|
||||||
|
|
||||||
if (!messageDiv) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const scrollToBottom = this.isScrolledToBottom();
|
|
||||||
const receivedHtml = document.createElement("div");
|
|
||||||
receivedHtml.innerHTML = message.html;
|
|
||||||
const html = receivedHtml.querySelector(".text").innerHTML;
|
|
||||||
const textElement = messageDiv.querySelector(".text");
|
|
||||||
textElement.innerHTML = html;
|
|
||||||
textElement.style.display = message.text == "" ? "none" : "block";
|
|
||||||
if(scrollToBottom)
|
|
||||||
this.scrollToBottom(true)
|
|
||||||
}
|
}
|
||||||
triggerGlow(uid,color) {
|
|
||||||
app.starField.glowColor(color)
|
triggerGlow(uid, color) {
|
||||||
let lastElement = null;
|
if (!uid || !color) return;
|
||||||
this.querySelectorAll(".avatar").forEach((el) => {
|
app.starField.glowColor(color);
|
||||||
const div = el.closest("a");
|
let lastElement = null;
|
||||||
if (el.href.indexOf(uid) != -1) {
|
this.querySelectorAll('.avatar').forEach((el) => {
|
||||||
|
const anchor = el.closest('a');
|
||||||
|
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
|
||||||
lastElement = el;
|
lastElement = el;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (lastElement) {
|
if (lastElement) {
|
||||||
lastElement.classList.add("glow");
|
lastElement.classList.add('glow');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
lastElement.classList.remove("glow");
|
lastElement.classList.remove('glow');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set data(items) {
|
updateTimes() {
|
||||||
this.items = items;
|
this.visibleSet.forEach((messageElement) => {
|
||||||
this.render();
|
if (messageElement instanceof MessageElement) {
|
||||||
|
messageElement.updateUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
render() {
|
|
||||||
this.innerHTML = "";
|
|
||||||
|
|
||||||
//this.insertAdjacentHTML("beforeend", html);
|
upsertMessage(data) {
|
||||||
|
let message = this.messageMap.get(data.uid);
|
||||||
|
if (message && (data.is_final || !data.message)) {
|
||||||
|
message.parentElement?.removeChild(message);
|
||||||
|
// TO force insert
|
||||||
|
message = null;
|
||||||
|
|
||||||
|
}
|
||||||
|
if (!data.message) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.innerHTML = data.html;
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
// If the old element is already custom, only update its message children
|
||||||
|
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
|
||||||
|
} else {
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
if (scrolledToBottom) this.scrollToBottom(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
customElements.define("chat-message", MessageElement);
|
||||||
customElements.define("message-list", MessageList);
|
customElements.define("message-list", MessageList);
|
||||||
|
|
||||||
|
|||||||
+40
-14
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
class RestClient {
|
class RestClient {
|
||||||
constructor({ baseURL = '', headers = {} } = {}) {
|
constructor({ baseURL = '', headers = {} } = {}) {
|
||||||
this.baseURL = baseURL;
|
this.baseURL = baseURL;
|
||||||
@@ -210,27 +208,52 @@ class Njet extends HTMLElement {
|
|||||||
customElements.define(name, component);
|
customElements.define(name, component);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor(config) {
|
||||||
super();
|
super();
|
||||||
|
// Store the config for use in render and other methods
|
||||||
|
this.config = config || {};
|
||||||
|
|
||||||
if (!Njet._root) {
|
if (!Njet._root) {
|
||||||
Njet._root = this
|
Njet._root = this
|
||||||
Njet._rest = new RestClient({ baseURL: '/' || null })
|
Njet._rest = new RestClient({ baseURL: '/' || null })
|
||||||
}
|
}
|
||||||
this.root._elements.push(this)
|
this.root._elements.push(this)
|
||||||
this.classList.add('njet');
|
this.classList.add('njet');
|
||||||
|
|
||||||
|
// Initialize properties from config before rendering
|
||||||
|
this.initProps(this.config);
|
||||||
|
|
||||||
|
// Call render after properties are initialized
|
||||||
this.render.call(this);
|
this.render.call(this);
|
||||||
//this.initProps(config);
|
|
||||||
//if (typeof this.config.construct === 'function')
|
// Call construct if defined
|
||||||
// this.config.construct.call(this)
|
if (typeof this.config.construct === 'function') {
|
||||||
|
this.config.construct.call(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initProps(config) {
|
initProps(config) {
|
||||||
const props = Object.keys(config)
|
const props = Object.keys(config)
|
||||||
props.forEach(prop => {
|
props.forEach(prop => {
|
||||||
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];
|
this[prop] = config[prop];
|
||||||
|
} else if (prop in this) {
|
||||||
|
// Property exists, set it directly
|
||||||
|
this[prop] = config[prop];
|
||||||
|
} else {
|
||||||
|
// Set as attribute for unknown properties
|
||||||
|
this.setAttribute(prop, config[prop]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.classes) {
|
if (config.classes) {
|
||||||
this.classList.add(...config.classes);
|
this.classList.add(...config.classes);
|
||||||
}
|
}
|
||||||
@@ -342,7 +365,7 @@ class NjetDialog extends Component {
|
|||||||
const buttonContainer = document.createElement('div');
|
const buttonContainer = document.createElement('div');
|
||||||
buttonContainer.style.marginTop = '20px';
|
buttonContainer.style.marginTop = '20px';
|
||||||
buttonContainer.style.display = 'flex';
|
buttonContainer.style.display = 'flex';
|
||||||
buttonContainer.style.justifyContent = 'flenjet-end';
|
buttonContainer.style.justifyContent = 'flex-end';
|
||||||
buttonContainer.style.gap = '10px';
|
buttonContainer.style.gap = '10px';
|
||||||
if (secondaryButton) {
|
if (secondaryButton) {
|
||||||
const secondary = new NjetButton(secondaryButton);
|
const secondary = new NjetButton(secondaryButton);
|
||||||
@@ -372,8 +395,9 @@ class NjetWindow extends Component {
|
|||||||
header.textContent = title;
|
header.textContent = title;
|
||||||
this.appendChild(header);
|
this.appendChild(header);
|
||||||
}
|
}
|
||||||
this.config.items.forEach(item => this.appendChild(item));
|
if (this.config.items) {
|
||||||
|
this.config.items.forEach(item => this.appendChild(item));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
show(){
|
show(){
|
||||||
@@ -408,7 +432,8 @@ class NjetGrid extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Njet.registerComponent('njet-grid', NjetGrid);
|
Njet.registerComponent('njet-grid', NjetGrid);
|
||||||
/*
|
|
||||||
|
/* Example usage:
|
||||||
const button = new NjetButton({
|
const button = new NjetButton({
|
||||||
classes: ['my-button'],
|
classes: ['my-button'],
|
||||||
text: 'Shared',
|
text: 'Shared',
|
||||||
@@ -493,7 +518,7 @@ document.body.appendChild(dialog);
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
class NjetComponent extends Component {}
|
class NjetComponent extends Component {}
|
||||||
const njet = Njet
|
const njet = Njet
|
||||||
njet.showDialog = function(args){
|
njet.showDialog = function(args){
|
||||||
const dialog = new NjetDialog(args)
|
const dialog = new NjetDialog(args)
|
||||||
dialog.show()
|
dialog.show()
|
||||||
@@ -545,15 +570,16 @@ njet.showWindow = function(args) {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
njet.publish = function(event, data) {
|
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))
|
this.root._subscriptions[event].forEach(callback => callback(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
njet.subscribe = function(event, callback) {
|
njet.subscribe = function(event, callback) {
|
||||||
|
if (!this.root) return;
|
||||||
if (!this.root._subscriptions[event]) {
|
if (!this.root._subscriptions[event]) {
|
||||||
this.root._subscriptions[event] = []
|
this.root._subscriptions[event] = []
|
||||||
}
|
}
|
||||||
this.root._subscriptions[event].push(callback)
|
this.root._subscriptions[event].push(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow,eventBus };
|
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow, eventBus };
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+20
-12
@@ -1,7 +1,7 @@
|
|||||||
DEFAULT_LIMIT = 30
|
DEFAULT_LIMIT = 30
|
||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
|
import traceback
|
||||||
from snek.system.model import BaseModel
|
from snek.system.model import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +51,9 @@ class BaseMapper:
|
|||||||
kwargs["uid"] = uid
|
kwargs["uid"] = uid
|
||||||
if not kwargs.get("deleted_at"):
|
if not kwargs.get("deleted_at"):
|
||||||
kwargs["deleted_at"] = None
|
kwargs["deleted_at"] = None
|
||||||
record = await self.run_in_executor(self.table.find_one, **kwargs)
|
#traceback.print_exc()
|
||||||
|
|
||||||
|
record = await self.db.get(self.table_name, kwargs)
|
||||||
if not record:
|
if not record:
|
||||||
return None
|
return None
|
||||||
record = dict(record)
|
record = dict(record)
|
||||||
@@ -61,23 +63,29 @@ class BaseMapper:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
async def exists(self, **kwargs):
|
async def exists(self, **kwargs):
|
||||||
return await self.run_in_executor(self.table.exists, **kwargs)
|
return await self.db.count(self.table_name, kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
#return await self.run_in_executor(self.table.exists, **kwargs)
|
||||||
|
|
||||||
async def count(self, **kwargs) -> int:
|
async def count(self, **kwargs) -> int:
|
||||||
return await self.run_in_executor(self.table.count, **kwargs)
|
return await self.db.count(self.table_name,kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def save(self, model: BaseModel) -> bool:
|
async def save(self, model: BaseModel) -> bool:
|
||||||
if not model.record.get("uid"):
|
if not model.record.get("uid"):
|
||||||
raise Exception(f"Attempt to save without uid: {model.record}.")
|
raise Exception(f"Attempt to save without uid: {model.record}.")
|
||||||
model.updated_at.update()
|
model.updated_at.update()
|
||||||
return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True)
|
await self.upsert(model)
|
||||||
|
return model
|
||||||
|
#return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True)
|
||||||
|
|
||||||
async def find(self, **kwargs) -> typing.AsyncGenerator:
|
async def find(self, **kwargs) -> typing.AsyncGenerator:
|
||||||
if not kwargs.get("_limit"):
|
if not kwargs.get("_limit"):
|
||||||
kwargs["_limit"] = self.default_limit
|
kwargs["_limit"] = self.default_limit
|
||||||
if not kwargs.get("deleted_at"):
|
if not kwargs.get("deleted_at"):
|
||||||
kwargs["deleted_at"] = None
|
kwargs["deleted_at"] = None
|
||||||
for record in await self.run_in_executor(self.table.find, **kwargs):
|
for record in await self.db.find(self.table_name, kwargs):
|
||||||
model = await self.new()
|
model = await self.new()
|
||||||
for key, value in record.items():
|
for key, value in record.items():
|
||||||
model[key] = value
|
model[key] = value
|
||||||
@@ -88,21 +96,21 @@ class BaseMapper:
|
|||||||
return "insert" in sql or "update" in sql or "delete" in sql
|
return "insert" in sql or "update" in sql or "delete" in sql
|
||||||
|
|
||||||
async def query(self, sql, *args):
|
async def query(self, sql, *args):
|
||||||
for record in await self.run_in_executor(self.db.query, sql, *args, use_semaphore=await self._use_semaphore(sql)):
|
for record in await self.db.query(sql, *args):
|
||||||
yield dict(record)
|
yield dict(record)
|
||||||
|
|
||||||
async def update(self, model):
|
async def update(self, model):
|
||||||
if not model["deleted_at"] is None:
|
if not model["deleted_at"] is None:
|
||||||
raise Exception("Can't update deleted record.")
|
raise Exception("Can't update deleted record.")
|
||||||
model.updated_at.update()
|
model.updated_at.update()
|
||||||
return await self.run_in_executor(self.table.update, model.record, ["uid"],use_semaphore=True)
|
return await self.db.update(self.table_name, model.record, {"uid": model["uid"]})
|
||||||
|
|
||||||
async def upsert(self, model):
|
async def upsert(self, model):
|
||||||
model.updated_at.update()
|
model.updated_at.update()
|
||||||
return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True)
|
await self.db.upsert(self.table_name, model.record, {"uid": model["uid"]})
|
||||||
|
return model
|
||||||
|
|
||||||
async def delete(self, **kwargs) -> int:
|
async def delete(self, **kwargs) -> int:
|
||||||
if not kwargs or not isinstance(kwargs, dict):
|
if not kwargs or not isinstance(kwargs, dict):
|
||||||
raise Exception("Can't execute delete with no filter.")
|
raise Exception("Can't execute delete with no filter.")
|
||||||
kwargs["use_semaphore"] = True
|
return await self.db.delete(self.table_name, kwargs)
|
||||||
return await self.run_in_executor(self.table.delete, **kwargs)
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async def auth_middleware(request, handler):
|
|||||||
request["user"] = None
|
request["user"] = None
|
||||||
if request.session.get("uid") and request.session.get("logged_in"):
|
if request.session.get("uid") and request.session.get("logged_in"):
|
||||||
request["user"] = await request.app.services.user.get(
|
request["user"] = await request.app.services.user.get(
|
||||||
uid=request.app.session.get("uid")
|
uid=request.session.get("uid")
|
||||||
)
|
)
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
|
|
||||||
@@ -69,5 +69,5 @@ async def cors_middleware(request, handler):
|
|||||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ class BaseService:
|
|||||||
return await self.mapper.new()
|
return await self.mapper.new()
|
||||||
|
|
||||||
async def query(self, sql, *args):
|
async def query(self, sql, *args):
|
||||||
for record in self.app.db.query(sql, *args):
|
for record in await self.app.db.query(sql, *args):
|
||||||
yield record
|
yield record
|
||||||
|
|
||||||
async def get(self, *args, **kwargs):
|
async def get(self, *args, **kwargs):
|
||||||
if not "deleted_at" in kwargs:
|
if not "deleted_at" in kwargs:
|
||||||
kwargs["deleted_at"] = None
|
kwargs["deleted_at"] = None
|
||||||
uid = kwargs.get("uid")
|
uid = kwargs.get("uid")
|
||||||
if args:
|
if args:
|
||||||
uid = args[0]
|
uid = args[0]
|
||||||
@@ -50,7 +50,7 @@ class BaseService:
|
|||||||
if result and result.__class__ == self.mapper.model_class:
|
if result and result.__class__ == self.mapper.model_class:
|
||||||
return result
|
return result
|
||||||
kwargs["uid"] = uid
|
kwargs["uid"] = uid
|
||||||
|
print(kwargs,"ZZZZZZZ")
|
||||||
result = await self.mapper.get(**kwargs)
|
result = await self.mapper.get(**kwargs)
|
||||||
if result:
|
if result:
|
||||||
await self.cache.set(result["uid"], result)
|
await self.cache.set(result["uid"], result)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
+29
-77
@@ -79,44 +79,38 @@ emoji.EMOJI_DATA[
|
|||||||
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
|
] = {"en": ":a1:", "status": 2, "E": 0.6, "alias": [":a1:"]}
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [
|
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + ["picture"]
|
||||||
"img",
|
|
||||||
"video",
|
|
||||||
"audio",
|
|
||||||
"source",
|
|
||||||
"iframe",
|
|
||||||
"picture",
|
|
||||||
"span",
|
|
||||||
]
|
|
||||||
ALLOWED_ATTRIBUTES = {
|
|
||||||
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
|
|
||||||
"img": ["src", "alt", "title", "width", "height"],
|
|
||||||
"a": ["href", "title", "target", "rel", "referrerpolicy", "class"],
|
|
||||||
"iframe": [
|
|
||||||
"src",
|
|
||||||
"width",
|
|
||||||
"height",
|
|
||||||
"frameborder",
|
|
||||||
"allow",
|
|
||||||
"allowfullscreen",
|
|
||||||
"title",
|
|
||||||
"referrerpolicy",
|
|
||||||
"style",
|
|
||||||
],
|
|
||||||
"video": ["src", "controls", "width", "height"],
|
|
||||||
"audio": ["src", "controls"],
|
|
||||||
"source": ["src", "type"],
|
|
||||||
"span": ["class"],
|
|
||||||
"picture": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_html(value):
|
def sanitize_html(value):
|
||||||
|
|
||||||
|
soup = BeautifulSoup(value, 'html.parser')
|
||||||
|
|
||||||
|
for script in soup.find_all('script'):
|
||||||
|
script.decompose()
|
||||||
|
|
||||||
|
#for iframe in soup.find_all('iframe'):
|
||||||
|
#iframe.decompose()
|
||||||
|
|
||||||
|
for tag in soup.find_all(['object', 'embed']):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
for tag in soup.find_all():
|
||||||
|
event_attributes = ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']
|
||||||
|
for attr in event_attributes:
|
||||||
|
if attr in tag.attrs:
|
||||||
|
del tag[attr]
|
||||||
|
|
||||||
|
for img in soup.find_all('img'):
|
||||||
|
if 'onerror' in img.attrs:
|
||||||
|
img.decompose()
|
||||||
|
|
||||||
|
return soup.prettify()
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html2(value):
|
||||||
return bleach.clean(
|
return bleach.clean(
|
||||||
value,
|
value,
|
||||||
tags=ALLOWED_TAGS,
|
protocols=list(bleach.sanitizer.ALLOWED_PROTOCOLS) + ["data"],
|
||||||
attributes=ALLOWED_ATTRIBUTES,
|
|
||||||
protocols=bleach.sanitizer.ALLOWED_PROTOCOLS + ["data"],
|
|
||||||
strip=True,
|
strip=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,50 +126,8 @@ def set_link_target_blank(text):
|
|||||||
|
|
||||||
return str(soup)
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
SAFE_ATTRIBUTES = {
|
|
||||||
"href",
|
|
||||||
"src",
|
|
||||||
"alt",
|
|
||||||
"title",
|
|
||||||
"width",
|
|
||||||
"height",
|
|
||||||
"style",
|
|
||||||
"id",
|
|
||||||
"class",
|
|
||||||
"rel",
|
|
||||||
"type",
|
|
||||||
"name",
|
|
||||||
"value",
|
|
||||||
"placeholder",
|
|
||||||
"aria-hidden",
|
|
||||||
"aria-label",
|
|
||||||
"srcset",
|
|
||||||
"target",
|
|
||||||
"rel",
|
|
||||||
"referrerpolicy",
|
|
||||||
"controls",
|
|
||||||
"frameborder",
|
|
||||||
"allow",
|
|
||||||
"allowfullscreen",
|
|
||||||
"referrerpolicy",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def whitelist_attributes(html):
|
def whitelist_attributes(html):
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
return sanitize_html(html)
|
||||||
|
|
||||||
for tag in soup.find_all():
|
|
||||||
if hasattr(tag, "attrs"):
|
|
||||||
if tag.name in ["script", "form", "input"]:
|
|
||||||
tag.replace_with("")
|
|
||||||
continue
|
|
||||||
attrs = dict(tag.attrs)
|
|
||||||
for attr in list(attrs):
|
|
||||||
# Check if attribute is in the safe list or is a data-* attribute
|
|
||||||
if not (attr in SAFE_ATTRIBUTES or attr.startswith("data-")):
|
|
||||||
del tag.attrs[attr]
|
|
||||||
return str(soup)
|
|
||||||
|
|
||||||
|
|
||||||
def embed_youtube(text):
|
def embed_youtube(text):
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ app.starField.renderWord("H4x0r 1337")
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
document.addEventListener("keydown", async() => {
|
document.addEventListener("keydown", async(event) => {
|
||||||
if(prevKey == "Escape"){
|
if(prevKey == "Escape"){
|
||||||
document.querySelector("chat-input").querySelector("textarea").value = "";
|
document.querySelector("chat-input").querySelector("textarea").value = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<div style="max-width:100%;" data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" class="message"><a class="avatar" style="background-color: {{color}}; color: black;" href="/user/{{user_uid}}.html"><img class="avatar-img" width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></a><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class="time no-select" data-created_at="{{created_at}}"></div></div></div>
|
<chat-message data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</chat-message>
|
||||||
@@ -12,7 +12,7 @@ function showTerm(options){
|
|||||||
|
|
||||||
|
|
||||||
class StarField {
|
class StarField {
|
||||||
constructor({ count = 200, 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: 200});
|
const starField = new StarField({starCount: 50});
|
||||||
app.starField = starField;
|
app.starField = starField;
|
||||||
|
|
||||||
class DemoSequence {
|
class DemoSequence {
|
||||||
|
|||||||
+15
-69
@@ -25,12 +25,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for message in messages %}
|
{% for message in messages|reverse %}
|
||||||
{% autoescape false %}
|
{% autoescape false %}
|
||||||
{{ message.html }}
|
{{ message.html }}
|
||||||
{% endautoescape %}
|
{% endautoescape %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="message-list-bottom"></div>
|
|
||||||
</message-list>
|
</message-list>
|
||||||
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
|
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
|
||||||
</section>
|
</section>
|
||||||
@@ -73,18 +72,19 @@ function throttle(fn, wait) {
|
|||||||
// --- Scroll: load extra messages, throttled ---
|
// --- Scroll: load extra messages, throttled ---
|
||||||
let isLoadingExtra = false;
|
let isLoadingExtra = false;
|
||||||
async function loadExtra() {
|
async function loadExtra() {
|
||||||
const firstMessage = messagesContainer.querySelector(".message:first-child");
|
const firstMessage = messagesContainer.lastElementChild;
|
||||||
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
|
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
|
||||||
isLoadingExtra = true;
|
isLoadingExtra = true;
|
||||||
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
|
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
|
||||||
if (messages.length) {
|
if (messages.length) {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
|
messages.reverse();
|
||||||
messages.forEach(msg => {
|
messages.forEach(msg => {
|
||||||
const temp = document.createElement("div");
|
const temp = document.createElement("div");
|
||||||
temp.innerHTML = msg.html;
|
temp.innerHTML = msg.html;
|
||||||
frag.appendChild(temp.firstChild);
|
frag.appendChild(temp.firstChild);
|
||||||
});
|
});
|
||||||
firstMessage.parentNode.insertBefore(frag, firstMessage);
|
messagesContainer.appendChild(frag);
|
||||||
updateLayout(false);
|
updateLayout(false);
|
||||||
}
|
}
|
||||||
isLoadingExtra = false;
|
isLoadingExtra = false;
|
||||||
@@ -93,32 +93,7 @@ messagesContainer.addEventListener("scroll", throttle(loadExtra, 200));
|
|||||||
|
|
||||||
// --- Only update visible times ---
|
// --- Only update visible times ---
|
||||||
function updateTimes() {
|
function updateTimes() {
|
||||||
const containers = messagesContainer.querySelectorAll(".time");
|
messagesContainer.updateTimes();
|
||||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
||||||
containers.forEach(container => {
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= viewportHeight) {
|
|
||||||
const messageDiv = container.closest('.message');
|
|
||||||
let text = messageDiv.querySelector(".text").innerText;
|
|
||||||
const time = document.createElement("span");
|
|
||||||
time.innerText = app.timeDescription(container.dataset.created_at);
|
|
||||||
messageDiv.querySelector(".text").querySelectorAll("img").forEach(img => {
|
|
||||||
text += " " + img.src
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
container.replaceChildren(time);
|
|
||||||
const reply = document.createElement("a");
|
|
||||||
reply.innerText = " reply";
|
|
||||||
reply.href = "#reply";
|
|
||||||
container.appendChild(reply);
|
|
||||||
reply.addEventListener('click', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
replyMessage(text);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setInterval(() => requestIdleCallback(updateTimes), 30000);
|
setInterval(() => requestIdleCallback(updateTimes), 30000);
|
||||||
|
|
||||||
@@ -164,10 +139,16 @@ chatInputField.textarea.focus();
|
|||||||
|
|
||||||
// --- Reply helper ---
|
// --- Reply helper ---
|
||||||
function replyMessage(message) {
|
function replyMessage(message) {
|
||||||
chatInputField.value = "```markdown\n> " + (message || '') + "\n```\n";
|
chatInputField.value = "```markdown\n> " + (message || '').trim().split("\n").join("\n> ") + "\n```\n";
|
||||||
|
chatInputField.textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
chatInputField.focus();
|
chatInputField.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
messagesContainer.addEventListener("reply", (e) => {
|
||||||
|
const messageText = e.replyText || e.messageTextTarget.textContent.trim();
|
||||||
|
replyMessage(messageText);
|
||||||
|
})
|
||||||
|
|
||||||
// --- Mention helpers ---
|
// --- Mention helpers ---
|
||||||
function extractMentions(message) {
|
function extractMentions(message) {
|
||||||
return [...new Set(message.match(/@\w+/g) || [])];
|
return [...new Set(message.match(/@\w+/g) || [])];
|
||||||
@@ -223,22 +204,8 @@ app.addEventListener("channel-message", (data) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const lastElement = messagesContainer.querySelector(".message-list-bottom");
|
|
||||||
const doScrollDown = messagesContainer.isScrolledToBottom();
|
|
||||||
|
|
||||||
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
|
|
||||||
if (oldMessage) {
|
|
||||||
oldMessage.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = document.createElement("div");
|
messagesContainer.upsertMessage(data)
|
||||||
|
|
||||||
|
|
||||||
message.innerHTML = data.html;
|
|
||||||
message.style.display = display;
|
|
||||||
messagesContainer.insertBefore(message.firstChild, lastElement);
|
|
||||||
updateLayout(doScrollDown);
|
|
||||||
setTimeout(() => updateLayout(doScrollDown), 1000);
|
|
||||||
app.rpc.markAsRead(channelUid);
|
app.rpc.markAsRead(channelUid);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,7 +222,7 @@ document.addEventListener('keydown', function(event) {
|
|||||||
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
|
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
|
||||||
if (gPressCount === 2) {
|
if (gPressCount === 2) {
|
||||||
gPressCount = 0;
|
gPressCount = 0;
|
||||||
messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
|
messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" });
|
||||||
loadExtra();
|
loadExtra();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,27 +251,6 @@ document.addEventListener('keydown', function(event) {
|
|||||||
// --- Layout update ---
|
// --- Layout update ---
|
||||||
function updateLayout(doScrollDown) {
|
function updateLayout(doScrollDown) {
|
||||||
updateTimes();
|
updateTimes();
|
||||||
let previousUser = null, previousDate = null;
|
|
||||||
messagesContainer.querySelectorAll(".message").forEach((message) => {
|
|
||||||
if (previousUser !== message.dataset.user_uid) {
|
|
||||||
message.classList.add("switch-user");
|
|
||||||
previousUser = message.dataset.user_uid;
|
|
||||||
previousDate = new Date(message.dataset.created_at);
|
|
||||||
} else {
|
|
||||||
message.classList.remove("switch-user");
|
|
||||||
if (!previousDate) {
|
|
||||||
previousDate = new Date(message.dataset.created_at);
|
|
||||||
} else {
|
|
||||||
const currentDate = new Date(message.dataset.created_at);
|
|
||||||
if (currentDate.getTime() - previousDate.getTime() > 1000 * 60 * 20) {
|
|
||||||
message.classList.add("long-time");
|
|
||||||
} else {
|
|
||||||
message.classList.remove("long-time");
|
|
||||||
}
|
|
||||||
previousDate = currentDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (doScrollDown) messagesContainer.scrollToBottom?.();
|
if (doScrollDown) messagesContainer.scrollToBottom?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +261,7 @@ function updateLayout(doScrollDown) {
|
|||||||
function isScrolledPastHalf() {
|
function isScrolledPastHalf() {
|
||||||
let scrollTop = messagesContainer.scrollTop;
|
let scrollTop = messagesContainer.scrollTop;
|
||||||
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
|
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
|
||||||
return scrollTop < scrollableHeight / 2;
|
return Math.abs(scrollTop) > scrollableHeight / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Initial layout update ---
|
// --- Initial layout update ---
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
|
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
|
||||||
|
|
||||||
|
from snek.system.stats import update_websocket_stats
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
import random
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from snek.system.model import now
|
from snek.system.model import now
|
||||||
@@ -305,7 +305,7 @@ class RPCView(BaseView):
|
|||||||
|
|
||||||
async def send_message(self, channel_uid, message, is_final=True):
|
async def send_message(self, channel_uid, message, is_final=True):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
|
|
||||||
message = message.strip()
|
message = message.strip()
|
||||||
|
|
||||||
if not is_final:
|
if not is_final:
|
||||||
@@ -507,7 +507,9 @@ class RPCView(BaseView):
|
|||||||
raise Exception("Method not found")
|
raise Exception("Method not found")
|
||||||
success = True
|
success = True
|
||||||
try:
|
try:
|
||||||
|
update_websocket_stats(self.app)
|
||||||
result = await method(*args)
|
result = await method(*args)
|
||||||
|
update_websocket_stats(self.app)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
result = {"exception": str(ex), "traceback": traceback.format_exc()}
|
result = {"exception": str(ex), "traceback": traceback.format_exc()}
|
||||||
success = False
|
success = False
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class WebView(BaseView):
|
|||||||
channel = await self.services.channel.get(
|
channel = await self.services.channel.get(
|
||||||
uid=self.request.match_info.get("channel")
|
uid=self.request.match_info.get("channel")
|
||||||
)
|
)
|
||||||
|
print(self.session.get("uid"),"ZZZZZZZZZZ")
|
||||||
|
qq = await self.services.user.get(uid=self.session.get("uid"))
|
||||||
|
|
||||||
|
print("GGGGGGGGGG",qq)
|
||||||
if not channel:
|
if not channel:
|
||||||
user = await self.services.user.get(
|
user = await self.services.user.get(
|
||||||
uid=self.request.match_info.get("channel")
|
uid=self.request.match_info.get("channel")
|
||||||
@@ -55,7 +59,7 @@ class WebView(BaseView):
|
|||||||
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
|
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
|
||||||
)
|
)
|
||||||
if not channel_member:
|
if not channel_member:
|
||||||
if not channel["is_private"]:
|
if not channel["is_private"] and not channel.is_dm:
|
||||||
channel_member = await self.app.services.channel_member.create(
|
channel_member = await self.app.services.channel_member.create(
|
||||||
channel_uid=channel["uid"],
|
channel_uid=channel["uid"],
|
||||||
user_uid=self.session.get("uid"),
|
user_uid=self.session.get("uid"),
|
||||||
@@ -82,7 +86,6 @@ class WebView(BaseView):
|
|||||||
await self.app.services.notification.mark_as_read(
|
await self.app.services.notification.mark_as_read(
|
||||||
self.session.get("uid"), message["uid"]
|
self.session.get("uid"), message["uid"]
|
||||||
)
|
)
|
||||||
print(messages)
|
|
||||||
name = await channel_member.get_name()
|
name = await channel_member.get_name()
|
||||||
return await self.render_template(
|
return await self.render_template(
|
||||||
"web.html",
|
"web.html",
|
||||||
|
|||||||
+370
-409
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user