Compare commits

...

25 Commits
main ... main

Author SHA1 Message Date
6350a7851c Merge pull request 'Added youtube music url to options for embedding' () from BordedDev/snek:feat/embed-youtube-music into main
Reviewed-on: 
2025-06-19 14:37:17 +02:00
5b3e12ec4b Merge pull request 'Add missing HTML attributes to whitelist' () from BordedDev/snek:bugfix/missing-attributes into main
Reviewed-on: 
2025-06-19 14:35:40 +02:00
82dc018676 Merge branch 'main' into bugfix/missing-attributes 2025-06-19 09:33:17 +02:00
BordedDev
40d11dc66a
Clean up 2025-06-19 09:33:05 +02:00
BordedDev
b9a142ba48
Add missing HTML attributes to whitelist 2025-06-19 09:30:33 +02:00
afeb59d0c1 Merge branch 'main' into feat/embed-youtube-music 2025-06-19 08:23:14 +02:00
BordedDev
56ef299c5d
Added youtube music url to options for embedding 2025-06-19 08:11:27 +02:00
fd5d1c3401 Update. 2025-06-15 02:30:57 +02:00
4a6bba7734 Update. 2025-06-15 02:21:49 +02:00
c32d3509b3 Update. 2025-06-15 02:18:45 +02:00
213136b92e Update. 2025-06-15 02:16:08 +02:00
0dc75452d6 Removed ddebuglines. 2025-06-14 18:36:40 +02:00
9d1f6831df Update. 2025-06-14 17:04:14 +02:00
0341e2f2d8 Update. 2025-06-14 15:42:29 +02:00
c4739ff7d3 Update. 2025-06-14 15:20:04 +02:00
cac691e9ff Update. 2025-06-14 13:35:41 +02:00
0e12f7a75c Nice. 2025-06-14 13:08:31 +02:00
3872dafaf1 Nice. 2025-06-14 13:02:53 +02:00
6a905c1948 Nice. 2025-06-14 12:53:40 +02:00
bf576bc0e3 Update. 2025-06-14 12:42:34 +02:00
2c182ad48d Update. 2025-06-14 12:34:26 +02:00
a4d29b9d6f Update. 2025-06-14 09:15:08 +02:00
52538d0181 Update. 2025-06-14 08:31:37 +02:00
75f12c1971 We did do the cool stuff. 2025-06-14 08:24:48 +02:00
a41da84e3f Progress. 2025-06-12 21:59:37 +02:00
21 changed files with 600 additions and 208 deletions

View File

@ -5,6 +5,7 @@ import ssl
import uuid import uuid
import signal import signal
from datetime import datetime from datetime import datetime
from contextlib import asynccontextmanager
from snek import snode from snek import snode
from snek.view.threads import ThreadsView from snek.view.threads import ThreadsView
@ -231,6 +232,7 @@ class Application(BaseApplication):
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") self.db.query("PRAGMA journal_mode=WAL")
self.db.query("PRAGMA syncnorm=off") self.db.query("PRAGMA syncnorm=off")
@ -245,7 +247,7 @@ class Application(BaseApplication):
except: except:
pass pass
await app.services.drive.prepare_all() await self.services.drive.prepare_all()
self.loop.create_task(self.task_runner()) self.loop.create_task(self.task_runner())
def setup_router(self): def setup_router(self):
@ -407,6 +409,11 @@ class Application(BaseApplication):
request.session.get("uid") request.session.get("uid")
) )
try:
context["nonce"] = request['csp_nonce']
except:
context['nonce'] = '?'
rendered = await super().render_template(template, request, context) rendered = await super().render_template(template, request, context)
self.jinja2_env.loader = self.original_loader self.jinja2_env.loader = self.original_loader
@ -453,6 +460,27 @@ class Application(BaseApplication):
template_paths.append(self.template_path) template_paths.append(self.template_path)
return FileSystemLoader(template_paths) return FileSystemLoader(template_paths)
@asynccontextmanager
async def no_save(self):
stats = {
'count': 0
}
async def patched_save(*args, **kwargs):
await self.cache.set(args[0]["uid"], args[0])
stats['count'] = stats['count'] + 1
print(f"save is ignored {stats['count']} times")
return args[0]
save_original = self.services.channel_message.mapper.save
self.services.channel_message.mapper.save = patched_save
raised_exception = None
try:
yield
except Exception as ex:
raised_exception = ex
finally:
self.services.channel_message.mapper.save = save_original
if raised_exception:
raise raised_exception
app = Application(db_path="sqlite:///snek.db") app = Application(db_path="sqlite:///snek.db")

View File

@ -11,11 +11,15 @@ class ChannelModel(BaseModel):
is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True) is_listed = ModelField(name="is_listed", required=True, kind=bool, value=True)
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)
async def get_last_message(self) -> ChannelMessageModel: async def get_last_message(self) -> ChannelMessageModel:
history_start_filter = ""
if self["history_start"]:
history_start_filter = f" AND created_at > '{self['history_start']}' "
try: try:
async for model in self.app.services.channel_message.query( async for model in self.app.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1", "SELECT uid FROM channel_message WHERE channel_uid=:channel_uid" + history_start_filter + " ORDER BY created_at DESC LIMIT 1",
{"channel_uid": self["uid"]}, {"channel_uid": self["uid"]},
): ):

View File

