Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

6 changed files with 66 additions and 178 deletions

View File

@ -6,6 +6,7 @@ import uuid
import signal
from datetime import datetime
from contextlib import asynccontextmanager
import aiohttp_debugtoolbar
from snek import snode
from snek.view.threads import ThreadsView
@ -496,6 +497,7 @@ class Application(BaseApplication):
raise raised_exception
app = Application(db_path="sqlite:///snek.db")
#aiohttp_debugtoolbar.setup(app)
async def main():

View File

@ -39,20 +39,19 @@ CREATE TABLE IF NOT EXISTS channel (
);
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
CREATE TABLE IF NOT EXISTS channel_member (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
is_banned BOOLEAN,
is_moderator BOOLEAN,
is_muted BOOLEAN,
is_read_only BOOLEAN,
label TEXT,
new_count BIGINT,
last_read_at TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
is_banned BOOLEAN,
is_moderator BOOLEAN,
is_muted BOOLEAN,
is_read_only BOOLEAN,
label TEXT,
new_count BIGINT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);

View File

@ -1,5 +1,4 @@
from snek.system.service import BaseService
from snek.system.model import now
class ChannelMemberService(BaseService):
@ -9,7 +8,6 @@ class ChannelMemberService(BaseService):
async def mark_as_read(self, channel_uid, user_uid):
channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)
channel_member["new_count"] = 0
channel_member["last_read_at"] = now()
return await self.save(channel_member)
async def get_user_uids(self, channel_uid):

View File

