v0.12.41 New settings options for messages

This commit is contained in:
2026-03-30 08:04:36 -04:00
parent ff6743c9b1
commit 6a2f4438f9
11 changed files with 154 additions and 24 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.40",
"version": "0.12.41",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -404,7 +404,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
This channel is read-only
</div>
) : (
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} />
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={(isTyping) => { if (socket && group) socket.emit(isTyping ? 'typing:start' : 'typing:stop', { groupId: group.id }); }} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} />
)}
</div>
{showInfo && (

View File

@@ -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 */}

View File

@@ -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 }) {
</button>
</div>
{user.role === 'admin' && (
{user.role === 'admin' && (msgU2U || msgPrivateGroup || msgPublic) && (
<div className="flex gap-2" style={{ marginBottom: 20 }}>
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Direct Message</button>
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>
{(msgU2U || msgPrivateGroup) && <button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Direct Message</button>}
{msgPublic && <button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>}
</div>
)}
{/* 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)) && (
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
<input

View File

@@ -10,6 +10,94 @@ const APP_TYPES = {
'RosterChirp-Team': { label: 'RosterChirp-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' },
};
// ── Toggle switch ─────────────────────────────────────────────────────────────
function Toggle({ checked, onChange }) {
return (
<div
onClick={() => 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',
}}
>
<div style={{
position: 'absolute', top: 2, left: checked ? 22 : 2,
width: 20, height: 20, borderRadius: '50%',
background: 'white', transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</div>
);
}
// ── 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 (
<div>
<div className="settings-section-label">Message Features</div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 16 }}>
Disable a feature to hide it from all menus, sidebars, and modals.
</p>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 16 }}>
{rows.map((r, i) => (
<div key={r.key} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{r.label}</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 2 }}>{r.desc}</div>
</div>
<Toggle checked={settings[r.key]} onChange={() => toggle(r.key)} />
</div>
))}
</div>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
);
}
// ── 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 }) {
))}
</div>
{tab === 'messages' && <MessagesTab />}
{tab === 'team' && <TeamManagementTab />}
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
</div>

View File

@@ -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
</div>
<div className="groups-list">
{!groupMessagesMode && publicFiltered.length > 0 && (
{!groupMessagesMode && msgPublic && publicFiltered.length > 0 && (
<div className="group-section">
<div className="section-label">PUBLIC MESSAGES</div>
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}

View File

@@ -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' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
</div>
);
@@ -834,7 +838,7 @@ export default function Chat() {
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
</div>

View File

@@ -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'),