Compare commits
No commits in common. "main" and "main" have entirely different histories.
@ -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():
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user