= {
+ general: 'General', work: 'Work', report: 'Report',
+ discuss: 'Discussion', triage: 'Triage', custom: 'Custom', dm: 'Direct Messages',
+}
type XType = (typeof X_TYPES)[number]
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean; isPublic?: boolean; closed?: boolean }
@@ -412,13 +419,17 @@ export default function ChatPage() {
setError('Triage channels require an on-duty user')
return
}
+ if (newChannelXType === 'dm' && selectedMemberIds.length !== 1) {
+ setError('A DM needs exactly one other participant')
+ return
+ }
setError('')
try {
const payload = {
name: newChannelName.trim(),
guildId: effectiveGuildId,
xType: newChannelXType,
- isPublic: newChannelPublic,
+ isPublic: newChannelXType === 'dm' ? false : newChannelPublic,
memberUserIds: selectedMemberIds,
onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined,
listeners: newChannelXType === 'custom' ? newChannelListeners : [],
@@ -444,6 +455,33 @@ export default function ChatPage() {
}
}
+ // DM channels are not unique: this always creates a fresh 1:1 dm channel
+ // between the current user and `m` (private; no rotation/wakeup).
+ async function createDmChannel(m: { userId: string; name?: string; email?: string }) {
+ if (!guild || !guildToken) return
+ const effectiveGuildId = guildDbId || selectedGuildId
+ if (!effectiveGuildId) {
+ setError('Cannot create DM: no guild selected')
+ return
+ }
+ const label = m.name || m.email || m.userId.slice(0, 8)
+ setError('')
+ try {
+ const res = await guildApi().post('/channels', {
+ name: `DM · ${label}`,
+ guildId: effectiveGuildId,
+ xType: 'dm',
+ isPublic: false,
+ memberUserIds: [m.userId],
+ })
+ const createdId = res.data?.id as string | undefined
+ await loadChannels()
+ if (createdId) setSelectedChannelId(createdId)
+ } catch {
+ setError('Failed to create DM channel')
+ }
+ }
+
async function joinChannel(c: GuildChannel) {
if (!guild || !guildToken) return
try {
@@ -700,6 +738,15 @@ export default function ChatPage() {
{ kind: 'item', label: 'Copy user ID', onClick: () => copyText(m.userId) },
{ kind: 'item', label: 'Copy name', onClick: () => copyText(label) },
]
+ if (!isSelf)
+ items.push(
+ { kind: 'sep' },
+ {
+ kind: 'item',
+ label: 'Create new DM channel',
+ onClick: () => void createDmChannel(m),
+ },
+ )
if (inChannel && rotationChannel && !bypassMemberIds.includes(m.userId))
items.push(
{ kind: 'sep' },
@@ -920,7 +967,7 @@ export default function ChatPage() {
}
return (
- openCtx(e, 'Fabric', blankMenu('root'))}>
+
openCtx(e, APP_NAME, blankMenu('root'))}>
openCtx(e, 'Guilds', blankMenu('guilds'))}>
{guilds.map((g) => (
{channels.length ? (
- channels.map((c) => (
-
setSelectedChannelId(c.id)}
- onContextMenu={(e) => openCtx(e, `#${c.name}`, channelMenu(c))}
- >
- #
- {c.name}
- {c.xType ? {c.xType} : null}
-
+ XTYPE_ORDER.filter((xt) =>
+ channels.some((c) => (c.xType ?? 'general') === xt),
+ ).map((xt) => (
+
+
{XTYPE_LABEL[xt] ?? xt}
+ {channels
+ .filter((c) => (c.xType ?? 'general') === xt)
+ .map((c) => (
+
setSelectedChannelId(c.id)}
+ onContextMenu={(e) => openCtx(e, `#${c.name}`, channelMenu(c))}
+ >
+ #
+ {c.name}
+
+ ))}
+
))
) : (
{guild ? 'No channels yet' : 'Pick a guild from the left'}
@@ -1373,33 +1428,61 @@ export default function ChatPage() {
) : null}
-
- setNewChannelPublic(e.target.checked)} />
- Public — visible to all guild members
-
-
- {newChannelPublic
- ? "You're added automatically; every guild member can see it."
- : "You're added automatically. Pick who else to include:"}
-
-
- {members
- .filter((m) => m.userId !== session?.user.id)
- .map((m) => (
-
- toggleMember(m.userId)}
- />
- {m.name || m.email}
-
- ))}
- {members.filter((m) => m.userId !== session?.user.id).length === 0 ? (
-
No other members in this guild.
- ) : null}
-
+ {newChannelXType === 'dm' ? (
+ <>
+
+ Direct message — private 1:1, not unique. Pick exactly one user:
+
+
+ {members
+ .filter((m) => m.userId !== session?.user.id)
+ .map((m) => (
+
+ setSelectedMemberIds([m.userId])}
+ />
+ {m.name || m.email}
+
+ ))}
+ {members.filter((m) => m.userId !== session?.user.id).length === 0 ? (
+
No other members in this guild.
+ ) : null}
+
+ >
+ ) : (
+ <>
+
+ setNewChannelPublic(e.target.checked)} />
+ Public — visible to all guild members
+
+
+ {newChannelPublic
+ ? "You're added automatically; every guild member can see it."
+ : "You're added automatically. Pick who else to include:"}
+
+
+ {members
+ .filter((m) => m.userId !== session?.user.id)
+ .map((m) => (
+
+ toggleMember(m.userId)}
+ />
+ {m.name || m.email}
+
+ ))}
+ {members.filter((m) => m.userId !== session?.user.id).length === 0 ? (
+
No other members in this guild.
+ ) : null}
+
+ >
+ )}
Create
setShowCreateChannelModal(false)}>Cancel
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
index f0054e4..e77013b 100644
--- a/src/pages/LoginPage.tsx
+++ b/src/pages/LoginPage.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/auth-context'
+import { APP_NAME, LOGO_URL } from '../lib/brand'
export default function LoginPage() {
const navigate = useNavigate()
@@ -25,9 +26,14 @@ export default function LoginPage() {
return (
+ {LOGO_URL ? (
+
+ ) : (
+
{APP_NAME}
+ )}
Welcome back
- {isAuthed ? `Signed in as ${session?.user.email}` : 'Sign in to continue to Fabric'}
+ {isAuthed ? `Signed in as ${session?.user.email}` : `Sign in to continue to ${APP_NAME}`}