@devchitchat/chat 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +313 -0
  2. package/index.js +148 -0
  3. package/migrate/001-drop-channel-invites.js +3 -0
  4. package/migrate/002-invite-initial-roles.js +5 -0
  5. package/migrate/003-dm-channels.js +35 -0
  6. package/migrate/004-notifications.js +15 -0
  7. package/migrate/005-uploads.js +21 -0
  8. package/migrate/006-mention-priority.js +3 -0
  9. package/migrate/007-push-subscriptions.js +14 -0
  10. package/migrate/008-messages-channel-seq-index.js +3 -0
  11. package/migrate/009-message-reactions.js +15 -0
  12. package/migrate/010-edit-messages.js +7 -0
  13. package/package.json +51 -0
  14. package/pages/_error.html +12 -0
  15. package/pages/_layout.html +31 -0
  16. package/pages/_layout.js +13 -0
  17. package/pages/admin/_layout.html +52 -0
  18. package/pages/admin/_layout.js +8 -0
  19. package/pages/admin/bots/[userId].js +88 -0
  20. package/pages/admin/bots/[userId].phtml +89 -0
  21. package/pages/admin/bots/index.js +41 -0
  22. package/pages/admin/bots/index.phtml +58 -0
  23. package/pages/admin/index.js +8 -0
  24. package/pages/admin/invites/index.js +72 -0
  25. package/pages/admin/invites/index.phtml +88 -0
  26. package/pages/admin/users/[userId].js +60 -0
  27. package/pages/admin/users/[userId].phtml +57 -0
  28. package/pages/admin/users/index.js +20 -0
  29. package/pages/admin/users/index.phtml +37 -0
  30. package/pages/api/uploads/index.js +66 -0
  31. package/pages/api/user/settings.js +26 -0
  32. package/pages/auth/signout.js +14 -0
  33. package/pages/channels/[channelId].js +99 -0
  34. package/pages/channels/[channelId].phtml +173 -0
  35. package/pages/index.js +33 -0
  36. package/pages/invite/[token].js +10 -0
  37. package/pages/login/index.js +57 -0
  38. package/pages/login/index.phtml +29 -0
  39. package/pages/public/client/action-sheet.js +77 -0
  40. package/pages/public/client/app.js +38 -0
  41. package/pages/public/client/auth-tabs.js +13 -0
  42. package/pages/public/client/emoji-data.js +197 -0
  43. package/pages/public/client/islands/call.js +1770 -0
  44. package/pages/public/client/islands/sidebar.js +1197 -0
  45. package/pages/public/client/long-press.js +59 -0
  46. package/pages/public/client/modal.js +50 -0
  47. package/pages/public/client/router.js +87 -0
  48. package/pages/public/client/rtc-peer-manager.js +344 -0
  49. package/pages/public/client/settings-sync.js +76 -0
  50. package/pages/public/client/shared/messages.js +147 -0
  51. package/pages/public/client/swipe-nav.js +98 -0
  52. package/pages/public/client/theme.js +27 -0
  53. package/pages/public/client/ws.js +71 -0
  54. package/pages/public/favicon.ico +0 -0
  55. package/pages/public/favicon.png +0 -0
  56. package/pages/public/icon.png +0 -0
  57. package/pages/public/manifest.json +11 -0
  58. package/pages/public/sw.js +38 -0
  59. package/pages/public/themes/base.css +1786 -0
  60. package/pages/public/themes/dark.css +22 -0
  61. package/pages/public/themes/forest.css +22 -0
  62. package/pages/public/themes/light.css +23 -0
  63. package/pages/public/themes/ocean.css +22 -0
  64. package/pages/public/themes/rose.css +22 -0
  65. package/pages/registration/index.js +35 -0
  66. package/pages/registration/index.phtml +38 -0
  67. package/pages/uploads/[uploadId]/[filename].js +45 -0
  68. package/src/adapters/InMemoryAuthRepository.js +74 -0
  69. package/src/adapters/InMemoryChannelRepository.js +138 -0
  70. package/src/adapters/InMemoryDeliveryRepository.js +52 -0
  71. package/src/adapters/InMemoryFileStore.js +53 -0
  72. package/src/adapters/InMemoryHubRepository.js +85 -0
  73. package/src/adapters/InMemoryMessageRepository.js +35 -0
  74. package/src/adapters/InMemoryReactionRepository.js +45 -0
  75. package/src/adapters/InMemorySearchRepository.js +37 -0
  76. package/src/adapters/InMemorySignalingRepository.js +35 -0
  77. package/src/adapters/InMemoryUploadRepository.js +36 -0
  78. package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
  79. package/src/adapters/LocalFileStore.js +40 -0
  80. package/src/adapters/SqliteAuthRepository.js +184 -0
  81. package/src/adapters/SqliteChannelRepository.js +149 -0
  82. package/src/adapters/SqliteDeliveryRepository.js +53 -0
  83. package/src/adapters/SqliteHubRepository.js +99 -0
  84. package/src/adapters/SqliteMessageRepository.js +90 -0
  85. package/src/adapters/SqlitePushRepository.js +39 -0
  86. package/src/adapters/SqliteReactionRepository.js +50 -0
  87. package/src/adapters/SqliteSearchRepository.js +42 -0
  88. package/src/adapters/SqliteSignalingRepository.js +50 -0
  89. package/src/adapters/SqliteUploadRepository.js +34 -0
  90. package/src/adapters/SqliteUserSettingsRepository.js +23 -0
  91. package/src/adminAuth.js +25 -0
  92. package/src/config.js +11 -0
  93. package/src/context.js +77 -0
  94. package/src/core/dm.js +10 -0
  95. package/src/core/mentions.js +27 -0
  96. package/src/core/messages.js +21 -0
  97. package/src/core/reactions.js +6 -0
  98. package/src/core/roles.js +5 -0
  99. package/src/core/uploads.js +107 -0
  100. package/src/db/initDb.js +225 -0
  101. package/src/db/openDb.js +18 -0
  102. package/src/db/runMigrations.js +45 -0
  103. package/src/db/transaction.js +11 -0
  104. package/src/ports/IFileStore.js +34 -0
  105. package/src/services/AuthService.js +208 -0
  106. package/src/services/BotService.js +148 -0
  107. package/src/services/ChannelService.js +176 -0
  108. package/src/services/DeliveryService.js +28 -0
  109. package/src/services/HubService.js +133 -0
  110. package/src/services/MessageService.js +122 -0
  111. package/src/services/NotificationService.js +45 -0
  112. package/src/services/PresenceService.js +55 -0
  113. package/src/services/ReactionService.js +57 -0
  114. package/src/services/SearchService.js +21 -0
  115. package/src/services/SignalingService.js +177 -0
  116. package/src/services/UploadService.js +111 -0
  117. package/src/services/UserSettingsService.js +30 -0
  118. package/src/services/WebPushService.js +217 -0
  119. package/src/util/crypto.js +21 -0
  120. package/src/util/errors.js +14 -0
  121. package/src/util/ids.js +3 -0
  122. package/src/util/logger.js +21 -0
  123. package/src/ws/ChatServer.js +478 -0
  124. package/src/ws/handlers/authHandlers.js +152 -0
  125. package/src/ws/handlers/channelHandlers.js +166 -0
  126. package/src/ws/handlers/hubHandlers.js +82 -0
  127. package/src/ws/handlers/messageHandlers.js +88 -0
  128. package/src/ws/handlers/pushHandlers.js +25 -0
  129. package/src/ws/handlers/reactionHandlers.js +27 -0
  130. package/src/ws/handlers/rtcHandlers.js +126 -0
  131. package/styles.css +22 -0
