@devchitchat/chat 4.0.0 → 4.0.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devchitchat/chat",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "A small chat app. p2p video and screenshare.",
5
5
  "scripts": {
6
6
  "dev": "bun --watch index.js",
@@ -1,5 +1,5 @@
1
1
  import { requireAdminSession } from '../../../src/adminAuth.js'
2
- import { botService, channelService } from '../../../src/context.js'
2
+ import { botService, channelService, hubService } from '../../../src/context.js'
3
3
  import { randomToken } from '../../../src/util/crypto.js'
4
4
  import { p } from '../../../src/config.js'
5
5
 
@@ -25,10 +25,34 @@ export function GET(req) {
25
25
  if (flashId) tokenFlashes.delete(flashId)
26
26
  const flash = url.searchParams.get('flash') ?? null
27
27
 
28
- // All channels for channel assignment checkboxes
29
28
  const allChannels = channelService.listChannels(session.user.user_id, session.user.roles)
29
+ const allHubs = hubService.listHubs(session.user.user_id, session.user.roles)
30
30
  const botChannelIds = new Set(bot.channels.map(c => c.channel_id))
31
31
 
32
+ // Group channels by hub, preserving hub order; collect channels with no hub separately
33
+ const hubMap = new Map(allHubs.map(h => [h.hub_id, { ...h, channels: [] }]))
34
+ const noHubChannels = []
35
+ for (const ch of allChannels) {
36
+ const entry = { ...ch, checked: botChannelIds.has(ch.channel_id) }
37
+ if (ch.hub_id && hubMap.has(ch.hub_id)) {
38
+ hubMap.get(ch.hub_id).channels.push(entry)
39
+ } else {
40
+ noHubChannels.push(entry)
41
+ }
42
+ }
43
+
44
+ // Compute hub-level checked/indeterminate state for UI rendering
45
+ const hubGroups = [...hubMap.values()]
46
+ .filter(h => h.channels.length > 0)
47
+ .map(h => {
48
+ const checkedCount = h.channels.filter(c => c.checked).length
49
+ return {
50
+ ...h,
51
+ hubChecked: checkedCount === h.channels.length,
52
+ hubIndeterminate: checkedCount > 0 && checkedCount < h.channels.length,
53
+ }
54
+ })
55
+
32
56
  return {
33
57
  user: session.user,
34
58
  pageTitle: `Admin — Bot: ${bot.handle}`,
@@ -37,16 +61,14 @@ export function GET(req) {
37
61
  flash,
38
62
  tokens: bot.tokens.map(t => ({
39
63
  ...t,
40
- created_at_fmt: new Date(t.created_at).toLocaleString(),
41
- expires_at_fmt: t.expires_at ? new Date(t.expires_at).toLocaleString() : 'Never',
64
+ created_at_fmt: new Date(t.created_at).toLocaleString(),
65
+ expires_at_fmt: t.expires_at ? new Date(t.expires_at).toLocaleString() : 'Never',
42
66
  last_used_at_fmt: t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'Never',
43
- revoked: !!t.revoked_at,
44
- expired: !t.revoked_at && t.expires_at != null && t.expires_at <= Date.now(),
45
- })),
46
- allChannels: allChannels.map(c => ({
47
- ...c,
48
- checked: botChannelIds.has(c.channel_id),
67
+ revoked: !!t.revoked_at,
68
+ expired: !t.revoked_at && t.expires_at != null && t.expires_at <= Date.now(),
49
69
  })),
70
+ hubGroups,
71
+ noHubChannels,
50
72
  }
51
73
  }
52
74
 
@@ -75,15 +75,88 @@
75
75
 
76
76
  <section class="admin-section">
77
77
  <h2>Channel access</h2>
78
- <form method="POST" class="admin-form">
78
+ <form method="POST" class="admin-form" id="channel-access-form">
79
79
  <input type="hidden" name="action" value="set_channels">
80
- {{#each allChannels}}
81
- <label class="checkbox-label">
82
- <input type="checkbox" name="channel_ids" value="{{channel_id}}" {{#if checked}}checked{{/if}}>
83
- # {{name}}
84
- </label>
80
+
81
+ {{#each hubGroups}}
82
+ <details class="hub-group" {{#if hubChecked}}open{{/if}}{{#if hubIndeterminate}}open{{/if}}>
83
+ <summary class="hub-group__summary">
84
+ <input type="checkbox"
85
+ class="hub-checkbox"
86
+ data-hub="{{hub_id}}"
87
+ {{#if hubChecked}}checked{{/if}}
88
+ {{#if hubIndeterminate}}data-indeterminate="true"{{/if}}>
89
+ <span class="hub-group__name">{{name}}</span>
90
+ <span class="hub-group__badge">{{visibility}}</span>
91
+ </summary>
92
+ <div class="hub-group__channels">
93
+ {{#each channels}}
94
+ <label class="checkbox-label checkbox-label--indented">
95
+ <input type="checkbox"
96
+ class="channel-checkbox"
97
+ name="channel_ids"
98
+ value="{{channel_id}}"
99
+ data-hub="{{hub_id}}"
100
+ {{#if checked}}checked{{/if}}>
101
+ # {{name}}
102
+ </label>
103
+ {{/each}}
104
+ </div>
105
+ </details>
85
106
  {{/each}}
107
+
108
+ {{#if noHubChannels}}
109
+ <details class="hub-group" open>
110
+ <summary class="hub-group__summary">
111
+ <span class="hub-group__name">No hub</span>
112
+ </summary>
113
+ <div class="hub-group__channels">
114
+ {{#each noHubChannels}}
115
+ <label class="checkbox-label checkbox-label--indented">
116
+ <input type="checkbox"
117
+ class="channel-checkbox"
118
+ name="channel_ids"
119
+ value="{{channel_id}}"
120
+ {{#if checked}}checked{{/if}}>
121
+ # {{name}}
122
+ </label>
123
+ {{/each}}
124
+ </div>
125
+ </details>
126
+ {{/if}}
127
+
86
128
  <button type="submit" class="btn">Save channel access</button>
87
129
  </form>
88
130
  </section>
131
+
132
+ <script>
133
+ // Set indeterminate state (can't be done in HTML, only via JS property)
134
+ document.querySelectorAll('.hub-checkbox[data-indeterminate="true"]').forEach(cb => {
135
+ cb.indeterminate = true
136
+ })
137
+
138
+ // Hub checkbox toggles all its channel checkboxes
139
+ document.querySelectorAll('.hub-checkbox').forEach(hubCb => {
140
+ hubCb.addEventListener('change', () => {
141
+ const hubId = hubCb.dataset.hub
142
+ document.querySelectorAll(`.channel-checkbox[data-hub="${hubId}"]`).forEach(ch => {
143
+ ch.checked = hubCb.checked
144
+ })
145
+ hubCb.indeterminate = false
146
+ })
147
+ })
148
+
149
+ // Channel checkbox updates its hub checkbox state
150
+ document.querySelectorAll('.channel-checkbox[data-hub]').forEach(chanCb => {
151
+ chanCb.addEventListener('change', () => {
152
+ const hubId = chanCb.dataset.hub
153
+ const hubCb = document.querySelector(`.hub-checkbox[data-hub="${hubId}"]`)
154
+ if (!hubCb) return
155
+ const siblings = [...document.querySelectorAll(`.channel-checkbox[data-hub="${hubId}"]`)]
156
+ const checkedCount = siblings.filter(c => c.checked).length
157
+ hubCb.checked = checkedCount === siblings.length
158
+ hubCb.indeterminate = checkedCount > 0 && checkedCount < siblings.length
159
+ })
160
+ })
161
+ </script>
89
162
  </div>
@@ -1484,6 +1484,64 @@ body:has(.admin-topbar) main {
1484
1484
  cursor: pointer;
1485
1485
  }
1486
1486
 
1487
+ .checkbox-label--indented {
1488
+ padding-left: 24px;
1489
+ }
1490
+
1491
+ .hub-group {
1492
+ border: 1px solid var(--border);
1493
+ border-radius: 6px;
1494
+ margin-bottom: 8px;
1495
+ overflow: hidden;
1496
+ }
1497
+
1498
+ .hub-group__summary {
1499
+ display: flex;
1500
+ align-items: center;
1501
+ gap: 8px;
1502
+ padding: 8px 12px;
1503
+ background: var(--bg-secondary);
1504
+ cursor: pointer;
1505
+ font-size: 14px;
1506
+ font-weight: 500;
1507
+ list-style: none;
1508
+ user-select: none;
1509
+ }
1510
+
1511
+ .hub-group__summary::-webkit-details-marker { display: none; }
1512
+
1513
+ .hub-group__summary::before {
1514
+ content: '▶';
1515
+ font-size: 10px;
1516
+ color: var(--text-muted);
1517
+ transition: transform 0.15s;
1518
+ flex-shrink: 0;
1519
+ }
1520
+
1521
+ details.hub-group[open] > .hub-group__summary::before {
1522
+ transform: rotate(90deg);
1523
+ }
1524
+
1525
+ .hub-group__name {
1526
+ flex: 1;
1527
+ }
1528
+
1529
+ .hub-group__badge {
1530
+ font-size: 11px;
1531
+ font-weight: normal;
1532
+ color: var(--text-muted);
1533
+ background: var(--bg-input);
1534
+ padding: 2px 6px;
1535
+ border-radius: 10px;
1536
+ }
1537
+
1538
+ .hub-group__channels {
1539
+ padding: 8px 12px;
1540
+ display: flex;
1541
+ flex-direction: column;
1542
+ gap: 6px;
1543
+ }
1544
+
1487
1545
  .token-display {
1488
1546
  display: inline-block;
1489
1547
  margin-top: 8px;
@@ -49,6 +49,18 @@ export class SqliteChannelRepository {
49
49
  ).all()
50
50
  }
51
51
 
52
+ listMemberships({ userId }) {
53
+ return this.db.prepare(
54
+ `SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order, h.name AS hub_name
55
+ FROM channels c
56
+ JOIN hubs h ON c.hub_id = h.hub_id
57
+ JOIN channel_members cm ON cm.channel_id = c.channel_id
58
+ WHERE c.deleted_at IS NULL AND h.deleted_at IS NULL
59
+ AND cm.user_id = ? AND cm.left_at IS NULL AND cm.banned_at IS NULL
60
+ ORDER BY h.name, c.sort_order ASC, c.created_at ASC`
61
+ ).all(userId)
62
+ }
63
+
52
64
  listAccessible({ userId, isGuest = false }) {
53
65
  return this.db.prepare(
54
66
  `SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order, h.name AS hub_name
@@ -11,10 +11,11 @@ import { randomToken, hashToken } from '../util/crypto.js'
11
11
  import { ServiceError } from '../util/errors.js'
12
12
 
13
13
  export class BotService {
14
- constructor({ authService, authRepo, channelRepo, nowFn = () => Date.now() }) {
14
+ constructor({ authService, authRepo, channelRepo, hubService, nowFn = () => Date.now() }) {
15
15
  this.authService = authService
16
16
  this.authRepo = authRepo
17
17
  this.channelRepo = channelRepo
18
+ this.hubService = hubService
18
19
  this.nowFn = nowFn
19
20
  }
20
21
 
@@ -119,10 +120,22 @@ export class BotService {
119
120
  for (const channelId of toLeave) {
120
121
  this.channelRepo.setMemberLeft({ channelId, userId, now })
121
122
  }
123
+
124
+ // Ensure hub membership for every channel in the final set.
125
+ // Done after the membership loop so it covers both new and pre-existing
126
+ // channel memberships (upsert is idempotent so re-running is safe).
127
+ const hubIds = new Set()
128
+ for (const channelId of next) {
129
+ const channel = this.channelRepo.findById({ channelId })
130
+ if (channel?.hub_id) hubIds.add(channel.hub_id)
131
+ }
132
+ for (const hubId of hubIds) {
133
+ this.hubService.joinHub(hubId, userId)
134
+ }
122
135
  }
123
136
 
124
137
  _getBotChannels(userId) {
125
- return this.channelRepo.listAccessible({ userId })
138
+ return this.channelRepo.listMemberships({ userId })
126
139
  }
127
140
 
128
141
  // ── Internals ──────────────────────────────────────────────────────────────
@@ -77,7 +77,7 @@ export class ChatServer {
77
77
  this.notificationService = new NotificationService({ deliveryService: this.deliveryService, authService: this.auth })
78
78
  this.presenceService = new PresenceService()
79
79
  this.signalingService = new SignalingService({ signalingRepo: new SqliteSignalingRepository({ db }) })
80
- this.botService = new BotService({ authService: this.auth, authRepo, channelRepo })
80
+ this.botService = new BotService({ authService: this.auth, authRepo, channelRepo, hubService: this.hubService })
81
81
  this.pushService = new WebPushService({
82
82
  vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? null,
83
83
  vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? null,
@@ -323,8 +323,12 @@ export class ChatServer {
323
323
  })
324
324
  .filter(Boolean)
325
325
  } else {
326
+ const memberIds = new Set(
327
+ this.channelService.listChannelMembers(channelId).map(m => m.user_id)
328
+ )
326
329
  candidates = this.auth.listUsersBasic()
327
- .filter(u => u.user_id !== senderId && !u.roles.includes('bot'))
330
+ .filter(u => u.user_id !== senderId)
331
+ .filter(u => !u.roles.includes('bot') || memberIds.has(u.user_id))
328
332
  .map(u => ({ user_id: u.user_id, handle: u.handle }))
329
333
  }
330
334
 
@@ -337,7 +341,6 @@ export class ChatServer {
337
341
  }))
338
342
  if (priority === 'now' && this.pushService.isConfigured()) {
339
343
  const sender = this.auth.getUser(senderId)
340
- const channel = this.channelService.getChannel(channelId)
341
344
  this.pushService.sendToUser({
342
345
  userId: user_id,
343
346
  title: `@${sender?.handle ?? 'someone'} mentioned you`,