diff --git a/src/index.css b/src/index.css index 8b7f744..24fb718 100644 --- a/src/index.css +++ b/src/index.css @@ -341,32 +341,73 @@ button { color: var(--text-h); } .chan-btn { + --xt: #9aa0a6; + position: relative; width: 100%; display: flex; align-items: center; gap: 6px; text-align: left; border: none; - background: transparent; + background: color-mix(in srgb, var(--xt) 9%, transparent); border-radius: var(--radius-sm); - padding: 8px 10px; - margin: 1px 0; - color: var(--text-muted); + padding: 8px 10px 8px 14px; + margin: 2px 0; + color: color-mix(in srgb, var(--xt) 55%, var(--text)); font-size: 15px; cursor: pointer; } +.chan-btn::before { + content: ''; + position: absolute; + left: 3px; + top: 7px; + bottom: 7px; + width: 3px; + border-radius: 3px; + background: var(--xt); + opacity: 0.7; +} .chan-btn .hash { - color: var(--text-faint); - font-weight: 600; + color: var(--xt); + font-weight: 700; +} +.chan-btn .chan-tag { + color: var(--xt); + border-color: color-mix(in srgb, var(--xt) 60%, transparent); + background: color-mix(in srgb, var(--xt) 14%, transparent); } .chan-btn:hover { - background: var(--hover); - color: var(--text); + background: color-mix(in srgb, var(--xt) 18%, transparent); + color: var(--text-h); } .chan-btn.active { - background: var(--active); + background: color-mix(in srgb, var(--xt) 26%, transparent); color: var(--text-h); } +.chan-btn.active::before { + opacity: 1; +} + +/* per x_type accent */ +.chan-btn.xt-general { + --xt: #9aa0a6; +} +.chan-btn.xt-work { + --xt: #5865f2; +} +.chan-btn.xt-report { + --xt: #faa61a; +} +.chan-btn.xt-discuss { + --xt: #3ba55d; +} +.chan-btn.xt-triage { + --xt: #ed4245; +} +.chan-btn.xt-custom { + --xt: #c084fc; +} .chan-tag { margin-left: auto; font-size: 10px; @@ -515,6 +556,32 @@ button { color: var(--text-faint); font-style: italic; } +.meta-badge { + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + color: var(--text-faint); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 4px; +} +.meta-badge.on { + color: #fff; + background: var(--online); + border-color: var(--online); +} +.meta-raw { + margin: 4px 0 0; + padding: 6px 8px; + background: var(--elevated); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + font-family: var(--mono); + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; +} .dc-empty-center { margin: auto; text-align: center; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index cd09f72..ad7ab39 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -11,8 +11,13 @@ type MessageItem = { isDeleted?: boolean authorUserId?: string createdAt?: string + // per-push metadata (only present on socket-delivered messages); the app is + // normally metadata-agnostic โ€” only surfaced in developer mode + wakeup?: boolean } +const DEV_KEY = 'fabric.debug' + function initials(s: string): string { const t = (s || '?').trim() return t.slice(0, 2).toUpperCase() @@ -28,7 +33,7 @@ function timeOf(iso?: string): string { const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom'] as const type XType = (typeof X_TYPES)[number] -type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType } +type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean } type MemberItem = { userId: string; email: string; name: string; status: string } export default function ChatPage() { @@ -45,14 +50,23 @@ export default function ChatPage() { const [selectedMemberIds, setSelectedMemberIds] = useState([]) const [newChannelPublic, setNewChannelPublic] = useState(false) const [newChannelXType, setNewChannelXType] = useState('general') + const [newChannelOnDuty, setNewChannelOnDuty] = useState('') + const [newChannelListeners, setNewChannelListeners] = useState([]) const [showCreateChannelModal, setShowCreateChannelModal] = useState(false) const [showSettingsModal, setShowSettingsModal] = useState(false) const [showAddGuildModal, setShowAddGuildModal] = useState(false) const [settingsName, setSettingsName] = useState('') const [showMembers, setShowMembers] = useState(true) + const [devMode, setDevMode] = useState(() => localStorage.getItem(DEV_KEY) === '1') const [loading, setLoading] = useState(false) const [error, setError] = useState('') + function toggleDevMode(on: boolean) { + setDevMode(on) + if (on) localStorage.setItem(DEV_KEY, '1') + else localStorage.removeItem(DEV_KEY) + } + const guilds = session?.guilds ?? [] useEffect(() => { @@ -170,6 +184,20 @@ export default function ChatPage() { setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId])) } + function toggleListener(userId: string) { + setNewChannelListeners((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId])) + } + + function openCreateChannel() { + setNewChannelName('') + setSelectedMemberIds([]) + setNewChannelPublic(false) + setNewChannelXType('general') + setNewChannelOnDuty(session?.user.id ?? '') + setNewChannelListeners([]) + setShowCreateChannelModal(true) + } + async function createChannel() { if (!guild || !guildToken || !newChannelName.trim()) return const effectiveGuildId = guildDbId || selectedGuildId @@ -177,6 +205,10 @@ export default function ChatPage() { setError('Cannot create channel: no guild selected') return } + if (newChannelXType === 'triage' && !newChannelOnDuty) { + setError('Triage channels require an on-duty user') + return + } setError('') try { const payload = { @@ -185,6 +217,8 @@ export default function ChatPage() { xType: newChannelXType, isPublic: newChannelPublic, memberUserIds: selectedMemberIds, + onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined, + listeners: newChannelXType === 'custom' ? newChannelListeners : [], } const res = await guildApi().post('/channels', payload) const createdId = res.data?.id as string | undefined @@ -192,6 +226,8 @@ export default function ChatPage() { setSelectedMemberIds([]) setNewChannelPublic(false) setNewChannelXType('general') + setNewChannelOnDuty(session?.user.id ?? '') + setNewChannelListeners([]) setShowCreateChannelModal(false) await loadChannels() if (createdId) setSelectedChannelId(createdId) @@ -200,6 +236,32 @@ export default function ChatPage() { } } + async function joinChannel(c: GuildChannel) { + if (!guild || !guildToken) return + try { + await guildApi().post(`/channels/${c.id}/join`) + await loadChannels() + await loadMembers() + } catch { + setError('Failed to join channel') + } + } + + async function leaveChannel(c: GuildChannel) { + if (!guild || !guildToken) return + try { + await guildApi().post(`/channels/${c.id}/leave`) + if (selectedChannelId === c.id) { + setSelectedChannelId('') + setMessages([]) + } + await loadChannels() + await loadMembers() + } catch { + setError('Failed to leave channel') + } + } + async function saveName() { const trimmed = settingsName.trim() if (!trimmed) { @@ -280,7 +342,7 @@ export default function ChatPage() {
Channels -
@@ -288,7 +350,7 @@ export default function ChatPage() { channels.map((c) => ( + ) : ( + + ) + ) : null}
+ + {newChannelXType === 'triage' ? ( +
+ + +

+ Added to the channel automatically and put into wake_mapping. +

+
+ ) : null} + + {newChannelXType === 'custom' ? ( +
+ +
+ {members.map((m) => ( + + ))} + {members.length === 0 ?
No members in this guild.
: null} +
+

+ Each selected user gets a wake_mapping record for this channel. +

+
+ ) : null} +