ChessVania commited on
Commit
484ec23
·
verified ·
1 Parent(s): a8a723c

Deploy SalGram (Docker)

Browse files
.dockerignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Сборки и десктоп-артефакты
2
+ SalGram.exe
3
+ dist/
4
+ build/
5
+ *.spec
6
+ build_exe.py
7
+ desktop.py
8
+ android/
9
+
10
+ # Кеш и служебное
11
+ __pycache__/
12
+ *.pyc
13
+ .claude/
14
+ .git/
15
+ .gitignore
16
+
17
+ # Локальные данные и секреты — на сервере создаются заново в /data.
18
+ # НЕ попадают в образ: иначе чужой ключ/база/секреты утекут в публичный Space.
19
+ salgram.db
20
+ secret.key
21
+ vapid.json
22
+ photos/
23
+ files/
24
+ gifs/
25
+ avatars/
26
+ audio/
27
+ wallpapers/
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SalGram — Docker-образ для Hugging Face Spaces (sdk: docker).
2
+ FROM python:3.13-slim
3
+
4
+ # Пользователь с uid 1000 — так требует Hugging Face Spaces; /data — точка
5
+ # монтирования постоянного хранилища (если оно подключено в настройках Space).
6
+ RUN useradd -m -u 1000 user && mkdir -p /data && chown user /data
7
+
8
+ USER user
9
+ ENV PATH="/home/user/.local/bin:$PATH" \
10
+ SALGRAM_DATA_DIR=/data \
11
+ PYTHONUNBUFFERED=1 \
12
+ PORT=7860
13
+
14
+ WORKDIR /home/user/app
15
+
16
+ # Сначала зависимости — кешируется отдельно от кода.
17
+ COPY --chown=user requirements-web.txt ./
18
+ RUN pip install --no-cache-dir -r requirements-web.txt
19
+
20
+ COPY --chown=user . .
21
+
22
+ EXPOSE 7860
23
+
24
+ # Один воркер обязателен: WebSocket-соединения хранятся в памяти процесса
25
+ # (см. run_production в app.py). Каждое WS-соединение занимает поток.
26
+ CMD ["gunicorn", "--workers", "1", "--threads", "100", "--timeout", "120", \
27
+ "--bind", "0.0.0.0:7860", "app:app"]
README.md CHANGED
@@ -1,10 +1,58 @@
1
  ---
2
- title: Salgram
3
- emoji: 🔥
4
- colorFrom: pink
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SalGram
3
+ emoji: 💬
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # SalGram
12
+
13
+ Мессенджер на Flask + flask-sock (WebSocket) + SQLite. Аккаунты, профили,
14
+ личная и групповая переписка, каналы, голосовые, GIF, push-уведомления,
15
+ модерация.
16
+
17
+ ## Запуск на Hugging Face Spaces
18
+
19
+ Space типа **Docker**. Контейнер слушает порт `7860` (gunicorn, 1 воркер,
20
+ многопоточный — этого требует хранение WebSocket-соединений в памяти процесса).
21
+
22
+ ### Хранение данных
23
+
24
+ Все данные (база `salgram.db`, ключ шифрования, загруженные файлы) пишутся в
25
+ каталог из переменной `SALGRAM_DATA_DIR` (по умолчанию `/data`). На бесплатном
26
+ Space `/data` эфемерно — при пересборке/перезапуске/«засыпании» данные пропадают.
27
+
28
+ ### Резервное копирование в приватный репозиторий (бесплатная персистентность)
29
+
30
+ Чтобы данные переживали перезапуски без платного Persistent storage, приложение
31
+ само раз в 20 минут выгружает консистентную копию БД (вместе с ключом шифрования
32
+ и медиа) в **приватный** dataset-репозиторий, а при старте контейнера
33
+ восстанавливает оттуда (см. `hf_backup.py`). Код остаётся в публичном Space,
34
+ а данные лежат только в приватном репо — их видишь и качаешь только ты.
35
+
36
+ Включается переменными окружения (задаются в **Settings → Variables and secrets**
37
+ у Space):
38
+
39
+ | Переменная | Тип | Назначение |
40
+ |---|---|---|
41
+ | `HF_BACKUP_REPO` | variable | id приватного репо, напр. `username/salgram-data` |
42
+ | `HF_TOKEN` | **secret** | токен с правом записи в этот репо |
43
+ | `HF_BACKUP_INTERVAL` | variable (необяз.) | период в секундах, по умолчанию `1200` (20 мин) |
44
+
45
+ Без этих переменных бэкап выключен (no-op) — локальная разработка и Amvera не
46
+ затронуты. Скачать актуальную БД можно из приватного репо (файл `salgram.db`).
47
+
48
+ Ключ шифрования `secret.key` и VAPID-ключи генерируются автоматически при
49
+ первом запуске, если их нет (и далее переносятся бэкапом).
50
+
51
+ ## Локальный запуск
52
+
53
+ ```bash
54
+ pip install -r requirements.txt # десктоп-версия (с pywebview/pystray)
55
+ python app.py # http://localhost:8000
56
+ ```
57
+
58
+ Для веб/сервера достаточно `requirements-web.txt`.
app.py ADDED
The diff for this file is too large to render. See raw diff
 
assetlinks.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "relation": ["delegate_permission/common.handle_all_urls"],
4
+ "target": {
5
+ "namespace": "android_app",
6
+ "package_name": "app.salgram.twa",
7
+ "sha256_cert_fingerprints": [
8
+ "REPLACE_WITH_APK_SIGNING_KEY_SHA256_FINGERPRINT"
9
+ ]
10
+ }
11
+ }
12
+ ]
favicon.png ADDED
hf_backup.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Резервное копирование данных SalGram в приватный репозиторий Hugging Face.
2
+
3
+ Зачем: на бесплатном HF Space диск эфемерный — при пересборке/перезапуске/
4
+ «засыпании» Space всё в контейнере пропадает. Этот модуль раз в
5
+ HF_BACKUP_INTERVAL секунд снимает консистентную копию БД и выгружает её
6
+ (вместе с ключом шифрования и медиафайлами) в ПРИВАТНЫЙ dataset-репозиторий,
7
+ а при старте контейнера восстанавливает оттуда. Так код остаётся в публичном
8
+ Space, а данные лежат только в приватном репо — их видишь и качаешь только ты.
9
+
10
+ Включается только если заданы переменные окружения:
11
+ HF_BACKUP_REPO — id приватного репо, напр. "username/salgram-data"
12
+ HF_TOKEN — токен с правом записи в этот репо
13
+ HF_BACKUP_INTERVAL — (необяз.) период в секундах, по умолчанию 1200 (20 мин)
14
+
15
+ Без них модуль ничего не делает — локальная разработка и Amvera не затронуты.
16
+ """
17
+ import json
18
+ import os
19
+ import shutil
20
+ import sqlite3
21
+ import tempfile
22
+ import threading
23
+ import time
24
+ import traceback
25
+
26
+ REPO_ENV = "HF_BACKUP_REPO"
27
+ TOKEN_ENV = "HF_TOKEN"
28
+ INTERVAL_ENV = "HF_BACKUP_INTERVAL"
29
+ DEFAULT_INTERVAL = 1200 # 20 минут
30
+
31
+ REPO_TYPE = "dataset"
32
+ DB_NAME = "salgram.db"
33
+ EXTRA_FILES = ("secret.key", "vapid.json") # без них восстановленную БД не расшифровать
34
+ MEDIA_DIRS = ("avatars", "photos", "files", "wallpapers", "gifs", "audio")
35
+ _INDEX = ".hf_uploaded.json" # локальный список уже выгруженных файлов (чтобы не слать повторно)
36
+
37
+
38
+ def _config():
39
+ """(repo, token, interval) если бэкап настроен, иначе None."""
40
+ repo = os.environ.get(REPO_ENV)
41
+ token = os.environ.get(TOKEN_ENV)
42
+ if not repo or not token:
43
+ return None
44
+ try:
45
+ interval = int(os.environ.get(INTERVAL_ENV, DEFAULT_INTERVAL))
46
+ except ValueError:
47
+ interval = DEFAULT_INTERVAL
48
+ return repo, token, max(60, interval)
49
+
50
+
51
+ def _log(msg):
52
+ print(f"[hf_backup] {msg}", flush=True)
53
+
54
+
55
+ def _snapshot_db(data_dir, dst_path):
56
+ """Консистентная копия БД через online-backup API SQLite (безопасно при WAL)."""
57
+ src = sqlite3.connect(os.path.join(data_dir, DB_NAME), timeout=30)
58
+ try:
59
+ dst = sqlite3.connect(dst_path)
60
+ try:
61
+ with dst:
62
+ src.backup(dst)
63
+ finally:
64
+ dst.close()
65
+ finally:
66
+ src.close()
67
+
68
+
69
+ def restore_on_boot(data_dir):
70
+ """Если локальной БД нет — скачать последнюю копию из приватного репо.
71
+
72
+ Вызывать ДО _load_key() и init_db(): восстановленные ключ и БД нужны им.
73
+ """
74
+ cfg = _config()
75
+ if not cfg:
76
+ return
77
+ repo, token, _ = cfg
78
+ if os.path.exists(os.path.join(data_dir, DB_NAME)):
79
+ _log("локальная БД уже на месте — восстановление пропущено")
80
+ return
81
+ try:
82
+ from huggingface_hub import snapshot_download
83
+ snap = snapshot_download(repo_id=repo, repo_type=REPO_TYPE, token=token)
84
+ except Exception as e: # репо ещё нет / пустой / нет сети — стартуем с чистой БД
85
+ _log(f"копий пока нет или репо недоступен ({e!r}) — старт с чистой БД")
86
+ return
87
+ count = 0
88
+ for root, _dirs, files in os.walk(snap):
89
+ for name in files:
90
+ if name.startswith("."):
91
+ continue # служебные .gitattributes / .cache
92
+ rel = os.path.relpath(os.path.join(root, name), snap)
93
+ target = os.path.join(data_dir, rel)
94
+ os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
95
+ # snapshot_download кладёт симлинки на кеш — копируем именно содержимое.
96
+ shutil.copyfile(os.path.realpath(os.path.join(root, name)), target)
97
+ count += 1
98
+ _log(f"восстановлено файлов из бэкапа: {count}")
99
+
100
+
101
+ def _remote_files(repo, token):
102
+ """Список файлов, уже лежащих в репо (для первичного заполнения индекса)."""
103
+ try:
104
+ from huggingface_hub import HfApi
105
+ return set(HfApi(token=token).list_repo_files(repo, repo_type=REPO_TYPE))
106
+ except Exception:
107
+ return set()
108
+
109
+
110
+ def backup_now(data_dir):
111
+ """Один цикл бэкапа: снимок БД + новые медиа/ключи -> приватный репо."""
112
+ cfg = _config()
113
+ if not cfg:
114
+ return
115
+ repo, token, _ = cfg
116
+ from huggingface_hub import CommitOperationAdd, HfApi
117
+ api = HfApi(token=token)
118
+ api.create_repo(repo, repo_type=REPO_TYPE, private=True, exist_ok=True)
119
+
120
+ index_path = os.path.join(data_dir, _INDEX)
121
+ if os.path.exists(index_path):
122
+ with open(index_path) as f:
123
+ uploaded = set(json.load(f))
124
+ else: # первый бэкап в этом контейнере — берём, что уже есть в репо
125
+ uploaded = _remote_files(repo, token)
126
+
127
+ with tempfile.TemporaryDirectory() as tmp:
128
+ snap = os.path.join(tmp, DB_NAME)
129
+ _snapshot_db(data_dir, snap)
130
+ ops = [CommitOperationAdd(DB_NAME, snap)] # БД меняется всегда — шлём каждый раз
131
+ added = []
132
+
133
+ for name in EXTRA_FILES:
134
+ path = os.path.join(data_dir, name)
135
+ if os.path.exists(path) and name not in uploaded:
136
+ ops.append(CommitOperationAdd(name, path))
137
+ added.append(name)
138
+
139
+ for d in MEDIA_DIRS:
140
+ ddir = os.path.join(data_dir, d)
141
+ if not os.path.isdir(ddir):
142
+ continue
143
+ for name in sorted(os.listdir(ddir)):
144
+ rel = f"{d}/{name}"
145
+ path = os.path.join(ddir, name)
146
+ if os.path.isfile(path) and rel not in uploaded:
147
+ ops.append(CommitOperationAdd(rel, path))
148
+ added.append(rel)
149
+
150
+ api.create_commit(repo_id=repo, repo_type=REPO_TYPE, operations=ops,
151
+ commit_message="SalGram backup")
152
+
153
+ uploaded.update(added)
154
+ with open(index_path, "w") as f:
155
+ json.dump(sorted(uploaded), f)
156
+ return len(added)
157
+
158
+
159
+ def start(data_dir):
160
+ """Запустить фоновый поток периодического бэкапа (no-op, если не настроено)."""
161
+ cfg = _config()
162
+ if not cfg:
163
+ return
164
+ _, _, interval = cfg
165
+
166
+ def loop():
167
+ while True:
168
+ time.sleep(interval)
169
+ try:
170
+ n = backup_now(data_dir)
171
+ _log(f"бэкап выполнен (+{n} новых файлов)")
172
+ except Exception:
173
+ _log("ошибка бэкапа:\n" + traceback.format_exc())
174
+
175
+ threading.Thread(target=loop, name="hf-backup", daemon=True).start()
176
+ _log(f"периодический бэкап включён: каждые {interval} с -> {os.environ[REPO_ENV]}")
requirements-web.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Зависимости для веб-деплоя (Docker / Hugging Face Spaces).
2
+ # Десктопные pywebview и pystray сюда намеренно не входят.
3
+ flask
4
+ flask-sock
5
+ pillow
6
+ cryptography
7
+ gunicorn
8
+ pywebpush
9
+ huggingface_hub
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ flask-sock
3
+ pillow
4
+ cryptography
5
+ gunicorn
6
+ pywebpush
7
+ pywebview
8
+ pystray
static/app.js ADDED
The diff for this file is too large to render. See raw diff
 
static/favicon.ico ADDED
static/icon-192.png ADDED
static/icon-512.png ADDED
static/icon.ico ADDED
static/index.html ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>SalGram</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <!-- PWA: установка на телефон/десктоп + уведомления -->
9
+ <link rel="manifest" href="manifest.webmanifest">
10
+ <meta name="theme-color" content="#17212b">
11
+ <meta name="apple-mobile-web-app-capable" content="yes">
12
+ <meta name="apple-mobile-web-app-title" content="SalGram">
13
+ <link rel="apple-touch-icon" href="icon-192.png">
14
+ <link rel="icon" type="image/x-icon" href="favicon.ico">
15
+ <link rel="icon" type="image/png" href="icon-192.png">
16
+ </head>
17
+ <body>
18
+
19
+ <!-- ===== Экран авторизации ===== -->
20
+ <main id="auth" class="screen" hidden>
21
+ <section class="card">
22
+ <h1 class="logo">SalGram</h1>
23
+ <p class="tagline">Безопасный мессенджер</p>
24
+
25
+ <!-- Форма регистрации (по умолчанию) -->
26
+ <form id="register-form" class="form">
27
+ <input name="display_name" placeholder="Отображаемый ник" maxlength="50" required>
28
+ <input name="username" placeholder="Юзернейм" maxlength="32"
29
+ autocomplete="username" required>
30
+ <input name="password" type="password" placeholder="Пароль (мин. 8 символов)"
31
+ minlength="8" autocomplete="new-password" required>
32
+ <label class="tos-row">
33
+ <input type="checkbox" name="tos" required>
34
+ <span>Я принимаю <button type="button" class="link" id="tos-open">условия использования</button></span>
35
+ </label>
36
+ <p class="error" hidden></p>
37
+ <button class="primary">Создать аккаунт</button>
38
+ <button type="button" class="link switch" data-target="login-form">Уже есть аккаунт</button>
39
+ </form>
40
+
41
+ <!-- Форма входа -->
42
+ <form id="login-form" class="form" hidden>
43
+ <input name="username" placeholder="Юзернейм" maxlength="32"
44
+ autocomplete="username" required>
45
+ <input name="password" type="password" placeholder="Пароль"
46
+ autocomplete="current-password" required>
47
+ <p class="error" hidden></p>
48
+ <button class="primary">Войти</button>
49
+ <button type="button" class="link switch" data-target="register-form">Нет аккаунта</button>
50
+ </form>
51
+ </section>
52
+ </main>
53
+
54
+ <!-- ===== Основной экран мессенджера ===== -->
55
+ <main id="app" class="app-layout" hidden>
56
+
57
+ <!-- Левая панель -->
58
+ <aside class="sidebar">
59
+ <header class="sidebar-head">SalGram
60
+ <button class="icon-btn" id="group-create" title="Создать группу или канал">
61
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
62
+ </button>
63
+ </header>
64
+
65
+ <!-- Поиск: пользователи или сообщения, частичное/точное совпадение.
66
+ У заблокированного аккаунта вместо поиска — красная надпись. -->
67
+ <div class="search-box">
68
+ <p id="banned-banner" class="banned-banner" hidden>Ваш аккаунт заблокирован!</p>
69
+ <input id="search-input" placeholder="Поиск" autocomplete="off">
70
+ <div class="search-opts" id="search-opts" hidden>
71
+ <select id="search-type">
72
+ <option value="users">Пользователи</option>
73
+ <option value="groups">Группы</option>
74
+ <option value="messages">Сообщения</option>
75
+ </select>
76
+ <select id="search-mode">
77
+ <option value="partial">Частичное</option>
78
+ <option value="exact">Точное</option>
79
+ </select>
80
+ </div>
81
+ </div>
82
+
83
+ <div id="chat-list" class="chat-list"></div>
84
+ <div id="search-results" class="chat-list" hidden></div>
85
+
86
+ <nav class="toolbar">
87
+ <button id="nav-settings" title="Настройки">
88
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.08a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.08a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.08a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
89
+ </button>
90
+ <button id="nav-profile" title="Профиль">
91
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
92
+ </button>
93
+ <button id="nav-logout" title="Выйти">
94
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
95
+ </button>
96
+ </nav>
97
+ </aside>
98
+
99
+ <!-- Правая область -->
100
+ <section class="main">
101
+
102
+ <!-- Заглушка -->
103
+ <div id="view-empty" class="view view-center">
104
+ <p class="stub">Выберите чат</p>
105
+ </div>
106
+
107
+ <!-- Чат -->
108
+ <div id="view-chat" class="view view-chat" hidden>
109
+ <header class="chat-head" id="chat-head" title="Открыть профиль">
110
+ <button class="icon-btn mobile-back" id="chat-back" title="Назад">
111
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
112
+ </button>
113
+ <div class="ava" id="chat-ava"></div>
114
+ <div class="chat-head-info">
115
+ <span id="chat-name"></span>
116
+ <span id="chat-status" class="muted small"></span>
117
+ </div>
118
+ <button class="icon-btn chat-del" id="chat-delete" title="Удалить переписку">
119
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
120
+ </button>
121
+ </header>
122
+ <!-- Обои чата: фото или видео фоном под сообщениями -->
123
+ <div id="chat-bg" class="chat-bg"></div>
124
+ <!-- Закреплённое сообщение (последнее из закреплённых) -->
125
+ <div id="pinned-bar" class="pinned-bar" hidden>
126
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
127
+ <div class="pinned-info">
128
+ <small>Закреплённое сообщение</small>
129
+ <span id="pinned-text"></span>
130
+ </div>
131
+ <button class="icon-btn" id="pinned-unpin" title="Открепить">
132
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
133
+ </button>
134
+ </div>
135
+ <div id="messages" class="messages"></div>
136
+ <p class="error chat-error" id="chat-error" hidden></p>
137
+ <p class="chat-blocked" id="chat-blocked" hidden></p>
138
+ <!-- Панель ответа: цитата сообщения, на которое отвечаем (над полем ввода) -->
139
+ <div id="reply-bar" class="reply-bar" hidden>
140
+ <svg class="reply-bar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
141
+ <div class="reply-bar-info">
142
+ <small id="reply-bar-name"></small>
143
+ <span id="reply-bar-text"></span>
144
+ </div>
145
+ <button type="button" class="icon-btn" id="reply-cancel" title="Отменить ответ">
146
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
147
+ </button>
148
+ </div>
149
+ <!-- Панель правки: показывается, когда сообщение редактируется в поле ввода -->
150
+ <div id="edit-bar" class="reply-bar" hidden>
151
+ <svg class="reply-bar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
152
+ <div class="reply-bar-info">
153
+ <small>Редактирование</small>
154
+ <span>Enter — сохранить, Esc — отменить</span>
155
+ </div>
156
+ <button type="button" class="icon-btn" id="edit-cancel" title="Отменить редактирование">
157
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
158
+ </button>
159
+ </div>
160
+ <form id="msg-form" class="msg-form">
161
+ <button type="button" class="icon-btn big" id="attach-btn" title="Прикрепить фото или файл (до 25 МБ)">
162
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
163
+ </button>
164
+ <button type="button" class="icon-btn big gif-btn" id="gif-btn" title="Отправить GIF">GIF</button>
165
+ <div id="msg-input" class="msg-input" contenteditable="true" role="textbox"
166
+ aria-multiline="true" data-placeholder="Сообщение"></div>
167
+ <input type="color" id="fmt-color" hidden>
168
+ <button type="button" class="icon-btn big" id="mic-btn" title="Записать голосовое сообщение">
169
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
170
+ </button>
171
+ <button class="icon-btn big" title="Отправить">
172
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
173
+ </button>
174
+ <input type="file" id="photo-input" hidden>
175
+ </form>
176
+ <!-- Панель записи голосового: показывается во время записи вместо ввода -->
177
+ <div id="rec-bar" class="rec-bar" hidden>
178
+ <button type="button" class="icon-btn big rec-cancel" id="rec-cancel" title="Отменить">
179
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
180
+ </button>
181
+ <span class="rec-dot"></span>
182
+ <span class="rec-time" id="rec-time">0:00</span>
183
+ <span class="rec-hint">Идёт запись…</span>
184
+ <button type="button" class="icon-btn big rec-send" id="rec-send" title="Отправить голосовое">
185
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
186
+ </button>
187
+ </div>
188
+ <!-- Панель чата с ботом @addgif: вместо ввода — добавление GIF в библиотеку -->
189
+ <div id="addgif-bar" class="addgif-bar" hidden>
190
+ <button type="button" class="primary" id="addgif-add">Добавить GIF в библиотеку</button>
191
+ </div>
192
+ <!-- Панель канала для подписчика: открыть обсуждение (слева) и отписаться (справа) -->
193
+ <div id="channel-bar" class="channel-bar" hidden>
194
+ <button type="button" class="primary" id="channel-open-chat">Открыть чат</button>
195
+ <button type="button" class="primary danger-btn" id="channel-unsub">Отписаться</button>
196
+ </div>
197
+ </div>
198
+
199
+ <!-- Профиль другого пользователя -->
200
+ <div id="view-user" class="view" hidden>
201
+ <div class="panel profile">
202
+ <button class="panel-back mobile-back" data-back>
203
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
204
+ Назад
205
+ </button>
206
+ <div class="avatar readonly">
207
+ <img id="user-ava" alt="Аватарка" hidden>
208
+ <svg id="user-ava-stub" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
209
+ </div>
210
+ <div class="profile-row">
211
+ <h2 id="user-name"></h2>
212
+ <span id="user-status" class="muted small"></span>
213
+ </div>
214
+ <div class="profile-actions">
215
+ <button class="primary small-btn" id="user-add-contact">Добавить в контакты</button>
216
+ <button class="primary small-btn danger-btn" id="user-block">Заблокировать</button>
217
+ </div>
218
+ <span id="user-username" class="muted"></span>
219
+ <button class="primary write-btn" id="user-write">Написать</button>
220
+ <p id="user-desc" class="desc"></p>
221
+ <section class="contacts" id="user-contacts-sec" hidden>
222
+ <div class="profile-row"><h3>Контакты</h3></div>
223
+ <ul id="user-contact-list"></ul>
224
+ </section>
225
+ <div class="profile-actions bottom">
226
+ <button class="link" id="user-report">Пожаловаться</button>
227
+ <button class="link danger-link" id="user-delete" hidden>Удалить аккаунт</button>
228
+ </div>
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Профиль группы -->
233
+ <div id="view-group" class="view" hidden>
234
+ <div class="panel profile">
235
+ <button class="panel-back mobile-back" data-back>
236
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
237
+ Назад
238
+ </button>
239
+ <div class="ava big" id="group-ava"></div>
240
+ <input type="file" id="group-avatar-input" accept="image/*" hidden>
241
+ <div class="profile-row"><h2 id="group-title"></h2></div>
242
+ <span id="group-meta" class="muted"></span>
243
+ <button class="primary write-btn" id="group-open">Открыть чат</button>
244
+ <button class="primary write-btn" id="group-join">Вступить</button>
245
+ <!-- Пригласительная ссылка (видна участникам) -->
246
+ <div class="invite-row" id="group-invite-row" hidden>
247
+ <span id="group-invite" class="invite-link"></span>
248
+ <button class="link" id="group-invite-copy">Копировать</button>
249
+ </div>
250
+ <!-- Управление комментариями канала (открыть / привязать / отвязать группу) -->
251
+ <div id="group-channel-extra" class="group-channel-extra"></div>
252
+ <!-- Настройки модерации (для владельца/админов группы) -->
253
+ <div id="group-mod-row" class="group-mod-row"></div>
254
+ <section class="contacts" id="group-members-sec" hidden>
255
+ <div class="profile-row">
256
+ <h3 id="group-members-title">Участники</h3>
257
+ <button class="icon-btn" id="group-add-member" title="Добавить участника" hidden>
258
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
259
+ </button>
260
+ </div>
261
+ <ul id="group-member-list"></ul>
262
+ </section>
263
+ <div class="profile-actions bottom">
264
+ <button class="link danger-link" id="group-leave" hidden>Выйти из группы</button>
265
+ <button class="link danger-link" id="group-delete" hidden>Удалить группу</button>
266
+ </div>
267
+ </div>
268
+ </div>
269
+
270
+ <!-- Настройки -->
271
+ <div id="view-settings" class="view" hidden>
272
+ <div class="panel">
273
+ <!-- Корень настроек -->
274
+ <div id="page-root" class="settings-page">
275
+ <button class="panel-back mobile-back" data-back>
276
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
277
+ Назад
278
+ </button>
279
+ <h2>Настройки</h2>
280
+ <button class="settings-item" data-page="page-account">Аккаунт
281
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
282
+ </button>
283
+ <button class="settings-item" data-page="page-privacy">Приватность
284
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
285
+ </button>
286
+ <button class="settings-item" data-page="page-chats">Чаты
287
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
288
+ </button>
289
+ <button class="settings-item" id="root-appearance" data-page="page-appearance">Оформление
290
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
291
+ </button>
292
+ <!-- Только в ПК-приложении: кэш, оформление, уведомления и автозапуск в одном месте -->
293
+ <button class="settings-item" id="root-app" data-page="page-app" hidden>Приложение
294
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
295
+ </button>
296
+ <button class="settings-item" data-page="page-misc">Прочее
297
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
298
+ </button>
299
+ </div>
300
+
301
+ <!-- Аккаунт -->
302
+ <div id="page-account" class="settings-page" hidden>
303
+ <button class="settings-back" data-page="page-root">
304
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
305
+ Назад
306
+ </button>
307
+ <h2>Аккаунт</h2>
308
+ <button class="settings-item" id="opt-nick">Изменить ник</button>
309
+ <button class="settings-item" id="opt-username">Изменить юзернейм</button>
310
+ <button class="settings-item" id="opt-password">Изменить пароль</button>
311
+ <button class="settings-item danger" id="opt-delete">Удалить аккаунт</button>
312
+ </div>
313
+
314
+ <!-- Приватность -->
315
+ <div id="page-privacy" class="settings-page" hidden>
316
+ <button class="settings-back" data-page="page-root">
317
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
318
+ Назад
319
+ </button>
320
+ <h2>Приватность</h2>
321
+ <label class="settings-row">Кому видна аватарка
322
+ <select id="privacy-avatar">
323
+ <option value="everyone">Всем</option>
324
+ <option value="contacts">Только контактам</option>
325
+ <option value="nobody">Никому</option>
326
+ </select>
327
+ </label>
328
+ <label class="settings-row">Кому виден список контактов
329
+ <select id="privacy-contacts">
330
+ <option value="everyone">Всем</option>
331
+ <option value="contacts">Только контактам</option>
332
+ <option value="nobody">Никому</option>
333
+ </select>
334
+ </label>
335
+ <label class="settings-row">Кто может добавлять меня в группы
336
+ <select id="privacy-group-invite">
337
+ <option value="everyone">Все</option>
338
+ <option value="contacts">Только мои контакты</option>
339
+ <option value="nobody">Никто</option>
340
+ </select>
341
+ </label>
342
+ </div>
343
+
344
+ <!-- Чаты: медленный режим -->
345
+ <div id="page-chats" class="settings-page" hidden>
346
+ <button class="settings-back" data-page="page-root">
347
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
348
+ Назад
349
+ </button>
350
+ <h2>Чаты</h2>
351
+ <label class="settings-row">Медленный режим: интервал (сек)
352
+ <input type="number" id="slow-seconds" min="0" max="3600" class="num">
353
+ </label>
354
+ <label class="settings-row">Действует на
355
+ <select id="slow-scope">
356
+ <option value="nobody">Ни на кого</option>
357
+ <option value="noncontacts">Всех, кроме контактов</option>
358
+ <option value="everyone">Всех</option>
359
+ </select>
360
+ </label>
361
+ <p class="hint">Ограничивает, как часто вам могут отправлять сообщения.</p>
362
+ <div class="settings-row" id="row-cache">Кэш сообщений
363
+ <span class="cache-row">
364
+ <span id="cache-size" class="muted">пусто</span>
365
+ <button type="button" class="link" id="cache-clear">Очистить</button>
366
+ </span>
367
+ </div>
368
+ <p class="hint" id="hint-cache">Переписки сохраняются на этом устройстве, чтобы чаты открывались
369
+ мгновенно. Удалённые сообщения удаляются и из кэша. Кэш очищается при выходе
370
+ из аккаунта.</p>
371
+ </div>
372
+
373
+ <!-- Оформление: тема и обои -->
374
+ <div id="page-appearance" class="settings-page" hidden>
375
+ <button class="settings-back" data-page="page-root">
376
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
377
+ Назад
378
+ </button>
379
+ <h2>Оформление</h2>
380
+ <label class="settings-row" id="row-theme">Цветовая тема
381
+ <select id="theme-select">
382
+ <option value="dark">Тёмная</option>
383
+ <option value="light">Светлая</option>
384
+ <option value="oled">Чёрная (OLED)</option>
385
+ <option value="blue">Синяя</option>
386
+ </select>
387
+ </label>
388
+ <div class="settings-row" id="row-wallpaper">Обои чата
389
+ <span class="cache-row">
390
+ <button type="button" class="link" id="wallpaper-upload">Загрузить</button>
391
+ <button type="button" class="link" id="wallpaper-remove" hidden>Удалить</button>
392
+ </span>
393
+ </div>
394
+ <p class="hint" id="hint-wallpaper">Обои — фото или видео (до 25 МБ), показываются фоном в переписке.
395
+ Тема сохраняется на этом устройстве.</p>
396
+ <input type="file" id="wallpaper-input" accept="image/*,video/*" hidden>
397
+ </div>
398
+
399
+ <!-- Приложение: только для ПК-версии. Сюда переносятся оформление,
400
+ кэш и уведомления; автозапуск существует только здесь. -->
401
+ <div id="page-app" class="settings-page" hidden>
402
+ <button class="settings-back" data-page="page-root">
403
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
404
+ Назад
405
+ </button>
406
+ <h2>Приложение</h2>
407
+ <!-- Блоки «Оформление», «Кэш» и «Уведомления» переносятся сюда из своих
408
+ разделов скриптом applyDesktopUI(). Здесь — только автозапуск. -->
409
+ <h3 class="settings-sub" id="sub-autostart">Автозапуск</h3>
410
+ <label class="settings-row" id="row-autostart">Запускать при старте системы
411
+ <input type="checkbox" id="autostart-toggle">
412
+ </label>
413
+ <p class="hint" id="hint-autostart">При включении SalGram будет автоматически
414
+ запускаться при входе в Windows. По умолчанию выключено.</p>
415
+ </div>
416
+
417
+ <!-- Прочее: часовой пояс -->
418
+ <div id="page-misc" class="settings-page" hidden>
419
+ <button class="settings-back" data-page="page-root">
420
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
421
+ Назад
422
+ </button>
423
+ <h2>Прочее</h2>
424
+ <label class="settings-row" id="row-notify">Уведомления о сообщениях
425
+ <input type="checkbox" id="notify-toggle">
426
+ </label>
427
+ <p class="hint" id="notify-hint">Показывать системные уведомления о новых сообщениях
428
+ (в том числе когда приложение свёрнуто). Настройка для этого устройства.</p>
429
+ <label class="settings-row">Часовой пояс
430
+ <select id="tz-offset"></select>
431
+ </label>
432
+ <p class="hint">Определяется автоматически и сохраняется на этом устройстве (в cookie).
433
+ Время сообщений и статусов «был(а) в сети» показывается в этом поясе.</p>
434
+ </div>
435
+ </div>
436
+ </div>
437
+
438
+ <!-- Собственный профиль -->
439
+ <div id="view-profile" class="view" hidden>
440
+ <div class="panel profile">
441
+ <button class="panel-back mobile-back" data-back>
442
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
443
+ Назад
444
+ </button>
445
+ <label class="avatar" title="Изменить аватарку">
446
+ <img id="avatar-img" alt="Аватарка" hidden>
447
+ <svg id="avatar-stub" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
448
+ <input type="file" id="avatar-input" accept="image/*,video/*" hidden>
449
+ </label>
450
+
451
+ <div class="profile-row">
452
+ <h2 id="profile-name"></h2>
453
+ <button class="icon-btn" id="edit-nick" title="Изменить ник">
454
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
455
+ </button>
456
+ </div>
457
+ <div class="profile-row">
458
+ <span id="profile-username" class="muted"></span>
459
+ <button class="icon-btn" id="edit-username" title="Изменить юзернейм">
460
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
461
+ </button>
462
+ </div>
463
+ <div class="profile-row">
464
+ <p id="profile-desc" class="desc"></p>
465
+ <button class="icon-btn" id="edit-desc" title="Изменить описание">
466
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
467
+ </button>
468
+ </div>
469
+
470
+ <section class="contacts">
471
+ <div class="profile-row">
472
+ <button class="contacts-toggle" id="contacts-toggle" aria-expanded="false">
473
+ <svg class="contacts-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
474
+ <h3>Контакты</h3>
475
+ </button>
476
+ <button class="icon-btn" id="contact-add" title="Добавить контакт">
477
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
478
+ </button>
479
+ </div>
480
+ <ul id="contact-list" hidden></ul>
481
+ </section>
482
+ </div>
483
+ </div>
484
+
485
+ </section>
486
+ </main>
487
+
488
+ <!-- ===== Модальное окно с условиями использования ===== -->
489
+ <div id="tos-modal" class="modal" hidden>
490
+ <div class="modal-card">
491
+ <h2>Условия использования сервиса SalGram</h2>
492
+ <ol>
493
+ <li><b>Изменение условий.</b> Администрация Сервиса вправе в одностороннем
494
+ порядке изменять настоящее Соглашение в любой момент без предварительного
495
+ уведомления. Продолжение использования Сервиса после внесения изменений
496
+ означает согласие Пользователя с новой редакцией Соглашения.</li>
497
+ <li><b>Предоставление сервиса.</b> Сервис предоставляется на условиях
498
+ «как есть» (as is). Администрация не гарантирует бесперебойную,
499
+ своевременную и безошибочную работу Сервиса: он может быть недоступен
500
+ или работать нестабильно.</li>
501
+ <li><b>Ограничение ответственности.</b> Разработчик не несёт ответственности
502
+ за действия Пользователей, а также за любую информацию и контент,
503
+ передаваемые или размещаемые внутри Сервиса.</li>
504
+ <li><b>Конфиденциальность переписок.</b> Все переписки Пользователей
505
+ хранятся исключительно в зашифрованном виде.</li>
506
+ <li><b>Персональные данные.</b> Персональные данные Пользователя
507
+ используются исключительно для аутентификации (входа в аккаунт)
508
+ и не применяются в каких-либо иных целях.</li>
509
+ </ol>
510
+ <button type="button" class="primary" id="tos-close">Закрыть</button>
511
+ </div>
512
+ </div>
513
+
514
+ <script src="app.js"></script>
515
+ </body>
516
+ </html>
static/manifest.webmanifest ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "SalGram",
3
+ "short_name": "SalGram",
4
+ "description": "Безопасный мессенджер",
5
+ "start_url": "/",
6
+ "scope": "/",
7
+ "display": "standalone",
8
+ "orientation": "portrait-primary",
9
+ "background_color": "#161618",
10
+ "theme_color": "#17212b",
11
+ "lang": "ru",
12
+ "dir": "ltr",
13
+ "categories": ["social", "communication"],
14
+ "icons": [
15
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
16
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
17
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
18
+ ]
19
+ }
static/style.css ADDED
@@ -0,0 +1,1133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* SalGram — минималистичная тёмно-серая тема. */
2
+
3
+ :root {
4
+ --bg: #161618; /* фон страницы */
5
+ --card: #1f1f23; /* фон карточек и панелей */
6
+ --field: #29292e; /* поля ввода */
7
+ --border: #3a3a41;
8
+ --text: #e8e8ea;
9
+ --muted: #8b8b93;
10
+ --accent: #44444c; /* брендовый тёмно-серый */
11
+ --accent-hover: #55555e;
12
+ --error: #e07070;
13
+ --danger: #b05050;
14
+ --link: #8db6de; /* @юзернеймы, подписи закрепов, авторы в группах */
15
+ --field-focus: #2e2e34; /* фон поля ввода в фокусе */
16
+ --time-mine: #c5c5cc; /* время в своих сообщениях */
17
+ --wallpaper-dim: rgba(0, 0, 0, .35); /* затемнение обоев для читаемости */
18
+ --radius: 12px;
19
+ }
20
+
21
+ /* ===== Цветовые темы (data-theme на <html>, выбор в «Оформлении») ===== */
22
+
23
+ :root[data-theme="light"] {
24
+ --bg: #f1f1f4;
25
+ --card: #ffffff;
26
+ --field: #e9e9ee;
27
+ --border: #d7d7de;
28
+ --text: #1d1d21;
29
+ --muted: #73737e;
30
+ --accent: #d3d3dc;
31
+ --accent-hover: #c0c0cc;
32
+ --error: #c4453f;
33
+ --danger: #b05050;
34
+ --link: #3a6ea8;
35
+ --field-focus: #ffffff;
36
+ --time-mine: #5b5b66;
37
+ --wallpaper-dim: rgba(255, 255, 255, .25);
38
+ }
39
+
40
+ :root[data-theme="oled"] {
41
+ --bg: #000000;
42
+ --card: #0e0e10;
43
+ --field: #1b1b1e;
44
+ --border: #2c2c30;
45
+ --accent: #2e2e36;
46
+ --accent-hover: #3d3d47;
47
+ --field-focus: #232327;
48
+ }
49
+
50
+ :root[data-theme="blue"] {
51
+ --bg: #0e1621;
52
+ --card: #17212b;
53
+ --field: #242f3d;
54
+ --border: #31404e;
55
+ --muted: #7f91a4;
56
+ --accent: #2b5278;
57
+ --accent-hover: #36648f;
58
+ --link: #6ab2f2;
59
+ --field-focus: #2b3847;
60
+ --time-mine: #9fb9d4;
61
+ }
62
+
63
+ * { box-sizing: border-box; margin: 0; }
64
+
65
+ /* Атрибут hidden должен побеждать любые display-правила (.modal, .form и т.д.) */
66
+ [hidden] { display: none !important; }
67
+
68
+ body {
69
+ font-family: "Segoe UI", system-ui, sans-serif;
70
+ background: var(--bg);
71
+ color: var(--text);
72
+ }
73
+
74
+ .muted { color: var(--muted); }
75
+ .empty { color: var(--muted); font-size: 14px; text-align: center; padding: 16px; list-style: none; }
76
+ .stub { color: var(--muted); font-size: 20px; }
77
+
78
+ /* ===== Экран авторизации ===== */
79
+
80
+ .screen {
81
+ min-height: 100vh;
82
+ min-height: 100dvh; /* мобильные браузеры: высота без учёта панелей UI */
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: center;
86
+ padding: 16px;
87
+ }
88
+
89
+ .card {
90
+ width: 100%;
91
+ max-width: 380px;
92
+ background: var(--card);
93
+ border: 1px solid var(--border);
94
+ border-radius: var(--radius);
95
+ padding: 36px 32px;
96
+ text-align: center;
97
+ animation: fade-up .5s ease both;
98
+ }
99
+
100
+ .logo { font-size: 28px; letter-spacing: 1px; }
101
+ .tagline { color: var(--muted); font-size: 14px; margin: 6px 0 24px; }
102
+
103
+ .form { display: flex; flex-direction: column; gap: 12px; }
104
+
105
+ .tos-row {
106
+ display: flex;
107
+ gap: 8px;
108
+ align-items: center;
109
+ font-size: 13px;
110
+ color: var(--muted);
111
+ text-align: left;
112
+ }
113
+ .tos-row input { accent-color: var(--accent-hover); }
114
+
115
+ /* ===== Поля ввода ===== */
116
+
117
+ input:not([type=checkbox]), textarea, select {
118
+ background: var(--field);
119
+ border: 1px solid transparent;
120
+ border-radius: var(--radius);
121
+ padding: 12px 14px;
122
+ font: inherit;
123
+ font-size: 15px;
124
+ color: var(--text);
125
+ transition: border-color .2s, background .2s;
126
+ }
127
+ input::placeholder, textarea::placeholder { color: var(--muted); }
128
+ input:focus, textarea:focus, select:focus {
129
+ outline: none;
130
+ border-color: var(--accent-hover);
131
+ background: var(--field-focus);
132
+ }
133
+ textarea { resize: vertical; min-height: 90px; }
134
+
135
+ /* ===== Кнопки ===== */
136
+
137
+ button { font: inherit; cursor: pointer; }
138
+
139
+ .primary {
140
+ background: var(--accent);
141
+ color: var(--text);
142
+ border: none;
143
+ border-radius: var(--radius);
144
+ padding: 12px;
145
+ font-size: 15px;
146
+ transition: background .2s, transform .1s;
147
+ }
148
+ .primary:hover { background: var(--accent-hover); }
149
+ .primary:active { transform: scale(.98); }
150
+
151
+ .link {
152
+ background: none;
153
+ border: none;
154
+ padding: 0;
155
+ color: var(--muted);
156
+ font-size: 14px;
157
+ text-decoration: underline;
158
+ transition: color .2s;
159
+ }
160
+ .link:hover { color: var(--text); }
161
+
162
+ .icon-btn {
163
+ background: none;
164
+ border: none;
165
+ padding: 4px;
166
+ color: var(--muted);
167
+ display: inline-flex;
168
+ border-radius: 8px;
169
+ transition: color .2s, background .2s;
170
+ }
171
+ .icon-btn:hover { color: var(--text); background: var(--field); }
172
+ .icon-btn svg { width: 16px; height: 16px; }
173
+
174
+ .error { color: var(--error); font-size: 13px; animation: shake .3s ease; }
175
+
176
+ /* ===== Макет мессенджера ===== */
177
+
178
+ .app-layout { display: flex; height: 100vh; height: 100dvh; }
179
+
180
+ .sidebar {
181
+ width: 300px;
182
+ flex-shrink: 0;
183
+ display: flex;
184
+ flex-direction: column;
185
+ background: var(--card);
186
+ border-right: 1px solid var(--border);
187
+ }
188
+ .sidebar-head {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: space-between;
192
+ padding: 16px 16px 10px;
193
+ font-size: 18px;
194
+ font-weight: 600;
195
+ letter-spacing: 1px;
196
+ }
197
+ .sidebar-head .icon-btn svg { width: 20px; height: 20px; }
198
+ .chat-list { flex: 1; overflow-y: auto; }
199
+
200
+ /* --- Поиск --- */
201
+ .search-box {
202
+ padding: 0 12px 10px;
203
+ border-bottom: 1px solid var(--border);
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 8px;
207
+ }
208
+ .search-box input { padding: 9px 12px; font-size: 14px; width: 100%; }
209
+ .search-opts { display: flex; gap: 8px; }
210
+ .search-opts select { flex: 1; padding: 7px 8px; font-size: 13px; }
211
+
212
+ /* --- Строки списка чатов и поисковой выдачи --- */
213
+ .chat-row {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ width: 100%;
218
+ padding: 10px 12px;
219
+ background: none;
220
+ border: none;
221
+ color: var(--text);
222
+ text-align: left;
223
+ transition: background .15s;
224
+ }
225
+ .chat-row:hover, .chat-row.active { background: var(--field); }
226
+ .chat-row-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
227
+ .chat-row-info span { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
228
+ .chat-row-info small { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
229
+ .chat-row time { color: var(--muted); font-size: 11px; flex-shrink: 0; }
230
+
231
+ /* Кружок-аватарка: первая буква имени либо картинка поверх */
232
+ .ava {
233
+ width: 40px;
234
+ height: 40px;
235
+ border-radius: 50%;
236
+ background: var(--accent);
237
+ color: var(--text);
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: center;
241
+ font-size: 16px;
242
+ flex-shrink: 0;
243
+ position: relative;
244
+ overflow: hidden;
245
+ }
246
+ .ava img, .ava video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
247
+
248
+ /* Нижняя панель: настройки / профиль / выход */
249
+ .toolbar {
250
+ display: flex;
251
+ border-top: 1px solid var(--border);
252
+ background: var(--card);
253
+ }
254
+ .toolbar button {
255
+ flex: 1;
256
+ padding: 14px 0;
257
+ background: none;
258
+ border: none;
259
+ color: var(--muted);
260
+ display: flex;
261
+ justify-content: center;
262
+ transition: color .2s, background .2s;
263
+ }
264
+ .toolbar button:hover, .toolbar button.active { color: var(--text); background: var(--field); }
265
+ .toolbar svg { width: 22px; height: 22px; }
266
+
267
+ .main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
268
+ .view { flex: 1; padding: 32px; animation: fade-up .3s ease both; }
269
+ .view-center { display: flex; align-items: center; justify-content: center; }
270
+ .panel { max-width: 460px; margin: 0 auto; width: 100%; }
271
+
272
+ /* ===== Чат ===== */
273
+
274
+ /* overflow: hidden + min-height: 0 не дают чату растягиваться по контенту:
275
+ скроллится только .messages, поэтому поле ввода всегда закреплено снизу,
276
+ а обои (.chat-bg) остаются размером с видимую область, а не со всю переписку. */
277
+ .view-chat {
278
+ padding: 0;
279
+ display: flex;
280
+ flex-direction: column;
281
+ position: relative;
282
+ overflow: hidden;
283
+ min-height: 0;
284
+ }
285
+
286
+ /* Обои чата: фото (фоном) или видео, с затемнением для читаемости */
287
+ .chat-bg {
288
+ position: absolute;
289
+ inset: 0;
290
+ z-index: 0;
291
+ background-size: cover;
292
+ background-position: center;
293
+ }
294
+ .chat-bg video { width: 100%; height: 100%; object-fit: cover; }
295
+ .chat-bg.has-wp::after { content: ""; position: absolute; inset: 0; background: var(--wallpaper-dim); }
296
+ .view-chat > :not(.chat-bg) { position: relative; z-index: 1; }
297
+
298
+ .chat-head {
299
+ display: flex;
300
+ align-items: center;
301
+ gap: 12px;
302
+ padding: 10px 16px;
303
+ border-bottom: 1px solid var(--border);
304
+ background: var(--card);
305
+ cursor: pointer;
306
+ }
307
+ .chat-head-info { display: flex; flex-direction: column; gap: 1px; }
308
+ .chat-head-info span:first-child { font-size: 15px; font-weight: 600; }
309
+ .small { font-size: 12px; }
310
+
311
+ .messages {
312
+ flex: 1;
313
+ overflow-y: auto;
314
+ padding: 16px;
315
+ display: flex;
316
+ flex-direction: column;
317
+ gap: 8px;
318
+ }
319
+
320
+ .msg {
321
+ max-width: 65%;
322
+ align-self: flex-start;
323
+ background: var(--card);
324
+ border: 1px solid var(--border);
325
+ border-radius: var(--radius);
326
+ border-bottom-left-radius: 4px;
327
+ padding: 8px 12px 6px;
328
+ animation: fade-up .2s ease both;
329
+ position: relative;
330
+ }
331
+ /* Аватарка автора в группе — слева от пузыря, выровнена по нижнему краю */
332
+ .msg.has-ava { margin-left: 42px; }
333
+ .msg-ava {
334
+ position: absolute;
335
+ left: -42px;
336
+ bottom: 0;
337
+ width: 32px;
338
+ height: 32px;
339
+ font-size: 13px;
340
+ cursor: pointer;
341
+ }
342
+ .msg.mine {
343
+ align-self: flex-end;
344
+ background: var(--accent);
345
+ border-color: transparent;
346
+ border-bottom-left-radius: var(--radius);
347
+ border-bottom-right-radius: 4px;
348
+ }
349
+ .msg p { font-size: 14px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
350
+ .msg img { max-width: 320px; max-height: 320px; border-radius: 8px; display: block; cursor: pointer; }
351
+ .msg time { display: block; text-align: right; font-size: 10px; color: var(--muted); margin-top: 3px; }
352
+ .msg.mine time { color: var(--time-mine); }
353
+
354
+ /* Автор сообщения в группе (кликабельно — открывает профиль) */
355
+ .msg-author {
356
+ display: block;
357
+ color: var(--link);
358
+ font-size: 12px;
359
+ font-weight: 600;
360
+ margin-bottom: 2px;
361
+ cursor: pointer;
362
+ }
363
+ .msg-author:hover { text-decoration: underline; }
364
+
365
+ .msg-form {
366
+ display: flex;
367
+ align-items: center;
368
+ gap: 6px;
369
+ padding: 10px 12px;
370
+ border-top: 1px solid var(--border);
371
+ background: var(--card);
372
+ transition: background .2s;
373
+ }
374
+ .msg-form.drag { background: var(--field); }
375
+ .msg-form input[type=text], #msg-input { flex: 1; padding: 10px 14px; font-size: 14px; }
376
+ .icon-btn.big { padding: 8px; }
377
+ .icon-btn.big svg { width: 20px; height: 20px; }
378
+
379
+ /* Поле ввода сообщения (contenteditable: поддерживает форматирование) */
380
+ .msg-input {
381
+ position: relative;
382
+ background: var(--field);
383
+ border: 1px solid transparent;
384
+ border-radius: var(--radius);
385
+ color: var(--text);
386
+ line-height: 1.4;
387
+ max-height: 140px;
388
+ overflow-y: auto;
389
+ white-space: pre-wrap;
390
+ word-break: break-word;
391
+ outline: none;
392
+ cursor: text;
393
+ transition: border-color .2s, background .2s;
394
+ }
395
+ .msg-input:focus { border-color: var(--accent-hover); background: var(--field-focus); }
396
+ .msg-input.is-empty::before {
397
+ content: attr(data-placeholder);
398
+ position: absolute;
399
+ color: var(--muted);
400
+ pointer-events: none;
401
+ }
402
+
403
+ /* Стили форматирования текста (в сообщениях и в поле ввода) */
404
+ .f-b { font-weight: 700; }
405
+ .f-i { font-style: italic; }
406
+ .f-u { text-decoration: underline; }
407
+ .f-s { text-decoration: line-through; }
408
+ .f-u.f-s, .f-s .f-u, .f-u .f-s { text-decoration: underline line-through; }
409
+ .f-code { font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
410
+ background: rgba(127,127,127,.18); border-radius: 4px; padding: 0 3px; }
411
+ .f-quote { display: inline-block; border-left: 3px solid var(--accent-hover);
412
+ padding-left: 8px; margin: 1px 0; }
413
+ /* Абсолютные размеры — чтобы вложенные/перекрывающие пометки не складывались,
414
+ а «Обычный» сбрасывал размер обратно к базовому (14px). */
415
+ .f-size[data-size=huge] { font-size: 26px; }
416
+ .f-size[data-size=big] { font-size: 19px; }
417
+ .f-size[data-size=normal] { font-size: 14px; }
418
+ .f-size[data-size=small] { font-size: 11px; }
419
+ .f-size[data-size=tiny] { font-size: 9px; }
420
+
421
+ /* Разделитель/заголовок в контекстном меню форматирования */
422
+ .msg-menu-sep {
423
+ font-size: 11px;
424
+ text-transform: uppercase;
425
+ letter-spacing: .04em;
426
+ color: var(--muted);
427
+ padding: 6px 12px 2px;
428
+ border-top: 1px solid var(--border);
429
+ margin-top: 2px;
430
+ }
431
+
432
+ /* Панель ответа над полем ввода */
433
+ .reply-bar {
434
+ display: flex;
435
+ align-items: center;
436
+ gap: 8px;
437
+ padding: 8px 12px;
438
+ border-top: 1px solid var(--border);
439
+ background: var(--card);
440
+ }
441
+ .reply-bar-icon { width: 18px; height: 18px; color: var(--accent-hover); flex-shrink: 0; }
442
+ .reply-bar-info { flex: 1; min-width: 0; border-left: 2px solid var(--accent-hover); padding-left: 8px; }
443
+ .reply-bar-info small { display: block; color: var(--link); font-weight: 600; font-size: 12px; }
444
+ .reply-bar-info span { display: block; color: var(--muted); font-size: 13px;
445
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
446
+
447
+ /* Галочки доставки/прочтения у своих сообщений */
448
+ .ticks { display: inline-block; margin-left: 4px; vertical-align: baseline; }
449
+ .ticks::after { content: "✓"; }
450
+ .ticks.read::after { content: "✓✓"; letter-spacing: -3px; padding-right: 3px; }
451
+
452
+ /* Кнопка-сворачивание списка контактов */
453
+ .contacts-toggle {
454
+ display: flex; align-items: center; gap: 4px;
455
+ background: none; border: none; cursor: pointer; color: var(--text); padding: 0;
456
+ }
457
+ .contacts-toggle h3 { margin: 0; }
458
+ .contacts-chevron { width: 16px; height: 16px; color: var(--muted); transition: transform .2s; }
459
+ .contacts-toggle.open .contacts-chevron { transform: rotate(90deg); }
460
+
461
+ .chat-blocked {
462
+ padding: 16px;
463
+ text-align: center;
464
+ color: var(--muted);
465
+ font-size: 14px;
466
+ border-top: 1px solid var(--border);
467
+ background: var(--card);
468
+ }
469
+ .chat-error { padding: 6px 16px; text-align: center; }
470
+
471
+ /* ===== Профиль другого пользователя ===== */
472
+
473
+ .avatar.readonly { cursor: default; }
474
+ .avatar.readonly:hover { border-color: var(--border); transform: none; }
475
+ .profile-actions { display: flex; gap: 10px; margin: 6px 0 2px; }
476
+ .small-btn { padding: 8px 14px; font-size: 13px; }
477
+ .danger-btn { color: var(--error); }
478
+ .write-btn { margin: 10px 0 6px; padding: 10px 32px; }
479
+
480
+ .hint { color: var(--muted); font-size: 12px; line-height: 1.5; }
481
+ .num { width: 90px; text-align: center; }
482
+ .profile-actions.bottom { margin-top: 24px; }
483
+ .danger-link { color: var(--error); }
484
+
485
+ /* Галочка администратора */
486
+ .badge { width: 15px; height: 15px; color: #6da8dc; margin-left: 5px; vertical-align: -2px; }
487
+
488
+ /* Кликабельные @юзернеймы */
489
+ .mention { color: var(--link); cursor: pointer; }
490
+ .mention:hover { text-decoration: underline; }
491
+
492
+ /* Кликабельные ссылки в сообщениях */
493
+ .msg-link {
494
+ color: var(--link);
495
+ text-decoration: underline;
496
+ word-break: break-all; /* длинные URL не растягивают пузырь */
497
+ }
498
+ .msg-link:hover { text-decoration: none; }
499
+
500
+ /* Красная надпись вместо поиска у заблокированного аккаунта */
501
+ .banned-banner {
502
+ color: var(--error);
503
+ font-weight: 600;
504
+ font-size: 14px;
505
+ text-align: center;
506
+ padding: 9px 0;
507
+ }
508
+
509
+ /* Кнопка удаления переписки в шапке чата */
510
+ .chat-head .chat-del { margin-left: auto; }
511
+ .chat-head .chat-del:hover { color: var(--error); }
512
+
513
+ /* Реакции под сообщением */
514
+ .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
515
+ .reaction-chip {
516
+ background: var(--field);
517
+ border: 1px solid transparent;
518
+ border-radius: 10px;
519
+ padding: 2px 8px;
520
+ font-size: 13px;
521
+ color: var(--text);
522
+ transition: border-color .15s, background .15s;
523
+ }
524
+ .reaction-chip:hover { border-color: var(--accent-hover); }
525
+ .reaction-chip.mine { border-color: var(--accent-hover); background: var(--bg); }
526
+
527
+ /* Контекстное меню (ПКМ): реакции / закрепить / изменить / удалить */
528
+ .msg-menu {
529
+ position: fixed;
530
+ z-index: 30;
531
+ display: flex;
532
+ flex-direction: column;
533
+ gap: 2px;
534
+ background: var(--card);
535
+ border: 1px solid var(--border);
536
+ border-radius: var(--radius);
537
+ padding: 6px;
538
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .45);
539
+ animation: fade-in .15s ease both;
540
+ }
541
+ .msg-menu-reactions { display: flex; gap: 2px; align-items: center; }
542
+ .msg-menu-reactions button, .msg-menu-grid button {
543
+ background: none;
544
+ border: 1px solid transparent;
545
+ font-size: 17px;
546
+ padding: 4px 6px;
547
+ border-radius: 8px;
548
+ transition: background .15s, border-color .15s;
549
+ }
550
+ .msg-menu-reactions button:hover, .msg-menu-grid button:hover { background: var(--field); }
551
+ .msg-menu-reactions button.pinned, .msg-menu-grid button.pinned {
552
+ border-color: var(--accent-hover); /* закреплённый эмодзи подсвечен */
553
+ }
554
+ .msg-menu-reactions .more {
555
+ color: var(--muted);
556
+ font-size: 14px;
557
+ padding: 4px 8px;
558
+ }
559
+ .msg-menu-reactions .more:hover { color: var(--text); }
560
+
561
+ /* Полная палитра реакций (раскрывается кнопкой «⌄») */
562
+ .msg-menu-grid {
563
+ display: grid;
564
+ grid-template-columns: repeat(8, 1fr);
565
+ gap: 2px;
566
+ max-width: 296px;
567
+ max-height: 190px;
568
+ overflow-y: auto;
569
+ border-top: 1px solid var(--border);
570
+ margin-top: 4px;
571
+ padding-top: 6px;
572
+ }
573
+ .msg-menu-item {
574
+ background: none;
575
+ border: none;
576
+ color: var(--text);
577
+ font-size: 14px;
578
+ text-align: left;
579
+ padding: 8px 10px;
580
+ border-radius: 8px;
581
+ transition: background .15s;
582
+ }
583
+ .msg-menu-item:hover { background: var(--field); }
584
+ .msg-menu-item.danger { color: var(--error); }
585
+
586
+ /* Плавающая панель форматирования над выделенным текстом в поле ввода */
587
+ .fmt-bar {
588
+ position: fixed;
589
+ z-index: 35;
590
+ display: flex;
591
+ gap: 2px;
592
+ background: var(--card);
593
+ border: 1px solid var(--border);
594
+ border-radius: 10px;
595
+ padding: 4px;
596
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .45);
597
+ animation: fade-in .12s ease both;
598
+ }
599
+ .fmt-bar-btn {
600
+ background: none;
601
+ border: none;
602
+ color: var(--text);
603
+ font-size: 15px;
604
+ line-height: 1;
605
+ min-width: 30px;
606
+ height: 30px;
607
+ padding: 0 8px;
608
+ border-radius: 7px;
609
+ cursor: pointer;
610
+ transition: background .15s;
611
+ }
612
+ .fmt-bar-btn:hover { background: var(--field); }
613
+ .fmt-bar-btn svg { display: block; }
614
+ .fmt-bar-clear { border-left: 1px solid var(--border); border-radius: 0 7px 7px 0; margin-left: 2px; }
615
+ .fmt-bar-btn small { font-size: 10px; vertical-align: super; }
616
+ .fmt-bar-btn s { text-decoration: line-through; }
617
+ .fmt-bar-swatch {
618
+ display: inline-block;
619
+ width: 15px;
620
+ height: 15px;
621
+ border-radius: 50%;
622
+ vertical-align: middle;
623
+ background: conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red);
624
+ }
625
+
626
+ /* Просмотр фото внутри мессенджера (лайтбокс) */
627
+ .photo-modal { cursor: zoom-out; z-index: 40; }
628
+ .photo-modal img { max-width: 92vw; max-height: 92vh; border-radius: 8px; }
629
+
630
+ /* Панель закреплённого сообщения */
631
+ .pinned-bar {
632
+ display: flex;
633
+ align-items: center;
634
+ gap: 10px;
635
+ padding: 8px 16px;
636
+ background: var(--card);
637
+ border-bottom: 1px solid var(--border);
638
+ cursor: pointer;
639
+ }
640
+ .pinned-bar > svg { width: 16px; height: 16px; color: var(--muted); flex-shrink: 0; }
641
+ .pinned-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
642
+ .pinned-info small { color: var(--link); font-size: 11px; }
643
+ .pinned-info span {
644
+ font-size: 13px;
645
+ white-space: nowrap;
646
+ overflow: hidden;
647
+ text-overflow: ellipsis;
648
+ }
649
+
650
+ /* Подсветка сообщения при переходе из закреплённой панели */
651
+ .msg.flash { box-shadow: 0 0 0 2px var(--accent-hover); transition: box-shadow .4s; }
652
+
653
+ /* Видео в сообщении */
654
+ .msg video { max-width: 320px; max-height: 320px; border-radius: 8px; display: block; }
655
+
656
+ /* Карточка файла в сообщении */
657
+ .file-row {
658
+ display: flex;
659
+ align-items: center;
660
+ gap: 10px;
661
+ padding: 6px 4px;
662
+ color: var(--text);
663
+ text-decoration: none;
664
+ border-radius: 8px;
665
+ transition: background .15s;
666
+ }
667
+ .file-row:hover { background: rgba(255, 255, 255, .05); }
668
+ .file-row svg { width: 26px; height: 26px; color: var(--muted); flex-shrink: 0; }
669
+ .file-info { display: flex; flex-direction: column; min-width: 0; }
670
+ .file-info span {
671
+ font-size: 14px;
672
+ white-space: nowrap;
673
+ overflow: hidden;
674
+ text-overflow: ellipsis;
675
+ max-width: 240px;
676
+ }
677
+ .file-info small { color: var(--muted); font-size: 11px; }
678
+ .file-preview-btn { font-size: 13px; margin: 2px 4px; align-self: flex-start; }
679
+ .file-preview {
680
+ margin-top: 4px;
681
+ padding: 6px 8px;
682
+ background: rgba(0, 0, 0, .18);
683
+ border-radius: 8px;
684
+ color: var(--muted);
685
+ font-size: 12px;
686
+ line-height: 1.4;
687
+ max-height: 90px;
688
+ overflow: hidden;
689
+ }
690
+
691
+ /* Аудио-сообщения и голосовые */
692
+ .audio-msg { display: flex; flex-direction: column; gap: 6px; min-width: 220px; max-width: 320px; }
693
+ .audio-head {
694
+ display: flex;
695
+ align-items: center;
696
+ gap: 8px;
697
+ font-size: 14px;
698
+ color: var(--text);
699
+ }
700
+ .audio-head svg { width: 20px; height: 20px; color: var(--accent-hover); flex-shrink: 0; }
701
+ .audio-head span {
702
+ white-space: nowrap;
703
+ overflow: hidden;
704
+ text-overflow: ellipsis;
705
+ flex: 1;
706
+ }
707
+ .audio-head small { color: var(--muted); font-size: 12px; flex-shrink: 0; }
708
+ .audio-msg audio { width: 100%; height: 36px; }
709
+ .audio-msg.voice audio { filter: saturate(0); }
710
+
711
+ /* Панель записи голосового сообщения */
712
+ .rec-bar {
713
+ display: flex;
714
+ align-items: center;
715
+ gap: 10px;
716
+ padding: 6px 8px;
717
+ }
718
+ .rec-bar .rec-time { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--text); }
719
+ .rec-bar .rec-hint { color: var(--muted); font-size: 13px; flex: 1; }
720
+ .rec-bar .rec-cancel:hover { color: #e25; }
721
+ .rec-dot {
722
+ width: 11px;
723
+ height: 11px;
724
+ border-radius: 50%;
725
+ background: #e25;
726
+ animation: rec-pulse 1s infinite;
727
+ flex-shrink: 0;
728
+ }
729
+ @keyframes rec-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
730
+
731
+ /* Окно предпросмотра файла (PDF) */
732
+ .preview-modal { z-index: 40; }
733
+ .preview-card {
734
+ width: min(900px, 94vw);
735
+ height: 88vh;
736
+ background: var(--card);
737
+ border: 1px solid var(--border);
738
+ border-radius: var(--radius);
739
+ display: flex;
740
+ flex-direction: column;
741
+ overflow: hidden;
742
+ animation: fade-up .3s ease both;
743
+ }
744
+ .preview-head {
745
+ display: flex;
746
+ align-items: center;
747
+ justify-content: space-between;
748
+ gap: 10px;
749
+ padding: 10px 14px;
750
+ border-bottom: 1px solid var(--border);
751
+ font-size: 14px;
752
+ }
753
+ .preview-head span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
754
+ .preview-card iframe { flex: 1; border: none; background: #fff; }
755
+
756
+ /* Кнопки Забанить/Отклонить в сообщении-жалобе */
757
+ .report-actions { display: flex; gap: 8px; margin-top: 8px; }
758
+
759
+ /* Всплывающее уведомление */
760
+ .toast {
761
+ position: fixed;
762
+ bottom: 24px;
763
+ left: 50%;
764
+ transform: translateX(-50%);
765
+ background: var(--card);
766
+ border: 1px solid var(--border);
767
+ border-radius: var(--radius);
768
+ padding: 10px 18px;
769
+ font-size: 14px;
770
+ z-index: 20;
771
+ animation: fade-up .25s ease both;
772
+ max-width: 90vw;
773
+ }
774
+
775
+ /* ===== Настройки ===== */
776
+
777
+ .settings-page { display: flex; flex-direction: column; gap: 10px; }
778
+ .settings-page.appear { animation: slide-in .3s ease both; }
779
+ .settings-page h2 { font-size: 20px; margin-bottom: 8px; }
780
+ /* Подзаголовки секций внутри раздела «Приложение» (ПК-версия) */
781
+ .settings-sub { font-size: 13px; font-weight: 600; color: var(--muted);
782
+ text-transform: uppercase; letter-spacing: .4px; margin: 6px 0 -2px; }
783
+
784
+ .settings-item {
785
+ display: flex;
786
+ align-items: center;
787
+ justify-content: space-between;
788
+ background: var(--card);
789
+ border: 1px solid var(--border);
790
+ border-radius: var(--radius);
791
+ padding: 14px 16px;
792
+ color: var(--text);
793
+ font-size: 15px;
794
+ text-align: left;
795
+ transition: background .2s, border-color .2s;
796
+ }
797
+ .settings-item:hover { background: var(--field); border-color: var(--accent-hover); }
798
+ .settings-item svg { width: 18px; height: 18px; color: var(--muted); }
799
+ .settings-item.danger { color: var(--error); border-color: var(--danger); }
800
+ .settings-item.danger:hover { background: rgba(176, 80, 80, .12); }
801
+
802
+ .settings-back {
803
+ display: inline-flex;
804
+ align-items: center;
805
+ gap: 4px;
806
+ background: none;
807
+ border: none;
808
+ color: var(--muted);
809
+ font-size: 14px;
810
+ padding: 0;
811
+ align-self: flex-start;
812
+ transition: color .2s;
813
+ }
814
+ .settings-back:hover { color: var(--text); }
815
+ .settings-back svg { width: 16px; height: 16px; }
816
+
817
+ .settings-row {
818
+ display: flex;
819
+ align-items: center;
820
+ justify-content: space-between;
821
+ gap: 16px;
822
+ background: var(--card);
823
+ border: 1px solid var(--border);
824
+ border-radius: var(--radius);
825
+ padding: 12px 16px;
826
+ font-size: 14px;
827
+ }
828
+ .settings-row select { padding: 8px 10px; font-size: 14px; }
829
+
830
+ /* Кэш сообщений: вес + кнопка очистки в одной строке */
831
+ .cache-row { display: flex; align-items: center; gap: 14px; }
832
+
833
+ /* ===== Профиль группы ===== */
834
+
835
+ .ava.big { width: 120px; height: 120px; font-size: 44px; margin-bottom: 12px; }
836
+ /* Владелец группы меняет аватарку кликом по кружку */
837
+ .ava.editable { cursor: pointer; transition: box-shadow .2s; }
838
+ .ava.editable:hover { box-shadow: 0 0 0 2px var(--accent-hover); }
839
+ .invite-row {
840
+ display: flex;
841
+ align-items: center;
842
+ gap: 10px;
843
+ max-width: 100%;
844
+ margin-top: 4px;
845
+ }
846
+ .invite-link {
847
+ font-size: 13px;
848
+ color: var(--muted);
849
+ overflow: hidden;
850
+ text-overflow: ellipsis;
851
+ white-space: nowrap;
852
+ }
853
+
854
+ /* ===== Профиль ===== */
855
+
856
+ .profile { display: flex; flex-direction: column; align-items: center; gap: 6px; }
857
+
858
+ .avatar {
859
+ width: 120px;
860
+ height: 120px;
861
+ border-radius: 50%;
862
+ background: var(--field);
863
+ border: 1px solid var(--border);
864
+ display: flex;
865
+ align-items: center;
866
+ justify-content: center;
867
+ overflow: hidden;
868
+ cursor: pointer;
869
+ margin-bottom: 12px;
870
+ transition: border-color .2s, transform .2s;
871
+ }
872
+ .avatar:hover { border-color: var(--accent-hover); transform: scale(1.03); }
873
+ .avatar img, .avatar video.avatar-media { width: 100%; height: 100%; object-fit: cover; }
874
+ .avatar svg { width: 36px; height: 36px; color: var(--muted); }
875
+
876
+ .profile-row { display: flex; align-items: center; gap: 8px; max-width: 100%; }
877
+ .profile-row h2 { font-size: 22px; }
878
+ .profile-row h3 { font-size: 16px; }
879
+ .desc {
880
+ font-size: 14px;
881
+ max-width: 360px;
882
+ text-align: center;
883
+ white-space: pre-wrap;
884
+ word-break: break-word;
885
+ }
886
+
887
+ .contacts { width: 100%; margin-top: 28px; }
888
+ .contacts .profile-row { justify-content: center; margin-bottom: 10px; }
889
+ .contacts ul { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 8px; }
890
+ .contacts li {
891
+ display: flex;
892
+ align-items: center;
893
+ gap: 8px;
894
+ background: var(--card);
895
+ border: 1px solid var(--border);
896
+ border-radius: var(--radius);
897
+ padding: 10px 14px;
898
+ font-size: 14px;
899
+ }
900
+ .contacts li small { color: var(--muted); flex: 1; }
901
+
902
+ /* ===== Модальные окна ===== */
903
+
904
+ .modal {
905
+ position: fixed;
906
+ inset: 0;
907
+ display: flex;
908
+ align-items: center;
909
+ justify-content: center;
910
+ padding: 16px;
911
+ background: rgba(0, 0, 0, .6);
912
+ backdrop-filter: blur(3px);
913
+ animation: fade-in .25s ease both;
914
+ z-index: 10;
915
+ }
916
+
917
+ .modal-card {
918
+ width: 100%;
919
+ max-width: 560px;
920
+ max-height: 80vh;
921
+ overflow-y: auto;
922
+ background: var(--card);
923
+ border: 1px solid var(--border);
924
+ border-radius: var(--radius);
925
+ padding: 28px;
926
+ animation: fade-up .3s ease both;
927
+ }
928
+ .modal-card h2 { font-size: 18px; margin-bottom: 16px; }
929
+ .modal-card ol { padding-left: 20px; display: grid; gap: 10px; }
930
+ .modal-card li { font-size: 14px; line-height: 1.55; color: var(--muted); }
931
+ .modal-card li b { color: var(--text); }
932
+ .modal-card .primary { width: 100%; margin-top: 20px; }
933
+
934
+ /* Диалоги с полями (генерируются в JS) */
935
+ .dialog { max-width: 380px; display: flex; flex-direction: column; gap: 12px; }
936
+ .dialog h2 { margin-bottom: 4px; }
937
+ .dialog-buttons { display: flex; justify-content: flex-end; gap: 16px; align-items: center; }
938
+ .dialog .primary, .modal-card.dialog .primary { width: auto; margin-top: 0; padding: 10px 20px; }
939
+
940
+ /* Пикер пользователей (добавление в группу): список контактов в диалоге */
941
+ .member-picker { list-style: none; padding: 0; margin: 0; display: flex;
942
+ flex-direction: column; gap: 8px; max-height: 40vh; overflow-y: auto; }
943
+ .member-picker li {
944
+ display: flex; align-items: center; gap: 8px;
945
+ background: var(--bg); border: 1px solid var(--border);
946
+ border-radius: var(--radius); padding: 10px 14px; font-size: 14px; color: var(--text);
947
+ }
948
+ .member-picker li small { color: var(--muted); flex: 1; }
949
+ .member-picker li.empty { color: var(--muted); justify-content: center; cursor: default; }
950
+
951
+ /* ===== GIF ===== */
952
+
953
+ .gif-btn { font-size: 12px; font-weight: 700; letter-spacing: .5px; }
954
+ .msg-gif { max-width: 320px; max-height: 320px; border-radius: 8px; display: block; cursor: pointer; }
955
+
956
+ /* Панель чата с ботом @addgif (вместо поля ввода) */
957
+ .addgif-bar { padding: 12px 16px; border-top: 1px solid var(--border); }
958
+ .addgif-bar .primary { width: 100%; }
959
+
960
+ /* Пикер GIF: поиск + сетка превью */
961
+ .gif-picker { max-width: 520px; display: flex; flex-direction: column; gap: 12px; }
962
+ .gif-picker input { width: 100%; }
963
+ .gif-grid {
964
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
965
+ gap: 8px; max-height: 55vh; overflow-y: auto; min-height: 80px;
966
+ }
967
+ .gif-grid img {
968
+ width: 100%; height: 120px; object-fit: cover; border-radius: 8px;
969
+ cursor: pointer; background: var(--bg); transition: transform .15s;
970
+ }
971
+ .gif-grid img:hover { transform: scale(1.04); }
972
+ .gif-grid .empty { grid-column: 1 / -1; text-align: center; color: var(--muted); padding: 24px 0; }
973
+
974
+ /* Кнопка выбора файла в диалоге добавления GIF */
975
+ .gif-file-label {
976
+ display: block; text-align: center; padding: 12px; cursor: pointer;
977
+ background: var(--bg); border: 1px dashed var(--border); border-radius: var(--radius);
978
+ font-size: 14px; color: var(--text); word-break: break-all;
979
+ }
980
+ .gif-file-label:hover { border-color: var(--accent-hover); }
981
+
982
+ /* ===== Каналы: комментарии ===== */
983
+
984
+ .group-channel-extra { display: flex; flex-direction: column; align-items: center;
985
+ gap: 8px; width: 100%; margin-top: 4px; }
986
+ .comments-link { display: inline-block; margin-top: 4px; font-size: 13px; padding: 0; }
987
+
988
+ /* Панель подписчика канала: «Открыть чат» (слева) + «Отписаться» (справа) */
989
+ .channel-bar { display: flex; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border); }
990
+ .channel-bar .primary { flex: 1; }
991
+
992
+ /* Метка дубля поста канала в группе-обсуждении */
993
+ .channel-post-tag { display: block; font-size: 12px; color: var(--accent);
994
+ font-weight: 600; margin-bottom: 4px; }
995
+ /* Цитата сообщения, на которое отвечает комментарий/ответ */
996
+ .reply-quote { font-size: 12px; color: var(--muted); border-left: 2px solid var(--accent);
997
+ padding: 2px 0 2px 8px; margin-bottom: 4px; max-width: 320px;
998
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
999
+ .reply-quote.clickable { cursor: pointer; }
1000
+ .reply-quote.clickable:hover { border-left-color: var(--accent-hover); }
1001
+ .reply-quote small { display: block; color: var(--link); font-weight: 600;
1002
+ overflow: hidden; text-overflow: ellipsis; }
1003
+ .reply-quote span { display: block; overflow: hidden; text-overflow: ellipsis; }
1004
+ .msg.mine .reply-quote { border-left-color: rgba(255,255,255,.5); }
1005
+ .msg.mine .reply-quote small { color: #fff; }
1006
+
1007
+ /* ===== Окно комментариев (тред) ===== */
1008
+
1009
+ .thread-modal {
1010
+ position: fixed; inset: 0; z-index: 40; display: flex; flex-direction: column;
1011
+ background: var(--bg);
1012
+ }
1013
+ @media (min-width: 720px) {
1014
+ .thread-modal { inset: 0 0 0 auto; width: 420px; box-shadow: -2px 0 16px rgba(0,0,0,.4);
1015
+ border-left: 1px solid var(--border); }
1016
+ }
1017
+ .thread-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px;
1018
+ background: var(--card); border-bottom: 1px solid var(--border); flex-shrink: 0; }
1019
+ .thread-title { font-weight: 600; flex: 1; }
1020
+ .thread-root { padding: 12px 16px; border-bottom: 1px solid var(--border);
1021
+ background: var(--card); flex-shrink: 0; max-height: 35vh; overflow-y: auto; }
1022
+ .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 16px;
1023
+ display: flex; flex-direction: column; gap: 10px; }
1024
+ .comment { background: var(--card); border-radius: 10px; padding: 8px 10px;
1025
+ max-width: 92%; align-self: flex-start; word-wrap: break-word; }
1026
+ .comment.mine { align-self: flex-end; background: var(--accent); color: #fff; }
1027
+ .comment.root { background: transparent; padding: 0; max-width: 100%; }
1028
+ .comment p { margin: 0; white-space: pre-wrap; word-break: break-word; }
1029
+ .comment time { display: block; font-size: 11px; color: var(--muted); margin-top: 2px; }
1030
+ .comment.mine time { color: rgba(255,255,255,.8); }
1031
+ .comment img { max-width: 240px; border-radius: 8px; display: block; cursor: pointer; }
1032
+ .comment-reply { font-size: 12px; padding: 0; margin-top: 2px; }
1033
+ .thread-reply { display: flex; align-items: center; gap: 8px; padding: 6px 16px;
1034
+ background: var(--card); border-top: 1px solid var(--border); font-size: 13px; }
1035
+ .thread-reply span { flex: 1; color: var(--muted); overflow: hidden;
1036
+ text-overflow: ellipsis; white-space: nowrap; }
1037
+ .thread-form { display: flex; align-items: center; gap: 8px; padding: 10px 12px;
1038
+ border-top: 1px solid var(--border); background: var(--card); }
1039
+ .thread-input { flex: 1; padding: 10px 12px; border-radius: 20px;
1040
+ border: 1px solid var(--border); background: var(--bg); color: var(--text); }
1041
+
1042
+ /* ===== Модерация ===== */
1043
+
1044
+ .group-mod-row { display: flex; justify-content: center; width: 100%; margin-top: 8px; }
1045
+ .member-actions { width: 28px; height: 28px; font-size: 18px; line-height: 1;
1046
+ flex-shrink: 0; color: var(--muted); }
1047
+ .member-actions:hover { color: var(--text); }
1048
+
1049
+ /* ===== Звонки ===== */
1050
+
1051
+ /* Историческая запись о звонке в чате (звонки больше не поддерживаются) */
1052
+ .call-msg { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
1053
+ .call-msg span { font-size: 14px; }
1054
+
1055
+ /* ===== Анимации ===== */
1056
+
1057
+ @keyframes fade-up {
1058
+ from { opacity: 0; transform: translateY(14px); }
1059
+ to { opacity: 1; transform: none; }
1060
+ }
1061
+ @keyframes fade-in {
1062
+ from { opacity: 0; }
1063
+ to { opacity: 1; }
1064
+ }
1065
+ @keyframes slide-in {
1066
+ from { opacity: 0; transform: translateX(24px); }
1067
+ to { opacity: 1; transform: none; }
1068
+ }
1069
+ @keyframes shake {
1070
+ 25% { transform: translateX(-4px); }
1071
+ 75% { transform: translateX(4px); }
1072
+ }
1073
+
1074
+ /* Анимация для переключения форм авторизации */
1075
+ .form.appear { animation: fade-up .35s ease both; }
1076
+
1077
+ /* ===== Мобильная версия ===== */
1078
+
1079
+ /* Кнопки «Назад» существуют только на узких экранах */
1080
+ .mobile-back { display: none; }
1081
+
1082
+ /* Стиль как у .settings-back: стрелка + подпись в начале панели */
1083
+ .panel-back {
1084
+ align-items: center;
1085
+ gap: 4px;
1086
+ background: none;
1087
+ border: none;
1088
+ color: var(--muted);
1089
+ font-size: 14px;
1090
+ padding: 0;
1091
+ align-self: flex-start;
1092
+ margin-bottom: 4px;
1093
+ transition: color .2s;
1094
+ }
1095
+ .panel-back:hover { color: var(--text); }
1096
+ .panel-back svg { width: 16px; height: 16px; }
1097
+
1098
+ @media (max-width: 700px) {
1099
+ /* Одна панель на экран: список чатов либо открытый вид.
1100
+ Класс show-main на #app ставится в openView(). */
1101
+ .sidebar { width: 100%; border-right: none; }
1102
+ .main { display: none; }
1103
+ .app-layout.show-main .sidebar { display: none; }
1104
+ .app-layout.show-main .main { display: flex; }
1105
+
1106
+ .mobile-back { display: inline-flex; }
1107
+ .chat-head .mobile-back { margin-left: -6px; }
1108
+ .chat-head .mobile-back svg { width: 20px; height: 20px; }
1109
+
1110
+ .view { padding: 20px 16px; }
1111
+ .view-chat { padding: 0; } /* у чата отступов нет (правило выше перебило бы базовое) */
1112
+ .panel { max-width: none; }
1113
+
1114
+ /* Пузыри и вложения занимают почти всю ширину */
1115
+ .msg { max-width: 85%; }
1116
+ .msg img, .msg video { max-width: 100%; }
1117
+ .file-info span { max-width: 170px; }
1118
+ .desc { max-width: 100%; }
1119
+
1120
+ /* Долгое нажатие открывает меню — отключаем выделение текста и
1121
+ системную «лупу» iOS на сообщениях и строках чатов */
1122
+ .msg, .chat-row, .pinned-bar {
1123
+ -webkit-touch-callout: none;
1124
+ -webkit-user-select: none;
1125
+ user-select: none;
1126
+ }
1127
+
1128
+ /* Палитра реакций не шире экрана */
1129
+ .msg-menu-grid { max-width: calc(100vw - 32px); }
1130
+
1131
+ /* iOS зумит страницу при фокусе полей со шрифтом < 16px */
1132
+ input:not([type=checkbox]), textarea, select { font-size: 16px; }
1133
+ }
static/sw.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* SalGram service worker: оболочка PWA + Web Push уведомления.
2
+ Версию кэша поднимать при изменении статики, чтобы клиенты обновились. */
3
+ const CACHE = "salgram-v3";
4
+ const SHELL = ["/", "/index.html", "/app.js", "/style.css",
5
+ "/manifest.webmanifest", "/icon-192.png", "/icon-512.png"];
6
+
7
+ self.addEventListener("install", e => {
8
+ self.skipWaiting();
9
+ e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL).catch(() => {})));
10
+ });
11
+
12
+ self.addEventListener("activate", e => {
13
+ e.waitUntil((async () => {
14
+ const keys = await caches.keys();
15
+ await Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)));
16
+ await self.clients.claim();
17
+ })());
18
+ });
19
+
20
+ /* Оболочка кэшируется по стратегии «сеть в приоритете» (network-first): пока есть
21
+ связь, всегда отдаём свежий код и обновляем кэш — так правки в app.js/style.css
22
+ доходят до клиента сразу, без ручного поднятия версии. Без сети — отдаём из кэша.
23
+ API, медиа и WebSocket всегда идут напрямую в сеть. */
24
+ self.addEventListener("fetch", e => {
25
+ const req = e.request;
26
+ const url = new URL(req.url);
27
+ if (req.method !== "GET" || url.origin !== location.origin) return;
28
+ if (url.pathname.startsWith("/api/") || url.pathname === "/ws") return;
29
+ if (req.mode === "navigate") {
30
+ e.respondWith(networkFirst(req, "/index.html"));
31
+ return;
32
+ }
33
+ if (SHELL.includes(url.pathname))
34
+ e.respondWith(networkFirst(req, url.pathname));
35
+ });
36
+
37
+ /* Берём из сети, по пути кладём свежую копию в кэш; офлайн — отдаём из кэша. */
38
+ async function networkFirst(req, cacheKey) {
39
+ try {
40
+ const res = await fetch(req);
41
+ if (res && res.ok) {
42
+ const copy = res.clone();
43
+ caches.open(CACHE).then(c => c.put(cacheKey, copy)).catch(() => {});
44
+ }
45
+ return res;
46
+ } catch {
47
+ return (await caches.match(cacheKey)) || (await caches.match(req)) || Response.error();
48
+ }
49
+ }
50
+
51
+ /* Web Push: уведомление о новом сообщении, когда приложение закрыто. */
52
+ self.addEventListener("push", e => {
53
+ let d = {};
54
+ try { d = e.data ? e.data.json() : {}; } catch { /* ignore */ }
55
+ e.waitUntil(self.registration.showNotification(d.title || "SalGram", {
56
+ body: d.body || "Новое сообщение",
57
+ icon: "/icon-192.png",
58
+ badge: "/icon-192.png",
59
+ tag: d.peer ? "dm-" + d.peer : d.group ? "grp-" + d.group : "salgram",
60
+ renotify: true,
61
+ data: d,
62
+ }));
63
+ });
64
+
65
+ /* Клик по уведомлению: фокус на открытое окно или открытие приложения. */
66
+ self.addEventListener("notificationclick", e => {
67
+ e.notification.close();
68
+ const data = e.notification.data || {};
69
+ e.waitUntil((async () => {
70
+ const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
71
+ for (const c of all) {
72
+ if ("focus" in c) { await c.focus(); c.postMessage({ type: "notification-click", data }); return; }
73
+ }
74
+ if (self.clients.openWindow) await self.clients.openWindow("/");
75
+ })());
76
+ });