diff --git a/README.md b/README.md index 20fb656..4759ed7 100644 --- a/README.md +++ b/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 ๅผ ๏ผ‰ | diff --git a/README_EN.md b/README_EN.md index 52b7f3d..862df5e 100644 --- a/README_EN.md +++ b/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) | diff --git a/client/src/api.js b/client/src/api.js index 873a887..b5e1f09 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -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}`), diff --git a/client/src/app.js b/client/src/app.js index 783a48a..322d66b 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -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 ``; } +function groupIcon() { + return ``; +} function contactIcon() { return ``; } diff --git a/client/src/i18n.js b/client/src/i18n.js index cd39225..e2c10ce 100644 --- a/client/src/i18n.js +++ b/client/src/i18n.js @@ -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', }, }; diff --git a/client/src/pages/chat.js b/client/src/pages/chat.js index cfa24a3..0f43368 100644 --- a/client/src/pages/chat.js +++ b/client/src/pages/chat.js @@ -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) {
${esc(chat.name)}
+ ${chat.type === 'group' ? ` + ` : ''} +
${t('addMembers')}
+
+
+
+ + `; + 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); + }); +} diff --git a/client/src/pages/groups.js b/client/src/pages/groups.js new file mode 100644 index 0000000..4ead966 --- /dev/null +++ b/client/src/pages/groups.js @@ -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,'>'); + +let _listEl = null; + +export function renderGroups(root) { + root.innerHTML = ` +
+
+
${t('groupsTitle')}
+ +
+
+ +
+
+ `; + + _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 = ` +
+
+
${t('noGroups')}
+
${t('noGroupsHint')}
+
`; + 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 = ` +
+ ${esc(g.name)} + ${g.member_count || ''}${t('nMembers')} +
+
+ ${esc(g.notice || '')} +
`; + 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 = ` + + `; + 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 = ``; + 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 = ``; + } else { + selected.add(f.id); + checkbox.innerHTML = ``; + } + 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'); + } + }; +} diff --git a/client/src/style.css b/client/src/style.css index d7e28ee..4a92798 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -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; +} diff --git a/server/db/schema.sql b/server/db/schema.sql index 460e9e0..435b0df 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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. diff --git a/server/src/routes/groups.js b/server/src/routes/groups.js index 067d772..035562b 100644 --- a/server/src/routes/groups.js +++ b/server/src/routes/groups.js @@ -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; diff --git a/server/src/routes/messages.js b/server/src/routes/messages.js index 57a31c6..dfe70cc 100644 --- a/server/src/routes/messages.js +++ b/server/src/routes/messages.js @@ -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] diff --git a/server/src/ws/wsServer.js b/server/src/ws/wsServer.js index 5508936..fa92356 100644 --- a/server/src/ws/wsServer.js +++ b/server/src/ws/wsServer.js @@ -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)); + } } /**