@ -407,48 +407,34 @@ a {
width: 250px;
padding-left: 20px;
padding-right: 20px;
padding-top: 20px;
padding-top: 10px;
overflow-y: auto;
grid-area: sidebar;
}
.sidebar h2 {
color: #f05a28;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
margin-top: 20px;
}
.sidebar h2:first-child {
margin-top: 0;
font-size: 1.2em;
margin-bottom: 20px;
}
.sidebar ul {
list-style: none;
margin-bottom: 15px;
}
.sidebar ul li {
margin-bottom: 4px;
margin-bottom: 15px;
}
.sidebar ul li a {
color: #ccc;
text-decoration: none;
font-size: 0.9em;
font-size: 1em;
transition: color 0.3s;
display: block;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
}
.sidebar ul li a:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
}
@keyframes glow {

View File

@ -10,9 +10,6 @@
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<div id="terminal" class="hidden"></div>
<button id="jump-to-unread-btn" style="display: none; position: absolute; top: 10px; right: 10px; z-index: 1000; padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
Jump to First Unread
</button>
<message-list class="chat-messages">
{% if not messages %}
@ -40,9 +37,10 @@
{% include "dialog_help.html" %}
{% include "dialog_online.html" %}
<script type="module">
import {app} from "/app.js";
import { app } from "/app.js";
import { Schedule } from "/schedule.js";
// --- Cache selectors ---
// --- Cache selectors ---
const chatInputField = document.querySelector("chat-input");
const messagesContainer = document.querySelector(".chat-messages");
const chatArea = document.querySelector(".chat-area");
@ -101,8 +99,50 @@ setInterval(() => requestIdleCallback(updateTimes), 30000);
// --- Paste & drag/drop uploads ---
const textBox = chatInputField.textarea;
textBox.addEventListener("paste", async (e) => {
try {
e.preventDefault();
const uploadButton = chatInputField.fileUploadGrid;
function uploadDataTransfer(dt) {
const clipboardItems = await navigator.clipboard.read()
for (const item of clipboardItems) {
if (item.types.every(v => v === "text/plain")) {
const text = await (await item.getType("text/plain")).text();
chatInputField.value += text;
continue
}
if (item.types.every(t => t.startsWith('text/'))) {
console.log("All types are text:", item.types);
const codeType = item.types.find(t => !t.startsWith('text/plain') && !t.startsWith('text/html'));
let code = await(await item.getType(codeType ?? 'text/plain')).text();
const minIndentDepth = code.split('\n').reduce((acc, line) => {
if (!line.trim()) return acc;
const match = line.match(/^(\s*)/);
return match ? Math.min(acc, match[1].length) : acc;
}, 9000)
code = code.split('\n').map(line => line.slice(minIndentDepth)).join('\n')
chatInputField.value += `\`\`\`${codeType?.split('/')?.[1] ?? ''}\n${code}\n\`\`\`\n`;
} else {
for (const type of item.types.filter(t => !t.startsWith('text/'))) {
const blob = await item.getType(type);
const name = type.replace('/', '.')
uploadButton.uploadsStarted++
uploadButton.createTile(new File([blob], name, {type}))
}
}
}
} catch (error) {
console.error("Failed to read clipboard contents: ", error);
}
});
chatArea.addEventListener("drop", async (e) => {
e.preventDefault();
const dt = e.dataTransfer;
if (dt.items.length > 0) {
const uploadButton = chatInputField.fileUploadGrid;
@ -112,58 +152,10 @@ function uploadDataTransfer(dt) {
if (file) {
uploadButton.uploadsStarted++
uploadButton.createTile(file)
} else {
console.error("Failed to get file from DataTransferItem");
}
}
}
}
}
textBox.addEventListener("paste", async (e) => {
try {
console.log("Pasted data:", e.clipboardData);
if (e.clipboardData.types.every(v => v.startsWith("text/"))) {
const codeType = e.clipboardData.types.find(t => !t.startsWith('text/plain') && !t.startsWith('text/html') && !t.startsWith('text/rtf'));
const probablyCode = codeType ||e.clipboardData.types.some(t => !t.startsWith('text/plain'));
for (const item of e.clipboardData.items) {
if (item.kind === "string" && item.type === "text/plain") {
e.preventDefault();
item.getAsString(text => {
const value = chatInputField.value;
if (probablyCode) {
let code = text;
const minIndentDepth = code.split('\n').reduce((acc, line) => {
if (!line.trim()) return acc;
const match = line.match(/^(\s*)/);
return match ? Math.min(acc, match[1].length) : acc;
}, 9000);
code = code.split('\n').map(line => line.slice(minIndentDepth)).join('\n')
text = `\`\`\`${codeType?.split('/')?.[1] ?? ''}\n${code}\n\`\`\`\n`;
}
const area = chatInputField.textarea
if(area){
const start = area.selectionStart
if ("\n" !== value[start - 1]) {
text = `\n${text}`;
}
document.execCommand('insertText', false, text);
}
});
break;
}
}
} else {
uploadDataTransfer(e.clipboardData);
}
} catch (error) {
console.error("Failed to read clipboard contents: ", error);
}
});
chatArea.addEventListener("drop", async (e) => {
e.preventDefault();
uploadDataTransfer(e.dataTransfer);
});
chatArea.addEventListener("dragover", e => {
e.preventDefault();
@ -303,76 +295,7 @@ function isScrolledPastHalf() {
// --- Initial layout update ---
updateLayout(true);
// --- Jump to unread functionality ---
const jumpToUnreadBtn = document.getElementById('jump-to-unread-btn');
let firstUnreadMessageUid = null;
async function checkForUnreadMessages() {
try {
const uid = await app.rpc.getFirstUnreadMessageUid(channelUid);
if (uid) {
firstUnreadMessageUid = uid;
const messageElement = messagesContainer.querySelector(`[data-uid="${uid}"]`);
if (messageElement && !messagesContainer.isElementVisible(messageElement)) {
jumpToUnreadBtn.style.display = 'block';
} else {
jumpToUnreadBtn.style.display = 'none';
}
} else {
jumpToUnreadBtn.style.display = 'none';
}
} catch (error) {
console.error('Error checking for unread messages:', error);
}
}
async function jumpToUnread() {
if (!firstUnreadMessageUid) {
await checkForUnreadMessages();
}
if (firstUnreadMessageUid) {
let messageElement = messagesContainer.querySelector(`[data-uid="${firstUnreadMessageUid}"]`);
if (!messageElement) {
const messages = await app.rpc.getMessages(channelUid, 0, null);
const targetMessage = messages.find(m => m.uid === firstUnreadMessageUid);
if (targetMessage) {
const temp = document.createElement("div");
temp.innerHTML = targetMessage.html;
const newMessageElement = temp.firstChild;
messagesContainer.endOfMessages.after(newMessageElement);
messagesContainer.messageMap.set(targetMessage.uid, newMessageElement);
messagesContainer._observer.observe(newMessageElement);
messageElement = newMessageElement;
}
}
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.style.animation = 'highlight-fade 2s';
setTimeout(() => {
messageElement.style.animation = '';
}, 2000);
jumpToUnreadBtn.style.display = 'none';
}
}
}
jumpToUnreadBtn.addEventListener('click', jumpToUnread);
checkForUnreadMessages();
const style = document.createElement('style');
style.textContent = `
@keyframes highlight-fade {
0% { background-color: rgba(255, 255, 0, 0.3); }
100% { background-color: transparent; }
}
`;
document.head.appendChild(style);
</script>
{% endblock %}

View File

@ -175,26 +175,6 @@ class RPCView(BaseView):
messages.append(extended_dict)
return messages
async def get_first_unread_message_uid(self, channel_uid):
self._require_login()
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
return None
last_read_at = channel_member.get("last_read_at")
if not last_read_at:
return None
async for message in self.services.channel_message.query(
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid AND created_at > :last_read_at AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1",
{"channel_uid": channel_uid, "last_read_at": last_read_at}
):
return message["uid"]
return None
async def get_channels(self):
self._require_login()
channels = []