Skip to content

feat: add Slack-like channels with sidebar UI and channel-scoped messaging#130

Merged
mahata merged 6 commits intomainfrom
feat/channels
Mar 26, 2026
Merged

feat: add Slack-like channels with sidebar UI and channel-scoped messaging#130
mahata merged 6 commits intomainfrom
feat/channels

Conversation

@mahata
Copy link
Copy Markdown
Owner

@mahata mahata commented Mar 26, 2026

Summary

  • Add a Slack-like Channel feature where each message belongs to a single channel and channels can have multiple members
  • All users are auto-joined to a #general channel on first visit; #general cannot be left
  • Switch WebSocket protocol from plain text to JSON with channel-scoped broadcast (only channel members receive messages)

Changes

Database

  • New channels table (id, name, created_by_email, created_at) with unique name constraint
  • New channel_members table (id, channel_id, user_email, joined_at) with unique (channel_id, user_email) constraint
  • Added channel_id (NOT NULL, FK) column to messages table
  • Migration seeds #general and backfills all existing messages into it

API

  • GET /api/channels — list all channels (public)
  • POST /api/channels — create a new channel (creator auto-joins)
  • POST /api/channels/:id/join — join a channel
  • POST /api/channels/:id/leave — leave a channel (blocked for #general)
  • GET /api/messages?channelId=N — now requires channelId query parameter

WebSocket

  • Protocol changed from plain text to JSON in both directions
  • Client sends: { "type": "message", "channelId": 1, "content": "Hello!" }
  • Server broadcasts: { "type": "message", "channelId": 1, "content": "Hello!", "userName": "...", "userEmail": "..." }
  • clients changed from Set<WSContext> to Map<WSContext, { userEmail }> for channel-scoped delivery

UI

  • Dark-themed Slack-like layout with a 240px sidebar and main chat area
  • Sidebar shows: joined channels (with Leave button), browsable channels (with Join button), and a Create Channel (+) button
  • Create Channel modal with name input
  • Clicking a channel switches the active view, loads that channel's message history, and filters incoming WebSocket messages

Tests

  • 14 new tests for channel routes (channels.test.ts)
  • Updated messages.test.ts for channelId requirement (4 tests)
  • Updated ws.test.ts for Map-based client tracking
  • Updated ChatPage.test.ts and index.test.tsx for new HTML structure
  • All 60 tests pass

Deployment Note

Run pnpm db:migrate to apply the new migration before deploying.

…aging

Add a Channel feature where each message belongs to a single channel and
channels can have multiple members. All users are auto-joined to #general.

- Add channels and channel_members tables with migration (seeds #general,
  backfills existing messages)
- Add channel CRUD API routes (list, create, join, leave) with 14 tests
- Update messages API to require channelId query parameter
- Switch WebSocket protocol from plain text to JSON with channel-scoped
  broadcast (only channel members receive messages)
- Add dark-themed Slack-like UI with channel sidebar, create channel modal,
  join/leave functionality
- Auto-join users to #general on page visit
- Update all existing tests for new HTML structure and API changes
Copilot AI review requested due to automatic review settings March 26, 2026 06:45
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces Slack-like channels to the MLack chat app, making messages channel-scoped across the database, HTTP APIs, WebSocket protocol, and UI.

Changes:

  • Add channels and channel_members tables; make messages belong to a channel via channel_id and seed/backfill #general.
  • Add channel CRUD-ish endpoints (list/create/join/leave) and require channelId for GET /api/messages.
  • Update UI to a sidebar-based channel experience and switch WS traffic to JSON with channel-scoped broadcast.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
hono/types.ts Adds a shared Channel type.
hono/static/ChatPage.ts Implements channel sidebar behavior, channel-scoped message loading, and JSON WS send/receive.
hono/routes/ws.ts Switches WS protocol to JSON and scopes broadcast by channel membership.
hono/routes/ws.test.ts Updates WS tests for Map-based client tracking and membership query mocking.
hono/routes/messages.ts Requires channelId query parameter and filters messages by channel.
hono/routes/messages.test.ts Updates/extends tests for the new channelId requirement.
hono/routes/index.tsx Auto-joins authenticated users into #general on page load.
hono/routes/index.test.tsx Updates root page assertions for the new channel header structure.
hono/routes/channels.ts Adds channel list/create/join/leave endpoints.
hono/routes/channels.test.ts Adds test coverage for channel endpoints.
hono/db/schema.ts Adds new tables and messages.channel_id FK.
hono/db/migrations/0002_narrow_maggott.sql Creates channel tables, seeds general, and backfills existing messages.
hono/db/migrations/meta/_journal.json Registers the new migration.
hono/db/migrations/meta/0002_snapshot.json Captures the new schema snapshot for Drizzle migrations.
hono/components/ChatPage.tsx Reworks chat layout into Slack-like sidebar + main area and adds create-channel modal markup.
hono/components/ChatPage.test.ts Updates component tests for the new structure/classnames.
hono/components/ChatPage.css Adds new dark Slack-like styling for sidebar/main/modal.
hono/app.tsx Registers the new channels route and updates WS client tracking to Map.
Comments suppressed due to low confidence (2)

hono/routes/channels.ts:53

  • Channel creation does two separate inserts (into channels and then channel_members) without a transaction, so a failure on the second insert can leave a channel that the creator is not a member of. Wrap these writes in a single DB transaction (and consider validating name length <= 255 to avoid DB errors) to keep state consistent.
    const [created] = await db
      .insert(channels)
      .values({ name, createdByEmail: user.email || "unknown" })
      .returning();

    await db.insert(channelMembers).values({ channelId: created.id, userEmail: user.email || "unknown" });

hono/components/ChatPage.tsx:66

  • The create-channel modal markup lacks dialog accessibility semantics (e.g., role="dialog", aria-modal="true", and an accessible label via aria-labelledby). Without these, screen readers may not announce the modal properly or confine navigation; add the appropriate ARIA attributes and ensure focus management when opening/closing.
        <div id="createChannelModal" className="modal hidden">
          <div className="modal-content">
            <h3>Create Channel</h3>
            <input type="text" id="newChannelName" placeholder="channel-name" />
            <div className="modal-actions">
              <button type="button" id="cancelCreateChannel">Cancel</button>
              <button type="button" id="confirmCreateChannel">Create</button>
            </div>

Comment thread hono/db/schema.ts Outdated
Comment on lines 23 to 32
export const messages = pgTable("messages", {
id: serial("id").primaryKey(),
content: text("content").notNull(),
userEmail: varchar("user_email", { length: 255 }).notNull(),
userName: varchar("user_name", { length: 255 }),
channelId: integer("channel_id")
.notNull()
.references(() => channels.id),
createdAt: timestamp("created_at").defaultNow(),
});
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Queries now filter messages by channel_id and order by created_at (e.g., /api/messages?channelId=…). There’s no index to support WHERE channel_id = ? ORDER BY created_at DESC LIMIT 100, which will lead to sequential scans as the table grows; consider adding an index on (channel_id, created_at) (and/or at least channel_id).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread hono/routes/messages.ts Outdated
Comment thread hono/routes/ws.ts
Comment thread hono/routes/ws.ts
Comment thread hono/routes/index.tsx
Comment on lines +27 to +38
const existingMembership = await db
.select()
.from(channelMembers)
.where(
and(eq(channelMembers.channelId, generalChannel.id), eq(channelMembers.userEmail, user.email || "unknown")),
);

if (existingMembership.length === 0) {
await db.insert(channelMembers).values({
channelId: generalChannel.id,
userEmail: user.email || "unknown",
});
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using user.email || "unknown" when creating/checking membership can merge multiple users into the same "unknown" identity if email is missing, which can leak messages/memberships across accounts. Prefer treating missing user.email as an error (e.g., redirect to login or return 500) and avoid writing a shared placeholder email to the DB.

Copilot uses AI. Check for mistakes.
Comment thread hono/routes/channels.ts
Comment on lines +36 to +37
const name = body.name?.trim();

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const name = body.name?.trim(); will throw if body.name is present but not a string (e.g., number/object), since optional chaining here only guards null/undefined. Validate the request body type (ensure typeof body.name === "string") before calling .trim() and return 400 on invalid input.

This issue also appears on line 47 of the same file.

Suggested change
const name = body.name?.trim();
if (!body || typeof body !== "object" || body === null || typeof (body as any).name !== "string") {
return c.json({ error: "Channel name must be a string" }, 400);
}
const name = (body as { name: string }).name.trim();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Shouldn't this be covered by type checking instead?

Comment thread hono/routes/messages.ts Outdated
Comment on lines +17 to +32
const channelIdParam = c.req.query("channelId");
if (!channelIdParam) {
return c.json({ error: "channelId query parameter is required" }, 400);
}

const channelId = Number(channelIdParam);
if (Number.isNaN(channelId)) {
return c.json({ error: "Invalid channelId" }, 400);
}

const allMessages = await db
.select()
.from(messages)
.where(eq(messages.channelId, channelId))
.orderBy(desc(messages.createdAt))
.limit(100); // Limit to last 100 messages
.limit(100);
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint returns messages for any channelId without verifying the requesting user is a member of that channel. That allows authenticated users to read message history from channels they haven't joined; add an authorization check against channel_members (and return 403/404) before querying messages.

Copilot uses AI. Check for mistakes.
Comment thread hono/routes/channels.ts
Comment on lines +8 to +16
channelsRoute.get("/api/channels", async (c) => {
try {
const session = c.get("session");
const user = session.get("user") as User | undefined;

if (!user) {
return c.json({ error: "Unauthorized" }, 401);
}

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says GET /api/channels is public, but this handler returns 401 when there is no session user. Either adjust the implementation to match the described public behavior, or update the PR description/API contract to reflect that authentication is required.

Copilot uses AI. Check for mistakes.
Comment thread hono/static/ChatPage.ts Outdated
Comment thread hono/routes/ws.ts Outdated
Comment on lines +36 to +48
let parsed: { type: string; channelId: number; content: string };
try {
parsed = JSON.parse(rawStr);
} catch {
return;
}

if (parsed.type !== "message" || !parsed.channelId || !parsed.content?.trim()) {
return;
}

// Get user info from session
const session = c.get("session");
const user = session.get("user") as User | undefined;
const channelId = parsed.channelId;
const content = parsed.content.trim();
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incoming WS payload validation is too loose: channelId is assumed to be a number, but JSON could provide a string/float/NaN and it will flow into DB insert/query. Add strict runtime validation (e.g., typeof channelId === 'number' && Number.isInteger(channelId)) and ensure content is a string before using them.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 26, 2026

@mahata I've opened a new pull request, #132, to work on those changes. Once the pull request is ready, I'll request review from you.

mahata and others added 4 commits March 26, 2026 16:14
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Initial plan

* feat: add composite index on (channel_id, created_at) to messages table

Agent-Logs-Url: https://github.com/mahata/mlack/sessions/d188e657-de69-47e9-9277-0c7227faaffd

Co-authored-by: mahata <23497+mahata@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mahata <23497+mahata@users.noreply.github.com>
# Conflicts:
#	hono/app.tsx
#	hono/components/ChatPage.tsx
#	hono/db/migrations/meta/0002_snapshot.json
#	hono/db/migrations/meta/_journal.json
#	hono/db/schema.ts
#	hono/routes/messages.test.ts
#	hono/routes/messages.ts
#	hono/routes/ws.test.ts
#	hono/routes/ws.ts
* Initial plan

* fix: add h1 Hello, world! heading to ChatPage for e2e test

Agent-Logs-Url: https://github.com/mahata/mlack/sessions/00133b70-6800-4b81-9ad8-fb4b19a6e07c

Co-authored-by: mahata <23497+mahata@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mahata <23497+mahata@users.noreply.github.com>
@mahata mahata merged commit 5f02a91 into main Mar 26, 2026
3 checks passed
@mahata mahata deleted the feat/channels branch March 26, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants