更新群聊,增加免打扰模式

This commit is contained in:
619dev
2026-03-28 21:07:09 +08:00
parent 1f60567bf9
commit bd0b796912
13 changed files with 1095 additions and 63 deletions

View File

@@ -16,6 +16,7 @@
| 🔐 端对端加密 | 无状态 ECDH + XSalsa20-Poly1305逐消息临时密钥前向保密 |
| 🗝️ 零知识服务器 | 服务器只存储密文,私钥仅在设备本地(四层持久化) |
| 📹 视频/语音通话 | WebRTC P2P1:1+ Mesh多人Cloudflare TURN 穿透 |
| 👥 群聊 | 最多 2000 人群组,纯文本消息(无加密),免打扰模式,成员管理 |
| 🔔 消息推送 | Web Push (VAPID) + OneSignal 双通道,离线也能收到通知 |
| 🌐 多语言 | 中文、英文、日语、韩语、法语(自动检测 + 手动切换) |
| 📱 iOS 永久免签 | PWA H5 → Safari「添加到主屏幕」无需企业证书 |
@@ -290,14 +291,16 @@ paperphone/
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM-768
│ └── keystore.js # 四层私钥持久化(内存/localStorage/sessionStorage/IndexedDB
── pages/
├── login.js # 登录/注册(含密钥生成、语言切换)
├── chats.js # 会话列表
├── chat.js # 聊天窗口E2E 加密、通话按钮)
├── contacts.js # 通讯录(好友申请/在线状态
├── discover.js # 发现页
├── profile.js # 我的/设置语言、指纹、通知、PWA
── call.js # 通话 UI来电/通话中/多人视频)
── pages/
├── login.js # 登录/注册(含密钥生成、语言切换)
├── chats.js # 会话列表
├── chat.js # 聊天窗口E2E 加密、通话按钮)
├── groups.js # 群聊列表(创建群、搜索群
├── groupInfo.js # 群信息(成员管理、免打扰、退出/解散)
├── contacts.js # 通讯录(好友申请/在线状态
── discover.js # 发现页
│ ├── profile.js # 我的/设置语言、指纹、通知、PWA
│ └── call.js # 通话 UI来电/通话中/多人视频)
```
---
@@ -311,7 +314,7 @@ paperphone/
| `users` | 用户信息 + ECDH/OPK 公钥 |
| `prekeys` | X3DH 一次性预密钥池 |
| `friends` | 好友关系pending/accepted/blocked |
| `groups` / `group_members` | 群组 + 成员 |
| `groups` / `group_members` | 群组 + 成员(含免打扰状态) |
| `messages` | 加密消息(离线缓冲,送达后可删) |
| `moments` | 朋友圈动态(文字 ≤1024 字) |
| `moment_images` | 动态图片(每条最多 9 张) |

View File

@@ -13,8 +13,9 @@ A WeChat-style end-to-end encrypted instant messaging app with stateless ECDH +
| Feature | Description |
|---------|-------------|
| 🔐 End-to-End Encryption | Stateless ECDH + XSalsa20-Poly1305 — ephemeral keys per message, forward secrecy |
| 🗝 Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 🗑 Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 📹 Video & Voice Calls | WebRTC P2P (1:1) + Mesh (group), Cloudflare TURN for NAT traversal |
| 👥 Group Chat | Up to 2000 members, plain-text messages (no encryption), Do Not Disturb mode, member management |
| 🔔 Push Notifications | Web Push (VAPID) + OneSignal dual-channel — reach users even when offline |
| 🌐 Multi-Language | Chinese, English, Japanese, Korean, French — auto-detect + manual switch |
| 📱 iOS — No Enterprise Cert | PWA via Safari "Add to Home Screen", works permanently without Apple signing |
@@ -289,14 +290,16 @@ paperphone/
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM-768
│ └── keystore.js # IndexedDB private key store
── pages/
├── login.js # Login / Register (key generation, language picker)
├── chats.js # Chat list
├── chat.js # Chat window (E2E encryption, call buttons)
├── contacts.js # Contacts (friend requests, online status)
├── discover.js # Discover page
├── profile.js # Me / Settings (language, fingerprint, notifications, PWA)
── call.js # Call UI (incoming / active / multi-party video)
── pages/
├── login.js # Login / Register (key generation, language picker)
├── chats.js # Chat list
├── chat.js # Chat window (E2E encryption, call buttons)
├── groups.js # Group list (create group, search)
├── groupInfo.js # Group info (member management, DND, leave/disband)
├── contacts.js # Contacts (friend requests, online status)
── discover.js # Discover page
│ ├── profile.js # Me / Settings (language, fingerprint, notifications, PWA)
│ └── call.js # Call UI (incoming / active / multi-party video)
```
---
@@ -310,7 +313,7 @@ paperphone/
| `users` | User profiles + ECDH/OPK public keys |
| `prekeys` | X3DH one-time prekey pool |
| `friends` | Friendship relationships (pending / accepted / blocked) |
| `groups` / `group_members` | Group chats + membership |
| `groups` / `group_members` | Group chats + membership (incl. mute/DND status) |
| `messages` | Encrypted payloads (offline buffer, deletable after delivery) |
| `moments` | Social posts (text ≤ 1024 chars) |
| `moment_images` | Post images (up to 9 per post) |

View File

@@ -56,10 +56,15 @@ export const api = {
removeFriend: id => req('DELETE', `/api/friends/${id}`),
// Groups
groups: () => req('GET', '/api/groups'),
groupInfo: id => req('GET', `/api/groups/${id}`),
createGroup: d => req('POST', '/api/groups', d),
addMember: (gid, uid) => req('POST', `/api/groups/${gid}/members`, { user_id: uid }),
groups: () => req('GET', '/api/groups'),
groupInfo: id => req('GET', `/api/groups/${id}`),
createGroup: d => req('POST', '/api/groups', d),
updateGroup: (id, d) => req('PATCH', `/api/groups/${id}`, d),
addMember: (gid, uid) => req('POST', `/api/groups/${gid}/members`, { user_id: uid }),
removeMember: (gid, uid) => req('DELETE', `/api/groups/${gid}/members/${uid}`),
leaveGroup: id => req('DELETE', `/api/groups/${id}/members/me`),
disbandGroup: id => req('DELETE', `/api/groups/${id}`),
muteGroup: (id, muted) => req('PATCH', `/api/groups/${id}/mute`, { muted }),
// Messages
privateHistory: pid => req('GET', `/api/messages/private/${pid}`),

View File

@@ -1,11 +1,12 @@
/**
* Main App Router — PaperPhone
* Single-page app with 4-tab navigation
* Single-page app with 5-tab navigation
*/
import { getToken, clearToken, api } from './api.js';
import { connect, disconnect, onEvent } from './socket.js';
import { renderLogin } from './pages/login.js';
import { renderChats, refreshChatList } from './pages/chats.js';
import { renderGroups, refreshGroupList } from './pages/groups.js';
import { renderContacts } from './pages/contacts.js';
import { renderDiscover } from './pages/discover.js';
import { renderProfile } from './pages/profile.js';
@@ -20,8 +21,10 @@ export const state = {
chats: [], // [{ id, type, name, avatar, lastMsg, lastTs, unread }]
sessions: {}, // userId/groupId -> ratchet state
contacts: [],
groupsList: [], // [{ id, name, avatar, notice, owner_id, role, member_count }]
activeTab: 'chats',
chatView: null, // { id, type } or null
groupInfoView: null, // groupId or null
contactBadge: 0, // pending friend requests count
call: null, // active call info or null
};
@@ -69,6 +72,7 @@ export function formatTime(ts) {
function buildTabBar(active) {
const tabs = [
{ id: 'chats', label: t('tabChats'), icon: chatIcon() },
{ id: 'groups', label: t('tabGroups'), icon: groupIcon() },
{ id: 'contacts', label: t('tabContacts'), icon: contactIcon() },
{ id: 'discover', label: t('tabDiscover'), icon: discoverIcon() },
{ id: 'me', label: t('tabMe'), icon: meIcon() },
@@ -105,12 +109,20 @@ export function navigateTo(tab, data) {
if (appEl?._cleanup) { appEl._cleanup(); appEl._cleanup = null; }
state.activeTab = tab;
state.chatView = null;
state.groupInfoView = null;
render();
if (data) window._navData = data;
}
export function openChat(chat) {
state.chatView = chat;
state.groupInfoView = null;
render();
}
export function openGroupInfo(groupId) {
state.groupInfoView = groupId;
state.chatView = null;
render();
}
@@ -118,6 +130,11 @@ export function goBack() {
// Cleanup chat listeners before going back
const appEl = document.getElementById('app');
if (appEl?._cleanup) { appEl._cleanup(); appEl._cleanup = null; }
if (state.groupInfoView) {
state.groupInfoView = null;
render();
return;
}
state.chatView = null;
render();
}
@@ -134,6 +151,7 @@ export async function navigateAfterLogin(userData) {
try {
const [friends, groups] = await Promise.all([api.friends(), api.groups()]);
state.contacts = friends;
state.groupsList = groups;
state.chats = [
...friends.map(f => ({
id: f.id, type: 'private', name: f.nickname || f.username, avatar: f.avatar,
@@ -141,7 +159,7 @@ export async function navigateAfterLogin(userData) {
})),
...groups.map(g => ({
id: g.id, type: 'group', name: g.name, avatar: g.avatar,
lastMsg: '', lastTs: 0, unread: 0,
lastMsg: '', lastTs: 0, unread: 0, muted: !!g.muted,
})),
];
} catch { /* continue with empty lists */ }
@@ -157,6 +175,11 @@ function render() {
return;
}
if (state.groupInfoView) {
import('./pages/groupInfo.js').then(m => m.renderGroupInfo(root, state.groupInfoView));
return;
}
if (state.chatView) {
// Full-screen chat; tabs hidden
import('./pages/chat.js').then(m => m.renderChat(root, state.chatView));
@@ -169,6 +192,7 @@ function render() {
switch (state.activeTab) {
case 'chats': renderChats(page); break;
case 'groups': renderGroups(page); break;
case 'contacts': renderContacts(page); break;
case 'discover': renderDiscover(page); break;
case 'me': renderProfile(page); break;
@@ -185,8 +209,11 @@ function setupGlobalSocketHandlers() {
const key = msg.group_id || msg.from;
const chat = state.chats.find(c => c.id === key);
if (chat && state.chatView?.id !== key) {
chat.unread = (chat.unread || 0) + 1;
chat.lastMsg = t('encryptedMsg');
// Skip unread increment for muted groups
if (!chat.muted) {
chat.unread = (chat.unread || 0) + 1;
}
chat.lastMsg = msg.group_id ? (msg.ciphertext || '').slice(0, 30) : t('encryptedMsg');
chat.lastTs = msg.ts;
// re-render tab bar badge + chat list
const tabBar = root.querySelector('.tabbar');
@@ -198,9 +225,9 @@ function setupGlobalSocketHandlers() {
state.chats.unshift({
id: key,
type: msg.group_id ? 'group' : 'private',
name: contact ? (contact.nickname || contact.username) : t('newMessage'),
name: contact ? (contact.nickname || contact.username) : (msg.from_nickname || t('newMessage')),
avatar: contact?.avatar || null,
lastMsg: t('encryptedMsg'),
lastMsg: msg.group_id ? (msg.ciphertext || '').slice(0, 30) : t('encryptedMsg'),
lastTs: msg.ts,
unread: 1,
});
@@ -219,6 +246,42 @@ function setupGlobalSocketHandlers() {
});
onEvent('friend_accepted', () => showToast(t('friendAccepted')));
// ── Group events ────────────────────────────────────────────────────
onEvent('group_created', ({ group }) => {
if (!state.groupsList) state.groupsList = [];
if (!state.groupsList.find(g => g.id === group.id)) {
state.groupsList.push(group);
}
if (!state.chats.find(c => c.id === group.id)) {
state.chats.push({
id: group.id, type: 'group', name: group.name, avatar: group.avatar || null,
lastMsg: '', lastTs: Date.now(), unread: 0,
});
}
refreshGroupList();
refreshChatList();
});
onEvent('group_disbanded', ({ group_id }) => {
state.groupsList = (state.groupsList || []).filter(g => g.id !== group_id);
state.chats = state.chats.filter(c => c.id !== group_id);
refreshGroupList();
refreshChatList();
if (state.chatView?.id === group_id || state.groupInfoView === group_id) {
navigateTo('groups');
}
});
onEvent('group_member_removed', ({ group_id, user_id }) => {
if (user_id === state.user.id) {
state.groupsList = (state.groupsList || []).filter(g => g.id !== group_id);
state.chats = state.chats.filter(c => c.id !== group_id);
refreshGroupList();
refreshChatList();
if (state.chatView?.id === group_id || state.groupInfoView === group_id) {
navigateTo('groups');
}
}
});
// ── Session Revoked (device kicked by another session) ───────────────
onEvent('session_revoked', () => {
clearToken();
@@ -231,6 +294,7 @@ function setupGlobalSocketHandlers() {
state.user = null;
state.chats = [];
state.contacts = [];
state.groupsList = [];
alert(t('sessionRevoked'));
window.location.reload();
});
@@ -280,6 +344,7 @@ async function init() {
const [friends, groups] = await Promise.all([api.friends(), api.groups()]);
state.contacts = friends;
state.groupsList = groups;
state.chats = [
...friends.map(f => ({
id: f.id, type: 'private', name: f.nickname || f.username, avatar: f.avatar,
@@ -287,7 +352,7 @@ async function init() {
})),
...groups.map(g => ({
id: g.id, type: 'group', name: g.name, avatar: g.avatar,
lastMsg: '', lastTs: 0, unread: 0,
lastMsg: '', lastTs: 0, unread: 0, muted: !!g.muted,
})),
];
} catch {
@@ -371,6 +436,9 @@ onLangChange(() => {
function chatIcon() {
return `<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>`;
}
function groupIcon() {
return `<svg viewBox="0 0 24 24"><path d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58C.48 14.9 0 15.62 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm3.78 1.58c-.85-.37-1.79-.58-2.78-.58-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57c0-.81-.48-1.53-1.22-1.85zM12 12c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3z"/></svg>`;
}
function contactIcon() {
return `<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>`;
}

View File

@@ -157,6 +157,33 @@ const TRANSLATIONS = {
sessionRevoked: '此设备已被退出登录',
noOtherDevices: '没有其他已登录设备',
lastActive: '最后活跃',
// Groups
tabGroups: '群聊',
groupsTitle: '群聊',
createGroup: '创建群聊',
groupName: '群名称',
groupNamePlaceholder: '输入群名称',
selectMembers: '选择成员',
noGroups: '暂无群聊',
noGroupsHint: '创建一个群聊开始聊天吧',
groupCreated: '群聊已创建',
groupNotice: '群公告',
groupNoNotice: '暂无群公告',
groupMembers: '群成员',
leaveGroup: '退出群聊',
leaveGroupConfirm: '确定退出该群聊?',
disbandGroup: '解散群聊',
disbandGroupConfirm: '确定解散该群聊?此操作不可撤销。',
memberLimitReached: '群成员已达上限 (2000人)',
groupOwner: '群主',
groupAdmin: '管理员',
nMembers: '人',
groupInfo: '群聊信息',
addMembers: '添加成员',
removeMemberConfirm: '确定将该成员移出群聊?',
muteGroup: '消息免打扰',
muteEnabled: '已开启免打扰',
muteDisabled: '已关闭免打扰',
},
en: {
@@ -304,6 +331,33 @@ const TRANSLATIONS = {
sessionRevoked: 'This device has been logged out',
noOtherDevices: 'No other active devices',
lastActive: 'Last active',
// Groups
tabGroups: 'Groups',
groupsTitle: 'Groups',
createGroup: 'Create Group',
groupName: 'Group Name',
groupNamePlaceholder: 'Enter group name',
selectMembers: 'Select Members',
noGroups: 'No groups yet',
noGroupsHint: 'Create a group to start chatting',
groupCreated: 'Group created',
groupNotice: 'Group Notice',
groupNoNotice: 'No group notice',
groupMembers: 'Members',
leaveGroup: 'Leave Group',
leaveGroupConfirm: 'Leave this group?',
disbandGroup: 'Disband Group',
disbandGroupConfirm: 'Disband this group? This cannot be undone.',
memberLimitReached: 'Member limit reached (2000)',
groupOwner: 'Owner',
groupAdmin: 'Admin',
nMembers: '',
groupInfo: 'Group Info',
addMembers: 'Add Members',
removeMemberConfirm: 'Remove this member from the group?',
muteGroup: 'Mute Notifications',
muteEnabled: 'Notifications muted',
muteDisabled: 'Notifications unmuted',
},
ja: {
@@ -451,6 +505,33 @@ const TRANSLATIONS = {
sessionRevoked: 'このデバイスはログアウトされました',
noOtherDevices: '他のアクティブなデバイスはありません',
lastActive: '最終活動',
// Groups
tabGroups: 'グループ',
groupsTitle: 'グループ',
createGroup: 'グループ作成',
groupName: 'グループ名',
groupNamePlaceholder: 'グループ名を入力',
selectMembers: 'メンバーを選択',
noGroups: 'グループがありません',
noGroupsHint: 'グループを作成してチャットを始めよう',
groupCreated: 'グループが作成されました',
groupNotice: 'グループ告知',
groupNoNotice: '告知はありません',
groupMembers: 'メンバー',
leaveGroup: 'グループを退出',
leaveGroupConfirm: 'このグループを退出しますか?',
disbandGroup: 'グループを解散',
disbandGroupConfirm: 'このグループを解散しますか?元に戻せません。',
memberLimitReached: 'メンバー上限に達しました (2000)',
groupOwner: 'オーナー',
groupAdmin: '管理者',
nMembers: '人',
groupInfo: 'グループ情報',
addMembers: 'メンバーを追加',
removeMemberConfirm: 'このメンバーをグループから除外しますか?',
muteGroup: '通知ミュート',
muteEnabled: '通知をミュートしました',
muteDisabled: '通知のミュートを解除しました',
},
ko: {
@@ -598,6 +679,33 @@ const TRANSLATIONS = {
sessionRevoked: '이 기기에서 로그아웃되었습니다',
noOtherDevices: '다른 활성 기기가 없습니다',
lastActive: '마지막 활동',
// Groups
tabGroups: '그룹',
groupsTitle: '그룹',
createGroup: '그룹 만들기',
groupName: '그룹 이름',
groupNamePlaceholder: '그룹 이름 입력',
selectMembers: '멤버 선택',
noGroups: '그룹이 없습니다',
noGroupsHint: '그룹을 만들어 채팅을 시작하세요',
groupCreated: '그룹이 생성되었습니다',
groupNotice: '그룹 공지',
groupNoNotice: '공지가 없습니다',
groupMembers: '멤버',
leaveGroup: '그룹 나가기',
leaveGroupConfirm: '이 그룹을 나가시겠습니까?',
disbandGroup: '그룹 해산',
disbandGroupConfirm: '이 그룹을 해산하시겠습니까? 되돌릴 수 없습니다.',
memberLimitReached: '멤버 제한에 도달했습니다 (2000)',
groupOwner: '그룹장',
groupAdmin: '관리자',
nMembers: '명',
groupInfo: '그룹 정보',
addMembers: '멤버 추가',
removeMemberConfirm: '이 멤버를 그룹에서 제거하시겠습니까?',
muteGroup: '알림 음소거',
muteEnabled: '알림이 음소거되었습니다',
muteDisabled: '알림 음소거가 해제되었습니다',
},
fr: {
@@ -745,6 +853,33 @@ const TRANSLATIONS = {
sessionRevoked: 'Cet appareil a été déconnecté',
noOtherDevices: 'Aucun autre appareil actif',
lastActive: 'Dernière activité',
// Groups
tabGroups: 'Groupes',
groupsTitle: 'Groupes',
createGroup: 'Créer un groupe',
groupName: 'Nom du groupe',
groupNamePlaceholder: 'Saisir le nom du groupe',
selectMembers: 'Sélectionner les membres',
noGroups: 'Aucun groupe',
noGroupsHint: 'Créez un groupe pour commencer à discuter',
groupCreated: 'Groupe créé',
groupNotice: 'Annonce du groupe',
groupNoNotice: "Pas d'annonce",
groupMembers: 'Membres',
leaveGroup: 'Quitter le groupe',
leaveGroupConfirm: 'Quitter ce groupe ?',
disbandGroup: 'Dissoudre le groupe',
disbandGroupConfirm: 'Dissoudre ce groupe ? Cette action est irréversible.',
memberLimitReached: 'Limite de membres atteinte (2000)',
groupOwner: 'Propriétaire',
groupAdmin: 'Admin',
nMembers: '',
groupInfo: 'Info du groupe',
addMembers: 'Ajouter des membres',
removeMemberConfirm: 'Retirer ce membre du groupe ?',
muteGroup: 'Mettre en sourdine',
muteEnabled: 'Notifications désactivées',
muteDisabled: 'Notifications réactivées',
},
};

View File

@@ -1,7 +1,7 @@
/**
* Chat Window — i18n v2 + E2EE v2 (stateless per-message ECDH)
*/
import { state, avatarEl, goBack, showToast, formatTime } from '../app.js';
import { state, avatarEl, goBack, showToast, formatTime, openGroupInfo } from '../app.js';
import { api } from '../api.js';
import { send, onEvent, offEvent } from '../socket.js';
import { getKey } from '../crypto/keystore.js';
@@ -26,6 +26,12 @@ export async function renderChat(root, chat) {
</button>
<div class="topbar-title" id="chat-title">${esc(chat.name)}</div>
<div class="topbar-call-btns">
${chat.type === 'group' ? `
<button class="topbar-btn" id="group-info-btn" title="${t('groupInfo')}">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>` : ''}
<button class="topbar-btn" id="voice-call-btn" title="${t('callVoice')}">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M6.6 10.8c1.4 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.27-.27.67-.36 1.02-.23 1.12.45 2.34.68 3.58.68.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.3 21 3 13.7 3 4.5c0-.55.45-1 1-1H8c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.1.35.02.74-.22 1.02L6.6 10.8z"/>
@@ -42,6 +48,11 @@ export async function renderChat(root, chat) {
root.appendChild(topbar);
topbar.querySelector('#back-btn').onclick = goBack;
// Group info button
topbar.querySelector('#group-info-btn')?.addEventListener('click', () => {
openGroupInfo(chat.id);
});
// Call buttons
topbar.querySelector('#voice-call-btn').onclick = async () => {
if (callManager.state !== 'idle') { showToast(t('callBusy')); return; }
@@ -155,22 +166,32 @@ export async function renderChat(root, chat) {
content = esc(text);
}
if (!fromMe) row.appendChild(avatarEl(chat.name, chat.avatar, 'avatar-sm'));
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.innerHTML = content;
if (msgType === 'image') {
bubble.querySelector('.bubble-image').addEventListener('click', e => showImageViewer(e.target.src));
// For group chat incoming messages: show sender avatar + name
if (!fromMe && chat.type === 'group') {
const senderAv = avatarEl(extra.senderName || '?', extra.senderAvatar, 'avatar-sm');
row.appendChild(senderAv);
const wrapper = document.createElement('div');
wrapper.className = 'group-bubble-wrap';
const senderLabel = document.createElement('div');
senderLabel.className = 'group-sender-name';
senderLabel.textContent = extra.senderName || '?';
wrapper.appendChild(senderLabel);
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.innerHTML = content;
wrapper.appendChild(bubble);
row.appendChild(wrapper);
if (msgType === 'image') bubble.querySelector('.bubble-image')?.addEventListener('click', e => showImageViewer(e.target.src));
if (msgType === 'voice') bubble.querySelector('.voice-play-btn')?.addEventListener('click', e => new Audio(e.currentTarget.dataset.src).play());
} else {
if (!fromMe) row.appendChild(avatarEl(chat.name, chat.avatar, 'avatar-sm'));
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.innerHTML = content;
if (msgType === 'image') bubble.querySelector('.bubble-image')?.addEventListener('click', e => showImageViewer(e.target.src));
if (msgType === 'voice') bubble.querySelector('.voice-play-btn')?.addEventListener('click', e => new Audio(e.currentTarget.dataset.src).play());
row.appendChild(bubble);
}
if (msgType === 'voice') {
bubble.querySelector('.voice-play-btn').addEventListener('click', e => {
const audio = new Audio(e.currentTarget.dataset.src);
audio.play();
});
}
row.appendChild(bubble);
const timeEl = document.createElement('div');
timeEl.className = `bubble-ts${fromMe ? ' bubble-ts-out' : ''}`;
@@ -200,7 +221,13 @@ export async function renderChat(root, chat) {
let text = t('encryptedMsg');
let extra = {};
if (chat.type === 'private' && !fromMe && row.header) {
if (chat.type === 'group') {
// Group messages are plain text (not encrypted)
text = row.ciphertext || '';
if (['image', 'voice', 'file'].includes(row.msg_type)) extra = { url: text };
extra.senderName = row.from_nickname || '?';
extra.senderAvatar = row.from_avatar || null;
} else if (chat.type === 'private' && !fromMe && row.header) {
const plain = await tryDecrypt(row.ciphertext, row.header);
if (plain !== null) {
text = plain;
@@ -258,6 +285,7 @@ export async function renderChat(root, chat) {
return;
}
}
// Group messages: no encryption, send plain text as ciphertext field
addBubble(text, true, Date.now(), msgType, { msgId, ...extra });
send({
@@ -423,7 +451,13 @@ export async function renderChat(root, chat) {
let text = msg.ciphertext; // fallback: show raw for groups
let extra = {};
if (chat.type === 'private' && msg.header && msg.ciphertext) {
if (chat.type === 'group') {
// Group messages are plain text
text = msg.ciphertext || '';
if (['image', 'voice', 'file'].includes(msg.msg_type)) extra = { url: text };
extra.senderName = msg.from_nickname || '?';
extra.senderAvatar = msg.from_avatar || null;
} else if (chat.type === 'private' && msg.header && msg.ciphertext) {
const plain = await tryDecrypt(msg.ciphertext, msg.header);
if (plain !== null) {
text = plain;

View File

@@ -0,0 +1,271 @@
/**
* Group Info page — view members, manage group
*/
import { state, avatarEl, showToast, goBack, navigateTo } from '../app.js';
import { api } from '../api.js';
import { t } from '../i18n.js';
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
export async function renderGroupInfo(root, groupId) {
root.innerHTML = '';
root.style.cssText = 'display:flex;flex-direction:column;height:100dvh;';
// Top bar
const topbar = document.createElement('div');
topbar.className = 'topbar';
topbar.innerHTML = `
<button class="topbar-btn topbar-back" id="gi-back">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
</button>
<div class="topbar-title">${t('groupInfo')}</div>
<div style="min-width:44px"></div>
`;
root.appendChild(topbar);
topbar.querySelector('#gi-back').onclick = goBack;
const content = document.createElement('div');
content.className = 'page-scroll';
content.style.cssText = 'flex:1;overflow-y:auto;padding-bottom:env(safe-area-inset-bottom, 16px);';
content.innerHTML = `<div style="padding:40px 0;text-align:center;" class="text-muted">...</div>`;
root.appendChild(content);
// Load group info
let info;
try {
info = await api.groupInfo(groupId);
} catch (err) {
content.innerHTML = `<div style="padding:40px 0;text-align:center;" class="text-muted">${esc(err.message)}</div>`;
return;
}
const isOwner = info.owner_id === state.user.id;
const myRole = info.members.find(m => m.id === state.user.id)?.role || 'member';
const isAdmin = myRole === 'owner' || myRole === 'admin';
content.innerHTML = '';
// ── Group header card ──
const header = document.createElement('div');
header.className = 'group-info-header';
header.style.cssText = 'display:flex;flex-direction:column;align-items:center;padding:24px 16px 16px;';
const av = avatarEl(info.name, info.avatar, 'avatar-lg');
av.style.cssText = 'width:72px;height:72px;font-size:28px;border-radius:20px;margin-bottom:12px;';
header.appendChild(av);
const nameEl = document.createElement('div');
nameEl.style.cssText = 'font-size:20px;font-weight:600;margin-bottom:4px;';
nameEl.textContent = info.name;
header.appendChild(nameEl);
const countEl = document.createElement('div');
countEl.className = 'text-muted';
countEl.style.fontSize = '14px';
countEl.textContent = `${info.member_count} ${t('nMembers')}`;
header.appendChild(countEl);
content.appendChild(header);
// ── Notice ──
const noticeSection = document.createElement('div');
noticeSection.className = 'settings-section';
noticeSection.innerHTML = `
<div class="section-header">${t('groupNotice')}</div>
<div class="list-item" style="padding:12px 16px;">
<span class="text-muted" style="font-size:14px;white-space:pre-wrap;">${esc(info.notice || t('groupNoNotice'))}</span>
</div>
`;
content.appendChild(noticeSection);
// ── Mute (Do Not Disturb) Toggle ──
const myGroupEntry = (state.groupsList || []).find(g => g.id === groupId);
let isMuted = !!myGroupEntry?.muted;
const muteSection = document.createElement('div');
muteSection.className = 'settings-section';
const muteRow = document.createElement('div');
muteRow.className = 'list-item';
muteRow.style.cssText = 'cursor:pointer;';
muteRow.innerHTML = `
<div style="flex:1;display:flex;align-items:center;gap:10px;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="var(--text-muted)">
<path d="M12 22c1.1 0 2-.9 2-2h-4a2 2 0 0 0 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
<span style="font-size:15px;font-weight:500;">${t('muteGroup')}</span>
</div>
<div class="mute-toggle ${isMuted ? 'mute-toggle-on' : ''}" id="mute-toggle">
<div class="mute-toggle-knob"></div>
</div>
`;
muteRow.onclick = async () => {
const newVal = !isMuted;
const toggle = muteRow.querySelector('#mute-toggle');
toggle.classList.toggle('mute-toggle-on', newVal);
try {
await api.muteGroup(groupId, newVal);
isMuted = newVal;
// Update local state
if (myGroupEntry) myGroupEntry.muted = newVal ? 1 : 0;
const chatEntry = state.chats.find(c => c.id === groupId);
if (chatEntry) chatEntry.muted = newVal;
showToast(newVal ? t('muteEnabled') : t('muteDisabled'));
} catch (err) {
toggle.classList.toggle('mute-toggle-on', !newVal);
showToast(err.message);
}
};
muteSection.appendChild(muteRow);
content.appendChild(muteSection);
// ── Members ──
const membersSection = document.createElement('div');
membersSection.className = 'settings-section';
membersSection.innerHTML = `<div class="section-header">${t('groupMembers')} (${info.members.length})</div>`;
// Add member button (if admin/owner)
if (isAdmin) {
const addRow = document.createElement('div');
addRow.className = 'list-item';
addRow.style.cursor = 'pointer';
addRow.innerHTML = `
<div class="avatar avatar-sm" style="background:var(--green);display:flex;align-items:center;justify-content:center;">
<svg viewBox="0 0 24 24" width="18" height="18" fill="#fff"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</div>
<span style="margin-left:12px;font-size:15px;font-weight:500;">${t('addMembers')}</span>
`;
addRow.onclick = () => showAddMemberModal(groupId, info.members.map(m => m.id), content, membersSection);
membersSection.appendChild(addRow);
}
info.members.forEach(m => {
const item = document.createElement('div');
item.className = 'list-item';
item.appendChild(avatarEl(m.nickname || m.username, m.avatar, 'avatar-sm'));
const info2 = document.createElement('div');
info2.className = 'flex-1';
info2.style.marginLeft = '12px';
let roleTag = '';
if (m.role === 'owner') roleTag = `<span class="role-badge role-owner">${t('groupOwner')}</span>`;
else if (m.role === 'admin') roleTag = `<span class="role-badge role-admin">${t('groupAdmin')}</span>`;
info2.innerHTML = `
<div style="font-size:15px;font-weight:500;">${esc(m.nickname || m.username)} ${roleTag}</div>
<div class="text-muted" style="font-size:13px;">@${esc(m.username)}</div>
`;
item.appendChild(info2);
// Remove button for admins (can't remove owner)
if (isAdmin && m.role !== 'owner' && m.id !== state.user.id) {
const removeBtn = document.createElement('button');
removeBtn.className = 'btn-pill btn-outline';
removeBtn.style.cssText = 'color:var(--red);border-color:var(--red);font-size:12px;padding:4px 10px;';
removeBtn.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 13H5v-2h14v2z"/></svg>`;
removeBtn.onclick = async (e) => {
e.stopPropagation();
if (!confirm(t('removeMemberConfirm'))) return;
try {
await api.removeMember(groupId, m.id);
item.remove();
showToast('✓');
} catch (err) { showToast(err.message); }
};
item.appendChild(removeBtn);
}
membersSection.appendChild(item);
});
content.appendChild(membersSection);
// ── Actions ──
const actionsSection = document.createElement('div');
actionsSection.className = 'settings-section';
actionsSection.style.paddingBottom = '32px';
if (!isOwner) {
const leaveBtn = document.createElement('div');
leaveBtn.className = 'list-item danger-action';
leaveBtn.style.cssText = 'justify-content:center;color:var(--red);cursor:pointer;font-weight:500;';
leaveBtn.textContent = t('leaveGroup');
leaveBtn.onclick = async () => {
if (!confirm(t('leaveGroupConfirm'))) return;
try {
await api.leaveGroup(groupId);
// Remove from local state
state.groupsList = (state.groupsList || []).filter(g => g.id !== groupId);
state.chats = state.chats.filter(c => c.id !== groupId);
showToast('✓');
navigateTo('groups');
} catch (err) { showToast(err.message); }
};
actionsSection.appendChild(leaveBtn);
}
if (isOwner) {
const disbandBtn = document.createElement('div');
disbandBtn.className = 'list-item danger-action';
disbandBtn.style.cssText = 'justify-content:center;color:var(--red);cursor:pointer;font-weight:600;';
disbandBtn.textContent = t('disbandGroup');
disbandBtn.onclick = async () => {
if (!confirm(t('disbandGroupConfirm'))) return;
try {
await api.disbandGroup(groupId);
state.groupsList = (state.groupsList || []).filter(g => g.id !== groupId);
state.chats = state.chats.filter(c => c.id !== groupId);
showToast('✓');
navigateTo('groups');
} catch (err) { showToast(err.message); }
};
actionsSection.appendChild(disbandBtn);
}
content.appendChild(actionsSection);
}
function showAddMemberModal(groupId, existingIds, contentEl, membersSection) {
const friends = (state.contacts || []).filter(f => !existingIds.includes(f.id));
if (!friends.length) { showToast(t('noContacts')); return; }
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal-card" style="width:90%;max-width:420px;max-height:70vh;display:flex;flex-direction:column;">
<div class="modal-header">
<button class="topbar-btn" id="add-cancel">${t('cancel')}</button>
<div class="topbar-title" style="font-size:16px">${t('addMembers')}</div>
<div style="min-width:44px"></div>
</div>
<div id="add-picker" style="padding:8px 0;flex:1;overflow-y:auto;"></div>
</div>
`;
document.body.appendChild(overlay);
const pickerEl = overlay.querySelector('#add-picker');
overlay.querySelector('#add-cancel').onclick = () => overlay.remove();
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
friends.forEach(f => {
const item = document.createElement('div');
item.className = 'list-item';
item.style.cursor = 'pointer';
item.appendChild(avatarEl(f.nickname || f.username, f.avatar, 'avatar-sm'));
const info = document.createElement('span');
info.style.cssText = 'font-size:15px;font-weight:500;margin-left:12px;flex:1;';
info.textContent = f.nickname || f.username;
item.appendChild(info);
const addBtn = document.createElement('button');
addBtn.className = 'btn-pill btn-green';
addBtn.textContent = t('add');
addBtn.onclick = async (e) => {
e.stopPropagation();
addBtn.disabled = true;
try {
await api.addMember(groupId, f.id);
addBtn.textContent = '✓';
addBtn.className = 'btn-pill btn-outline';
existingIds.push(f.id);
} catch (err) {
showToast(err.message);
addBtn.disabled = false;
}
};
item.appendChild(addBtn);
pickerEl.appendChild(item);
});
}

175
client/src/pages/groups.js Normal file
View File

@@ -0,0 +1,175 @@
/**
* Groups list page — create / browse groups
*/
import { state, openChat, avatarEl, showToast } from '../app.js';
import { api } from '../api.js';
import { t } from '../i18n.js';
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let _listEl = null;
export function renderGroups(root) {
root.innerHTML = `
<div class="topbar">
<div style="min-width:44px"></div>
<div class="topbar-title">${t('groupsTitle')}</div>
<button class="topbar-btn topbar-action" id="create-group-btn" title="${t('createGroup')}">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</button>
</div>
<div class="search-wrap">
<input class="search-input" id="groups-search" placeholder="${t('searchPlaceholder')}">
</div>
<div id="group-list"></div>
`;
_listEl = root.querySelector('#group-list');
const searchEl = root.querySelector('#groups-search');
renderList(getGroups());
searchEl.addEventListener('input', () => {
const q = searchEl.value.trim().toLowerCase();
const all = getGroups();
renderList(q ? all.filter(g => (g.name || '').toLowerCase().includes(q)) : all);
});
root.querySelector('#create-group-btn').onclick = () => showCreateGroupModal();
}
function getGroups() {
return (state.groupsList || []).slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}
export function refreshGroupList() {
if (!_listEl || !document.body.contains(_listEl)) return;
renderList(getGroups());
}
function renderList(items) {
if (!_listEl) return;
_listEl.innerHTML = '';
if (!items.length) {
_listEl.innerHTML = `
<div class="empty-state">
<div class="empty-icon"><svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor" opacity=".5"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg></div>
<div class="empty-title">${t('noGroups')}</div>
<div class="empty-hint">${t('noGroupsHint')}</div>
</div>`;
return;
}
items.forEach(g => {
const item = document.createElement('div');
item.className = 'list-item';
item.appendChild(avatarEl(g.name, g.avatar));
const meta = document.createElement('div');
meta.className = 'chat-meta';
meta.innerHTML = `
<div class="chat-name-row">
<span class="chat-name">${esc(g.name)}</span>
<span class="text-muted" style="font-size:12px">${g.member_count || ''}${t('nMembers')}</span>
</div>
<div class="chat-preview-row">
<span class="chat-preview text-muted">${esc(g.notice || '')}</span>
</div>`;
item.appendChild(meta);
item.addEventListener('click', () => {
openChat({ id: g.id, type: 'group', name: g.name, avatar: g.avatar });
});
_listEl.appendChild(item);
});
}
function showCreateGroupModal() {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal-card" style="width:90%;max-width:420px;max-height:80vh;display:flex;flex-direction:column;">
<div class="modal-header">
<button class="topbar-btn" id="modal-cancel">${t('cancel')}</button>
<div class="topbar-title" style="font-size:16px">${t('createGroup')}</div>
<button class="btn-pill btn-green" id="modal-create" disabled>${t('createGroup')}</button>
</div>
<div style="padding:16px;flex:1;overflow-y:auto;">
<input class="search-input" id="group-name-input" placeholder="${t('groupNamePlaceholder')}" style="margin-bottom:16px;">
<div class="section-header">${t('selectMembers')}</div>
<div id="member-picker" style="max-height:40vh;overflow-y:auto;"></div>
</div>
</div>
`;
document.body.appendChild(overlay);
const nameInput = overlay.querySelector('#group-name-input');
const createBtn = overlay.querySelector('#modal-create');
const cancelBtn = overlay.querySelector('#modal-cancel');
const pickerEl = overlay.querySelector('#member-picker');
const selected = new Set();
// Render friends as checkable items
const friends = state.contacts || [];
friends.forEach(f => {
const item = document.createElement('div');
item.className = 'list-item';
item.style.cursor = 'pointer';
const checkbox = document.createElement('div');
checkbox.className = 'member-checkbox';
checkbox.innerHTML = `<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="var(--text-muted)" stroke-width="2"/></svg>`;
item.appendChild(checkbox);
item.appendChild(avatarEl(f.nickname || f.username, f.avatar, 'avatar-sm'));
const info = document.createElement('span');
info.style.cssText = 'font-size:15px;font-weight:500;margin-left:8px;';
info.textContent = f.nickname || f.username;
item.appendChild(info);
item.addEventListener('click', () => {
if (selected.has(f.id)) {
selected.delete(f.id);
checkbox.innerHTML = `<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="var(--text-muted)" stroke-width="2"/></svg>`;
} else {
selected.add(f.id);
checkbox.innerHTML = `<svg viewBox="0 0 24 24" width="22" height="22" fill="var(--green)"><rect x="3" y="3" width="18" height="18" rx="4" fill="var(--green)"/><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" fill="#fff"/></svg>`;
}
updateCreateBtn();
});
pickerEl.appendChild(item);
});
function updateCreateBtn() {
createBtn.disabled = !nameInput.value.trim() || selected.size === 0;
}
nameInput.addEventListener('input', updateCreateBtn);
cancelBtn.onclick = () => overlay.remove();
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
createBtn.onclick = async () => {
const name = nameInput.value.trim();
if (!name || selected.size === 0) return;
createBtn.disabled = true;
createBtn.textContent = '...';
try {
const group = await api.createGroup({ name, member_ids: [...selected] });
// Add to local state
if (!state.groupsList) state.groupsList = [];
state.groupsList.push({ ...group, member_count: selected.size + 1 });
// Also add to chats list
state.chats.push({
id: group.id, type: 'group', name: group.name, avatar: group.avatar || null,
lastMsg: '', lastTs: Date.now(), unread: 0,
});
showToast(t('groupCreated'));
overlay.remove();
refreshGroupList();
} catch (err) {
showToast(err.message);
createBtn.disabled = false;
createBtn.textContent = t('createGroup');
}
};
}

View File

@@ -1472,3 +1472,168 @@ html, body {
font-size: 14px;
}
/* ── Modal Overlay ─────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: flex;
align-items: center;
justify-content: center;
z-index: 800;
animation: fadeIn .2s ease;
}
.modal-card {
background: var(--surface-solid);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
overflow: hidden;
animation: modalPop .25s cubic-bezier(.2,1.2,.4,1);
}
@keyframes modalPop {
from { transform: scale(.9) translateY(20px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 0.5px solid var(--divider);
}
/* ── Group Chat Bubbles ──────────────────────────────────── */
.group-bubble-wrap {
display: flex;
flex-direction: column;
gap: 2px;
max-width: min(72vw, 340px);
}
.group-sender-name {
font-size: 12px;
font-weight: 600;
color: var(--blue);
padding-left: 4px;
letter-spacing: -0.1px;
}
/* ── Member Checkbox ─────────────────────────────────────── */
.member-checkbox {
flex-shrink: 0;
width: 22px;
height: 22px;
margin-right: 4px;
}
/* ── Role Badges ──────────────────────────────────────────── */
.role-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: var(--radius-pill);
vertical-align: middle;
margin-left: 4px;
}
.role-owner {
background: rgba(255,149,0,.15);
color: var(--orange);
}
.role-admin {
background: rgba(0,122,255,.12);
color: var(--blue);
}
/* ── Danger Action Rows ───────────────────────────────────── */
.danger-action {
justify-content: center;
color: var(--red);
cursor: pointer;
font-weight: 500;
padding: 14px 16px;
background: var(--surface);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-radius: var(--radius-sm);
margin: 8px 16px;
transition: background .15s ease;
}
.danger-action:active {
background: rgba(255,59,48,.1);
}
/* ── Page Scroll Container ────────────────────────────────── */
.page-scroll {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: var(--bg);
}
.page-scroll::-webkit-scrollbar { display: none; }
/* ── Group Info Header ────────────────────────────────────── */
.group-info-header {
background: var(--surface);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
margin: 12px 16px;
border-radius: var(--radius);
border: 0.5px solid var(--glass-border);
box-shadow: var(--glass-specular), var(--shadow);
}
/* ── Settings Section ─────────────────────────────────────── */
.settings-section {
margin-bottom: 8px;
}
/* ── 5-tab badge positioning fix ──────────────────────────── */
.tab-badge-sm {
width: 8px;
min-width: 8px;
height: 8px;
padding: 0;
right: calc(50% - 16px);
top: 6px;
}
/* ── Mute Toggle Switch ───────────────────────────────────── */
.mute-toggle {
width: 50px;
height: 30px;
border-radius: 15px;
background: var(--surface-2);
border: 0.5px solid var(--glass-border);
position: relative;
cursor: pointer;
transition: background .25s ease, border-color .25s ease;
flex-shrink: 0;
}
.mute-toggle-on {
background: var(--green);
border-color: var(--green);
}
.mute-toggle-knob {
width: 26px;
height: 26px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 2px;
left: 2px;
box-shadow: 0 2px 6px rgba(0,0,0,.15);
transition: transform .25s cubic-bezier(.34,1.56,.64,1);
}
.mute-toggle-on .mute-toggle-knob {
transform: translateX(20px);
}
/* ── Mute indicator in chat list ──────────────────────────── */
.chat-muted-icon {
color: var(--text-muted);
flex-shrink: 0;
margin-left: 4px;
opacity: .6;
}

View File

@@ -68,12 +68,23 @@ CREATE TABLE IF NOT EXISTS group_members (
group_id VARCHAR(36) NOT NULL,
user_id VARCHAR(36) NOT NULL,
role ENUM('owner','admin','member') NOT NULL DEFAULT 'member',
muted TINYINT(1) NOT NULL DEFAULT 0,
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, user_id),
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migration: add muted column to group_members (idempotent)
SET @gm_muted = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'group_members' AND COLUMN_NAME = 'muted');
SET @gm_sql = IF(@gm_muted = 0,
'ALTER TABLE group_members ADD COLUMN muted TINYINT(1) NOT NULL DEFAULT 0 AFTER role',
'SELECT 1');
PREPARE gm_stmt FROM @gm_sql;
EXECUTE gm_stmt;
DEALLOCATE PREPARE gm_stmt;
-- ── Messages ──────────────────────────────────────────────────────────────
-- Server stores encrypted payloads for offline delivery only.
-- Once delivered via WebSocket, messages may be pruned.

View File

@@ -7,12 +7,15 @@ const { sendToUser, sendToGroup } = require('../ws/wsServer');
const router = express.Router();
router.use(authMiddleware);
const MAX_GROUP_MEMBERS = 2000;
// GET /api/groups — list my groups
router.get('/', async (req, res, next) => {
try {
const db = getDb();
const [rows] = await db.query(
`SELECT g.id, g.name, g.avatar, g.notice, g.owner_id, gm.role
`SELECT g.id, g.name, g.avatar, g.notice, g.owner_id, gm.role, gm.muted,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
FROM group_members gm
JOIN \`groups\` g ON g.id = gm.group_id
WHERE gm.user_id = ?`,
@@ -27,17 +30,29 @@ router.post('/', async (req, res, next) => {
try {
const { name, member_ids } = req.body;
if (!name) return res.status(400).json({ error: 'Group name required' });
const members = [...new Set([req.user.id, ...(member_ids || [])])];
if (members.length > MAX_GROUP_MEMBERS) {
return res.status(400).json({ error: `Group cannot exceed ${MAX_GROUP_MEMBERS} members` });
}
const db = getDb();
const id = uuidv4();
await db.query(
`INSERT INTO \`groups\` (id, name, owner_id) VALUES (?, ?, ?)`,
[id, name, req.user.id]
);
// Add owner + initial members
const members = [...new Set([req.user.id, ...(member_ids || [])])];
const rows = members.map(uid => [id, uid, uid === req.user.id ? 'owner' : 'member']);
await db.query('INSERT INTO group_members (group_id, user_id, role) VALUES ?', [rows]);
res.status(201).json({ id, name, owner_id: req.user.id });
const group = { id, name, owner_id: req.user.id, member_count: members.length };
// Notify added members via WS
for (const uid of members) {
if (uid !== req.user.id) {
sendToUser(uid, { type: 'group_created', group });
}
}
res.status(201).json(group);
} catch (err) { next(err); }
});
@@ -49,10 +64,39 @@ router.get('/:id', async (req, res, next) => {
if (!groups.length) return res.status(404).json({ error: 'Group not found' });
const [members] = await db.query(
`SELECT u.id, u.username, u.nickname, u.avatar, gm.role FROM group_members gm
JOIN users u ON u.id = gm.user_id WHERE gm.group_id = ?`,
JOIN users u ON u.id = gm.user_id WHERE gm.group_id = ?
ORDER BY FIELD(gm.role, 'owner', 'admin', 'member'), gm.joined_at ASC`,
[req.params.id]
);
res.json({ ...groups[0], members });
res.json({ ...groups[0], members, member_count: members.length });
} catch (err) { next(err); }
});
// PATCH /api/groups/:id — update group name / avatar / notice
router.patch('/:id', async (req, res, next) => {
try {
const db = getDb();
// Check caller is owner or admin
const [roleRows] = await db.query(
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
if (!roleRows.length || !['owner', 'admin'].includes(roleRows[0].role)) {
return res.status(403).json({ error: 'Only owner or admin can update group' });
}
const updates = [];
const vals = [];
if (req.body.name) { updates.push('name = ?'); vals.push(req.body.name); }
if (req.body.avatar !== undefined) { updates.push('avatar = ?'); vals.push(req.body.avatar); }
if (req.body.notice !== undefined) { updates.push('notice = ?'); vals.push(req.body.notice); }
if (!updates.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(req.params.id);
await db.query(`UPDATE \`groups\` SET ${updates.join(', ')} WHERE id = ?`, vals);
await sendToGroup(req.params.id, {
type: 'group_updated', group_id: req.params.id,
name: req.body.name, avatar: req.body.avatar, notice: req.body.notice,
});
res.json({ ok: true });
} catch (err) { next(err); }
});
@@ -61,25 +105,101 @@ router.post('/:id/members', async (req, res, next) => {
try {
const { user_id } = req.body;
const db = getDb();
// Check member count
const [countRows] = await db.query(
'SELECT COUNT(*) AS cnt FROM group_members WHERE group_id = ?',
[req.params.id]
);
if (countRows[0].cnt >= MAX_GROUP_MEMBERS) {
return res.status(400).json({ error: `Group cannot exceed ${MAX_GROUP_MEMBERS} members` });
}
await db.query(
`INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, 'member')`,
[req.params.id, user_id]
);
// Fetch group info to notify the new member
const [groups] = await db.query('SELECT id, name, avatar FROM `groups` WHERE id = ?', [req.params.id]);
sendToUser(user_id, { type: 'group_created', group: groups[0] });
sendToGroup(req.params.id, { type: 'group_member_added', group_id: req.params.id, user_id });
res.json({ ok: true });
} catch (err) { next(err); }
});
// DELETE /api/groups/:id/members/:uid — remove member (owner/admin only simplified)
router.delete('/:id/members/:uid', async (req, res, next) => {
// DELETE /api/groups/:id/members/me — leave group
router.delete('/:id/members/me', async (req, res, next) => {
try {
const db = getDb();
const [roleRows] = await db.query(
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
if (!roleRows.length) return res.status(404).json({ error: 'Not in this group' });
if (roleRows[0].role === 'owner') {
return res.status(400).json({ error: 'Owner cannot leave. Transfer ownership or disband the group.' });
}
await db.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[req.params.id, req.params.uid]
[req.params.id, req.user.id]
);
await sendToGroup(req.params.id, {
type: 'group_member_removed', group_id: req.params.id, user_id: req.user.id,
});
res.json({ ok: true });
} catch (err) { next(err); }
});
// DELETE /api/groups/:id/members/:uid — remove member (owner/admin only)
router.delete('/:id/members/:uid', async (req, res, next) => {
try {
const db = getDb();
// Check caller is owner or admin
const [roleRows] = await db.query(
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
if (!roleRows.length || !['owner', 'admin'].includes(roleRows[0].role)) {
return res.status(403).json({ error: 'Only owner or admin can remove members' });
}
await db.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[req.params.id, req.params.uid]
);
sendToUser(req.params.uid, {
type: 'group_member_removed', group_id: req.params.id, user_id: req.params.uid,
});
await sendToGroup(req.params.id, {
type: 'group_member_removed', group_id: req.params.id, user_id: req.params.uid,
});
res.json({ ok: true });
} catch (err) { next(err); }
});
// DELETE /api/groups/:id — disband group (owner only)
router.delete('/:id', async (req, res, next) => {
try {
const db = getDb();
const [groups] = await db.query('SELECT owner_id FROM `groups` WHERE id = ?', [req.params.id]);
if (!groups.length) return res.status(404).json({ error: 'Group not found' });
if (groups[0].owner_id !== req.user.id) {
return res.status(403).json({ error: 'Only the owner can disband the group' });
}
await sendToGroup(req.params.id, { type: 'group_disbanded', group_id: req.params.id });
await db.query('DELETE FROM `groups` WHERE id = ?', [req.params.id]);
res.json({ ok: true });
} catch (err) { next(err); }
});
// PATCH /api/groups/:id/mute — toggle mute (any member)
router.patch('/:id/mute', async (req, res, next) => {
try {
const db = getDb();
const muted = req.body.muted ? 1 : 0;
await db.query(
'UPDATE group_members SET muted = ? WHERE group_id = ? AND user_id = ?',
[muted, req.params.id, req.user.id]
);
res.json({ ok: true, muted });
} catch (err) { next(err); }
});
module.exports = router;

View File

@@ -37,11 +37,13 @@ router.get('/group/:groupId', async (req, res, next) => {
const before = req.query.before ? new Date(parseInt(req.query.before)) : null;
const [rows] = await db.query(
`SELECT id, from_id, ciphertext, header, msg_type, created_at, read_at
FROM messages
WHERE type = 'group' AND to_id = ?
${before ? 'AND created_at < ?' : ''}
ORDER BY created_at ASC
`SELECT m.id, m.from_id, m.ciphertext, m.header, m.msg_type, m.created_at, m.read_at,
u.nickname AS from_nickname, u.avatar AS from_avatar
FROM messages m
LEFT JOIN users u ON u.id = m.from_id
WHERE m.type = 'group' AND m.to_id = ?
${before ? 'AND m.created_at < ?' : ''}
ORDER BY m.created_at ASC
LIMIT ?`,
before
? [req.params.groupId, before, limit]

View File

@@ -167,10 +167,17 @@ function initWsServer(httpServer) {
// ── GROUP MESSAGE ─────────────────────────────────────────────────
if (msg.type === 'message' && msg.group_id) {
const msgId = uuidv4();
const db = getDb();
// Lookup sender nickname for display
const [senderRows] = await db.query('SELECT nickname, avatar FROM users WHERE id = ?', [ws.userId]);
const fromNick = senderRows[0]?.nickname || '';
const fromAvatar = senderRows[0]?.avatar || null;
const envelope = {
type: 'message',
id: msgId,
from: ws.userId,
from_nickname: fromNick,
from_avatar: fromAvatar,
group_id: msg.group_id,
msg_type: msg.msg_type || 'text',
ciphertext: msg.ciphertext,
@@ -178,7 +185,6 @@ function initWsServer(httpServer) {
ts: Date.now(),
};
// Store in DB for offline group members
const db = getDb();
await db.query(
`INSERT INTO messages (id, type, from_id, to_id, ciphertext, header, msg_type)
VALUES (?, 'group', ?, ?, ?, ?, ?)`,
@@ -274,6 +280,7 @@ function initWsServer(httpServer) {
}
async function flushOfflineMessages(userId, ws, db) {
// Private messages
const [rows] = await db.query(
`SELECT id, from_id, to_id, ciphertext, header, self_ciphertext, self_header, msg_type, created_at, read_at, type
FROM messages
@@ -297,6 +304,39 @@ async function flushOfflineMessages(userId, ws, db) {
ws.send(JSON.stringify(envelope));
await db.query('UPDATE messages SET delivered = 1 WHERE id = ?', [row.id]);
}
// Group messages — flush unread group messages user missed
const [groupRows] = await db.query(
`SELECT m.id, m.from_id, m.to_id AS group_id, m.ciphertext, m.header, m.msg_type, m.created_at,
u.nickname AS from_nickname, u.avatar AS from_avatar
FROM messages m
LEFT JOIN users u ON u.id = m.from_id
WHERE m.type = 'group'
AND m.to_id IN (SELECT group_id FROM group_members WHERE user_id = ?)
AND m.from_id != ?
AND m.created_at > COALESCE(
(SELECT last_active FROM sessions WHERE user_id = ? AND revoked = 0 ORDER BY last_active DESC LIMIT 1),
DATE_SUB(NOW(), INTERVAL 7 DAY)
)
ORDER BY m.created_at ASC
LIMIT 200`,
[userId, userId, userId]
);
for (const row of groupRows) {
const envelope = {
type: 'message',
id: row.id,
from: row.from_id,
from_nickname: row.from_nickname || '',
from_avatar: row.from_avatar || null,
group_id: row.group_id,
msg_type: row.msg_type,
ciphertext: row.ciphertext,
ts: new Date(row.created_at).getTime(),
offline: true,
};
ws.send(JSON.stringify(envelope));
}
}
/**