@@ -0,0 +1,57 @@
1
+ <div class="admin-page">
2
+ <nav class="admin-nav">
3
+ <a href="{{base}}/admin/invites" class="admin-nav-link">Invites</a>
4
+ <a href="{{base}}/admin/users" class="admin-nav-link active">Users</a>
5
+ <a href="{{base}}/admin/bots" class="admin-nav-link">Bots</a>
6
+ </nav>
7
+
8
+ <h1>Edit user: <code>{{target.handle}}</code></h1>
9
+
10
+ {{#if flash}}
11
+ <div class="admin-notice admin-notice--success">Saved.</div>
12
+ {{/if}}
13
+
14
+ <section class="admin-section">
15
+ <h2>Display name</h2>
16
+ <form method="POST" class="admin-form">
17
+ <input type="hidden" name="action" value="set_display_name">
18
+ <div class="form-row">
19
+ <label for="display_name">Display name</label>
20
+ <input type="text" id="display_name" name="display_name" value="{{target.display_name}}" required>
21
+ </div>
22
+ <button type="submit" class="btn">Save</button>
23
+ </form>
24
+ </section>
25
+
26
+ <section class="admin-section">
27
+ <h2>Roles</h2>
28
+ <form method="POST" class="admin-form">
29
+ <input type="hidden" name="action" value="set_roles">
30
+ <div class="form-row">
31
+ <label class="checkbox-label">
32
+ <input type="checkbox" name="roles" value="user" {{#if isUser}}checked{{/if}}> user
33
+ </label>
34
+ <label class="checkbox-label">
35
+ <input type="checkbox" name="roles" value="guest" {{#if isGuest}}checked{{/if}}> guest
36
+ </label>
37
+ <label class="checkbox-label">
38
+ <input type="checkbox" name="roles" value="admin" {{#if isAdmin}}checked{{/if}}> admin
39
+ </label>
40
+ </div>
41
+ <button type="submit" class="btn">Update roles</button>
42
+ </form>
43
+ </section>
44
+
45
+ <section class="admin-section">
46
+ <h2>Reset password</h2>
47
+ <form method="POST" class="admin-form">
48
+ <input type="hidden" name="action" value="set_password">
49
+ <div class="form-row">
50
+ <label for="new_password">New password</label>
51
+ <input type="password" id="new_password" name="new_password" minlength="8" required>
52
+ </div>
53
+ <button type="submit" class="btn btn-danger">Reset password</button>
54
+ </form>
55
+ <p class="admin-hint">Resets the password and revokes all active sessions for this user.</p>
56
+ </section>
57
+ </div>
@@ -0,0 +1,20 @@
1
+ import { requireAdminSession } from '../../../src/adminAuth.js'
2
+ import { auth } from '../../../src/context.js'
3
+
4
+ export function GET(req) {
5
+ const session = requireAdminSession(req)
6
+ if (session instanceof Response) return session
7
+
8
+ const users = auth.listUsers({ requestingUserId: session.user.user_id })
9
+
10
+ return {
11
+ user: session.user,
12
+ pageTitle: 'Admin — Users',
13
+ users: users.map(u => ({
14
+ ...u,
15
+ isAdmin: u.roles.includes('admin'),
16
+ isBot: u.roles.includes('bot'),
17
+ created_at_fmt: new Date(u.created_at).toLocaleString(),
18
+ })),
19
+ }
20
+ }
@@ -0,0 +1,37 @@
1
+ <div class="admin-page">
2
+ <nav class="admin-nav">
3
+ <a href="{{base}}/admin/invites" class="admin-nav-link">Invites</a>
4
+ <a href="{{base}}/admin/users" class="admin-nav-link active">Users</a>
5
+ <a href="{{base}}/admin/bots" class="admin-nav-link">Bots</a>
6
+ </nav>
7
+
8
+ <h1>Users</h1>
9
+
10
+ <section class="admin-section">
11
+ <table class="data-table">
12
+ <thead>
13
+ <tr>
14
+ <th>Handle</th>
15
+ <th>Display name</th>
16
+ <th>Roles</th>
17
+ <th>Joined</th>
18
+ <th></th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ {{#each users}}
23
+ <tr>
24
+ <td><code>{{handle}}</code></td>
25
+ <td>{{display_name}}</td>
26
+ <td>
27
+ {{#if isAdmin}}<span class="tag tag--admin">admin</span>{{/if}}
28
+ {{#if isBot}}<span class="tag tag--bot">bot</span>{{/if}}
29
+ </td>
30
+ <td>{{created_at_fmt}}</td>
31
+ <td><a href="{{base}}/admin/users/{{user_id}}" class="btn-ghost">Edit</a></td>
32
+ </tr>
33
+ {{/each}}
34
+ </tbody>
35
+ </table>
36
+ </section>
37
+ </div>
@@ -0,0 +1,66 @@
1
+ /**
2
+ * POST /api/uploads
3
+ *
4
+ * Accepts multipart/form-data with fields:
5
+ * file — binary file
6
+ * channel_id — target channel
7
+ *
8
+ * Returns 200 { upload_id, url, original_name, mime_type, size_bytes }
9
+ * or 4xx/5xx on error.
10
+ */
11
+ import { sessionFromRequest, botUserFromRequest, uploadService } from '../../../src/context.js'
12
+ import { ServiceError, httpStatus } from '../../../src/util/errors.js'
13
+
14
+ const MAGIC_BYTES = 16
15
+
16
+ export async function POST(req) {
17
+ const session = sessionFromRequest(req)
18
+ const botUser = session ? null : await botUserFromRequest(req)
19
+ const reqUser = session?.user ?? botUser
20
+ if (!reqUser) return new Response('Unauthorized', { status: 401 })
21
+
22
+ let formData
23
+ try {
24
+ formData = await req.formData()
25
+ } catch {
26
+ return new Response('Bad Request: expected multipart/form-data', { status: 400 })
27
+ }
28
+
29
+ const channelId = formData.get('channel_id')
30
+ if (!channelId) return new Response('Bad Request: channel_id required', { status: 400 })
31
+
32
+ const fileEntry = formData.get('file')
33
+ if (!fileEntry || typeof fileEntry === 'string') {
34
+ return new Response('Bad Request: file required', { status: 400 })
35
+ }
36
+
37
+ const filename = fileEntry.name || 'upload'
38
+ const sizeBytes = fileEntry.size
39
+
40
+ // Read the full file into memory — needed for MIME detection and storage
41
+ const fullBuf = await fileEntry.arrayBuffer()
42
+ const buf = new Uint8Array(fullBuf)
43
+ const magicBuf = buf.slice(0, MAGIC_BYTES)
44
+
45
+ try {
46
+ const result = await uploadService.upload({
47
+ userId: reqUser.user_id,
48
+ channelId,
49
+ userRoles: reqUser.roles ?? [],
50
+ filename,
51
+ stream: buf,
52
+ sizeBytes,
53
+ magicBuf,
54
+ })
55
+ return Response.json(result, { status: 200 })
56
+ } catch (err) {
57
+ if (err instanceof ServiceError) {
58
+ const status = httpStatus(err)
59
+ return Response.json({ error: err.message }, { status })
60
+ }
61
+ if (err.code === 'UNSUPPORTED_TYPE') {
62
+ return Response.json({ error: err.message }, { status: 415 })
63
+ }
64
+ throw err
65
+ }
66
+ }
@@ -0,0 +1,26 @@
1
+ import { sessionFromRequest, userSettingsService } from '../../../src/context.js'
2
+
3
+ export async function GET(req) {
4
+ const session = sessionFromRequest(req)
5
+ if (!session) return new Response('Unauthorized', { status: 401 })
6
+ const result = userSettingsService.getSettings(session.user.user_id)
7
+ return Response.json(result)
8
+ }
9
+
10
+ export async function PUT(req) {
11
+ const session = sessionFromRequest(req)
12
+ if (!session) return new Response('Unauthorized', { status: 401 })
13
+
14
+ let body
15
+ try {
16
+ body = await req.json()
17
+ } catch {
18
+ return new Response('Bad Request', { status: 400 })
19
+ }
20
+
21
+ const { settings, updated_at } = body
22
+ if (typeof updated_at !== 'number') return new Response('Bad Request', { status: 400 })
23
+
24
+ const result = userSettingsService.putSettings(session.user.user_id, settings ?? {}, updated_at)
25
+ return Response.json(result)
26
+ }
@@ -0,0 +1,14 @@
1
+ import { auth, sessionFromRequest, sessionCookie } from '../../src/context.js'
2
+ import { p } from '../../src/config.js'
3
+
4
+ export async function POST(req) {
5
+ const session = sessionFromRequest(req)
6
+ if (session) auth.revokeSession(session.session_id)
7
+ return new Response(null, {
8
+ status: 302,
9
+ headers: {
10
+ Location: p('/login'),
11
+ 'Set-Cookie': sessionCookie(null, { clear: true }),
12
+ }
13
+ })
14
+ }
@@ -0,0 +1,99 @@
1
+ import { sessionFromRequest, channelService, hubService, messageService, reactionService, auth, logger } from '../../src/context.js'
2
+ import { renderMarkdown } from '@devchitchat/index97/markdown'
3
+ import { p, BASE_PATH } from '../../src/config.js'
4
+
5
+ /// TODO: Come up with a better strategy to allow for styled messages. Maybe you build a custom markdown parser
6
+ // that drops everything else but the styled text?
7
+ function sanitizeForFrontEnd(html) {
8
+ let output = html.toString()
9
+ output = output.replaceAll('<script>', '')
10
+ output = output.replaceAll('</script>', '')
11
+
12
+ return output
13
+ }
14
+
15
+ export async function GET(req) {
16
+ const session = sessionFromRequest(req)
17
+ if (!session) return Response.redirect(new URL(p('/login'), req.url), 302)
18
+
19
+ const url = new URL(req.url)
20
+ const channelId = url.pathname.split('/').pop()
21
+ const user = session.user
22
+ let channel = channelService.getChannel(channelId)
23
+ if (!channel || channel.deleted_at) {
24
+ logger?.warn('channel.not_found', { channelId, userId: user.user_id })
25
+ return new Response('Channel not found', { status: 404 })
26
+ }
27
+
28
+ // Auto-join public channels on first visit
29
+ if (!channelService.isMember(channelId, user.user_id)) {
30
+ if (channel.visibility === 'public') {
31
+ try {
32
+ channelService.joinChannel({ channelId, userId: user.user_id, userRoles: user.roles })
33
+ } catch (err) {
34
+ logger?.error('channel.join_failed', { channelId, userId: user.user_id, error: err.message })
35
+ return new Response('Forbidden', { status: 403 })
36
+ }
37
+ } else {
38
+ logger?.warn('channel.access_denied', { channelId, userId: user.user_id, visibility: channel.visibility })
39
+ return new Response('Forbidden', { status: 403 })
40
+ }
41
+ }
42
+
43
+ // SSR: last 50 messages baked into the page for instant render
44
+ const { messages: rawSeedMessages } = messageService.listLatestMessages({
45
+ channelId,
46
+ userId: user.user_id,
47
+ limit: 50,
48
+ })
49
+ const seedMessages = reactionService
50
+ ? reactionService.enrichWithReactions({ messages: rawSeedMessages, requestingUserId: user.user_id })
51
+ : rawSeedMessages
52
+ const seedSeq = seedMessages.length ? seedMessages[seedMessages.length - 1].seq : 0
53
+ const seedFirstSeq = seedMessages.length ? seedMessages[0].seq : 0
54
+ const seedHasMore = seedFirstSeq > 1
55
+
56
+ // Sidebar data: hubs + channels for nav
57
+ const hubs = hubService.listHubs(user.user_id, user.roles)
58
+ const allChannels = channelService.listChannels(user.user_id, user.roles)
59
+ const hubsWithChannels = hubs.map(hub => ({
60
+ ...hub,
61
+ channels: allChannels
62
+ .filter(c => c.hub_id === hub.hub_id)
63
+ .map(c => ({
64
+ ...c,
65
+ className: channelId === c.channel_id ? 'channel-item active' : 'channel-item',
66
+ url: p(`/channels/${c.channel_id}`),
67
+ label: `# ${c.name}`
68
+ }))
69
+ }))
70
+
71
+ // For DM channels, replace the internal name with the other person's display name
72
+ if (channel.kind === 'dm') {
73
+ const otherUserId = channel.name.split(':').slice(1).find(id => id !== user.user_id)
74
+ const otherUser = otherUserId ? auth.getUser(otherUserId) : null
75
+ channel = { ...channel, name: otherUser?.display_name ?? 'Direct Message', topic: null }
76
+ }
77
+
78
+ return {
79
+ user,
80
+ isAdmin: user.roles?.includes('admin') ?? false,
81
+ channel,
82
+ currentChannelId: channelId,
83
+ vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? '',
84
+ base: BASE_PATH,
85
+ seedFirstSeq,
86
+ seedHasMore,
87
+ seedMessages: seedMessages.map(m => ({
88
+ ...m,
89
+ raw_text: m.text,
90
+ text: sanitizeForFrontEnd(renderMarkdown(m.text).html),
91
+ ts_fmt: new Date(m.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
92
+ attachments_json: m.attachments?.length ? JSON.stringify(m.attachments) : '',
93
+ reactions_json: m.reactions?.length ? JSON.stringify(m.reactions) : '',
94
+ edited_at: m.edited_at ?? '',
95
+ })),
96
+ seedSeq,
97
+ hubs: hubsWithChannels,
98
+ }
99
+ }
@@ -0,0 +1,173 @@
1
+ <!-- Sidebar island: hub/channel nav + online presence -->
2
+ <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}}">
3
+ <section each="hubs" key="hub_id" class="hub-group">
4
+ {{#each hubs}}
5
+ <details class="hub-header" data-key="{{hub_id}}" data-visibility="{{visibility}}" data-description="{{description}}" draggable="true" open>
6
+ <summary class="hub-name" data-hub-id="{{hub_id}}">
7
+ <span text="name">{{name}}</span>
8
+ <button class="btn-hub-gear btn-icon" title="Hub settings" aria-label="Hub settings" type="button">&#9881;</button>
9
+ <button class="btn-hub-add btn-icon" title="Create a channel" aria-label="Create a channel" type="button">+</button>
10
+ </summary>
11
+ <ul each="channels" key="channel_id" class="channel-list">
12
+ <template>
13
+ <li class="channel-item" cls="className" draggable="true">
14
+ <a attr="href:url" text="name" class="channel-link"
15
+ data-channel-id=""
16
+ data-channel-name=""
17
+ data-channel-topic=""
18
+ data-channel-kind=""
19
+ data-channel-visibility=""></a>
20
+ <span class="call-badge" aria-label="Call active"></span>
21
+ <button class="btn-channel-gear btn-icon" title="Channel settings" aria-label="Channel settings" type="button">&#9881;</button>
22
+ </li>
23
+ </template>
24
+ {{#each channels}}
25
+ <li data-key="{{channel_id}}" class="channel-item {{className}}" cls="className" draggable="true">
26
+ <a attr="href:url" text="name" class="channel-link"
27
+ href="{{base}}/channels/{{channel_id}}"
28
+ data-channel-id="{{channel_id}}"
29
+ data-channel-name="{{name}}"
30
+ data-channel-topic="{{topic}}"
31
+ data-channel-kind="{{kind}}"
32
+ data-channel-visibility="{{visibility}}">{{name}}</a>
33
+ <span class="call-badge" aria-label="Call active"></span>
34
+ <button class="btn-channel-gear btn-icon" title="Channel settings" aria-label="Channel settings" type="button">&#9881;</button>
35
+ </li>
36
+ {{/each}}
37
+ </ul>
38
+ </details>
39
+ {{/each}}
40
+ </section>
41
+
42
+ <section class="dm-section">
43
+ <h3 class="dm-section-title">Direct Messages</h3>
44
+ <ul class="dm-list" id="dm-list">
45
+ <!-- populated by sidebar.js via dm.list_result -->
46
+ </ul>
47
+ </section>
48
+
49
+ <footer class="sidebar-footer">
50
+ <button class="btn-new-hub" id="btn-new-hub" type="button" aria-label="Create a new hub">+ New hub</button>
51
+ <div class="sidebar-footer-controls">
52
+ {{#if user}}
53
+ <span class="sidebar-username">{{user.display_name}}</span>
54
+ {{/if}}
55
+ <label for="theme-picker" class="sr-only">Theme</label>
56
+ <select id="theme-picker" aria-label="Choose theme" autocomplete="off">
57
+ <option value="dark">Dark</option>
58
+ <option value="light">Light</option>
59
+ <option value="ocean">Ocean</option>
60
+ <option value="forest">Forest</option>
61
+ <option value="rose">Rose</option>
62
+ </select>
63
+ {{#if isAdmin}}
64
+ <a href="{{base}}/admin/invites" class="btn-ghost btn-sm">Admin</a>
65
+ {{/if}}
66
+ {{#if user}}
67
+ <form method="POST" action="{{base}}/auth/signout">
68
+ <button type="submit" class="btn-ghost btn-sm">Sign out</button>
69
+ </form>
70
+ {{/if}}
71
+ </div>
72
+ <!-- Mini-bar: shown when the user is in a call and navigates to another channel -->
73
+ <div class="call-mini-bar" id="call-mini-bar">
74
+ <span class="call-mini-bar-channel" id="mini-bar-channel-name"></span>
75
+ <button class="btn-icon" id="mini-bar-mic" aria-label="Toggle mic" type="button">🎙</button>
76
+ <button class="btn-ghost" id="mini-bar-return" aria-label="Return to call" type="button">↩</button>
77
+ <button class="btn-ghost" id="mini-bar-leave" aria-label="Leave call" style="color: var(--color-danger)" type="button">✕</button>
78
+ </div>
79
+ </footer>
80
+ </aside>
81
+
82
+ <!-- Main content wrapper: slides in from right on mobile -->
83
+ <div class="main-content">
84
+
85
+ <!-- Chat + call island: messages, composer, and WebRTC -->
86
+ <section class="chat-panel" island="{{base}}/client/islands/call.js"
87
+ data-id="{{channel.channel_id}}"
88
+ data-name="{{channel.name}}"
89
+ data-topic="{{channel.topic}}"
90
+ data-kind="{{channel.kind}}"
91
+ data-user-id="{{user.user_id}}"
92
+ data-user-handle="{{user.handle}}"
93
+ data-user-display-name="{{user.display_name}}"
94
+ data-user-roles="{{user.roles}}"
95
+ data-seed-seq="{{seedSeq}}"
96
+ data-seed-first-seq="{{seedFirstSeq}}"
97
+ data-seed-has-more="{{seedHasMore}}"
98
+ >
99
+
100
+ <header class="chat-header">
101
+ <button class="btn-back-mobile" aria-label="Back to channels" type="button">&#8592;</button>
102
+ <hgroup>
103
+ <h2 class="chat-title" text="channelName">{{channel.name}}</h2>
104
+ <p class="chat-topic" text="channelTopic">{{channel.topic}}</p>
105
+ </hgroup>
106
+ <div class="chat-header-actions">
107
+ <button class="btn-ghost btn-start-call" id="btn-start-call" aria-label="Start call" type="button">
108
+ Start call
109
+ </button>
110
+ </div>
111
+ </header>
112
+
113
+ <!-- Call status row: shown when a call is active and user hasn't joined -->
114
+ <div class="call-status" id="call-status" hidden>
115
+ <span class="call-status-dot"></span>
116
+ <span class="call-status-info" id="call-status-info">Call in progress</span>
117
+ <div class="call-status-avatars" id="call-status-avatars"></div>
118
+ <button class="btn-primary" id="btn-join-call" style="padding: 6px 14px; font-size: 13px" type="button">
119
+ Join call
120
+ </button>
121
+ </div>
122
+
123
+ <!-- In-call controls bar: shown when user is in the call -->
124
+ <div class="call-controls-bar" id="call-controls-bar">
125
+ <button class="btn-icon" id="ctrl-mic" aria-label="Toggle microphone" type="button">🎙</button>
126
+ <button class="btn-icon" id="ctrl-cam" aria-label="Toggle camera" type="button">📷</button>
127
+ <button class="btn-icon" id="ctrl-screen" aria-label="Share screen" type="button">🖥</button>
128
+ <button class="btn-icon" id="ctrl-devices" aria-label="Switch camera or microphone" type="button">&#9881;</button>
129
+ <span class="call-peer-count" id="call-peer-count"></span>
130
+ <button class="btn-leave" id="btn-leave-call" type="button">Leave</button>
131
+ </div>
132
+
133
+ <!-- Server-rendered initial messages baked in -->
134
+ <div class="messages" id="messages" aria-live="polite" aria-label="Messages" role="log">
135
+ <div class="load-more-sentinel" id="load-more-sentinel" aria-hidden="true" hidden></div>
136
+ {{#each seedMessages}}
137
+ <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}}">
138
+ <span class="message-handle" data-user-id="{{user_id}}">{{user_display_name}}</span>
139
+ <time class="message-time" datetime="{{ts}}">{{ts_fmt}}{{#if edited_at}}<span class="message-edited">(edited)</span>{{/if}}</time>
140
+ <p class="message-text">{{{text}}}</p>
141
+ <div class="reaction-bar"></div>
142
+ </article>
143
+ {{/each}}
144
+ <template>
145
+ <article class="message">
146
+ <span class="message-handle"></span>
147
+ <time class="message-time" datetime=""></time>
148
+ <p class="message-text" text="text"></p>
149
+ </article>
150
+ </template>
151
+ </div>
152
+
153
+ <footer class="composer">
154
+ <button onclick="toggleUrgentMode" class="btn-urgent-toggle" type="button"
155
+ cls="urgentClass"
156
+ title="Toggle urgent mode — Enter sends as urgent (Ctrl+Enter for one-shot)" aria-label="Toggle urgent send">&#x1F514;</button>
157
+ <textarea model="draft" id="message-input" placeholder="Message in {{channel.name}}" aria-label="Write a message"
158
+ rows="1" onkeydown="handleComposerKey">
159
+ </textarea>
160
+ <button onclick="sendMessage" class="btn-send" aria-label="Send message" type="button">Send</button>
161
+ </footer>
162
+ </section><!-- /.chat-panel -->
163
+
164
+ <!-- Tile panel: sibling of chat-panel, never inside it -->
165
+ <div class="tile-panel" id="tile-panel">
166
+ <div class="tile-panel-header">
167
+ <span class="tile-panel-title" id="tile-panel-title">Call</span>
168
+ <button class="btn-icon tile-panel-collapse" id="tile-panel-collapse" aria-label="Collapse tile panel" type="button">&#8854;</button>
169
+ </div>
170
+ <div class="tile-grid" id="tile-grid"></div>
171
+ </div>
172
+
173
+ </div><!-- /.main-content -->
package/pages/index.js ADDED
@@ -0,0 +1,33 @@
1
+ import { sessionFromRequest, channelService, hubService, userSettingsService } from '../src/context.js'
2
+ import { p } from '../src/config.js'
3
+
4
+ export async function GET(req) {
5
+ const session = sessionFromRequest(req)
6
+ if (!session) {
7
+ return Response.redirect(new URL(p('/login'), req.url), 302)
8
+ }
9
+
10
+ const user = session.user
11
+
12
+ // Fresh-device fallback: redirect to last visited channel if recorded and still accessible.
13
+ // Clear the setting and fall through if the channel is gone or the user lost access,
14
+ // so they are never trapped in a redirect loop.
15
+ const { settings } = userSettingsService.getSettings(user.user_id)
16
+ if (settings.last_channel_id) {
17
+ if (channelService.canAccessChannel(settings.last_channel_id, user.user_id, user.roles)) {
18
+ return Response.redirect(new URL(p(`/channels/${settings.last_channel_id}`), req.url), 302)
19
+ }
20
+ userSettingsService.putSettings(user.user_id, { last_channel_id: null }, Date.now())
21
+ }
22
+
23
+ const channels = channelService.listChannels(user.user_id, user.roles)
24
+ if (channels.length > 0) {
25
+ return Response.redirect(new URL(p(`/channels/${channels[0].channel_id}`), req.url), 302)
26
+ }
27
+
28
+ // No channels yet — bootstrap defaults and redirect
29
+ const hub = hubService.ensureDefaultHub(user.user_id)
30
+ const channel = channelService.ensureDefaultChannel(hub.hub_id, user.user_id)
31
+ channelService.joinChannel({ channelId: channel.channel_id, userId: user.user_id, userRoles: user.roles })
32
+ return Response.redirect(new URL(p(`/channels/${channel.channel_id}`), req.url), 302)
33
+ }
@@ -0,0 +1,10 @@
1
+ import { p } from '../../src/config.js'
2
+
3
+ /**
4
+ * /invite/:token — redirect to the signup tab on /login with token pre-filled.
5
+ */
6
+ export async function GET(req) {
7
+ const url = new URL(req.url)
8
+ const token = url.pathname.split('/').pop()
9
+ return Response.redirect(new URL(p(`/login?invite=${encodeURIComponent(token)}`), req.url), 302)
10
+ }
@@ -0,0 +1,57 @@
1
+ import { auth, sessionFromRequest, sessionCookie } from '../../src/context.js'
2
+ import { p } from '../../src/config.js'
3
+
4
+ export async function GET(req) {
5
+ const session = sessionFromRequest(req)
6
+ if (session) return Response.redirect(new URL(p('/'), req.url), 302)
7
+
8
+ const url = new URL(req.url)
9
+ const inviteToken = url.searchParams.get('invite') ?? ''
10
+ const shouldShowSignup = !!inviteToken
11
+ return { error: null, shouldShowSignup, invite_token: inviteToken }
12
+ }
13
+
14
+ export async function POST(req) {
15
+ const form = await req.formData()
16
+ const action = form.get('_action')
17
+
18
+ try {
19
+ if (action === 'signin') {
20
+ const handle = form.get('handle')?.trim()
21
+ const password = form.get('password')
22
+ const result = await auth.signInWithPassword({ handle, password })
23
+ return new Response(null, {
24
+ status: 302,
25
+ headers: {
26
+ Location: p('/'),
27
+ 'Set-Cookie': sessionCookie(result.sessionToken),
28
+ }
29
+ })
30
+ }
31
+
32
+ if (action === 'signup') {
33
+ const inviteToken = form.get('invite_token')?.trim()
34
+ const handle = form.get('handle')?.trim()
35
+ const display_name = form.get('display_name')?.trim() || handle
36
+ const password = form.get('password')
37
+ const result = await auth.redeemInvite({ inviteToken, profile: { handle, display_name }, password })
38
+ return new Response(null, {
39
+ status: 302,
40
+ headers: {
41
+ Location: p('/'),
42
+ 'Set-Cookie': sessionCookie(result.sessionToken),
43
+ }
44
+ })
45
+ }
46
+
47
+ return new Response('Bad request', { status: 400 })
48
+ } catch (err) {
49
+ const showSignup = action === 'signup'
50
+ const invite_token = showSignup ? (form.get('invite_token') ?? '') : ''
51
+ return {
52
+ error: err.message ?? 'Something went wrong',
53
+ showSignup,
54
+ invite_token,
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,29 @@
1
+ <section class="signin">
2
+ {{#if error}}
3
+ <div class="alert alert-error" role="alert">{{error}}</div>
4
+ {{/if}}
5
+
6
+ <!-- Sign in -->
7
+ <section id="panel-signin" role="tabpanel">
8
+ <form method="POST" action="{{base}}/login" class="auth-form" novalidate>
9
+ <input type="hidden" name="_action" value="signin">
10
+ <div class="field">
11
+ <label for="signin-handle">Handle</label>
12
+ <input id="signin-handle" name="handle" type="text" autocomplete="username" autocapitalize="none" required
13
+ placeholder="your-handle">
14
+ </div>
15
+ <div class="field">
16
+ <label for="signin-password">Password</label>
17
+ <input id="signin-password" name="password" type="password" autocomplete="current-password" required
18
+ placeholder="••••••••">
19
+ </div>
20
+ <button type="submit" class="btn-primary">Sign in</button>
21
+ </form>
22
+ </section>
23
+ <footer>
24
+ <div class="auth-tabs" role="tablist">
25
+ <a role="tab" aria-controls="panel-signin" aria-selected="true" href="{{base}}/login" title="Sign in">Sign in</a>
26
+ <a role="tab" aria-controls="panel-signup" aria-selected="false" href="{{base}}/registration" title="Sign up">Sign up</a>
27
+ </div>
28
+ </footer>
29
+ </section>