Compare commits

...

9 Commits

Author SHA1 Message Date
abce2e03d1 Fix xss. 2025-07-24 01:46:34 +02:00
54d7d5b74e Several updates. 2025-07-24 01:07:33 +02:00
17bb88050a Merge pull request 'Fixed scrolling behavior, reply, cross channel messages and gg navigation' (#66) from BordedDev/snek:bugfix/multiple-issues-with-new-chat into main
Reviewed-on: retoor/snek#66
2025-07-20 19:55:47 +02:00
BordedDev
8c2e20dfe8 Fix reply text 2025-07-20 03:38:18 +02:00
BordedDev
3e2dd7ea04 Moved replay to custom event 2025-07-20 01:11:05 +02:00
BordedDev
70eebefac7 Fixed upsert error when typing 2025-07-19 23:31:23 +02:00
BordedDev
ac47d201d8 Fixed scrolled to bottom check 2025-07-19 00:13:06 +02:00
BordedDev
11e19f48e8 Fix g scroll 2025-07-19 00:00:29 +02:00
BordedDev
5ac49522d9 Fixed scrolling behavior, reply, cross channel messages 2025-07-18 23:57:41 +02:00
9 changed files with 150 additions and 132 deletions

View File

@ -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 = ""

View File

@ -69,10 +69,10 @@ class ChannelMessageService(BaseService):
"color": user["color"], "color": user["color"],
} }
) )
context['message'] = whitelist_attributes(context['message'])
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"])
except Exception as ex: except Exception as ex:
print(ex, flush=True) print(ex, flush=True)
@ -118,6 +118,7 @@ class ChannelMessageService(BaseService):
async def save(self, model): async def save(self, model):
context = {} context = {}
context.update(model.record) context.update(model.record)
context['message'] = whitelist_attributes(context['message'])
user = await self.app.services.user.get(model["user_uid"]) user = await self.app.services.user.get(model["user_uid"])
context.update( context.update(
{ {
@ -129,7 +130,6 @@ 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"])
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):

View File

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

View File

@ -9,8 +9,53 @@ import {app} from "./app.js";
const LONG_TIME = 1000 * 60 * 20 const LONG_TIME = 1000 * 60 * 20
export class ReplyEvent extends Event {
constructor(messageTextTarget) {
super('reply', { bubbles: true, composed: true });
this.messageTextTarget = messageTextTarget;
const newMessage = messageTextTarget.cloneNode(true);
newMessage.style.maxHeight = "0"
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
newMessage.querySelectorAll('.embed-url-link').forEach(link => {
link.remove()
})
newMessage.querySelectorAll('picture').forEach(picture => {
const img = picture.querySelector('img');
if (img) {
picture.replaceWith(img);
}
})
newMessage.querySelectorAll('img').forEach(img => {
const src = img.src || img.currentSrc;
img.replaceWith(document.createTextNode(src));
})
newMessage.querySelectorAll('iframe').forEach(iframe => {
const src = iframe.src || iframe.currentSrc;
iframe.replaceWith(document.createTextNode(src));
})
newMessage.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('href');
const text = a.innerText || a.textContent;
if (text === href || text === '') {
a.replaceWith(document.createTextNode(href));
} else {
a.replaceWith(document.createTextNode(`[${text}](${href})`));
}
})
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
newMessage.remove()
}
}
class MessageElement extends HTMLElement { class MessageElement extends HTMLElement {
static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid']; // static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid'];
isVisible() { isVisible() {
if (!this) return false; if (!this) return false;
@ -51,6 +96,12 @@ class MessageElement extends HTMLElement {
} }
this.timeDiv = this.querySelector('.time span'); this.timeDiv = this.querySelector('.time span');
this.replyDiv = this.querySelector('.time a');
this.replyDiv.addEventListener('click', (e) => {
e.preventDefault();
this.dispatchEvent(new ReplyEvent(this.messageDiv));
})
} }
if (!this.siblingGenerated && this.nextElementSibling) { if (!this.siblingGenerated && this.nextElementSibling) {
@ -99,7 +150,9 @@ class MessageList extends HTMLElement {
constructor() { constructor() {
super(); super();
app.ws.addEventListener("update_message_text", (data) => { app.ws.addEventListener("update_message_text", (data) => {
this.upsertMessage(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);
@ -108,29 +161,33 @@ class MessageList extends HTMLElement {
this.messageMap = new Map(); this.messageMap = new Map();
this.visibleSet = new Set(); this.visibleSet = new Set();
this._observer = new IntersectionObserver((entries) => { this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.visibleSet.add(entry.target); this.visibleSet.add(entry.target);
const messageElement = entry.target; const messageElement = entry.target;
if (messageElement instanceof MessageElement) { if (messageElement instanceof MessageElement) {
messageElement.updateUI(); messageElement.updateUI();
}
} else {
this.visibleSet.delete(entry.target);
} }
}); } else {
console.log(this.visibleSet); this.visibleSet.delete(entry.target);
}
});
}, { }, {
root: this, root: this,
threshold: 0.1 threshold: 0,
}) })
for(const c of this.children) { for(const c of this.children) {
this._observer.observe(c); this._observer.observe(c);
if (c instanceof MessageElement) { if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c); this.messageMap.set(c.dataset.uid, c);
} }
} }
this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages);
this.scrollToBottom(true); this.scrollToBottom(true);
} }
@ -174,7 +231,6 @@ class MessageList extends HTMLElement {
}; };
document.addEventListener('keydown', escListener); document.addEventListener('keydown', escListener);
}) })
} }
isElementVisible(element) { isElementVisible(element) {
if (!element) return false; if (!element) return false;
@ -187,13 +243,13 @@ class MessageList extends HTMLElement {
); );
} }
isScrolledToBottom() { isScrolledToBottom() {
return this.isElementVisible(this.firstElementChild); return this.isElementVisible(this.endOfMessages);
} }
scrollToBottom(force = false, behavior= 'smooth') { scrollToBottom(force = false, behavior= 'instant') {
if (force || this.isScrolledToBottom()) { if (force || !this.isScrolledToBottom()) {
this.firstElementChild.scrollIntoView({ behavior, block: 'start' }); this.firstElementChild.scrollIntoView({ behavior, block: 'end' });
setTimeout(() => { setTimeout(() => {
this.firstElementChild.scrollIntoView({ behavior, block: 'start' }); this.firstElementChild.scrollIntoView({ behavior, block: 'end' });
}, 200); }, 200);
} }
} }
@ -227,9 +283,8 @@ class MessageList extends HTMLElement {
upsertMessage(data) { upsertMessage(data) {
let message = this.messageMap.get(data.uid); let message = this.messageMap.get(data.uid);
const newMessage = !!message;
if (message) { if (message) {
message.parentElement.removeChild(message); message.parentElement?.removeChild(message);
} }
if (!data.message) return if (!data.message) return
@ -239,16 +294,16 @@ class MessageList extends HTMLElement {
wrapper.innerHTML = data.html; wrapper.innerHTML = data.html;
if (message) { if (message) {
message.updateMessage(...wrapper.firstElementChild._originalChildren); message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
} else { } else {
message = wrapper.firstElementChild; message = wrapper.firstElementChild;
this.messageMap.set(data.uid, message); this.messageMap.set(data.uid, message);
this._observer.observe(message); this._observer.observe(message);
} }
const scrolledToBottom = this.isScrolledToBottom(); const scrolledToBottom = this.isScrolledToBottom();
this.prepend(message); this.prepend(message);
if (scrolledToBottom) this.scrollToBottom(true, !newMessage ? 'smooth' : 'auto'); if (scrolledToBottom) this.scrollToBottom(true);
} }
} }

