v0.12.41 New settings options for messages
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.12.40",
|
||||
"version": "0.12.41",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
2
build.sh
2
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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.40",
|
||||
"version": "0.12.41",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user