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' ? `
+
` : ''}