Web3 / app.py
Aleksmorshen's picture
Update app.py
a74cdc1 verified
import os
import json
import uuid
from datetime import datetime
from flask import Flask, Response, request, jsonify
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
DB_FILE = 'db.json'
def init_db():
if not os.path.exists(DB_FILE):
with open(DB_FILE, 'w') as f:
json.dump({
"users": {},
"chatrooms": {},
"messages": {}
}, f, indent=4)
def read_db():
with open(DB_FILE, 'r') as f:
return json.load(f)
def write_db(data):
with open(DB_FILE, 'w') as f:
json.dump(data, f, indent=4)
@app.route('/')
def index():
html_content = '''
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Virton Messenger</title>
<script src="https://unpkg.com/@tonconnect/ui@latest/dist/tonconnect-ui.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--transition-fast: 0.2s ease;
--transition-medium: 0.3s ease;
}
html[data-theme='light'] {
--bg-primary: #FFFFFF;
--bg-secondary: #F0F0F0;
--bg-tertiary: #E3E3E3;
--bg-hover: #DCDCDC;
--bg-modal: rgba(255, 255, 255, 0.8);
--text-primary: #000000;
--text-secondary: #6D6D72;
--text-tertiary: #AEAEB2;
--border-color: #D1D1D6;
--accent-blue: #007AFF;
--accent-blue-light: #3395FF;
--accent-blue-gradient: linear-gradient(45deg, var(--accent-blue), var(--accent-blue-light));
--success-color: #34C759;
--error-color: #FF3B30;
--shadow-color: rgba(0, 0, 0, 0.1);
}
html[data-theme='dark'] {
--bg-primary: #000000;
--bg-secondary: #1C1C1E;
--bg-tertiary: #2C2C2E;
--bg-hover: #3A3A3C;
--bg-modal: rgba(28, 28, 30, 0.8);
--text-primary: #FFFFFF;
--text-secondary: #8E8E93;
--text-tertiary: #636366;
--border-color: #38383A;
--accent-blue: #0A84FF;
--accent-blue-light: #339dff;
--accent-blue-gradient: linear-gradient(45deg, var(--accent-blue), var(--accent-blue-light));
--success-color: #30D158;
--error-color: #FF453A;
--shadow-color: rgba(0, 0, 0, 0.5);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: var(--font-family);
background-color: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.main-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
transition: opacity 0.3s ease;
}
#login-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
background: var(--bg-primary);
}
#login-view img { width: 120px; height: 120px; margin-bottom: 24px; filter: drop-shadow(0 0 20px rgba(10, 132, 255, 0.5)); }
#login-view h1 { font-size: 3rem; font-weight: 700; background: var(--accent-blue-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 12px; }
#login-view p { font-size: 1.1rem; color: var(--text-secondary); margin-bottom: 40px; max-width: 300px; }
#app-container { display: none; width: 100%; height: 100%; }
.app-layout {
width: 100%;
height: 100%;
display: flex;
}
.main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
position: relative;
height: 100%;
overflow: hidden;
}
.content-view {
display: none;
flex-direction: column;
height: 100%;
width: 100%;
background-color: var(--bg-primary);
animation: fadeIn 0.3s ease;
}
.content-view.active { display: flex; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.view-header {
padding: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
}
.view-header h2 { font-size: 1.8rem; font-weight: 700; }
.item-list {
flex-grow: 1;
overflow-y: auto;
padding: 8px 0;
}
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.list-item:hover { background-color: var(--bg-hover); }
.list-item .item-info { flex-grow: 1; overflow: hidden; }
.list-item .item-name { font-weight: 500; font-size: 1.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.list-item .item-subtext { font-size: 0.9rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.list-item .lock-icon { width: 16px; height: 16px; fill: var(--text-secondary); flex-shrink: 0; }
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: 600;
color: white;
flex-shrink: 0;
}
#chat-window-view { display: none; }
.chat-header {
display: flex; align-items: center; gap: 12px; padding: 12px 16px; background-color: var(--bg-secondary); border-bottom: 1px solid var(--border-color); flex-shrink: 0;
}
.back-btn { background: none; border: none; cursor: pointer; display: none; }
.back-btn svg { width: 28px; height: 28px; fill: var(--accent-blue); }
.chat-header .avatar { width: 40px; height: 40px; }
#chat-header-title { font-size: 1.1rem; font-weight: 600; }
#messages-container {
flex-grow: 1; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px;
}
.message { display: flex; gap: 10px; max-width: 85%; }
.message .avatar { width: 36px; height: 36px; align-self: flex-end; }
.message-content { display: flex; flex-direction: column; gap: 4px; }
.message-sender { font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); word-break: break-all; cursor: pointer; padding: 0 12px; }
.message-bubble { padding: 10px 14px; border-radius: 20px; line-height: 1.4; word-wrap: break-word; font-size: 1.05rem; }
.message.sent { align-self: flex-end; }
.message.sent .message-bubble { background: var(--accent-blue-gradient); color: white; border-bottom-right-radius: 4px; }
.message.received { align-self: flex-start; }
.message.received .message-sender { color: var(--accent-blue); }
.message.received .message-bubble { background-color: var(--bg-tertiary); border-bottom-left-radius: 4px; }
.message-form-container {
display: flex; padding: 8px 16px; gap: 12px; background-color: var(--bg-secondary); border-top: 1px solid var(--border-color); flex-shrink: 0;
}
#message-input {
flex-grow: 1; padding: 12px 18px; border: none; background-color: var(--bg-tertiary); color: var(--text-primary); border-radius: 22px; outline: none; font-size: 1rem; resize: none;
}
.action-btn {
background: var(--accent-blue-gradient); color: white; border: none; padding: 12px 18px; border-radius: 12px; cursor: pointer; font-weight: 600; font-size: 1rem; transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.action-btn:hover { transform: scale(1.03); box-shadow: 0 4px 15px var(--shadow-color); }
.action-btn.send-btn { width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; padding: 0; display: flex; align-items: center; justify-content: center; }
.action-btn.send-btn svg { width: 22px; height: 22px; fill: white; }
.bottom-nav {
position: fixed; bottom: 0; left: 0; right: 0; height: 84px;
background: var(--bg-modal); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--border-color); display: flex; justify-content: space-around; align-items: flex-start; padding-top: 10px; z-index: 100;
}
.nav-item {
display: flex; flex-direction: column; align-items: center; gap: 4px; color: var(--text-secondary); cursor: pointer; transition: color var(--transition-fast);
}
.nav-item.active { color: var(--accent-blue); }
.nav-item.qr-btn { margin-top: -30px; }
.nav-item .nav-icon { fill: currentColor; width: 28px; height: 28px; }
.nav-item .nav-label { font-size: 0.7rem; font-weight: 500; }
.nav-item .qr-icon-wrapper {
width: 60px; height: 60px; border-radius: 50%; background: var(--accent-blue-gradient); display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 15px var(--shadow-color);
}
.nav-item .qr-icon-wrapper .nav-icon { fill: white; width: 32px; height: 32px; }
.main-content-wrapper {
width: 100%; height: 100%; display: flex;
}
.sidebar { display: none; }
#my-profile-view {
align-items: center; justify-content: center; text-align: center; padding: 20px;
}
#my-profile-view .avatar { width: 120px; height: 120px; font-size: 4rem; margin-bottom: 20px; }
#my-profile-username { font-size: 1.8rem; font-weight: 600; }
#my-profile-address { font-size: 0.9rem; color: var(--text-secondary); word-break: break-all; margin-top: 8px; max-width: 90%; }
#my-profile-wallet-info { font-size: 1rem; color: var(--text-secondary); margin-top: 16px; }
.profile-actions { margin-top: 30px; display: flex; flex-direction: column; gap: 15px; width: 100%; max-width: 300px; }
.theme-switcher { display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary); padding: 10px 16px; border-radius: 12px; }
.switch { position: relative; display: inline-block; width: 51px; height: 31px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--bg-tertiary); transition: var(--transition-medium); border-radius: 34px; }
.slider:before { position: absolute; content: ""; height: 27px; width: 27px; left: 2px; bottom: 2px; background-color: white; transition: var(--transition-medium); border-radius: 50%; }
input:checked + .slider { background-color: var(--success-color); }
input:checked + .slider:before { transform: translateX(20px); }
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); animation: fadeIn 0.3s;
}
.modal-content {
background-color: var(--bg-modal); padding: 24px; border-radius: 16px; width: 90%; max-width: 400px; border: 1px solid var(--border-color); box-shadow: 0 10px 40px var(--shadow-color);
}
.modal-content h3 { margin-bottom: 20px; font-weight: 600; font-size: 1.3rem; }
.modal-content label { display: block; margin-bottom: 8px; font-size: 0.9rem; color: var(--text-secondary); }
.modal-content input {
width: 100%; padding: 12px; margin-bottom: 16px; background-color: var(--bg-tertiary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; font-size: 1rem;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }
.modal-btn { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-weight: 500; }
.secondary-btn { background-color: var(--bg-hover); color: var(--text-primary); }
#status-bar {
position: fixed; bottom: 90px; left: 50%; transform: translateX(-50%); background-color: rgba(44, 44, 46, 0.85); color: white; padding: 12px 20px; border-radius: 12px; font-size: 0.9rem;
opacity: 0; visibility: hidden; transition: opacity var(--transition-medium), visibility var(--transition-medium), transform var(--transition-medium); z-index: 2000;
box-shadow: 0 5px 15px var(--shadow-color); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
}
#status-bar.visible { opacity: 1; visibility: visible; transform: translate(-50%, -10px); }
@media (min-width: 768px) {
body { padding: 20px; }
.app-layout { max-width: 1200px; max-height: 900px; margin: auto; border-radius: 20px; overflow: hidden; box-shadow: 0 20px 60px var(--shadow-color); border: 1px solid var(--border-color); }
.bottom-nav { display: none; }
.sidebar {
display: flex; flex-direction: column; align-items: center; width: 80px; flex-shrink: 0; padding: 20px 0;
background-color: var(--bg-secondary); border-right: 1px solid var(--border-color); z-index: 1;
}
.sidebar .nav-item { gap: 8px; padding: 15px 0; width: 100%; }
.sidebar .nav-item .nav-icon { width: 24px; height: 24px; }
.sidebar .nav-item .nav-label { font-size: 0.75rem; }
.sidebar .nav-item.qr-btn { margin-top: 0; order: -1; margin-bottom: 20px; }
.sidebar .nav-item .qr-icon-wrapper { width: 52px; height: 52px; }
.sidebar .nav-item .qr-icon-wrapper .nav-icon { width: 28px; height: 28px; }
.content-view { border-right: 1px solid var(--border-color); }
#chat-list-view, #user-list-view, #my-profile-view { width: 340px; flex-shrink: 0; }
#chat-list-view.active, #user-list-view.active, #my-profile-view.active { display: flex; }
.main-content {
flex-direction: row;
}
.back-btn { display: none !important; }
#chat-window-view { display: flex; flex-grow: 1; }
.placeholder-view { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center; color: var(--text-secondary); padding: 20px; background: var(--bg-primary); }
.placeholder-view img { width: 80px; margin-bottom: 20px; opacity: 0.5; }
#app-container { padding-bottom: 0; }
#status-bar { bottom: 30px; }
}
</style>
</head>
<body>
<div id="login-view" class="main-container">
<img src="https://ton.org/download/ton_symbol.svg" alt="TON Symbol">
<h1>Virton</h1>
<p>Децентрализованный и анонимный мессенджер на TON</p>
<div id="ton-connect-button"></div>
</div>
<div id="app-container">
<div class="app-layout">
<nav class="sidebar" id="sidebar-nav"></nav>
<div class="main-content">
<div id="chat-list-view" class="content-view">
<div class="view-header">
<h2>Чаты</h2>
<button id="create-room-show-modal" class="action-btn" style="padding: 8px 14px; font-size: 0.9rem;">Новый чат</button>
</div>
<div class="item-list" id="chatroom-list"></div>
</div>
<div id="user-list-view" class="content-view">
<div class="view-header">
<h2>Пользователи</h2>
</div>
<div class="item-list" id="user-list"></div>
</div>
<div id="my-profile-view" class="content-view">
<div id="my-profile-content" style="width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; padding: 20px;">
<div class="avatar" id="my-profile-avatar"></div>
<h2 id="my-profile-username"></h2>
<p id="my-profile-address"></p>
<p id="my-profile-wallet-info"></p>
<div class="profile-actions">
<form id="username-form" style="display: flex; gap: 8px;">
<input type="text" id="username-input" class="username-input" placeholder="Новый никнейм" autocomplete="off" style="width: 100%; padding: 12px; background-color: var(--bg-tertiary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; font-size: 1rem;">
<button type="submit" class="action-btn" style="padding: 0 16px;">✓</button>
</form>
<button id="my-qr-code-btn" class="action-btn secondary-btn" style="background: var(--bg-tertiary);">Мой QR-код</button>
<div class="theme-switcher">
<span>Тёмная тема</span>
<label class="switch">
<input type="checkbox" id="theme-toggle">
<span class="slider"></span>
</label>
</div>
</div>
</div>
</div>
<div id="chat-window-view">
<div class="placeholder-view" id="placeholder-view">
<img src="https://ton.org/download/ton_symbol.svg" alt="TON Symbol">
<h2>Выберите чат</h2>
<p>Начните общение или просмотрите список пользователей</p>
</div>
<div id="active-chat-content" style="display: none; width: 100%; height: 100%; flex-direction: column;">
<div class="chat-header">
<button class="back-btn" id="back-to-list-btn">
<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div id="chat-header-avatar" class="avatar"></div>
<span id="chat-header-title"></span>
</div>
<div id="messages-container"></div>
<div class="message-form-container">
<input id="message-input" placeholder="Сообщение..." autocomplete="off">
<button type="button" class="action-btn send-btn" id="send-btn">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<nav class="bottom-nav" id="bottom-nav-bar"></nav>
</div>
<div id="create-room-modal" class="modal-overlay">
<div class="modal-content">
<h3>Создать новый чат</h3>
<form id="create-room-form">
<label for="room-name">Название чата</label>
<input type="text" id="room-name" required>
<label for="room-password">Пароль (оставьте пустым для открытого)</label>
<input type="password" id="room-password">
<div class="modal-actions">
<button type="button" class="modal-btn secondary-btn" data-modal-close>Отмена</button>
<button type="submit" class="modal-btn action-btn">Создать</button>
</div>
</form>
</div>
</div>
<div id="password-modal" class="modal-overlay">
<div class="modal-content">
<h3>Вход в приватный чат</h3>
<form id="password-form">
<label for="password-input">Введите пароль</label>
<input type="password" id="password-input" required>
<div class="modal-actions">
<button type="button" class="modal-btn secondary-btn" data-modal-close>Отмена</button>
<button type="submit" class="modal-btn action-btn">Войти</button>
</div>
</form>
</div>
</div>
<div id="profile-modal" class="modal-overlay">
<div class="modal-content" style="text-align: center;">
<h3>Профиль</h3>
<div id="profile-avatar-container" style="margin: 20px auto; display: inline-block;"></div>
<p id="profile-username" style="font-size: 1.2rem; font-weight: 600;"></p>
<p id="profile-address" style="color: var(--text-secondary); font-size: 0.9rem; word-break: break-all; margin-top: 8px;"></p>
<div id="profile-qr-code" style="background: white; padding: 10px; margin: 20px auto; width: fit-content; border-radius: 8px;"></div>
<p style="text-align: center; color: var(--text-secondary); font-size: 0.8rem; margin-top: -10px; margin-bottom: 20px;">QR для открытия профиля</p>
<div class="modal-actions" style="flex-direction: column; gap: 12px; align-items: stretch;">
<button id="send-ton-btn" class="modal-btn action-btn">Отправить TON</button>
<button data-modal-close class="modal-btn secondary-btn">Закрыть</button>
</div>
</div>
</div>
<div id="scanner-modal" class="modal-overlay">
<div class="modal-content">
<h3>Сканировать QR-код</h3>
<div id="qr-reader" style="width: 100%; border: 1px solid var(--border-color); margin-top: 16px; border-radius: 8px; overflow: hidden;"></div>
<div class="modal-actions">
<button id="scanner-close-btn" class="modal-btn secondary-btn">Отмена</button>
</div>
</div>
</div>
<div id="status-bar"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tonConnectUI = new TON_CONNECT_UI.TonConnectUI({
manifestUrl: 'https://huggingface.co/spaces/Aleksmorshen/MorshenGroup/resolve/main/tonconnect-manifest.json',
buttonRootId: 'ton-connect-button'
});
let currentUser = { address: null, username: null, wallet: null };
let activeChatroomId = null;
let messagePollingInterval = null;
let chatroomsData = {};
let html5QrCode = null;
let profileQrCode = null;
const AVATAR_COLORS = ['#e57373', '#81c784', '#64b5f6', '#ffb74d', '#9575cd', '#4db6ac', '#f06292'];
const getAvatar = (name, size = '48px') => {
const initial = (name ? name[0] : '?').toUpperCase();
const charCode = initial.charCodeAt(0);
const color = AVATAR_COLORS[charCode % AVATAR_COLORS.length];
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.style.backgroundColor = color;
avatar.style.width = size;
avatar.style.height = size;
avatar.textContent = initial;
return avatar;
};
const apiCall = async (endpoint, options = {}) => {
try {
const response = await fetch(endpoint, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Request failed: ' + response.status }));
throw new Error(errorData.error || 'Unknown error');
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
showStatus(`Ошибка: ${error.message}`, 'error');
throw error;
}
};
const truncateAddress = (address) => address ? `${address.substring(0, 4)}...${address.substring(address.length - 4)}` : '';
const showStatus = (message, type = 'info', duration = 3000) => {
const statusBar = document.getElementById('status-bar');
statusBar.textContent = message;
statusBar.className = 'status-bar';
if (type === 'success') statusBar.style.backgroundColor = 'var(--success-color)';
else if (type === 'error') statusBar.style.backgroundColor = 'var(--error-color)';
else statusBar.style.backgroundColor = 'rgba(44, 44, 46, 0.85)';
statusBar.classList.add('visible');
setTimeout(() => statusBar.classList.remove('visible'), duration);
};
const showView = (viewId) => {
document.querySelectorAll('.content-view').forEach(v => v.classList.remove('active'));
document.getElementById(viewId)?.classList.add('active');
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
document.querySelectorAll(`[data-view='${viewId}']`).forEach(item => item.classList.add('active'));
const isMobile = window.innerWidth < 768;
if (isMobile) {
document.getElementById('app-container').scrollTo(0,0);
if (viewId === 'chat-window-view') {
document.querySelector('.app-layout').style.transform = 'translateX(-100%)';
} else {
document.querySelector('.app-layout').style.transform = 'translateX(0%)';
document.querySelectorAll('.content-view').forEach(v => v.style.display = 'none');
const targetView = document.getElementById(viewId);
if(targetView) targetView.style.display = 'flex';
}
}
};
const renderNav = () => {
const navItems = [
{ id: 'chat-list-view', label: 'Чаты', icon: '<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>' },
{ id: 'user-list-view', label: 'Контакты', icon: '<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>' },
{ id: 'scanner', label: 'Scan', icon: '<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm8-12v8h8V3h-8zm6 6h-4V5h4v4zm-2 10a2 2 0 100-4 2 2 0 000 4z"/>', isQr: true },
{ id: 'my-profile-view', label: 'Профиль', icon: '<path d="M12 5.9c1.16 0 2.1.94 2.1 2.1s-.94 2.1-2.1 2.1S9.9 9.16 9.9 8s.94-2.1 2.1-2.1m0 9c2.97 0 6.1 1.46 6.1 2.1v1.1H5.9V17c0-.64 3.13-2.1 6.1-2.1M12 4C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 9c-2.67 0-8 1.34-8 4v3h16v-3c0-2.66-5.33-4-8-4z"/>' }
];
const createNavItem = (item) => {
const el = document.createElement('div');
el.className = 'nav-item';
if (item.isQr) el.classList.add('qr-btn');
el.dataset.view = item.id;
if (item.isQr) {
el.innerHTML = `<div class="qr-icon-wrapper"><svg class="nav-icon" viewBox="0 0 24 24">${item.icon}</svg></div>`;
el.onclick = showScanner;
} else {
el.innerHTML = `<svg class="nav-icon" viewBox="0 0 24 24">${item.icon}</svg><span class="nav-label">${item.label}</span>`;
el.onclick = () => showView(item.id);
}
return el;
};
const bottomNav = document.getElementById('bottom-nav-bar');
const sidebarNav = document.getElementById('sidebar-nav');
bottomNav.innerHTML = '';
sidebarNav.innerHTML = '';
navItems.forEach(item => {
bottomNav.appendChild(createNavItem(item));
sidebarNav.appendChild(createNavItem(item));
});
};
const initializeUser = async (wallet) => {
currentUser.address = TON_CONNECT_UI.toUserFriendlyAddress(wallet.account.address, false);
currentUser.wallet = wallet;
try {
const data = await apiCall('/api/user_data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: currentUser.address })
});
currentUser.username = data.username;
} catch (err) { currentUser.username = null; }
document.getElementById('login-view').style.display = 'none';
document.getElementById('app-container').style.display = 'block';
renderNav();
renderMyProfile();
fetchChatrooms();
fetchAndRenderUsers();
showView('chat-list-view');
};
const renderMyProfile = () => {
const username = currentUser.username || `User ${truncateAddress(currentUser.address)}`;
document.getElementById('my-profile-avatar').innerHTML = getAvatar(username, '120px').innerHTML;
document.getElementById('my-profile-username').textContent = username;
document.getElementById('my-profile-address').textContent = currentUser.address;
document.getElementById('username-input').value = currentUser.username || '';
if(currentUser.wallet?.device) {
document.getElementById('my-profile-wallet-info').textContent = `Кошелек: ${currentUser.wallet.device.appName}`;
}
};
document.getElementById('username-form').addEventListener('submit', async (e) => {
e.preventDefault();
const newUsername = document.getElementById('username-input').value.trim();
if (!newUsername || newUsername.length < 3) return showStatus('Никнейм должен быть не короче 3 символов.', 'error');
await apiCall('/api/set_username', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: currentUser.address, username: newUsername })
});
currentUser.username = newUsername;
renderMyProfile();
fetchChatrooms();
fetchAndRenderUsers();
if (activeChatroomId) fetchMessages(activeChatroomId);
showStatus('Никнейм успешно обновлен!', 'success');
});
const renderList = (containerId, items, clickHandler, itemRenderer) => {
const list = document.getElementById(containerId);
list.innerHTML = '';
items.forEach(item => {
const itemEl = itemRenderer(item);
itemEl.onclick = () => clickHandler(item);
list.appendChild(itemEl);
});
};
const fetchChatrooms = async () => {
try {
const data = await apiCall('/api/chatrooms');
chatroomsData = data.chatrooms.reduce((acc, room) => ({...acc, [room.id]: room}), {});
renderList('chatroom-list', data.chatrooms, (room) => selectChatroom(room.id, room.is_private), (room) => {
const itemEl = document.createElement('div');
itemEl.className = 'list-item';
itemEl.appendChild(getAvatar(room.name, '48px'));
itemEl.innerHTML += `<div class="item-info"><div class="item-name">${room.name}</div><div class="item-subtext">${room.is_private ? "Приватный чат" : "Открытый чат"}</div></div>`;
if (room.is_private) itemEl.innerHTML += `<svg class="lock-icon" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z"/></svg>`;
return itemEl;
});
} catch (err) {}
};
const fetchAndRenderUsers = async () => {
try {
const data = await apiCall('/api/users');
renderList('user-list', data.users, (user) => showProfile(user.address), (user) => {
const itemEl = document.createElement('div');
itemEl.className = 'list-item';
itemEl.appendChild(getAvatar(user.username, '48px'));
itemEl.innerHTML += `<div class="item-info"><div class="item-name">${user.username || 'Без имени'}</div><div class="item-subtext">${truncateAddress(user.address)}</div></div>`;
return itemEl;
});
} catch (err) {}
};
const renderMessages = (messages) => {
const container = document.getElementById('messages-container');
const shouldScroll = container.scrollTop + container.clientHeight >= container.scrollHeight - 50;
container.innerHTML = '';
messages.forEach(msg => {
const msgDiv = document.createElement('div');
const isSent = msg.sender_address === currentUser.address;
msgDiv.className = 'message ' + (isSent ? 'sent' : 'received');
const avatar = getAvatar(msg.display_name, '36px');
avatar.style.cursor = 'pointer';
avatar.onclick = () => showProfile(msg.sender_address);
msgDiv.innerHTML = `
<div class="message-content">
<div class="message-sender" onclick="showProfile('${msg.sender_address}')">${isSent ? 'Вы' : msg.display_name}</div>
<div class="message-bubble">${msg.text.replace(/</g, "<").replace(/>/g, ">")}</div>
</div>
`;
msgDiv.insertBefore(avatar, msgDiv.firstChild);
container.appendChild(msgDiv);
});
if(shouldScroll) container.scrollTop = container.scrollHeight;
};
const fetchMessages = async (roomId) => {
try {
const data = await apiCall(`/api/messages/${roomId}`);
renderMessages(data.messages);
} catch (err) {
if (messagePollingInterval) clearInterval(messagePollingInterval);
}
};
const selectChatroom = (roomId, isPrivate) => {
const roomData = chatroomsData[roomId];
if (!roomData) return;
const proceedToRoom = () => {
if (messagePollingInterval) clearInterval(messagePollingInterval);
activeChatroomId = roomId;
document.getElementById('chat-header-title').textContent = roomData.name;
document.getElementById('chat-header-avatar').innerHTML = getAvatar(roomData.name, '40px').innerHTML;
document.getElementById('placeholder-view').style.display = 'none';
document.getElementById('active-chat-content').style.display = 'flex';
showView('chat-window-view');
fetchMessages(roomId).then(() => {
document.getElementById('messages-container').scrollTop = document.getElementById('messages-container').scrollHeight;
});
messagePollingInterval = setInterval(() => fetchMessages(roomId), 3000);
};
if (isPrivate) {
const passwordModal = document.getElementById('password-modal');
const passwordForm = document.getElementById('password-form');
const passwordInput = document.getElementById('password-input');
passwordModal.style.display = 'flex';
passwordInput.value = '';
passwordInput.focus();
passwordForm.onsubmit = async (e) => {
e.preventDefault();
const password = passwordInput.value;
passwordModal.style.display = 'none';
try {
await apiCall('/api/join_chatroom', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatroom_id: roomId, password })
});
proceedToRoom();
} catch (err) {}
};
} else {
proceedToRoom();
}
};
const sendMessage = async () => {
const input = document.getElementById('message-input');
const text = input.value.trim();
if (text && activeChatroomId) {
const tempText = input.value;
input.value = '';
try {
await apiCall('/api/send_message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatroom_id: activeChatroomId, sender_address: currentUser.address, text: text })
});
fetchMessages(activeChatroomId).then(() => {
document.getElementById('messages-container').scrollTop = document.getElementById('messages-container').scrollHeight;
});
} catch {
input.value = tempText;
}
}
};
document.getElementById('send-btn').addEventListener('click', sendMessage);
document.getElementById('message-input').addEventListener('keypress', (e) => {
if(e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
const showModal = (modalId) => document.getElementById(modalId).style.display = 'flex';
const hideAllModals = () => document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
document.getElementById('create-room-show-modal').onclick = () => showModal('create-room-modal');
document.querySelectorAll('[data-modal-close]').forEach(btn => btn.onclick = hideAllModals);
document.getElementById('create-room-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('room-name').value.trim();
const password = document.getElementById('room-password').value;
if (!name) return;
await apiCall('/api/create_chatroom', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, password: password || null, creator_address: currentUser.address })
});
hideAllModals();
showStatus('Чат успешно создан!', 'success');
fetchChatrooms();
});
const showProfile = async (address) => {
try {
const userData = await apiCall('/api/user_data', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address })
});
const username = userData.username || `User ${truncateAddress(address)}`;
document.getElementById('profile-avatar-container').innerHTML = getAvatar(username, '80px').outerHTML;
document.getElementById('profile-username').textContent = username;
document.getElementById('profile-address').textContent = address;
const qrCodeEl = document.getElementById('profile-qr-code');
qrCodeEl.innerHTML = '';
if (profileQrCode) profileQrCode.clear();
profileQrCode = new QRCode(qrCodeEl, { text: address, width: 150, height: 150, colorDark : "#000000", colorLight : "#ffffff", correctLevel : QRCode.CorrectLevel.H });
const sendTonBtn = document.getElementById('send-ton-btn');
sendTonBtn.onclick = async () => {
const amountString = prompt("Введите сумму в TON:", "0.1");
if (amountString === null) return;
const amount = parseFloat(amountString);
if (isNaN(amount) || amount <= 0) return showStatus('Неверная сумма.', 'error');
const transaction = {
validUntil: Math.floor(Date.now() / 1000) + 600,
messages: [ { address: address, amount: Math.floor(amount * 1e9).toString() } ]
};
try {
await tonConnectUI.sendTransaction(transaction);
showStatus(`Транзакция отправлена!`, 'success');
hideAllModals();
} catch (error) { showStatus('Транзакция отклонена.', 'error'); }
};
sendTonBtn.style.display = (address === currentUser.address) ? 'none' : 'block';
showModal('profile-modal');
} catch (err) {}
};
document.getElementById('my-qr-code-btn').onclick = () => {
if (currentUser.address) showProfile(currentUser.address);
};
const showScanner = () => {
showModal('scanner-modal');
html5QrCode = new Html5Qrcode("qr-reader");
html5QrCode.start({ facingMode: "environment" }, { fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
hideScanner();
if (decodedText && (decodedText.startsWith('EQ') || decodedText.startsWith('UQ'))) {
showProfile(decodedText);
} else {
showStatus('Отсканирован недействительный QR-код.', 'error');
}
})
.catch(err => showStatus('Не удалось запустить сканер.', 'error'));
};
const hideScanner = () => {
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.stop().catch(err => {});
}
hideAllModals();
};
document.getElementById('scanner-close-btn').onclick = hideScanner;
document.getElementById('back-to-list-btn').addEventListener('click', () => {
document.querySelector('.app-layout').style.transform = 'translateX(0%)';
});
const themeToggle = document.getElementById('theme-toggle');
const setTeam = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
themeToggle.checked = theme === 'dark';
};
themeToggle.addEventListener('change', () => {
setTeam(themeToggle.checked ? 'dark' : 'light');
});
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
setTeam(savedTheme || (prefersDark ? 'dark' : 'light'));
tonConnectUI.onStatusChange(wallet => {
if (wallet) {
initializeUser(wallet);
} else {
currentUser = { address: null, username: null, wallet: null };
document.getElementById('app-container').style.display = 'none';
document.getElementById('login-view').style.display = 'flex';
if (messagePollingInterval) clearInterval(messagePollingInterval);
activeChatroomId = null;
}
});
});
</script>
</body>
</html>
'''
return Response(html_content, mimetype='text/html')
@app.route('/api/users', methods=['GET'])
def get_users():
db = read_db()
users_list = []
for address, user_data in db['users'].items():
users_list.append({
'address': address,
'username': user_data.get('username')
})
return jsonify({'users': sorted(users_list, key=lambda x: x.get('username') or 'zzzz')})
@app.route('/api/user_data', methods=['POST'])
def get_user_data():
data = request.get_json()
address = data.get('address')
if not address:
return jsonify({'error': 'Address is required'}), 400
db = read_db()
user_info = db['users'].get(address)
username = user_info.get('username') if user_info else None
return jsonify({'username': username})
@app.route('/api/set_username', methods=['POST'])
def set_username():
data = request.get_json()
address = data.get('address')
username = data.get('username')
if not address or not username:
return jsonify({'error': 'Address and username are required'}), 400
if len(username) < 3 or len(username) > 20:
return jsonify({'error': 'Username must be between 3 and 20 characters'}), 400
db = read_db()
if address not in db['users']:
db['users'][address] = {}
db['users'][address]['username'] = username
write_db(db)
return jsonify({'success': True})
@app.route('/api/chatrooms', methods=['GET'])
def get_chatrooms():
db = read_db()
chatrooms_list = []
for room_id, room_data in db['chatrooms'].items():
chatrooms_list.append({
'id': room_id,
'name': room_data['name'],
'is_private': room_data['is_private']
})
return jsonify({'chatrooms': sorted(chatrooms_list, key=lambda x: x['name'])})
@app.route('/api/create_chatroom', methods=['POST'])
def create_chatroom():
data = request.get_json()
name = data.get('name')
password = data.get('password')
creator_address = data.get('creator_address')
if not name or not creator_address:
return jsonify({'error': 'Name and creator address are required'}), 400
db = read_db()
room_id = str(uuid.uuid4())
db['chatrooms'][room_id] = {
'name': name,
'creator': creator_address,
'is_private': bool(password),
'password_hash': generate_password_hash(password) if password else None
}
db['messages'][room_id] = []
write_db(db)
return jsonify({'success': True, 'chatroom_id': room_id})
@app.route('/api/join_chatroom', methods=['POST'])
def join_chatroom():
data = request.get_json()
chatroom_id = data.get('chatroom_id')
password = data.get('password')
db = read_db()
chatroom = db['chatrooms'].get(chatroom_id)
if not chatroom:
return jsonify({'error': 'Chatroom not found'}), 404
if chatroom['is_private']:
if not password or not check_password_hash(chatroom['password_hash'], password):
return jsonify({'error': 'Invalid password'}), 403
return jsonify({'success': True})
@app.route('/api/messages/<chatroom_id>', methods=['GET'])
def get_messages(chatroom_id):
db = read_db()
if chatroom_id not in db['messages']:
return jsonify({'error': 'Chatroom not found'}), 404
messages_with_names = []
room_messages = db['messages'].get(chatroom_id, [])
for msg in room_messages:
sender_address = msg['sender_address']
user_info = db['users'].get(sender_address)
display_name = (user_info.get('username') if user_info and user_info.get('username')
else f"{sender_address[:4]}...{sender_address[-4:]}")
msg_copy = msg.copy()
msg_copy['display_name'] = display_name
messages_with_names.append(msg_copy)
return jsonify({'messages': messages_with_names})
@app.route('/api/send_message', methods=['POST'])
def send_message():
data = request.get_json()
chatroom_id = data.get('chatroom_id')
sender_address = data.get('sender_address')
text = data.get('text')
if not all([chatroom_id, sender_address, text]):
return jsonify({'error': 'Missing data'}), 400
if len(text) > 1000:
return jsonify({'error': 'Message is too long'}), 400
db = read_db()
if chatroom_id not in db['messages']:
return jsonify({'error': 'Chatroom not found'}), 404
message = {
'id': str(uuid.uuid4()),
'sender_address': sender_address,
'text': text,
'timestamp': datetime.utcnow().isoformat() + "Z"
}
if len(db['messages'][chatroom_id]) >= 100:
db['messages'][chatroom_id].pop(0)
db['messages'][chatroom_id].append(message)
write_db(db)
return jsonify({'success': True})
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=7860)