@ -113,6 +113,12 @@ class ChannelService(BaseService):
channel = await self.get(uid=channel_member["channel_uid"]) channel = await self.get(uid=channel_member["channel_uid"])
yield channel yield channel
async def clear(self, channel_uid):
model = await self.get(uid=channel_uid)
model['history_from'] = datetime.now()
await self.save(model)
async def ensure_public_channel(self, created_by_uid): async def ensure_public_channel(self, created_by_uid):
model = await self.get(is_listed=True, tag="public") model = await self.get(is_listed=True, tag="public")
is_moderator = False is_moderator = False

View File

@ -6,8 +6,10 @@ class ChannelMessageService(BaseService):
mapper_name = "channel_message" mapper_name = "channel_message"
async def maintenance(self): async def maintenance(self):
args = {}
async for message in self.find(): async for message in self.find():
updated_at = message["updated_at"] updated_at = message["updated_at"]
message["is_final"] = True
html = message["html"] html = message["html"]
await self.save(message) await self.save(message)
@ -21,6 +23,19 @@ class ChannelMessageService(BaseService):
if html != message["html"]: if html != message["html"]:
print("Reredefined message", message["uid"]) print("Reredefined message", message["uid"])
while True:
changed = 0
async for message in self.find(is_final=False):
message["is_final"] = True
await self.save(message)
changed += 1
async for message in self.find(is_final=None):
message["is_final"] = False
await self.save(message)
changed += 1
if not changed:
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()
@ -87,13 +102,20 @@ class ChannelMessageService(BaseService):
model["html"] = whitelist_attributes(model["html"]) model["html"] = whitelist_attributes(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)
if not channel:
return []
history_start_filter = ""
if 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:
if timestamp: if timestamp:
async for model in self.query( async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset", f"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp {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,
@ -104,7 +126,7 @@ class ChannelMessageService(BaseService):
results.append(model) results.append(model)
elif page > 0: elif page > 0:
async for model in self.query( async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp 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,
@ -115,7 +137,7 @@ class ChannelMessageService(BaseService):
results.append(model) results.append(model)
else: else:
async for model in self.query( async for model in self.query(
"SELECT * FROM channel_message WHERE channel_uid=:channel_uid 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,
@ -124,7 +146,7 @@ class ChannelMessageService(BaseService):
): ):
results.append(model) results.append(model)
except: except Exception as ex:
pass print(ex)
results.sort(key=lambda x: x["created_at"]) results.sort(key=lambda x: x["created_at"])
return results return results

View File

@ -36,9 +36,21 @@ class ChatService(BaseService):
channel = await self.services.channel.get(uid=channel_uid) channel = await self.services.channel.get(uid=channel_uid)
if not channel: if not channel:
raise Exception("Channel not found.") raise Exception("Channel not found.")
channel_message = await self.services.channel_message.create( channel_message = await self.services.channel_message.get(
channel_uid, user_uid, message, is_final channel_uid=channel_uid,user_uid=user_uid, is_final=False
) )
if channel_message:
channel_message["message"] = message
channel_message["is_final"] = is_final
if not channel_message["is_final"]:
async with self.app.no_save():
await self.services.channel_message.save(channel_message)
else:
await self.services.channel_message.save(channel_message)
else:
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message, is_final
)
channel_message_uid = channel_message["uid"] channel_message_uid = channel_message["uid"]
user = await self.services.user.get(uid=user_uid) user = await self.services.user.get(uid=user_uid)

View File

@ -12,6 +12,7 @@ class UserService(BaseService):
async def search(self, query, **kwargs): async def search(self, query, **kwargs):
query = query.strip().lower() query = query.strip().lower()
kwarggs["deleted_at"] = None
if not query: if not query:
return [] return []
results = [] results = []

View File

@ -1,22 +1,23 @@
import { app } from "./app.js"; import { app } from "./app.js";
import { NjetComponent } from "./njet.js"; import { NjetComponent,eventBus } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js"; import { FileUploadGrid } from "./file-upload-grid.js";
class ChatInputComponent extends NjetComponent { class ChatInputComponent extends NjetComponent {
autoCompletions = { autoCompletions = {
"example 1": () => { "example 1": () => {},
}, "example 2": () => {},
"example 2": () => { };
},
}
hiddenCompletions = { hiddenCompletions = {
"/starsRender": () => { "/starsRender": () => {
app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", "")) app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
} }
} };
users = []
textarea = null users = [];
_value = "" textarea = null;
lastUpdateEvent = null _value = "";
lastUpdateEvent = null;
expiryTimer = null; expiryTimer = null;
queuedMessage = null; queuedMessage = null;
lastMessagePromise = null; lastMessagePromise = null;
@ -38,7 +39,7 @@ class ChatInputComponent extends NjetComponent {
} }
get allAutoCompletions() { get allAutoCompletions() {
return Object.assign({}, this.autoCompletions, this.hiddenCompletions) return Object.assign({}, this.autoCompletions, this.hiddenCompletions);
} }
resolveAutoComplete(input) { resolveAutoComplete(input) {
@ -65,7 +66,7 @@ class ChatInputComponent extends NjetComponent {
} }
getAuthors() { getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick]) return this.users.flatMap((user) => [user.username, user.nick]);
} }
extractMentions(text) { extractMentions(text) {
@ -82,17 +83,14 @@ class ChatInputComponent extends NjetComponent {
const lowerAuthor = author.toLowerCase(); const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor); let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) { if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10 distance += 10;
} }
if (distance < minDistance) { if (distance < minDistance) {
minDistance = distance; minDistance = distance;
closestAuthor = author; closestAuthor = author;
} }
}); });
return { mention, closestAuthor, distance: minDistance }; return { mention, closestAuthor, distance: minDistance };
@ -128,7 +126,6 @@ class ChatInputComponent extends NjetComponent {
return matrix[b.length][a.length]; return matrix[b.length][a.length];
} }
replaceMentionsWithAuthors(text) { replaceMentionsWithAuthors(text) {
const authors = this.getAuthors(); const authors = this.getAuthors();
const mentions = this.extractMentions(text); const mentions = this.extractMentions(text);
@ -143,21 +140,19 @@ class ChatInputComponent extends NjetComponent {
return updatedText; return updatedText;
} }
async connectedCallback() { async connectedCallback() {
this.user = null this.user = null;
app.rpc.getUser(null).then((user) => { app.rpc.getUser(null).then((user) => {
this.user = user this.user = user;
}) });
this.liveType = this.getAttribute("live-type") === "true"; this.liveType = this.getAttribute("live-type") !== "true";
this.liveTypeInterval = this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 6;
parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel"); this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users => { app.rpc.getRecentUsers(this.channelUid).then(users => {
this.users = users this.users = users;
}) });
this.messageUid = null; this.messageUid = null;
this.classList.add("chat-input"); this.classList.add("chat-input");
@ -181,24 +176,36 @@ class ChatInputComponent extends NjetComponent {
this.dispatchEvent(new CustomEvent("uploaded", e)); this.dispatchEvent(new CustomEvent("uploaded", e));
}); });
this.uploadButton.addEventListener("click", (e) => { this.uploadButton.addEventListener("click", (e) => {
// e.preventDefault(); e.preventDefault();
// this.fileUploadGrid.openFileDialog() this.fileUploadGrid.openFileDialog();
});
}) eventBus.subscribe("file-uploading", (e) => {
this.subscribe("file-uploading", (e) => { this.fileUploadGrid.style.display = "block";
this.uploadButton.style.display = "none";
this.fileUploadGrid.style.display = "block"; this.textarea.style.display = "none";
this.uploadButton.style.display = "none";
this.textarea.style.display = "none";
}) })
document.eventBus = eventBus;
this.appendChild(this.uploadButton); this.appendChild(this.uploadButton);
this.textarea.addEventListener("blur", () => { this.textarea.addEventListener("blur", () => {
this.updateFromInput(""); this.updateFromInput("");
}); });
eventBus.subscribe("file-uploads-done", (data)=>{
console.info("JEEJ", data)
this.textarea.style.display = "block";
this.uploadButton.style.display = "block";
this.fileUploadGrid.style.display = "none";
let message =data.reduce((file) => {
return `${message}[${file.filename}](/channel/attachment/${file.file})`;
}, '');
app.rpc.sendMessage(this.channelUid, message, true);
});
this.textarea.addEventListener("keyup", (e) => { this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
const message = this.replaceMentionsWithAuthors(this.value); const message = this.replaceMentionsWithAuthors(this.value);
e.target.value = ""; e.target.value = "";
@ -210,12 +217,10 @@ class ChatInputComponent extends NjetComponent {
autoCompletionHandler(); autoCompletionHandler();
this.value = ""; this.value = "";
e.target.value = ""; e.target.value = "";
return; return;
} }
this.finalizeMessage(this.messageUid) this.finalizeMessage(this.messageUid);
return; return;
} }
@ -254,9 +259,10 @@ class ChatInputComponent extends NjetComponent {
}, ''); }, '');
app.rpc.sendMessage(this.channelUid, message, true); app.rpc.sendMessage(this.channelUid, message, true);
}); });
setTimeout(() => { setTimeout(() => {
this.focus(); this.focus();
}, 1000) }, 1000);
} }
trackSecondsBetweenEvents(event1Time, event2Time) { trackSecondsBetweenEvents(event1Time, event2Time) {
@ -278,33 +284,16 @@ class ChatInputComponent extends NjetComponent {
flagTyping() { flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) { if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date(); this.lastUpdateEvent = new Date();
app.rpc.set_typing(this.channelUid, this.user.color).catch(() => { app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {});
});
} }
} }
finalizeMessage(messageUid) { async finalizeMessage(messageUid) {
if (!messageUid) { await app.rpc.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), true);
if (this.value.trim() === "") {
return;
}
this.sendMessage(this.channelUid, this.replaceMentionsWithAuthors(this.value), !this.liveType);
} else if (messageUid.startsWith("?")) {
const lastQueuedMessage = this.queuedMessage;
this.lastMessagePromise?.then((uid) => {
const updatePromise = lastQueuedMessage ? app.rpc.updateMessageText(uid, lastQueuedMessage) : Promise.resolve();
return updatePromise.finally(() => {
return app.rpc.finalizeMessage(uid);
})
})
} else {
app.rpc.finalizeMessage(messageUid)
}
this.value = ""; this.value = "";
this.messageUid = null; this.messageUid = null;
this.queuedMessage = null; this.queuedMessage = null;
this.lastMessagePromise = null this.lastMessagePromise = null;
} }
updateFromInput(value) { updateFromInput(value) {
@ -315,39 +304,12 @@ class ChatInputComponent extends NjetComponent {
this.value = value; this.value = value;
this.flagTyping() this.flagTyping();
if (this.liveType && value[0] !== "/") { if (this.liveType && value[0] !== "/") {
this.expiryTimer = setTimeout(() => {
this.finalizeMessage(this.messageUid)
}, this.liveTypeInterval * 1000);
const messageText = this.replaceMentionsWithAuthors(value); const messageText = this.replaceMentionsWithAuthors(value);
if (this.messageUid?.startsWith("?")) { this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType);
this.queuedMessage = messageText; return this.messageUid;
} else if (this.messageUid) {
app.rpc.updateMessageText(this.messageUid, messageText).then((d) => {
if (!d.success) {
this.messageUid = null
this.updateFromInput(value)
}
})
} else {
const placeHolderId = "?" + crypto.randomUUID();
this.messageUid = placeHolderId;
this.lastMessagePromise = this.sendMessage(this.channelUid, messageText, !this.liveType).then(async (uid) => {
if (this.liveType && this.messageUid === placeHolderId) {
if (this.queuedMessage && this.queuedMessage !== messageText) {
await app.rpc.updateMessageText(uid, this.queuedMessage)
}
this.messageUid = uid;
}
return uid
});
}
} }
} }
@ -360,3 +322,4 @@ class ChatInputComponent extends NjetComponent {
} }
customElements.define("chat-input", ChatInputComponent); customElements.define("chat-input", ChatInputComponent);

