diff --git a/backend/package.json b/backend/package.json index ce2fa3b..11b8104 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.40", + "version": "0.12.41", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 1343717..e7e9583 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -141,6 +141,17 @@ router.post('/register', authMiddleware, adminMiddleware, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); +router.patch('/messages', authMiddleware, adminMiddleware, async (req, res) => { + const { msgPublic, msgGroup, msgPrivateGroup, msgU2U } = req.body; + try { + if (msgPublic !== undefined) await setSetting(req.schema, 'feature_msg_public', msgPublic ? 'true' : 'false'); + if (msgGroup !== undefined) await setSetting(req.schema, 'feature_msg_group', msgGroup ? 'true' : 'false'); + if (msgPrivateGroup !== undefined) await setSetting(req.schema, 'feature_msg_private_group', msgPrivateGroup ? 'true' : 'false'); + if (msgU2U !== undefined) await setSetting(req.schema, 'feature_msg_u2u', msgU2U ? 'true' : 'false'); + res.json({ success: true }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => { const { toolManagers } = req.body; try { diff --git a/build.sh b/build.sh index b875192..76eaa2b 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.40}" +VERSION="${1:-0.12.41}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 4dd5c17..e00ebb4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.40", + "version": "0.12.41", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 75d7dce..3e90342 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -404,7 +404,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess This channel is read-only ) : ( - setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} /> + setReplyTo(null)} onTyping={(isTyping) => { if (socket && group) socket.emit(isTyping ? 'typing:start' : 'typing:stop', { groupId: group.id }); }} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} /> )} {showInfo && ( diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index db949be..4fc6f4b 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -66,7 +66,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, {/* User section */} {item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat', dot: unreadMessages })} - {hasUserGroups && item(NAV_ICON.groupmessages, 'Group Messages', onGroupMessages, { active: currentPage === 'groupmessages', dot: unreadGroupMessages })} + {hasUserGroups && (features.msgGroup ?? true) && item(NAV_ICON.groupmessages, 'Group Messages', onGroupMessages, { active: currentPage === 'groupmessages', dot: unreadGroupMessages })} {features.scheduleManager && item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })} {/* Admin section */} diff --git a/frontend/src/components/NewChatModal.jsx b/frontend/src/components/NewChatModal.jsx index 642ea14..1983316 100644 --- a/frontend/src/components/NewChatModal.jsx +++ b/frontend/src/components/NewChatModal.jsx @@ -4,10 +4,17 @@ import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; import Avatar from './Avatar.jsx'; -export default function NewChatModal({ onClose, onCreated }) { +export default function NewChatModal({ onClose, onCreated, features = {} }) { const { user } = useAuth(); const toast = useToast(); - const [tab, setTab] = useState('private'); // 'private' | 'public' + + const msgPublic = features.msgPublic ?? true; + const msgU2U = features.msgU2U ?? true; + const msgPrivateGroup = features.msgPrivateGroup ?? true; + + // Default to private if available, otherwise public + const defaultTab = (msgU2U || msgPrivateGroup) ? 'private' : 'public'; + const [tab, setTab] = useState(defaultTab); const [name, setName] = useState(''); const [isReadonly, setIsReadonly] = useState(false); const [search, setSearch] = useState(''); @@ -15,8 +22,8 @@ export default function NewChatModal({ onClose, onCreated }) { const [selected, setSelected] = useState([]); const [loading, setLoading] = useState(false); - // True when exactly 1 user selected on private tab = direct message - const isDirect = tab === 'private' && selected.length === 1; + // True when exactly 1 user selected on private tab AND U2U messages are enabled + const isDirect = tab === 'private' && selected.length === 1 && msgU2U; useEffect(() => { api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {}); @@ -30,12 +37,17 @@ export default function NewChatModal({ onClose, onCreated }) { const toggle = (u) => { if (u.id === user.id) return; - setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]); + // If private groups are disabled, cap selection at 1 (DM only) + setSelected(prev => { + if (prev.find(p => p.id === u.id)) return prev.filter(p => p.id !== u.id); + if (!msgPrivateGroup && prev.length >= 1) return prev; // can't add more for DM-only + return [...prev, u]; + }); }; const handleCreate = async () => { if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error'); - if (tab === 'private' && selected.length > 1 && !name.trim()) return toast('Name required', 'error'); + if (tab === 'private' && !isDirect && !name.trim()) return toast('Name required', 'error'); if (tab === 'public' && !name.trim()) return toast('Name required', 'error'); setLoading(true); @@ -86,15 +98,15 @@ export default function NewChatModal({ onClose, onCreated }) { - {user.role === 'admin' && ( + {user.role === 'admin' && (msgU2U || msgPrivateGroup || msgPublic) && (
- - + {(msgU2U || msgPrivateGroup) && } + {msgPublic && }
)} - {/* Message Name — only shown when needed: public always, private only when 2+ members selected */} - {(tab === 'public' || (tab === 'private' && selected.length > 1)) && ( + {/* Message Name — public always, private when not a DM and at least 1 member selected */} + {(tab === 'public' || (tab === 'private' && !isDirect && selected.length > 0)) && (
onChange(!checked)} + role="switch" + aria-checked={checked} + style={{ + width: 44, height: 24, borderRadius: 12, cursor: 'pointer', flexShrink: 0, + background: checked ? 'var(--primary)' : 'var(--border)', + position: 'relative', transition: 'background 0.2s', + }} + > +
+
+ ); +} + +// ── Messages Tab ────────────────────────────────────────────────────────────── +function MessagesTab() { + const toast = useToast(); + const [settings, setSettings] = useState({ + msgPublic: true, + msgGroup: true, + msgPrivateGroup: true, + msgU2U: true, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + api.getSettings().then(({ settings: s }) => { + setSettings({ + msgPublic: s.feature_msg_public !== 'false', + msgGroup: s.feature_msg_group !== 'false', + msgPrivateGroup: s.feature_msg_private_group !== 'false', + msgU2U: s.feature_msg_u2u !== 'false', + }); + }).catch(() => {}); + }, []); + + const toggle = (key) => setSettings(prev => ({ ...prev, [key]: !prev[key] })); + + const handleSave = async () => { + setSaving(true); + try { + await api.updateMessageSettings(settings); + toast('Message settings saved', 'success'); + window.dispatchEvent(new Event('rosterchirp:settings-changed')); + } catch (e) { toast(e.message, 'error'); } + finally { setSaving(false); } + }; + + const rows = [ + { key: 'msgPublic', label: 'Public Messages', desc: 'Public group channels visible to all members.' }, + { key: 'msgGroup', label: 'Group Messages', desc: 'Private group messages managed by User Groups.' }, + { key: 'msgPrivateGroup', label: 'Private Group Messages', desc: 'Private multi-member group conversations.' }, + { key: 'msgU2U', label: 'Private Messages (U2U)', desc: 'One-on-one direct messages between users.' }, + ]; + + return ( +
+
Message Features
+

+ Disable a feature to hide it from all menus, sidebars, and modals. +

+
+ {rows.map((r, i) => ( +
+
+
{r.label}
+
{r.desc}
+
+ toggle(r.key)} /> +
+ ))} +
+ +
+ ); +} + // ── Team Management Tab ─────────────────────────────────────────────────────── function TeamManagementTab() { const toast = useToast(); @@ -193,7 +281,7 @@ function RegistrationTab({ onFeaturesChanged }) { // ── Main modal ──────────────────────────────────────────────────────────────── export default function SettingsModal({ onClose, onFeaturesChanged }) { - const [tab, setTab] = useState('registration'); + const [tab, setTab] = useState('messages'); const [appType, setAppType] = useState('RosterChirp-Chat'); useEffect(() => { @@ -208,7 +296,8 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) { const isTeam = appType === 'RosterChirp-Team'; const tabs = [ - isTeam && { id: 'team', label: 'Team Management' }, + { id: 'messages', label: 'Messages' }, + isTeam && { id: 'team', label: 'Tools' }, { id: 'registration', label: 'Registration' }, ].filter(Boolean); @@ -231,6 +320,7 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) { ))}
+ {tab === 'messages' && } {tab === 'team' && } {tab === 'registration' && } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index a61d76e..7220227 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -124,6 +124,10 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica const toast = useToast(); const settings = useAppSettings(); + const msgPublic = features.msgPublic ?? true; + const msgU2U = features.msgU2U ?? true; + const msgPrivateGroup = features.msgPrivateGroup ?? true; + const allGroups = [ ...(groups.publicGroups || []), ...(groups.privateGroups || []) @@ -131,8 +135,16 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica const publicFiltered = allGroups.filter(g => g.type === 'public'); - // In groupMessagesMode show only managed groups; on main Messages hide managed groups - const privateFiltered = [...allGroups.filter(g => g.type === 'private' && (groupMessagesMode ? g.is_managed : !g.is_managed))].sort((a, b) => { + // In groupMessagesMode show only managed groups; on main Messages hide managed groups. + // Also filter individual groups based on message feature flags. + const privateFiltered = [...allGroups.filter(g => { + if (g.type !== 'private') return false; + if (groupMessagesMode) return g.is_managed; + if (g.is_managed) return false; + if (g.is_direct && !msgU2U) return false; + if (!g.is_direct && !msgPrivateGroup) return false; + return true; + })].sort((a, b) => { if (!a.last_message_at && !b.last_message_at) return 0; if (!a.last_message_at) return 1; if (!b.last_message_at) return -1; @@ -221,7 +233,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
- {!groupMessagesMode && publicFiltered.length > 0 && ( + {!groupMessagesMode && msgPublic && publicFiltered.length > 0 && (
PUBLIC MESSAGES
{publicFiltered.map(g => )} diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index ff99948..28edde0 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -46,7 +46,7 @@ export default function Chat() { const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager' const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages' const [drawerOpen, setDrawerOpen] = useState(false); - const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false }); + const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false, msgPublic: true, msgGroup: true, msgPrivateGroup: true, msgU2U: true }); const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [showSidebar, setShowSidebar] = useState(true); @@ -91,6 +91,10 @@ export default function Chat() { appType: settings.app_type || 'RosterChirp-Chat', teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'), isHostDomain: settings.is_host_domain === 'true', + msgPublic: settings.feature_msg_public !== 'false', + msgGroup: settings.feature_msg_group !== 'false', + msgPrivateGroup: settings.feature_msg_private_group !== 'false', + msgU2U: settings.feature_msg_u2u !== 'false', })); }).catch(() => {}); api.getMyUserGroups().then(({ userGroups }) => { @@ -690,7 +694,7 @@ export default function Chat() { {modal === 'branding' && setModal(null)} />} {modal === 'help' && setModal(null)} dismissed={helpDismissed} />} {modal === 'about' && setModal(null)} />} - {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />} + {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
); @@ -834,7 +838,7 @@ export default function Chat() { {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} - {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} + {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'about' && setModal(null)} />} {modal === 'help' && setModal(null)} dismissed={helpDismissed} />}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index f49748d..679fec2 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -104,6 +104,7 @@ export const api = { updateColors: (body) => req('PATCH', '/settings/colors', body), registerCode: (code) => req('POST', '/settings/register', { code }), updateTeamSettings: (body) => req('PATCH', '/settings/team', body), + updateMessageSettings: (body) => req('PATCH', '/settings/messages', body), // Schedule Manager getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),