diff --git a/README.md b/README.md
index bfec0b4..2610f35 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,30 @@
-# The Snowdrop Messenger
-Roadmap:
-
-- [x] WRAC(S) support
-- [x] User Agents
-- [x] Server list Management
-- [ ] Notifications
-- [ ] Settings
-- [x] Authentication
-- [ ] GitHub repository
-- [ ] Web version
-- [ ] Mobile UI
-- [ ] Mobile version
-- [ ] Proxy for RAC(S)
-- [ ] RAC(S) support
-- [ ] Desktop Version
+[English](README_EN.md)
+
+
+
+
+
+# Что такое Snowdrop?
+
+***Snowdrop Messenger*** - это красивый, современный, многофункциональный и кроссплатформенный мессенджер на базе протокола обмена сообщениями RAC, а также его новейших дополнений
+
+ # Какие преемущества у Snowdrop среди других RAC клиентов?
+
+В ***TSM*** есть уникальные функции, использовать которые можно через удобный графический интерфейс, также ***Snowdrop*** имеет очень малый вес, потому-что написан на чистом JavaScript с использованием своих библиотек и технологий - ВЕСЬ код, что есть в мессенджере СОВЕРШЕННО ТОЧНО используется, никакие строчки не лежат без дела занимая место
+Также присутствует ПОЛНЫЙ ПЕРЕВОД на русский и английский
+
+# Какие различия у разных версий Snowdrop?
+
+| TSM | Web | Desktop | Mobile |
+|---|---|---|---|
+| Нужен выход в интернет | ✅ | ❌ | ❌ |
+| Нужно скачивать | ❌ | ✅ | ✅ |
+| Поддержка WRAC протокола | ✅ | ✅ | ✅ |
+| Поддержка RAC протокола | ❌ | ✅ | ❌ |
+| Поддержка SSL/TLS | ✅ | ✅ | ✅ |
+| Авто-обновление | ❌ | ✅ | ❌ |
+| Бесплатно и открытый код | ✅ | ✅ | ✅ |
+
+# А где скачать?
+
+Пока-что доступна только Web версия, вскоре выйдет Mobile (Android и iOS) и Desktop (ПК)
diff --git a/README_EN.md b/README_EN.md
new file mode 100644
index 0000000..1102322
--- /dev/null
+++ b/README_EN.md
@@ -0,0 +1,28 @@
+
+
+
+
+# What is Snowdrop?
+
+***Snowdrop Messenger*** is a beautiful, modern, multifunctional and cross-platform messenger based on the RAC messaging protocol, as well as its latest additions
+
+# What are the advantages of Snowdrop among other RAC clients?
+
+***TSM*** has unique functions that can be used through a convenient graphical interface, also ***Snowdrop*** has a very small weight, because it is written in pure JavaScript using its own libraries and technologies - ALL the code that is in the messenger is ABSOLUTELY EXACTLY used, no lines lie idle taking up space
+Also there is a FULL TRANSLATION into Russian and English
+
+# What are the differences between the different versions of Snowdrop?
+
+| TSM | Web | Desktop | Mobile |
+|---|---|---|---|
+| Internet access required | ✅ | ❌ | ❌ |
+| Need to download | ❌ | ✅ | ✅ |
+| WRAC protocol support | ✅ | ✅ | ✅ |
+| RAC protocol support | ❌ | ✅ | ❌ |
+| SSL/TLS support | ✅ | ✅ | ✅ |
+| Auto-update | ❌ | ✅ | ❌ |
+| Free and open source | ✅ | ✅ | ✅ |
+
+# Where to download?
+
+So far, only the Web version is available, Mobile (Android and iOS) and Desktop (PC) will be released soon
diff --git a/assets/lft.png b/assets/lft.png
new file mode 100644
index 0000000..9b5be7f
Binary files /dev/null and b/assets/lft.png differ
diff --git a/assets/lftl.png b/assets/lftl.png
new file mode 100644
index 0000000..6f539ae
Binary files /dev/null and b/assets/lftl.png differ
diff --git a/assets/lfwb.png b/assets/lfwb.png
new file mode 100644
index 0000000..1a01fcf
Binary files /dev/null and b/assets/lfwb.png differ
diff --git a/assets/lfwbl.png b/assets/lfwbl.png
new file mode 100644
index 0000000..8785a3d
Binary files /dev/null and b/assets/lfwbl.png differ
diff --git a/assets/lmt.png b/assets/lmt.png
new file mode 100644
index 0000000..4c529c6
Binary files /dev/null and b/assets/lmt.png differ
diff --git a/assets/lmtl.png b/assets/lmtl.png
new file mode 100644
index 0000000..8d9792e
Binary files /dev/null and b/assets/lmtl.png differ
diff --git a/assets/lmwb.png b/assets/lmwb.png
new file mode 100644
index 0000000..03ac29a
Binary files /dev/null and b/assets/lmwb.png differ
diff --git a/assets/lmwbl.png b/assets/lmwbl.png
new file mode 100644
index 0000000..2ccecfe
Binary files /dev/null and b/assets/lmwbl.png differ
diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..70e2733
Binary files /dev/null and b/favicon.ico differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..0286b35
--- /dev/null
+++ b/index.html
@@ -0,0 +1,119 @@
+
+
+
+ Snowdrop
+
+ '+(n?i:R(i,!0))+`
+`:""+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`+${this.parser.parse(e)}+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`
${this.parser.parseInline(e)}
+`}table(e){let t="",n="";for(let i=0;i${R(e,!0)}`}br(e){return"An error occurred:
"+R(n.message+"",!0)+"";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); diff --git a/proto/rac.js b/proto/rac.js new file mode 100644 index 0000000..d342d07 --- /dev/null +++ b/proto/rac.js @@ -0,0 +1 @@ +// ъуъ \ No newline at end of file diff --git a/proto/wrac.js b/proto/wrac.js new file mode 100644 index 0000000..8ed932d --- /dev/null +++ b/proto/wrac.js @@ -0,0 +1,147 @@ + async function wRAC(onOpenCb) { + if (!connectedServer) return; + if (ws && ws.readyState === 1) return onOpenCb(); + + if (ws) { + ws.onopen = null; + ws.onerror = null; + ws.onclose = null; + ws.onmessage = null; + ws.close(); + } + + ws = new WebSocket(connectedServer); + ws.binaryType = "arraybuffer"; + + ws.onopen = function() { + if (onOpenCb) onOpenCb(); + }; + ws.onerror = function() {}; + ws.onclose = function() {}; + ws.onmessage = function(event) { + if (typeof event.data === "string") return; + let buf = new Uint8Array(event.data); + if (buf.length === 1 && (buf[0] === 0x01 || buf[0] === 0x02)) return; + let str = new TextDecoder().decode(buf).trim(); + if (/^\d+$/.test(str)) { + ws.send(new Uint8Array([0x00, 0x01])); + } else { + let lines = str.split('\n').filter(Boolean); + messages = lines; + showMessages(); + } + }; + } + + async function checkServerInfo({ proto, address, port }) { + protocolCheck.state = "loading"; + protocolCheck.protoVersion = null; + protocolCheck.serverSoftware = null; + updateServerInfoLabels(); + let url; + try { + url = buildServerUrl({ proto, address, port }); + let wsTest; + if (proto === "wRACs" || proto === "wRAC") { + wsTest = new WebSocket(url); + } else { + protocolCheck.state = "error"; + updateServerInfoLabels(); + return; + } + wsTest.binaryType = "arraybuffer"; + let resolved = false; + wsTest.onopen = () => { + wsTest.send(new Uint8Array([0x69])); + }; + wsTest.onmessage = (e) => { + if (resolved) return; + resolved = true; + let arr = new Uint8Array(e.data); + if (arr.length < 2) { + protocolCheck.state = "error"; + updateServerInfoLabels(); + wsTest.close(); + return; + } + let versionByte = arr[0]; + let ver = ""; + if (versionByte === 0x01) ver = "v1"; + else if (versionByte === 0x02) ver = "x1.99"; + else if (versionByte === 0x03) ver = "v2"; + else ver = "Unknown"; + protocolCheck.protoVersion = ver; + protocolCheck.serverSoftware = new TextDecoder().decode(arr.slice(1)); + protocolCheck.state = "done"; + updateServerInfoLabels(); + wsTest.close(); + }; + wsTest.onerror = () => { + protocolCheck.state = "error"; + updateServerInfoLabels(); + }; + } catch (e) { + protocolCheck.state = "error"; + updateServerInfoLabels(); + } +} + +async function registerUser({ proto, address, port, username, password }) { + return new Promise((resolve, reject) => { + const url = buildServerUrl({ proto, address, port }); + const wsReg = new WebSocket(url); + wsReg.binaryType = "arraybuffer"; + + let resolved = false; + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + wsReg.close(); + reject(t('connectionTimeout')); + } + }, 5000); + + wsReg.onopen = () => { + const encoder = new TextEncoder(); + const data = new Uint8Array([ + 0x03, + ...encoder.encode(username + '\n' + password) + ]); + wsReg.send(data); + }; + + wsReg.onmessage = (e) => { + clearTimeout(timeout); + resolved = true; + wsReg.close(); + + const data = new Uint8Array(e.data); + + if (data.length > 0 && data[0] === 0x01) { + reject(t('usernameTaken')); + } else if (data.length === 0) { + resolve(); + } else { + reject(t('unexpectedResponse')); + } + }; + + wsReg.onerror = (error) => { + clearTimeout(timeout); + if (!resolved) { + resolved = true; + reject(t('connectionFailed') + ': ' + error.message); + } + }; + + wsReg.onclose = (event) => { + if (!resolved) { + clearTimeout(timeout); + resolved = true; + if (event.code !== 1000) { + reject(t('connectionClosed') + ` (code ${event.code})`); + } + } + }; + }); +} \ No newline at end of file diff --git a/src/chat.js b/src/chat.js new file mode 100644 index 0000000..9e815b5 --- /dev/null +++ b/src/chat.js @@ -0,0 +1,156 @@ +const nickMarkers = [ + { marker: "\uB9AC\u3E70", color: "nick-green" }, + { marker: "\u2550\u2550\u2550", color: "nick-lightred" }, + { marker: "\u00B0\u0298", color: "nick-lightmagenta" }, + { marker: "\u2042", color: "nick-gold" }, + { marker: "\u0D9E", color: "nick-amogus" }, +]; + +function extractNickColor(str) { + let m = str.match(/<([^>]+)>/); + if (!m) return { nick: "unauth", colorClass: "nick-unauth" }; + let beforeNick = str.substring(0, m.index); + let nick = m[1]; + let colorClass = "nick-cyan"; + for (const { marker, color } of nickMarkers) { + if (beforeNick.includes(marker)) { + colorClass = color; + break; + } + } + return { nick, colorClass }; +} + +function parseMsg(msg) { + let date = ""; + let text = msg.trim(); + let colorClass = ""; + let nick = ""; + + let m = text.match(/^\[(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})\]\s*(.*)$/); + if (m) { + date = m[1]; + text = m[2]; + } + + let start = text.indexOf('<'); + let end = text.indexOf('>'); + if (start !== -1 && end !== -1 && end > start) { + text = text.slice(0, start) + text.slice(end + 1); + } + + let nickMatch = text.match(/^(.*?<[^>]+>)(\s?)(.*)$/); + if (nickMatch) { + let prefix = nickMatch[1]; + let afterNick = nickMatch[3]; + let ext = extractNickColor(prefix); + nick = ext.nick; + colorClass = ext.colorClass; + text = afterNick; + return { nick, text, date, colorClass }; + } + + if (date && !nick) { + return { nick: "unauth", text, date, colorClass: "nick-unauth" }; + } + return { nick: "unauth", text, date: "", colorClass: "nick-unauth" }; +} + +function getVisibleMessages(messages) { + if (connectedServer && connectedServer.startsWith("wss://meex.lol:52667") && messages.length > 3) { + return messages.slice(3); + } + return messages; +} + +if (window.marked) { + marked.setOptions({ + breaks: true, + gfm: true, + smartypants: true + }); + + const renderer = new marked.Renderer(); + renderer.link = function(href, title, text) { + return `${text}`; + }; + marked.setOptions({ renderer }); +} + +function showMessages() { + let chat = document.getElementById('chat-area'); + chat.innerHTML = ''; + let displayMessages = getVisibleMessages([...messages].reverse()); + + for (let msg of displayMessages) { + let { nick, text, date, colorClass } = parseMsg(msg); + + let msgHtml = ""; + if (text) { + if (window.marked) { + try { + msgHtml = marked.parse(text); + } catch (e) { + console.error("Markdown processing error:", e); + msgHtml = text.replace(/"); + } + } else { + msgHtml = text.replace(/"); + } + } + + chat.innerHTML += ` + `; + } +} + +function sendMsg() { + const msg = document.getElementById('chat-input').value.trim(); + if (!msg || !connectedServer) return; + const { username, password } = getActiveServerCreds(); + wRAC(() => { + let arr; + let format = (settings && settings.messageFormat) ? settings.messageFormat : DEFAULT_SETTINGS.messageFormat; + let formatted = format; + if (formatted.includes("{name}")) formatted = formatted.replace("{name}", username ?? ""); + if (formatted.includes("{text}")) formatted = formatted.replace("{text}", msg ?? ""); + if (username && password) { + let enc = new TextEncoder(); + let uname = enc.encode(username); + let pass = enc.encode(password); + let text = enc.encode(formatted); + let total = new Uint8Array(1 + uname.length + 1 + pass.length + 1 + text.length); + total[0] = 0x02; + total.set(uname, 1); + total[1 + uname.length] = 10; + total.set(pass, 1 + uname.length + 1); + total[1 + uname.length + 1 + pass.length] = 10; + total.set(text, 1 + uname.length + 1 + pass.length + 1); + arr = total; + } else { + arr = [0x01, ...new TextEncoder().encode(formatted)]; + arr = new Uint8Array(arr); + } + ws.send(arr); + document.getElementById('chat-input').value = ""; + setTimeout(fetchMessages, 200); + }); +} + +function fetchMessages() { + wRAC(() => { + ws.send(new Uint8Array([0x00])); + }); +} + +document.getElementById('send-btn').onclick = sendMsg; +document.getElementById('chat-input').addEventListener('keydown', function (e) { + if (e.key === "Enter") sendMsg(); +}); + +setInterval(fetchMessages, 6000); +window.onload = () => { fetchMessages(); }; \ No newline at end of file diff --git a/src/panel.js b/src/panel.js new file mode 100644 index 0000000..4d34c4c --- /dev/null +++ b/src/panel.js @@ -0,0 +1,417 @@ +function getProtoLabel(proto) { + let label = { proto, className: proto }; + if (proto === "wRACs") label.className = "wRACs"; + else if (proto === "wRAC") label.className = "wRAC"; + else if (proto === "RACs") label.className = "RACs"; + else if (proto === "RAC") label.className = "RAC"; + else label.className = ""; + return label; +} + +function buildServerUrl({ proto, address, port }) { + if (proto === "wRACs") return `wss://${address}:${port}`; + if (proto === "wRAC") return `ws://${address}:${port}`; + if (proto === "RACs") return `rac+tls://${address}:${port}`; + if (proto === "RAC") return `rac://${address}:${port}`; + return `${address}:${port}`; +} + +function parseServerUrl(url) { + let m; + if (m = url.match(/^wss:\/\/([^:\/]+):(\d+)$/)) return { proto: "wRACs", address: m[1], port: m[2] }; + if (m = url.match(/^ws:\/\/([^:\/]+):(\d+)$/)) return { proto: "wRAC", address: m[1], port: m[2] }; + if (m = url.match(/^rac\+tls:\/\/([^:\/]+):(\d+)$/)) return { proto: "RACs", address: m[1], port: m[2] }; + if (m = url.match(/^rac:\/\/([^:\/]+):(\d+)$/)) return { proto: "RAC", address: m[1], port: m[2] }; + return { proto: "wRACs", address: url, port: "" }; +} + +function getSavedServers() { + let arr = []; + try { arr = JSON.parse(localStorage.getItem('snowdrop_servers') || "[]") } catch (e) { } + if (arr.length && typeof arr[0] === "string") { + arr = arr.map(url => { + const { proto, address, port } = parseServerUrl(url); + return { title: address, proto, address, port }; + }); + } + return arr; +} + +function saveServers(arr) { + localStorage.setItem('snowdrop_servers', JSON.stringify(arr)); +} + +let servers = getSavedServers(); +let ws = null; +let messages = []; +let connectedServer = servers[0] ? buildServerUrl(servers[0]) : null; + +let protocolCheck = { + state: "idle", + protoVersion: null, + serverSoftware: null, + lastFields: {} +}; + +function updateServerInfoLabels() { + const protoInfo = document.getElementById('proto-version-info'); + const swInfo = document.getElementById('server-software-info'); + if (!protoInfo || !swInfo) return; + if (protocolCheck.state === "idle" || protocolCheck.state === "error") { + protoInfo.style.display = "none"; + swInfo.style.display = "none"; + } else if (protocolCheck.state === "loading") { + protoInfo.style.display = ""; + protoInfo.textContent = t('protocolChecking'); + swInfo.style.display = ""; + swInfo.textContent = t('serverChecking'); + } else if (protocolCheck.state === "done") { + protoInfo.style.display = ""; + protoInfo.textContent = t('protocolVersion', { version: protocolCheck.protoVersion }); + swInfo.style.display = ""; + swInfo.textContent = t('serverSoftware', { software: protocolCheck.serverSoftware }); + } +} + +let dragSrcIdx = null; + +function handleDragStart(e) { + dragSrcIdx = +this.getAttribute('data-server-idx'); + this.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragSrcIdx); +} + +function handleDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + this.classList.add('drag-over'); +} + +function handleDragLeave(e) { + this.classList.remove('drag-over'); +} + +function handleDrop(e) { + e.preventDefault(); + this.classList.remove('drag-over'); + const fromIdx = dragSrcIdx; + const toIdx = +this.getAttribute('data-server-idx'); + if (fromIdx === toIdx || fromIdx == null || toIdx == null) return; + + const moved = servers.splice(fromIdx, 1)[0]; + servers.splice(toIdx, 0, moved); + saveServers(servers); + + if (connectedServer === buildServerUrl(moved)) { + connectedServer = buildServerUrl(servers[toIdx]); + } + + renderChannels(); + fetchMessages(); +} + +function handleDragEnd(e) { + document.querySelectorAll('#channels .channel').forEach(c => c.classList.remove('dragging', 'drag-over')); + dragSrcIdx = null; +} + +function renderChannels() { + const channels = document.getElementById('channels'); + if (!channels) return; + channels.innerHTML = ''; + + if (!servers || !servers.length) { + return; + } + + servers.forEach((srv, idx) => { + const label = getProtoLabel(srv.proto); + const url = buildServerUrl(srv); + const isSelected = connectedServer === url || (!connectedServer && idx === 0); + + const div = document.createElement('div'); + div.className = 'channel' + (isSelected ? ' selected' : ''); + div.setAttribute('data-server-idx', idx); + + div.setAttribute('draggable', 'true'); + div.ondragstart = handleDragStart; + div.ondragover = handleDragOver; + div.ondragleave = handleDragLeave; + div.ondrop = handleDrop; + div.ondragend = handleDragEnd; + + div.innerHTML = ` +