226
src/snek/static/editor.js Normal file
View File

@ -0,0 +1,226 @@
import { NjetComponent} from "/njet.js"
class NjetEditor extends NjetComponent {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
#editor {
padding: 1rem;
outline: none;
white-space: pre-wrap;
line-height: 1.5;
height: 100%;
overflow-y: auto;
background: #1e1e1e;
color: #d4d4d4;
}
#command-line {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 0.2rem 1rem;
background: #333;
color: #0f0;
display: none;
font-family: monospace;
}
`;
this.editor = document.createElement('div');
this.editor.id = 'editor';
this.editor.contentEditable = true;
this.editor.innerText = `Welcome to VimEditor Component
Line 2 here
Another line
Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`;
this.cmdLine = document.createElement('div');
this.cmdLine.id = 'command-line';
this.shadowRoot.append(style, this.editor, this.cmdLine);
this.mode = 'normal'; // normal | insert | visual | command
this.keyBuffer = '';
this.lastDeletedLine = '';
this.yankedLine = '';
this.editor.addEventListener('keydown', this.handleKeydown.bind(this));
}
connectedCallback() {
this.editor.focus();
}
getCaretOffset() {
let caretOffset = 0;
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);
caretOffset = preCaretRange.toString().length;
return caretOffset;
}
setCaretOffset(offset) {
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())) {
if (currentOffset + node.length >= offset) {
range.setStart(node, offset - currentOffset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return;
}
currentOffset += node.length;
}
}
handleKeydown(e) {
const key = e.key;
if (this.mode === 'insert') {
if (key === 'Escape') {
e.preventDefault();
this.mode = 'normal';
this.editor.blur();
this.editor.focus();
}
return;
}
if (this.mode === 'command') {
if (key === 'Enter' || key === 'Escape') {
e.preventDefault();
this.cmdLine.style.display = 'none';
this.mode = 'normal';
this.keyBuffer = '';
}
return;
}
if (this.mode === 'visual') {
if (key === 'Escape') {
e.preventDefault();
this.mode = 'normal';
}
return;
}
// Handle normal mode
this.keyBuffer += key;
const text = this.editor.innerText;
const caretPos = this.getCaretOffset();
const lines = text.split('\n');
let charCount = 0, lineIdx = 0;
for (let i = 0; i < lines.length; i++) {
if (caretPos <= charCount + lines[i].length) {
lineIdx = i;
break;
}
charCount += lines[i].length + 1;
}
const offsetToLine = idx =>
text.split('\n').slice(0, idx).reduce((acc, l) => acc + l.length + 1, 0);
switch (this.keyBuffer) {
case 'i':
e.preventDefault();
this.mode = 'insert';
this.keyBuffer = '';
break;
case 'v':
e.preventDefault();
this.mode = 'visual';
this.keyBuffer = '';
break;
case ':':
e.preventDefault();
this.mode = 'command';
this.cmdLine.style.display = 'block';
this.cmdLine.textContent = ':';
this.keyBuffer = '';
break;
case 'yy':
e.preventDefault();
this.yankedLine = lines[lineIdx];
this.keyBuffer = '';
break;
case 'dd':
e.preventDefault();
this.lastDeletedLine = lines[lineIdx];
lines.splice(lineIdx, 1);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break;
case 'p':
e.preventDefault();
const lineToPaste = this.yankedLine || this.lastDeletedLine;
if (lineToPaste) {
lines.splice(lineIdx + 1, 0, lineToPaste);
this.editor.innerText = lines.join('\n');
this.setCaretOffset(offsetToLine(lineIdx + 1));
}
this.keyBuffer = '';
break;
case '0':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx));
this.keyBuffer = '';
break;
case '$':
e.preventDefault();
this.setCaretOffset(offsetToLine(lineIdx) + lines[lineIdx].length);
this.keyBuffer = '';
break;
case 'gg':
e.preventDefault();
this.setCaretOffset(0);
this.keyBuffer = '';
break;
case 'G':
e.preventDefault();
this.setCaretOffset(text.length);
this.keyBuffer = '';
break;
case 'Escape':
e.preventDefault();
this.mode = 'normal';
this.keyBuffer = '';
this.cmdLine.style.display = 'none';
break;
default:
// allow up to 2 chars for combos
if (this.keyBuffer.length > 2) this.keyBuffer = '';
break;
}
}
}
customElements.define('njet-editor', NjetEditor);
export {NjetEditor}

View File

@ -57,6 +57,7 @@ class FileUploadGrid extends NjetComponent {
} }
reset(){ reset(){
this.uploadResponses = [];
this.uploadsDone = 0; this.uploadsDone = 0;
this.uploadsStarted = 0; this.uploadsStarted = 0;
this._grid.innerHTML = ''; this._grid.innerHTML = '';
@ -70,6 +71,7 @@ class FileUploadGrid extends NjetComponent {
this.reset() this.reset()
this.uploadsDone = 0; this.uploadsDone = 0;
this.uploadsStarted = files.length; this.uploadsStarted = files.length;
[...files].forEach(file => this.createTile(file)); [...files].forEach(file => this.createTile(file));
} }
connectedCallback() { connectedCallback() {
@ -130,6 +132,7 @@ class FileUploadGrid extends NjetComponent {
startUpload(file, tile, progress) { startUpload(file, tile, progress) {
this.publish('file-uploading', {file: file, tile: tile, progress: progress}); this.publish('file-uploading', {file: file, tile: tile, progress: progress});
console.info("File uploading",file)
const protocol = location.protocol === "https:" ? "wss://" : "ws://"; const protocol = location.protocol === "https:" ? "wss://" : "ws://";
const ws = new WebSocket(`${protocol}${location.host}/channel/${this.channelUid}/attachment.sock`); const ws = new WebSocket(`${protocol}${location.host}/channel/${this.channelUid}/attachment.sock`);
@ -148,7 +151,7 @@ class FileUploadGrid extends NjetComponent {
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
console.info(event.data)
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'progress') { if (data.type === 'progress') {
@ -156,14 +159,20 @@ class FileUploadGrid extends NjetComponent {
progress.style.width = pct + '%'; progress.style.width = pct + '%';
this.publish('file-uploading', {file: file, tile: tile, progress: progress}); this.publish('file-uploading', {file: file, tile: tile, progress: progress});
} else if (data.type === 'done') { } else if (data.type === 'done') {
console.info("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH")
console.info("Done")
console.info(this.uploadResponses)
this.uploadsDone += 1; this.uploadsDone += 1;
this.publish('file-uploaded', {file: file, tile: tile, progress: progress}); this.publish('file-uploaded', {file: file, tile: tile, progress: progress});
progress.style.width = '100%'; progress.style.width = '100%';
tile.classList.add('fug-done'); tile.classList.add('fug-done');
console.info("Closed")
ws.close(); ws.close();
this.uploadResponses.push({file:file, remoteFile:data.file})
console.info(this.uploadsDone, this.uploadsStarted)
if(this.uploadsDone == this.uploadsStarted){
this.publish('file-uploads-done', this.uploadResponses);
}
this.reset() this.reset()
} }
}; };

View File

@ -210,19 +210,18 @@ class Njet extends HTMLElement {
customElements.define(name, component); customElements.define(name, component);
} }
constructor(config = {}) { constructor() {
super(); super();
if (!Njet._root) { if (!Njet._root) {
Njet._root = this Njet._root = this
Njet._rest = new RestClient({ baseURL: config.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');
this.config = config;
this.render.call(this); this.render.call(this);
this.initProps(config); //this.initProps(config);
if (typeof this.construct === 'function') //if (typeof this.config.construct === 'function')
this.construct.call(this) // this.config.construct.call(this)
} }
initProps(config) { initProps(config) {
@ -284,9 +283,12 @@ class Njet extends HTMLElement {
render() {} render() {}
} }
Njet.registerComponent('njet-root', Njet);
class Component extends Njet {} class Component extends Njet {}
Njet.registerComponent('njet-component', Component);
class NjetPanel extends Component { class NjetPanel extends Component {
render() { render() {
this.innerHTML = ''; this.innerHTML = '';
@ -491,18 +493,67 @@ 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()
return dialog return dialog
} }
class EventBus extends EventTarget {
constructor() {
super();
this.eventMap = new Map();
}
subscribe(eventName, callback) {
this.addEventListener(eventName, callback);
if (!this.eventMap.has(eventName)) {
this.eventMap.set(eventName, []);
}
this.eventMap.get(eventName).push(callback);
}
publish(eventName, detail = {}) {
const event = new CustomEvent(eventName, {
detail,
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}
unsubscribe(eventName, callback) {
this.removeEventListener(eventName, callback);
const subscribers = this.eventMap.get(eventName);
if (subscribers) {
const index = subscribers.indexOf(callback);
if (index > -1) subscribers.splice(index, 1);
}
}
}
const eventBus = new EventBus()
njet.showWindow = function(args) { njet.showWindow = function(args) {
const w = new NjetWindow(args) const w = new NjetWindow(args)
w.show() w.show()
return w return w
} }
njet.publish = function(event, data) {
if (this.root._subscriptions[event]) {
this.root._subscriptions[event].forEach(callback => callback(data))
}
}
njet.subscribe = function(event, callback) {
if (!this.root._subscriptions[event]) {
this.root._subscriptions[event] = []
}
this.root._subscriptions[event].push(callback)
}
window.njet = njet export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow,eventBus };
export { Njet, NjetButton, NjetPanel, NjetDialog, NjetGrid, NjetComponent, njet, NjetWindow };

View File

@ -112,9 +112,9 @@ class UploadButtonElement extends HTMLElement {
this.channelUid = this.getAttribute("channel"); this.channelUid = this.getAttribute("channel");
this.uploadButton = this.container.querySelector(".upload-button"); this.uploadButton = this.container.querySelector(".upload-button");
this.fileInput = this.container.querySelector(".hidden-input"); this.fileInput = this.container.querySelector(".hidden-input");
this.uploadButton.addEventListener("click", () => { /*this.uploadButton.addEventListener("click", () => {
this.fileInput.click(); this.fileInput.click();
}); });*/
this.fileInput.addEventListener("change", () => { this.fileInput.addEventListener("change", () => {
this.uploadFiles(); this.uploadFiles();
}); });

View File

@ -14,7 +14,7 @@ class Cache:
self.cache = {} self.cache = {}
self.max_items = max_items self.max_items = max_items
self.stats = {} self.stats = {}
self.enabled = False self.enabled = True
self.lru = [] self.lru = []
self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4 self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4

View File

@ -25,9 +25,20 @@ class BaseMapper:
return asyncio.get_event_loop() return asyncio.get_event_loop()
async def run_in_executor(self, func, *args, **kwargs): async def run_in_executor(self, func, *args, **kwargs):
async with self.semaphore: use_semaphore = kwargs.pop("use_semaphore", False)
return func(*args, **kwargs) if use_semaphore:
# return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs)) async with self.semaphore:
database_exception = None
for x in range(20):
try:
result = func(*args, **kwargs)
self.db.commit()
return result
except Exception as ex:
await asyncio.sleep(0)
database_exception = ex
raise database_exception
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
async def new(self): async def new(self):
return self.model_class(mapper=self, app=self.app) return self.model_class(mapper=self, app=self.app)
@ -39,7 +50,8 @@ class BaseMapper:
async def get(self, uid: str = None, **kwargs) -> BaseModel: async def get(self, uid: str = None, **kwargs) -> BaseModel:
if uid: if uid:
kwargs["uid"] = uid kwargs["uid"] = uid
if not kwargs.get("deleted_at"):
kwargs["deleted_at"] = None
record = await self.run_in_executor(self.table.find_one, **kwargs) record = await self.run_in_executor(self.table.find_one, **kwargs)
if not record: if not record:
return None return None
@ -48,7 +60,6 @@ class BaseMapper:
for key, value in record.items(): for key, value in record.items():
model[key] = value model[key] = value
return model return model
return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs): async def exists(self, **kwargs):
return await self.run_in_executor(self.table.exists, **kwargs) return await self.run_in_executor(self.table.exists, **kwargs)
@ -60,26 +71,39 @@ class BaseMapper:
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"]) 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"):
kwargs["deleted_at"] = None
for record in await self.run_in_executor(self.table.find, **kwargs): for record in await self.run_in_executor(self.table.find, **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
yield model yield model
async def _use_semaphore(self, sql):
sql = sql.lower().strip()
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): for record in await self.run_in_executor(self.db.query, sql, *args, use_semaphore=await self._use_semaphore(sql)):
yield dict(record) yield dict(record)
async def update(self, model): async def update(self, model):
if not model["deleted_at"] is None:
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"]) return await self.run_in_executor(self.table.update, model.record, ["uid"],use_semaphore=True)
async def upsert(self, model):
model.updated_at.update()
return await self.run_in_executor(self.table.upsert, model.record, ["uid"],use_semaphore=True)
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.run_in_executor(self.table.delete, **kwargs) return await self.run_in_executor(self.table.delete, **kwargs)

View File

@ -1,35 +1,33 @@
# Written by retoor@molodetz.nl # Written by retoor@molodetz.nl
# This code provides middleware functions for an aiohttp server to manage and modify CORS (Cross-Origin Resource Sharing) headers. # This code provides middleware functions for an aiohttp server to manage and modify CSP, CORS, and authentication headers.
# Imports from 'aiohttp' library are used to create middleware; they are not part of Python's standard library.
# MIT License: This code is distributed under the MIT License.
import secrets import secrets
from aiohttp import web from aiohttp import web
csp_policy = (
"default-src 'self'; "
"script-src 'self' https://*.cloudflare.com https://molodetz.nl 'nonce-{nonce}'; "
"style-src 'self' https://*.cloudflare.com https://molodetz.nl; "
"img-src 'self' https://*.cloudflare.com https://molodetz.nl data:; "
"connect-src 'self' https://*.cloudflare.com https://molodetz.nl;"
)
def generate_nonce():
return secrets.token_hex(16)
@web.middleware @web.middleware
async def csp_middleware(request, handler): async def csp_middleware(request, handler):
nonce = secrets.token_hex(16)
origin = request.headers.get('Origin')
csp_policy = (
"default-src 'self'; "
f"script-src 'self' {origin} 'nonce-{nonce}'; "
f"style-src 'self' 'unsafe-inline' {origin} 'nonce-{nonce}'; "
"img-src *; "
"connect-src 'self' https://umami.molodetz.nl; "
"font-src *; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-src 'self'; "
"worker-src *; "
"media-src *; "
"manifest-src 'self';"
)
request['csp_nonce'] = nonce
response = await handler(request) response = await handler(request)
return response #response.headers['Content-Security-Policy'] = csp_policy
nonce = generate_nonce()
response.headers["Content-Security-Policy"] = csp_policy.format(nonce=nonce)
return response return response
@ -39,7 +37,6 @@ async def no_cors_middleware(request, handler):
response.headers.pop("Access-Control-Allow-Origin", None) response.headers.pop("Access-Control-Allow-Origin", None)
return response return response
@web.middleware @web.middleware
async def cors_allow_middleware(request, handler): async def cors_allow_middleware(request, handler):
response = await handler(request) response = await handler(request)
@ -51,7 +48,6 @@ async def cors_allow_middleware(request, handler):
response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Credentials"] = "true"
return response return response
@web.middleware @web.middleware
async def auth_middleware(request, handler): async def auth_middleware(request, handler):
request["user"] = None request["user"] = None
@ -61,7 +57,6 @@ async def auth_middleware(request, handler):
) )
return await handler(request) return await handler(request)
@web.middleware @web.middleware
async def cors_middleware(request, handler): async def cors_middleware(request, handler):
if request.headers.get("Allow"): if request.headers.get("Allow"):

View File

@ -40,11 +40,11 @@ class BaseService:
yield record yield record
async def get(self, uid=None, **kwargs): async def get(self, uid=None, **kwargs):
kwargs["deleted_at"] = None
if uid: if uid:
if not kwargs: result = await self.cache.get(uid)
result = await self.cache.get(uid) if result and result.__class__ == self.mapper.model_class:
if False and result and result.__class__ == self.mapper.model_class: return result
return result
kwargs["uid"] = uid kwargs["uid"] = uid
result = await self.mapper.get(**kwargs) result = await self.mapper.get(**kwargs)
@ -52,7 +52,7 @@ class BaseService:
await self.cache.set(result["uid"], result) await self.cache.set(result["uid"], result)
return result return result
async def save(self, model: UserModel): async def save(self, model):
# if model.is_valid: You Know why not # if model.is_valid: You Know why not
if await self.mapper.save(model): if await self.mapper.save(model):
await self.cache.set(model["uid"], model) await self.cache.set(model["uid"], model)

View File

@ -151,6 +151,14 @@ SAFE_ATTRIBUTES = {
"aria-hidden", "aria-hidden",
"aria-label", "aria-label",
"srcset", "srcset",
"target",
"rel",
"referrerpolicy",
"controls",
"frameborder",
"allow",
"allowfullscreen",
"referrerpolicy",
} }
@ -180,6 +188,7 @@ def embed_youtube(text):
or url.hostname or url.hostname
in [ in [
"www.youtube.com", "www.youtube.com",
"music.youtube.com",
"youtube.com", "youtube.com",
"www.youtube-nocookie.com", "www.youtube-nocookie.com",
"youtube-nocookie.com", "youtube-nocookie.com",
@ -330,7 +339,7 @@ def embed_url(text):
attachments = {} attachments = {}
for element in soup.find_all("a"): for element in soup.find_all("a"):
if "href" in element.attrs and element.attrs["href"].startswith("http"): if "href" in element.attrs and element.attrs["href"].startswith("http") and ("data-noembed" not in element.attrs):
page_url = urlparse(element.attrs["href"]) page_url = urlparse(element.attrs["href"])
page = get_url_content(element.attrs["href"]) page = get_url_content(element.attrs["href"])
if page: if page:
@ -447,7 +456,7 @@ def embed_url(text):
description_element.append( description_element.append(
BeautifulSoup( BeautifulSoup(
f"<p class='page-description'>{page_description or "No description available."}</p>", f"<p class='page-description'>{page_description or 'No description available.'}</p>",
"html.parser", "html.parser",
) )
) )
@ -556,3 +565,4 @@ class PythonExtension(Extension):
return "".join(to_write) return "".join(to_write)
return str(fn(caller())) return str(fn(caller()))

View File

@ -18,6 +18,7 @@
<script src="/generic-form.js" type="module"></script> <script src="/generic-form.js" type="module"></script>
<script src="/html-frame.js" type="module"></script> <script src="/html-frame.js" type="module"></script>
<script src="/app.js" type="module"></script> <script src="/app.js" type="module"></script>
<script src="/editor.js" type="module"></script>
<script src="/file-manager.js" type="module"></script> <script src="/file-manager.js" type="module"></script>
<script src="/user-list.js"></script> <script src="/user-list.js"></script>
<script src="/message-list.js" type="module"></script> <script src="/message-list.js" type="module"></script>
@ -30,7 +31,7 @@
<link rel="stylesheet" href="/base.css"> <link rel="stylesheet" href="/base.css">
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64"> <link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script> <script nonce="{{nonce}}" defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
</head> </head>
<body> <body>
<header> <header>

View File

@ -1,6 +1,6 @@
<div id="star-tooltip" class="star-tooltip"></div> <div id="star-tooltip" class="star-tooltip"></div>
<div id="star-popup" class="star-popup"></div> <div id="star-popup" class="star-popup"></div>
<script type="module"> <script type="module" nonce="{{nonce}}">
import { app } from "/app.js"; import { app } from "/app.js";
import {WebTerminal} from "/dumb-term.js"; import {WebTerminal} from "/dumb-term.js";

View File

@ -192,16 +192,22 @@ class ChannelAttachmentUploadView(BaseView):
channel_uid=channel_uid, name=filename, user_uid=user_uid channel_uid=channel_uid, name=filename, user_uid=user_uid
) )
pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True) pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(attachment["path"], "wb") as f:
async with aiofiles.open(attachment["path"], "wb") as file:
print("File openend.", filename)
async for msg in ws: async for msg in ws:
if msg.type == web.WSMsgType.BINARY: if msg.type == web.WSMsgType.BINARY:
print("Binary",filename)
if file is not None: if file is not None:
await file.write(msg.data) await file.write(msg.data)
await ws.send_json({"type": "progress", "filename": filename, "bytes": file.tell()}) await ws.send_json({"type": "progress", "filename": filename, "bytes": await file.tell()})
elif msg.type == web.WSMsgType.TEXT: elif msg.type == web.WSMsgType.TEXT:
print("TExt",filename)
print(msg.json())
data = msg.json() data = msg.json()
if data.get('type') == 'end': if data.get('type') == 'end':
await ws.send_json({"type": "done", "filename": filename}) relative_url = urllib.parse.quote(attachment_record["relative_url"])
await ws.send_json({"type": "done", "file": relative_url, "filename": filename})
elif msg.type == web.WSMsgType.ERROR: elif msg.type == web.WSMsgType.ERROR:
break break
return ws return ws

View File

@ -30,6 +30,7 @@ class RPCView(BaseView):
self.ws = ws self.ws = ws
self.user_session = {} self.user_session = {}
self._scheduled = [] self._scheduled = []
self._finalize_task = None
async def _session_ensure(self): async def _session_ensure(self):
uid = await self.view.session_get("uid") uid = await self.view.session_get("uid")
@ -203,6 +204,13 @@ class RPCView(BaseView):
) )
return channels return channels
async def clear_channel(self, channel_uid):
self._require_login()
user = await self.services.user.get(uid=self.user_uid)
if not user["is_admin"]:
raise Exception("Not allowed")
return await self.services.channel_message.clear(channel_uid)
async def write_container(self, channel_uid, content,timeout=3): async def write_container(self, channel_uid, content,timeout=3):
self._require_login() self._require_login()
channel_member = await self.services.channel_member.get( channel_member = await self.services.channel_member.get(
@ -259,6 +267,21 @@ class RPCView(BaseView):
} }
return result return result
async def send_message(self, channel_uid, message, is_final=True):
self._require_login()
#if not is_final:
# check_message = await self.services.channel_message.get(channel_uid=channel_uid, user_uid=self.user_uid,is_final=False)
# if check_message:
# return await self.update_message_text(check_message["uid"], message)
is_final = True
message = await self.services.chat.send(
self.user_uid, channel_uid, message, is_final
)
return message["uid"]
async def start_container(self, channel_uid): async def start_container(self, channel_uid):
self._require_login() self._require_login()
channel_member = await self.services.channel_member.get( channel_member = await self.services.channel_member.get(
@ -286,6 +309,8 @@ class RPCView(BaseView):
raise Exception("Not allowed") raise Exception("Not allowed")
return await self.services.container.get_status(channel_uid) return await self.services.container.get_status(channel_uid)
async def finalize_message(self, message_uid): async def finalize_message(self, message_uid):
self._require_login() self._require_login()
message = await self.services.channel_message.get(message_uid) message = await self.services.channel_message.get(message_uid)
@ -301,48 +326,56 @@ class RPCView(BaseView):
return True return True
async def update_message_text(self, message_uid, text): async def update_message_text(self, message_uid, text):
async with self.app.no_save():
self._require_login()
message = await self.services.channel_message.get(message_uid)
if message["user_uid"] != self.user_uid:
raise Exception("Not allowed")
if message.get_seconds_since_last_update() > 5:
return {
"error": "Message too old",
"seconds_since_last_update": message.get_seconds_since_last_update(),
"success": False,
}
message["message"] = text
if not text:
message["deleted_at"] = now()
else:
message["deleted_at"] = None
await self.services.channel_message.save(message)
data = message.record
data["text"] = message["message"]
data["message_uid"] = message_uid
await self.services.socket.broadcast(
message["channel_uid"],
{
"channel_uid": message["channel_uid"],
"event": "update_message_text",
"data": message.record,
},
)
return {"success": True}
async def clear_channel(self, channel_uid):
self._require_login() self._require_login()
message = await self.services.channel_message.get(message_uid) user = await self.services.user.get(uid=self.user_uid)
if message["user_uid"] != self.user_uid: if not user["is_admin"]:
raise Exception("Not allowed") raise Exception("Not allowed")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise Exception("Channel not found")
channel['history_start'] = datetime.now()
await self.services.channel.save(channel)
return await self.services.channel_message.clear(channel_uid)
if message.get_seconds_since_last_update() > 3:
await self.finalize_message(message["uid"])
return {
"error": "Message too old",
"seconds_since_last_update": message.get_seconds_since_last_update(),
"success": False,
}
message["message"] = text
if not text:
message["deleted_at"] = now()
else:
message["deleted_at"] = None
await self.services.channel_message.save(message)
data = message.record
data["text"] = message["message"]
data["message_uid"] = message_uid
await self.services.socket.broadcast(
message["channel_uid"],
{
"channel_uid": message["channel_uid"],
"event": "update_message_text",
"data": message.record,
},
)
return {"success": True}
async def send_message(self, channel_uid, message, is_final=True):
self._require_login()
message = await self.services.chat.send(
self.user_uid, channel_uid, message, is_final
)
return message["uid"]
async def echo(self, *args): async def echo(self, *args):
self._require_login() self._require_login()

View File

@ -71,6 +71,7 @@ class WebView(BaseView):
await self.app.services.channel_member.save(channel_member) await self.app.services.channel_member.save(channel_member)
user = await self.services.user.get(uid=self.session.get("uid")) user = await self.services.user.get(uid=self.session.get("uid"))
messages = [ messages = [
await self.app.services.channel_message.to_extended_dict(message) await self.app.services.channel_message.to_extended_dict(message)
for message in await self.app.services.channel_message.offset( for message in await self.app.services.channel_message.offset(
@ -81,7 +82,7 @@ class WebView(BaseView):
await self.app.services.notification.mark_as_read( await self.app.services.notification.mark_as_read(
self.session.get("uid"), message["uid"] self.session.get("uid"), message["uid"]
) )
print(messages)
name = await channel_member.get_name() name = await channel_member.get_name()
return await self.render_template( return await self.render_template(
"web.html", "web.html",