Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 0 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,6 @@ export async function setup(config = {}) {
return new Response('WebSocket upgrade required', { status: 426 })
}

routes[p('/vendor/rdbl.js')] = () =>
new Response(Bun.file(new URL(import.meta.resolve('@devchitchat/rdbljs/src/rdbl.js'))), {
headers: { 'Content-Type': 'text/javascript' },
})

routes[p('/sw.js')] = () => new Response(
Bun.file(CHAT_PAGES_DIR + '/public/sw.js'),
{ headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Service-Worker-Allowed': `${basePath}/`, 'Cache-Control': 'no-cache, no-store' } }
Expand Down Expand Up @@ -191,10 +186,6 @@ export async function start(config = {}) {
})) return
return new Response('WebSocket upgrade required', { status: 426 })
},
[p('/vendor/rdbl.js')]: () =>
new Response(Bun.file(new URL(import.meta.resolve('@devchitchat/rdbljs/src/rdbl.js'))), {
headers: { 'Content-Type': 'text/javascript' },
}),
[p('/sw.js')]: () => new Response(
Bun.file(CHAT_PAGES_DIR + '/public/sw.js'),
{ headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Service-Worker-Allowed': `${basePath}/`, 'Cache-Control': 'no-cache, no-store' } }
Expand Down
4 changes: 2 additions & 2 deletions k8s/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ spec:
serviceAccountName: chat-web
containers:
- name: chat-web
image: local/chat-web:7cff415-1789135575426
image: local/chat-web:7c1ac8b-1789147908934
imagePullPolicy: IfNotPresent
env:
- name: PORT
Expand Down Expand Up @@ -93,7 +93,7 @@ spec:
spec:
containers:
- name: backup
image: local/chat-web:7cff415-1789135575426
image: local/chat-web:7c1ac8b-1789147908934
workingDir: /app
command:
- sh
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@
"styles.css"
],
"dependencies": {
"@devchitchat/index97": "^3.3.0",
"@devchitchat/rdbljs": "^2.0.1"
"@devchitchat/index97": "^3.3.0"
},
"main": "index.js",
"module": "index.js",
Expand Down
7 changes: 0 additions & 7 deletions pages/_layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,6 @@
<link rel="stylesheet" href="{{base}}/themes/base.css">
<link rel="stylesheet" href="{{base}}/themes/dark.css" id="theme-stylesheet">
<script>window.__BASE_PATH__ = "{{base}}"</script>
<script type="importmap">
{
"imports": {
"@devchitchat/rdbljs": "{{base}}/vendor/rdbl.js"
}
}
</script>
</head>
<body>
<main class="app-shell">
Expand Down
7 changes: 0 additions & 7 deletions pages/admin/_layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,6 @@
<link rel="stylesheet" href="{{base}}/themes/base.css">
<link rel="stylesheet" href="{{base}}/themes/dark.css" id="theme-stylesheet">
<script>window.__BASE_PATH__ = "{{base}}"</script>
<script type="importmap">
{
"imports": {
"@devchitchat/rdbljs": "{{base}}/vendor/rdbl.js"
}
}
</script>
</head>
<body>
<header class="admin-topbar">
Expand Down
57 changes: 17 additions & 40 deletions pages/channels/[channelId].phtml
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
<!-- Sidebar island: hub/channel nav + online presence -->
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Channels" island="{{base}}/client/islands/sidebar.js" data-currentChannel="{{currentChannelId}}" data-userid="{{user.user_id}}" data-vapid-key="{{vapidPublicKey}}">
<section each="hubs" key="hub_id" class="hub-group">
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Channels" data-currentChannel="{{currentChannelId}}" data-userid="{{user.user_id}}" data-vapid-key="{{vapidPublicKey}}">
<section class="hub-group">
{{#each hubs}}
<details class="hub-header" data-key="{{hub_id}}" data-visibility="{{visibility}}" data-description="{{description}}" draggable="true" open>
<details class="hub-header" data-hub-id="{{hub_id}}" data-visibility="{{visibility}}" data-description="{{description}}" draggable="true" open>
<summary class="hub-name" data-hub-id="{{hub_id}}">
<span text="name">{{name}}</span>
<span>{{name}}</span>
<button class="btn-hub-gear btn-icon" title="Hub settings" aria-label="Hub settings" type="button">&#9881;</button>
<button class="btn-hub-add btn-icon" title="Create a channel" aria-label="Create a channel" type="button">+</button>
</summary>
<ul each="channels" key="channel_id" class="channel-list">
<template>
<li class="channel-item" cls="className" draggable="true">
<a attr="href:url; title:name" text="name" class="channel-link"
data-channel-id=""
data-channel-name=""
data-channel-topic=""
data-channel-kind=""
data-channel-visibility=""></a>
<span class="call-badge" aria-label="Call active"></span>
<button class="btn-channel-gear btn-icon" title="Channel settings" aria-label="Channel settings" type="button">&#9881;</button>
</li>
</template>
<ul class="channel-list">
{{#each channels}}
<li data-key="{{channel_id}}" class="channel-item {{className}}" cls="className" draggable="true">
<a attr="href:url; title:name" text="name" class="channel-link"
<li class="channel-item {{className}}" draggable="true">
<a class="channel-link"
href="{{base}}/channels/{{channel_id}}"
title="{{name}}"
data-channel-id="{{channel_id}}"
Expand All @@ -43,7 +30,7 @@
<section class="dm-section">
<h3 class="dm-section-title">Direct Messages</h3>
<ul class="dm-list" id="dm-list">
<!-- populated by sidebar.js via dm.list_result -->
<!-- populated by SidebarView via dm.list_result -->
</ul>
</section>

Expand Down Expand Up @@ -83,8 +70,7 @@
<!-- Main content wrapper: slides in from right on mobile -->
<div class="main-content">

<!-- Chat + call island: messages, composer, and WebRTC -->
<section class="chat-panel" island="{{base}}/client/islands/call.js"
<section class="chat-panel"
data-id="{{channel.channel_id}}"
data-name="{{channel.name}}"
data-topic="{{channel.topic}}"
Expand All @@ -101,8 +87,8 @@
<header class="chat-header">
<button class="btn-back-mobile" aria-label="Back to channels" type="button">&#8592;</button>
<hgroup>
<h2 class="chat-title" text="channelName">{{channel.name}}</h2>
<p class="chat-topic" text="channelTopic">{{channel.topic}}</p>
<h2 class="chat-title">{{channel.name}}</h2>
<p class="chat-topic">{{channel.topic}}</p>
</hgroup>
<div class="chat-header-actions">
<button class="btn-ghost btn-start-call" id="btn-start-call" aria-label="Start call" type="button">
Expand Down Expand Up @@ -135,33 +121,24 @@
<div class="messages" id="messages" aria-live="polite" aria-label="Messages" role="log">
<div class="load-more-sentinel" id="load-more-sentinel" aria-hidden="true" hidden></div>
{{#each seedMessages}}
<article class="message" data-seq="{{seq}}" data-msg-id="{{msg_id}}" data-key="message_id" data-user-id="{{user_id}}" data-raw-text="{{raw_text}}" data-edited-at="{{edited_at}}" data-attachments="{{attachments_json}}" data-reactions="{{reactions_json}}" data-reply-count="{{reply_count}}">
<article class="message" data-seq="{{seq}}" data-msg-id="{{msg_id}}" data-user-id="{{user_id}}" data-raw-text="{{raw_text}}" data-edited-at="{{edited_at}}" data-attachments="{{attachments_json}}" data-reactions="{{reactions_json}}" data-reply-count="{{reply_count}}">
<span class="message-handle" data-user-id="{{user_id}}">{{user_display_name}}</span>
<time class="message-time" datetime="{{ts}}">{{ts_fmt}}{{#if edited_at}}<span class="message-edited">(edited)</span>{{/if}}</time>
<div class="message-text">{{{text}}}</div>
{{#if reply_count_label}}<a class="thread-replies-link" href="#" data-msg-id="{{msg_id}}">{{reply_count_label}}</a>{{/if}}
<div class="reaction-bar"></div>
</article>
{{/each}}
<template>
<article class="message">
<span class="message-handle"></span>
<time class="message-time" datetime=""></time>
<p class="message-text" text="text"></p>
</article>
</template>
</div>

<footer class="composer">
<button onclick="toggleUrgentMode" class="btn-urgent-toggle" type="button"
cls="urgentClass"
<button class="btn-urgent-toggle" type="button"
title="Toggle urgent mode — Enter sends as urgent (Ctrl+Enter for one-shot)" aria-label="Toggle urgent send">&#x1F514;</button>
<textarea model="draft" id="message-input" placeholder="Message in {{channel.name}}" aria-label="Write a message"
rows="1" onkeydown="handleComposerKey">
</textarea>
<button onclick="toggleComposeMode" class="btn-compose-expand btn-icon" type="button"
<textarea id="message-input" placeholder="Message in {{channel.name}}" aria-label="Write a message"
rows="1"></textarea>
<button class="btn-compose-expand btn-icon" type="button"
title="Compose mode (Ctrl+E)" aria-label="Open compose mode">&#x26F6;</button>
<button onclick="sendMessage" class="btn-send" aria-label="Send message" type="button">Send</button>
<button class="btn-send" aria-label="Send message" type="button">Send</button>
</footer>

<!-- Compose overlay — full-panel editor with Write / Preview tabs -->
Expand Down
149 changes: 136 additions & 13 deletions pages/public/client/app.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,161 @@
/**
* app.js — boots rdbljs islands and page-level setup.
* app.js — MVC bootstrap.
*
* Loaded as <script type="module" src="{{base}}/client/app.js"> from _layout.html.
* init(window) from rdbljs discovers all [island] elements, imports each
* module, calls the default export factory with (root, window), and wires
* up bind(root, scope) so all directives (model=, onclick=, each=, etc.) work.
* Wiring order (inside-out):
* 1. AppModel — single source of truth (EventTarget hub)
* 2. WebSocketController — maps WS events to model mutations
* 3. ChatController — maps document CustomEvents to ws.send / model
* 4. Views — listen to model, render DOM, dispatch controller events
* 5. Router + swipe nav — SPA navigation; notifies model on channel change
*
* No rdbljs. No islands. Plain browser APIs throughout.
*/
import { init } from '@devchitchat/rdbljs'

import { AppModel } from './model/AppModel.js'
import { WebSocketController } from './controllers/WebSocketController.js'
import { ChatController } from './controllers/ChatController.js'
import { MessageListView } from './views/MessageListView.js'
import { ThreadPanelView } from './views/ThreadPanelView.js'
import { ComposerView } from './views/ComposerView.js'
import { SidebarView } from './views/SidebarView.js'
import { CallView } from './views/CallView.js'
import { cancelActiveEdit } from './views/shared/MessageInteractions.js'
import { closeEmojiPicker } from './views/shared/EmojiPickerSingleton.js'
import { getSettings, syncFromServer, patchSettings } from './settings-sync.js'
import { initSwipeNav } from './swipe-nav.js'
import { initRouter } from './router.js'
import { initSwipeNav } from './swipe-nav.js'
import { initRouter } from './router.js'

const BASE_PATH = window.__BASE_PATH__ ?? ''

// On a channel page the intent is always to view the channel — persist the
// current channel and ensure mobile shows the chat panel, not the hub panel.
// ── 1. Pre-mount: apply settings to prevent layout flash ─────────────────────

if (location.pathname.startsWith(`${BASE_PATH}/channels/`)) {
const channelId = location.pathname.split('/').pop()
if (channelId) patchSettings({ last_channel_id: channelId, mobile_chat_open: true })
}

// Apply settings before islands mount (prevent layout flash)
const settings = getSettings()
if (window.matchMedia('(max-width: 1024px)').matches && settings.mobile_chat_open === false) {
document.body.classList.add('sidebar-open')
}

await init(window)
// ── 2. Model ──────────────────────────────────────────────────────────────────

const model = new AppModel()

// Seed identity from the chat panel's data attributes (written by SSR)
const chatPanelEl = document.querySelector('.chat-panel')
if (chatPanelEl) {
model.setIdentity({
userId: chatPanelEl.dataset.userId ?? null,
userHandle: chatPanelEl.dataset.userHandle ?? null,
})
// Seed the current channel
const channelId = chatPanelEl.dataset.id ?? null
const seedFirstSeq = parseInt(chatPanelEl.dataset.seedFirstSeq ?? '0', 10)
const seedHasMore = chatPanelEl.dataset.seedHasMore === 'true'
if (channelId) {
model.selectChannel(channelId, {
name: chatPanelEl.dataset.name ?? '',
topic: chatPanelEl.dataset.topic ?? '',
kind: chatPanelEl.dataset.kind ?? 'text',
})
model.seedMessages(channelId, { oldestSeq: seedFirstSeq, hasMore: seedHasMore })
}
}

// ── 3. Controllers ────────────────────────────────────────────────────────────

const wsController = new WebSocketController(model, `${BASE_PATH}/ws`)
const ws = wsController.ws
const chatController = new ChatController(model, ws)

// ── 4. Views ──────────────────────────────────────────────────────────────────

// Sidebar
const sidebarEl = document.querySelector('aside[data-sidebar], aside.sidebar, aside')
if (sidebarEl) {
new SidebarView(model, ws, sidebarEl)
}

// Chat panel views (only mount if the chat panel exists on this page)
if (chatPanelEl) {
const messagesEl = document.getElementById('messages')
const sentinelEl = document.getElementById('load-more-sentinel')
const composerEl = chatPanelEl.querySelector('.composer')
const threadPanelEl = document.getElementById('thread-panel')

if (messagesEl) {
new MessageListView(model, messagesEl, sentinelEl)
}

if (threadPanelEl) {
new ThreadPanelView(model, threadPanelEl)
}

if (composerEl) {
new ComposerView(model, composerEl)
}

// CallView (WebRTC) — only if call UI elements are present
if (document.getElementById('tile-panel') || document.getElementById('btn-start-call')) {
new CallView(model, ws, chatPanelEl)
}
}

// ── 5. SPA navigation ─────────────────────────────────────────────────────────

// router.js morphs the DOM and dispatches 'chatpanel:navigated'.
// We update the model here so views react automatically.
document.addEventListener('chatpanel:navigated', e => {
const { channelId, name, topic, kind, seedFirstSeq, seedHasMore } = e.detail

// Close transient UI on navigation
cancelActiveEdit()
closeEmojiPicker()
if (model.threadParentId) model.closeThread()

// Update identity if it changed (shouldn't, but guard anyway)
const panel = document.querySelector('.chat-panel')
if (panel?.dataset.userId) {
model.setIdentity({ userId: panel.dataset.userId, userHandle: panel.dataset.userHandle ?? null })
}

// Seed pagination bookmarks for the new channel
if (channelId) {
model.seedMessages(channelId, {
oldestSeq: parseInt(seedFirstSeq ?? '0', 10),
hasMore: seedHasMore ?? false,
})
}

// Navigate — fires 'channel-selected' → all views update
model.selectChannel(channelId, { name, topic, kind })

// Join the new channel on the server (WebSocketController handles the response)
ws.send({ t: 'channel.join', body: { channel_id: channelId } })

// Persist for PWA restore
if (channelId) patchSettings({ last_channel_id: channelId, mobile_chat_open: true })
})

// ── 6. Router + swipe nav ────────────────────────────────────────────────────

initRouter()
initSwipeNav()

// Reconcile with server in background after islands mount
// ── 7. Post-mount: reconcile settings with server ─────────────────────────────

syncFromServer().then(remoteSettings => {
if (!remoteSettings || !window.matchMedia('(max-width: 1024px)').matches) return
document.body.classList.toggle('sidebar-open', remoteSettings.mobile_chat_open === false)
})

// ── 8. Global keyboard shortcuts ──────────────────────────────────────────────

document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
cancelActiveEdit()
closeEmojiPicker()
}
})
Loading