mirror of
https://github.com/619dev/PaperPhone.git
synced 2026-05-06 22:12:41 +08:00
更新群聊,增加免打扰模式
This commit is contained in:
21
README.md
21
README.md
@@ -16,6 +16,7 @@
|
||||
| 🔐 端对端加密 | 无状态 ECDH + XSalsa20-Poly1305,逐消息临时密钥,前向保密 |
|
||||
| 🗝️ 零知识服务器 | 服务器只存储密文,私钥仅在设备本地(四层持久化) |
|
||||
| 📹 视频/语音通话 | WebRTC P2P(1: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 张) |
|
||||
|
||||
23
README_EN.md
23
README_EN.md
@@ -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) |
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
271
client/src/pages/groupInfo.js
Normal file
271
client/src/pages/groupInfo.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
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
175
client/src/pages/groups.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user