View File

@ -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 };

View File

@ -79,44 +79,12 @@ 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):
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 +100,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):

View File

@ -12,7 +12,7 @@ function showTerm(options){
class StarField { class StarField {
constructor({ count = 200, container = document.body } = {}) { constructor({ count = 100, 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: 100});
app.starField = starField; app.starField = starField;
class DemoSequence { class DemoSequence {

View File

@ -72,12 +72,13 @@ 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.children[messagesContainer.children.length - 1]; 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;
@ -138,10 +139,16 @@ chatInputField.textarea.focus();
// --- Reply helper --- // --- Reply helper ---
function replyMessage(message) { function replyMessage(message) {
chatInputField.value = "```markdown\n> " + (message || '').split("\n").join("\n> ") + "\n```\n"; chatInputField.value = "```markdown\n> " + (message || '').trim().split("\n").join("\n> ") + "\n```\n";
chatInputField.textarea.dispatchEvent(new Event('change', { bubbles: true }));
chatInputField.focus(); 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) || [])];
@ -215,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();
} }
} }
@ -254,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 ---

View File

@ -55,7 +55,7 @@ class WebView(BaseView):
user_uid=self.session.get("uid"), channel_uid=channel["uid"] user_uid=self.session.get("uid"), channel_uid=channel["uid"]
) )
if not channel_member: if not channel_member:
if not channel["is_private"]: 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"),