domma-cms 1.8.32 → 1.8.34
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/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** The pure part of Garage - admin/lib/garage.js. */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {arrangeVehicles, badgeFor, checkState, daysUntil, displayReg, normaliseReg, settingsWithKey, shortDay, vehicleAlert, vehicleRow, vehicleNotices}
|
|
5
|
+
from '../admin/lib/garage.js';
|
|
6
|
+
|
|
7
|
+
const TODAY = '2026-09-25';
|
|
8
|
+
const car = (reg, extra = {}) => ({id: reg, registrationNumber: reg, make: 'FORD', yearOfManufacture: 2019,
|
|
9
|
+
motStatus: 'Valid', motExpiryDate: '2027-06-01', taxStatus: 'Taxed', taxDueDate: '2027-03-01', isSaved: true, ...extra});
|
|
10
|
+
|
|
11
|
+
test('registrations: normalised for matching, spaced for reading', () => {
|
|
12
|
+
assert.equal(normaliseReg(' ab12 cde '), 'AB12CDE');
|
|
13
|
+
assert.equal(displayReg('ab12cde'), 'AB12 CDE');
|
|
14
|
+
assert.equal(displayReg('A123BCD'), 'A123BCD');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('days between calendar days, ignoring time and bad input', () => {
|
|
18
|
+
assert.equal(daysUntil('2026-09-26', TODAY), 1);
|
|
19
|
+
assert.equal(daysUntil('2026-09-20T10:00:00Z', TODAY), -5);
|
|
20
|
+
assert.equal(daysUntil(null, TODAY), null);
|
|
21
|
+
assert.equal(daysUntil('soon', TODAY), null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('a check: expired is danger, due soon is warn, SORN is warn, comfortably ahead is ok, unknown is muted', () => {
|
|
25
|
+
assert.equal(checkState('mot', 'Valid', '2026-09-20', TODAY).tone, 'danger');
|
|
26
|
+
assert.match(checkState('mot', 'Valid', '2026-09-20', TODAY).text, /expired 5 days ago/);
|
|
27
|
+
assert.equal(checkState('mot', 'Not valid', null, TODAY).tone, 'danger');
|
|
28
|
+
assert.equal(checkState('tax', 'Untaxed', null, TODAY).tone, 'danger');
|
|
29
|
+
const soon = checkState('mot', 'Valid', '2026-10-05', TODAY);
|
|
30
|
+
assert.equal(soon.tone, 'warn');
|
|
31
|
+
assert.equal(soon.text, 'MOT due in 10 days');
|
|
32
|
+
assert.equal(checkState('tax', 'SORN', null, TODAY).tone, 'warn');
|
|
33
|
+
assert.equal(checkState('mot', 'Valid', '2027-06-01', TODAY).tone, 'ok');
|
|
34
|
+
assert.equal(checkState('tax', 'Taxed', '2027-01-23', TODAY).text, 'Tax to 23/01/2027');
|
|
35
|
+
assert.equal(shortDay('2027-01-23'), '23/01/2027');
|
|
36
|
+
assert.equal(checkState('mot', '', null, TODAY).tone, 'muted');
|
|
37
|
+
assert.equal(checkState('mot', 'No details held by DVLA', null, TODAY).tone, 'muted');
|
|
38
|
+
assert.equal(checkState('tax', 'Taxed', '2026-09-25', TODAY).text, 'Tax due today');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a vehicle goes by the worse of its two checks', () => {
|
|
42
|
+
const a = vehicleAlert(car('X', {taxDueDate: '2026-10-01'}), TODAY);
|
|
43
|
+
assert.equal(a.tone, 'warn');
|
|
44
|
+
assert.equal(a.needsAttention, true);
|
|
45
|
+
assert.equal(vehicleAlert(car('Y'), TODAY).needsAttention, false);
|
|
46
|
+
const row = vehicleRow(car('AB12CDE', {colour: 'BLUE', fuelType: 'PETROL', engineCapacity: 1600, motExpiryDate: '2026-01-01'}), TODAY);
|
|
47
|
+
assert.equal(row.reg, 'AB12 CDE');
|
|
48
|
+
assert.equal(row.title, 'FORD · 2019');
|
|
49
|
+
assert.equal(row.meta, 'BLUE · PETROL · 1600cc');
|
|
50
|
+
assert.equal(row.motTone, 'is-danger');
|
|
51
|
+
assert.equal(row.rowCls, 'is-danger');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('arrange: attention first (worst, then soonest), filter and search', () => {
|
|
55
|
+
const list = [car('BB22BBB'), car('AA11AAA', {taxDueDate: '2026-10-20'}), car('CC33CCC', {motExpiryDate: '2026-09-01'}), car('DD44DDD', {taxDueDate: '2026-10-01'})];
|
|
56
|
+
assert.deepEqual(arrangeVehicles(list, {today: TODAY}).map(v => v.id), ['CC33CCC', 'DD44DDD', 'AA11AAA', 'BB22BBB']);
|
|
57
|
+
assert.deepEqual(arrangeVehicles(list, {today: TODAY, filter: 'attention'}).map(v => v.id), ['CC33CCC', 'DD44DDD', 'AA11AAA']);
|
|
58
|
+
assert.deepEqual(arrangeVehicles(list, {today: TODAY, query: 'bb22 b'}).map(v => v.id), ['BB22BBB']);
|
|
59
|
+
const hist = [car('A', {lookupDate: '2026-09-01'}), car('B', {lookupDate: '2026-09-20'})];
|
|
60
|
+
assert.deepEqual(arrangeVehicles(hist, {today: TODAY, order: 'recent'}).map(v => v.id), ['B', 'A']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('badge: what needs doing; red once anything has run out; nothing due = no badge', () => {
|
|
64
|
+
const b = badgeFor([car('AA11AAA', {motExpiryDate: '2026-09-01'}), car('BB22BBB', {taxDueDate: '2026-10-10'}), car('CC33CCC')], TODAY);
|
|
65
|
+
assert.equal(b.count, 2);
|
|
66
|
+
assert.equal(b.tone, 'danger');
|
|
67
|
+
assert.equal(b.label, '2 vehicles need attention (1 expired)');
|
|
68
|
+
assert.equal(b.details.items[0].text, 'AA11 AAA · FORD');
|
|
69
|
+
assert.equal(b.details.items[0].tone, 'danger');
|
|
70
|
+
assert.match(b.details.items[1].meta, /Tax due in 15 days/);
|
|
71
|
+
const calm = badgeFor([car('CC33CCC')], TODAY);
|
|
72
|
+
assert.equal(calm.count, 0);
|
|
73
|
+
assert.equal(calm.tone, 'info');
|
|
74
|
+
assert.equal(calm.label, '1 saved vehicle, nothing due');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('notices: expired is critical, due soon a warning, SORN and fine nothing - each to its owner', () => {
|
|
78
|
+
const vs = [
|
|
79
|
+
car('EXP1', {userId: 'u1', motExpiryDate: '2026-09-01'}),
|
|
80
|
+
car('DUE1', {userId: 'u2', taxDueDate: '2026-10-05'}),
|
|
81
|
+
car('SORN1', {userId: 'u3', taxStatus: 'SORN', taxDueDate: null}),
|
|
82
|
+
car('OK1', {userId: 'u4'}),
|
|
83
|
+
car('NOBODY', {motExpiryDate: '2026-09-01'})
|
|
84
|
+
];
|
|
85
|
+
const {raise, resolve} = vehicleNotices(vs, TODAY);
|
|
86
|
+
assert.equal(raise.length, 2);
|
|
87
|
+
const [exp, due] = raise;
|
|
88
|
+
assert.equal(exp.severity, 'critical');
|
|
89
|
+
assert.match(exp.title, /^EXP1 \(FORD\): MOT expired/);
|
|
90
|
+
assert.deepEqual(exp.audience, {userIds: ['u1']});
|
|
91
|
+
assert.equal(exp.dedupeKey, 'vehicle:EXP1:expired');
|
|
92
|
+
assert.equal(due.severity, 'warning');
|
|
93
|
+
assert.match(due.title, /Tax due in 10 days/);
|
|
94
|
+
assert.deepEqual(due.audience, {userIds: ['u2']});
|
|
95
|
+
assert.ok(resolve.includes('vehicle:EXP1:due'), 'moving to expired ends the due notice');
|
|
96
|
+
assert.ok(resolve.includes('vehicle:OK1:due') && resolve.includes('vehicle:OK1:expired'), 'renewed: both go');
|
|
97
|
+
assert.ok(resolve.includes('vehicle:SORN1:due'), 'SORN is declared on purpose - nothing raised');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a saved key keeps every other stored setting', () => {
|
|
101
|
+
const stored = {dvlaApiKey: '••••••••', maxHistoryItems: 40, rateLimit: {max: 5, windowSeconds: 60}};
|
|
102
|
+
assert.deepEqual(settingsWithKey(stored, ' new-key '), {dvlaApiKey: 'new-key', maxHistoryItems: 40, rateLimit: {max: 5, windowSeconds: 60}});
|
|
103
|
+
assert.equal(stored.dvlaApiKey.length, 8, 'the stored object is not changed');
|
|
104
|
+
assert.deepEqual(settingsWithKey(null, 'k'), {dvlaApiKey: 'k'});
|
|
105
|
+
assert.deepEqual(settingsWithKey(['x'], 'k'), {dvlaApiKey: 'k'});
|
|
106
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# messages — AI Assistant Guide
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Internal user-to-user and group messaging for any authenticated CMS user. Supports 1:1 DMs, ad-hoc group chats, and admin-managed groups. Real-time delivery via SSE. Attachments via the existing `/api/media` pipeline. Per-thread retention with an admin-set global ceiling.
|
|
6
|
+
|
|
7
|
+
## Files
|
|
8
|
+
|
|
9
|
+
- `plugin.json`, `plugin.js`, `config.js`
|
|
10
|
+
- `collections/user-message-threads/schema.json` — thread metadata
|
|
11
|
+
- `collections/user-messages/schema.json` — individual messages
|
|
12
|
+
- `admin/views/messages.js` — Domma SPA view (`messagesView`), on the Tool upgrade path (1.1.0): domma-reactive
|
|
13
|
+
state, conversation + message context menus (right-click / ⋮ / Shift+F10), slideover forms (new
|
|
14
|
+
conversation, Call…, edit message, rename, retention, managed groups) with a discard guard, banner actions,
|
|
15
|
+
help popovers, viewLifetime teardown (closes the SSE stream)
|
|
16
|
+
- `admin/templates/messages.html`, `admin/css/messages.css` (--dm-* tokens only)
|
|
17
|
+
- `lib/call.js` (1.4) — calls: the link secret (`data/call-secret`, made once), `joinLink`/`checkJoin` (HMAC
|
|
18
|
+
links), `whoIsCalling` (the engine's resolve rule), `endedText`. `plugin.public.js` + `templates/call.html` —
|
|
19
|
+
the call page `/messages-call/:thread`. Tests: `tests/call.test.js`
|
|
20
|
+
- `admin/lib/messages.js` — PURE, shared with plugin.js: titles, filters, rights, bubbles, drafts,
|
|
21
|
+
`cleanAttachments`, `unreadBadge`. Tests: `tests/messages.test.js`, `tests/api.test.js` (snapshots and
|
|
22
|
+
restores the CMS checkout's tracked user-messages / user-message-threads data files)
|
|
23
|
+
|
|
24
|
+
## Routes
|
|
25
|
+
|
|
26
|
+
All routes are under `/api/plugins/messages` and require authentication - except the call engine's `/call/*`, where the per-user link is the credential.
|
|
27
|
+
|
|
28
|
+
| Method | Path | Purpose | Location |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| GET | `/stream` | SSE stream; accepts `?token=` for native EventSource | plugin.js |
|
|
31
|
+
| GET | `/threads` | List threads for current user (includes unread counts) | plugin.js |
|
|
32
|
+
| POST | `/threads` | Create DM or ad-hoc group; deduplicates DMs | plugin.js |
|
|
33
|
+
| PUT | `/threads/:id` | Rename thread or change `retentionDays` | plugin.js |
|
|
34
|
+
| DELETE | `/threads/:id` | Leave thread; purges when last participant leaves | plugin.js |
|
|
35
|
+
| GET | `/threads/:id/messages` | Paginated messages; runs lazy retention pruning | plugin.js |
|
|
36
|
+
| POST | `/threads/:id/messages` | Send a message; broadcasts SSE `message:new` | plugin.js |
|
|
37
|
+
| PUT | `/threads/:id/messages/:msgId` | Edit within 15-min window; broadcasts `message:edited` | plugin.js |
|
|
38
|
+
| DELETE | `/threads/:id/messages/:msgId` | Soft-delete (tombstone); broadcasts `message:deleted` | plugin.js |
|
|
39
|
+
| POST | `/threads/:id/call` | Start the conversation's call (or join the running one); answers `{url, call}` - the caller's own 2 h link | plugin.js |
|
|
40
|
+
| * | `/call/:room/{join,poll,signal,chat,answer,recording,leave}` | The call engine's routes (`_lib/call`), room = thread id | plugin.js |
|
|
41
|
+
| POST | `/threads/:id/read` | Mark all messages as read for current user | plugin.js |
|
|
42
|
+
| POST | `/threads/:id/unread` | The newest message from someone else counts as unread again | plugin.js |
|
|
43
|
+
| POST | `/threads/:id/archive` | `{archived}` - per-user (`archivedBy[]`); a new message clears it for all | plugin.js |
|
|
44
|
+
| GET | `/me` | `{id, name, isAdmin, editWindowMinutes, …}` - the screen's rights come from here | plugin.js |
|
|
45
|
+
| GET | `/people` | `{id, name}` of every active user - the picker (/api/users needs a permission most lack) | plugin.js |
|
|
46
|
+
| GET | `/count` | Sidebar badge: unread in conversations not archived | plugin.js |
|
|
47
|
+
| GET | `/managed-groups` | Admin-only list of `group-managed` threads | plugin.js |
|
|
48
|
+
| POST | `/managed-groups` | Admin-only: create a managed group | plugin.js |
|
|
49
|
+
|
|
50
|
+
## Data & Storage
|
|
51
|
+
|
|
52
|
+
- **`user-message-threads`** — `kind` (`dm`|`group-adhoc`|`group-managed`), `participants[]`, `createdBy`, `retentionDays` (null = default), `lastMessageAt`, `lastMessagePreview`
|
|
53
|
+
- **`user-messages`** — `threadId`, `senderId`, `body`, `attachments[]` (denormalised: mediaId/filename/mime/size), `createdAt`, `editedAt`, `deletedAt`, `readBy[]`
|
|
54
|
+
|
|
55
|
+
Both go through `server/services/collections.js` — FileAdapter by default, MongoAdapter if configured.
|
|
56
|
+
|
|
57
|
+
## Config keys (`config.js`)
|
|
58
|
+
|
|
59
|
+
| Key | Default | Purpose |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `globalRetentionCeilingDays` | 365 | Hard ceiling; PUT /threads clamps to this |
|
|
62
|
+
| `defaultRetentionDays` | 90 | Used when thread has no per-thread override |
|
|
63
|
+
| `editWindowMinutes` | 15 | Minutes after send during which the sender can edit |
|
|
64
|
+
|
|
65
|
+
There is no `scope` key (the participant list is the scope) and no `storage` key: until 1.3.1 `config.js`
|
|
66
|
+
listed `scope: 'user'` and `storage: {}`, and nothing read either. The collection schemas declare no adapter,
|
|
67
|
+
so both collections are on the FILE adapter unless moved to MongoDB (the collection editor's Storage tab,
|
|
68
|
+
`migrate-storage`). (Older notes here said `storage.adapter` defaulted to `'mongodb'` - it never did.)
|
|
69
|
+
|
|
70
|
+
## SSE bus
|
|
71
|
+
|
|
72
|
+
Module-level `Map<userId, Set<res>>`. The `broadcast(userIds, eventName, payload)` helper writes to every active connection for each user ID. Evicts broken sockets silently. **Single-process only** — horizontal scaling would need a Redis pub/sub layer.
|
|
73
|
+
|
|
74
|
+
Client connects via `new EventSource('/api/plugins/messages/stream?token=<jwt>')` since native `EventSource` cannot set headers. The `authenticateSSE` shim in `plugin.js` moves the query-param token into `request.headers.authorization` before calling the standard `authenticate` handler.
|
|
75
|
+
|
|
76
|
+
## Retention pruning
|
|
77
|
+
|
|
78
|
+
Runs lazily on `GET /threads/:id/messages`. Deletes any message whose `createdAt` is older than `min(thread.retentionDays ?? defaultRetentionDays, globalRetentionCeilingDays)` days. No background scheduler needed.
|
|
79
|
+
|
|
80
|
+
## Gotchas
|
|
81
|
+
|
|
82
|
+
- **Admin is by effective level** (`getEffectiveLevel` from userRoles.js). The JWT user has no `level`; 1.0
|
|
83
|
+
read `request.user.level`, so admins could not create managed groups or delete others' messages, and the
|
|
84
|
+
screen never showed Managed groups.
|
|
85
|
+
- **Bulk writes are sequential.** The file adapter rewrites the whole collection per call; 1.0's
|
|
86
|
+
`Promise.all` in mark-read, prune and purge kept only one of the writes - mark-read left all but one
|
|
87
|
+
message unread (tests/api.test.js fails on the old loop).
|
|
88
|
+
- **Attachments are media NAMES.** `POST /api/media` answers `{name, url}`; 1.0 stored `media.id`
|
|
89
|
+
(undefined) and linked `/api/media/<id>/file` (no such route), so no attachment ever showed.
|
|
90
|
+
`cleanAttachments` keeps only a safe name and rebuilds `url` as `/media/<name>` - a client-sent url is
|
|
91
|
+
never stored or rendered.
|
|
92
|
+
- **Message cache.** `allMessages()` keeps the whole collection 30 s for the badge poll; every write goes
|
|
93
|
+
through the wrapped create/update/delete, which drop it.
|
|
94
|
+
- **Drafts** are per signed-in person and per conversation in `S` (`messages.drafts.<user id>`, `draftKey()`) and
|
|
95
|
+
include uploaded-but-unsent attachments; a half-made new conversation is `messages.newDraft.<user id>`. No user =
|
|
96
|
+
no draft; the unscoped keys of older builds are removed on mount, never read. A reply draft comes back silently
|
|
97
|
+
(same person, its own conversation); a new-conversation draft is ASKED about: "Throw away the changes you left
|
|
98
|
+
unsaved to a new conversation?" - OK throws it away, Cancel / Escape opens it again; removed only once answered.
|
|
99
|
+
Message edits keep no draft - the window is minutes.
|
|
100
|
+
- **Menu items that ask** (Leave conversation…, Delete…) run a task later (`later()`): run inside the menu's Enter
|
|
101
|
+
keydown, the confirm caught that same Enter and Leave left the conversation unasked.
|
|
102
|
+
|
|
103
|
+
- **DM deduplication**: `POST /threads` with `kind:'dm'` normalises participants to a sorted pair and returns the existing thread if one already exists — safe to call repeatedly.
|
|
104
|
+
- **Soft deletes**: deleted messages set `body:''`, `attachments:[]`, `deletedAt:<iso>` — the row is never removed until retention pruning sweeps it. Clients should render these as "message deleted".
|
|
105
|
+
- **Attachment rendering**: the attachment record is denormalised at send time (filename/mime/size copied from the media entry). Deleting the media file does not break display, but the download link will 404.
|
|
106
|
+
- **Edit window**: enforced server-side at `config.editWindowMinutes * 60 000ms`. The admin UI also hides the Edit button client-side once the window passes, but the server check is authoritative.
|
|
107
|
+
- **Admin-only routes**: `GET/POST /managed-groups` use `requireAdmin`. `POST /threads` with `kind:'group-managed'` checks `isAdminUser()` - effective level ≤ 1 via `getEffectiveLevel`, never `request.user.level` (the JWT has none; see above).
|
|
108
|
+
|
|
109
|
+
## Access (1.2.0)
|
|
110
|
+
|
|
111
|
+
- **Permission `messages` (read = use)**, declared in plugin.json, granted once to `admin` and `user`; the sidebar
|
|
112
|
+
entry needs it. Every route checks it (a local `authenticate` = core's + `requirePermission('messages','read')`).
|
|
113
|
+
Take it from a role under Roles and that role has no Messages.
|
|
114
|
+
- **Who you can message is enforced on the server** (`reachable()`, shared with `/people`): an admin reaches
|
|
115
|
+
every active account; anyone else staff, people sharing one of their roles, and people they already talk to.
|
|
116
|
+
POST /threads refuses anything else (403) and non-string ids (400). Until 1.2 it took any id.
|
|
117
|
+
- **Retention is the starter's (or an admin's)**, a whole number of days >= 1 (or null for the default). Until 1.2
|
|
118
|
+
any participant could set it, and a negative number put the cut-off in the future, so the next read hard-
|
|
119
|
+
deleted every message in the conversation. `pruneOldMessages` reads a stored value below 1 as the default.
|
|
120
|
+
- **A conversation's starter may remove others' messages only in a group**, never in a DM.
|
|
121
|
+
- **Notifications (1.3.0):** `messages:unread`, every 15m: `unreadNotices()` (admin/lib/messages.js, pure) - a message unread for 10+ minutes (under 2 days old), one notice per conversation per recipient, to THEM (userIds). Key `unread:<thread>:<user>`; POST /threads/:id/read and archiving resolve it.
|
|
122
|
+
|
|
123
|
+
## Calls (1.4.0)
|
|
124
|
+
|
|
125
|
+
- **Engine:** core's `_lib/call` (0.103+), mounted at the end of plugin.js by dynamic import (`ns: 'messages'`,
|
|
126
|
+
prefix `/call`); an older core logs a warning and POST /threads/:id/call answers 501. Room = thread id.
|
|
127
|
+
- **Page:** `plugin.public.js` serves `/messages-call/:thread?u=&x=&s=` in the site shell (renderBlogPage;
|
|
128
|
+
`{{}}` values are not escaped - `escHtml`, and `'` too since `data-dm-call` is single-quoted). Public, so it
|
|
129
|
+
cannot see the admin JWT: the link is a per-user HMAC (`thread|user|expiry`, 2 h) with the secret in
|
|
130
|
+
`data/call-secret` (`options.dataDir` in tests). resolve() re-checks it, the account (active) and that the
|
|
131
|
+
person is STILL a participant. Everyone is `host: false, autoAdmit: true` (no waiting room; the engine's host
|
|
132
|
+
only admits, records and is captioned "(host)"), `max: 6`,
|
|
133
|
+
`recordingAllowed: false`, identity `<thread>:<user>` (a reload replaces the old place).
|
|
134
|
+
- **State** is in memory (`live` Map, like the engine's rooms): `startCall` sets it before its first await, so
|
|
135
|
+
two presses make one call. It posts a `kind: 'call'` message (`call: {event: 'started'|'ended'}`) READ BY
|
|
136
|
+
EVERYONE (no badge, no unread reminders - the live notice and the `messages:calls` notification tell people),
|
|
137
|
+
broadcasts `call:started`, and notifies the others (`dedupeKey call:<thread>`). A 5 s sweep on `countRoom`
|
|
138
|
+
broadcasts `call:updated` (numbers in) and ends a call that emptied - or that nobody joined within
|
|
139
|
+
`RING_MS` (5 min) - with "Call ended (N min)" / "Call ended - nobody joined", `call:ended`, and resolves the
|
|
140
|
+
notice. Joining with a valid link when no call is running starts one. The last member leaving closes the room.
|
|
141
|
+
- Call lines cannot be edited (PUT 403). GET /threads carries `call` ({by, byName, startedAt, in, messageId} or null).
|
|
142
|
+
- **Screen:** Call / Join call (N) in the header (tooltip warns past 6 members); a Join button on the running
|
|
143
|
+
call's "started" line; "X is calling - Join" notices bottom right (`ringingCalls`, put aside per call).
|
|
144
|
+
The tab is opened ON the click before any await (pop-up blocker); if blocked, an "Open the call" link shows.
|
|
145
|
+
|
|
146
|
+
## The call's chat (1.6.0)
|
|
147
|
+
|
|
148
|
+
- Typed in the call = posted INTO the conversation: the engine's `chat.save(threadId, item, {identity})` (core with
|
|
149
|
+
`_lib/call` chat storage; `identity` is `<thread>:<user>` from `whoIsCalling`, server side only) creates an ordinary
|
|
150
|
+
message from that user (`senderId`, body = the text) plus `callChat: {id, seq, who}` (the engine item's), updates
|
|
151
|
+
the thread preview, un-archives, and broadcasts `message:new` to EVERY participant (the sender's screen did not send
|
|
152
|
+
it). Only if the user is still a participant. System items are not posted; joined/left lines only keep `present`
|
|
153
|
+
(threadId -> Set of users in the call now), and `readBy` = the sender + everyone present, so people in the call get
|
|
154
|
+
no unread badge and the rest do. The screen skips its local unread +1 for a message whose `readBy` holds me.
|
|
155
|
+
- `chat.load(threadId)` gives the engine the thread's `callChat` messages (not deleted, oldest first, last 500) as
|
|
156
|
+
items with the original id/seq/who (so "mine" and colours hold in the call) and the current body - a call started
|
|
157
|
+
again later shows the earlier call lines; never posted again (the engine does not save seeded history).
|
|
158
|
+
- Bubbles: `buildBubbles` adds "in the call" to the meta of a `callChat` message. Tests: tests/call.test.js (1.6),
|
|
159
|
+
tests/messages.test.js.
|
|
160
|
+
|
|
161
|
+
## Add people (1.7.0)
|
|
162
|
+
|
|
163
|
+
- **Ring another team member** into a conversation's call (core with `server/services/callInvites.js`, dynamic import
|
|
164
|
+
beside `mountCallRoutes`; an older core has no button). `whoIsCalling` gives every MEMBER `mayInvite: true,
|
|
165
|
+
inviteExternal: false` (team only: no contacts, typed emails or copied link). `invite.search`: `searchInvitees`
|
|
166
|
+
with `contacts: false`, excluding the conversation's participants, `teamFilter` = `reachableFor(inviter)` (the
|
|
167
|
+
same rule as /people, now callable without a request). `invite.send`: `resolveInvitees(..., {contacts: false,
|
|
168
|
+
emails: false, teamFilter})`, each target added to the live call's `st.invited` (Set, gone with the call), links
|
|
169
|
+
`inviteLink()` = `?u=&x=&i=1&s=` signed over `thread|user|expiry|invite` (checkInvite; a member link is not an
|
|
170
|
+
invitation and the other way round), core sends the notification / DM / email. Answers `{results}` (no link).
|
|
171
|
+
- resolve: an invitation link joins ONLY while `live.get(thread).invited` has the user (else 404 - never a way into a
|
|
172
|
+
later call, never starts one); `whoIsCalling(..., {invited: true})` lets a non-participant in (`autoAdmit`,
|
|
173
|
+
`mayInvite: false`) and `history: false` (the engine gives them the call's chat only from their own joined line on -
|
|
174
|
+
never the conversation's earlier call messages). They are NOT added to the conversation; what they type in the call
|
|
175
|
+
is not posted (not a participant). The public page accepts `i=1` too (checks the signature only; the join checks the invitation).
|
|
176
|
+
- **Core's direct messenger**: `hooks.registerDirectMessenger(sendDirect)` - `{fromUserId, toUserId, text}` -> the
|
|
177
|
+
pair's DM (made, `thread:created` to the other, when there is none), a message from `fromUserId` (read by the
|
|
178
|
+
sender), thread touched + un-archived, `message:new` to both. Both accounts must be active AND the recipient must
|
|
179
|
+
be in `reachableFor(sender)` (Messages' own rule), else `{sent: false, reason}` - the host's results say why. Used by every Tool's
|
|
180
|
+
Add people (Booking, Calendar, core instant calls) for team members. `onCalls` exposes `sendDirect` for tests.
|
|
181
|
+
- Tests: tests/call.test.js "1.7" (links, invited user joins, others cannot, ends with the call).
|
|
182
|
+
|
|
183
|
+
## Call… (1.5.0)
|
|
184
|
+
|
|
185
|
+
- **Banner action "Call…"** (and a button on the empty screen) opens the SAME people picker as New conversation
|
|
186
|
+
(`PEOPLE_LIST` / `peopleModel` / `wirePeople`), 1 to `CALL_PICK_MAX` (5) others - a call holds `CALL_MAX` (6).
|
|
187
|
+
- **Where the call goes is decided on the client, from GET /threads** - no new route: `callTarget(threads, myId,
|
|
188
|
+
picked)` (admin/lib/messages.js, pure). One person: the DM (archived or not - one per pair; POST /threads kind
|
|
189
|
+
`dm` dedupes too). Several: a `group-adhoc` with EXACTLY those people plus you, not archived by you, latest used
|
|
190
|
+
first; managed groups are never taken. Else `{create}` -> POST /threads; a group's name defaults to
|
|
191
|
+
`callGroupName` of everyone ("Alex, Priya and Sam") - why `/me` now carries `name`.
|
|
192
|
+
- **The tab is opened in the Call method itself, before any await** (the IIFE after it does the work);
|
|
193
|
+
`joinCall(id, tab)` takes the pre-opened tab (undefined = open one). Order: create -> loadThreads -> joinCall ->
|
|
194
|
+
openThread, so the fetched messages already hold the "started a call" line.
|
|
195
|
+
- The conversation menu (right-click / ⋮) also has **Call** / **Join call**.
|
|
196
|
+
- Tests: `callTarget`/`callGroupName`/`callTargetText` in tests/messages.test.js; the server round trip in
|
|
197
|
+
tests/call.test.js.
|
|
198
|
+
|
|
199
|
+
## 1.7.2 (2026-10-13 Tool sweep)
|
|
200
|
+
- `MAX_BODY` 10000 / `MAX_NAME` 80 (plugin.js): POST and edit bodies and a conversation's name are sliced server-side.
|
|
201
|
+
Before, only Fastify's 1 MB body limit stood, and every 1 MB message sat in `allMessages()`' cache, was re-read on
|
|
202
|
+
every `/count` poll and pushed over SSE to everyone in the conversation.
|
|
203
|
+
- `resolveUsers` answers `{id, name, avatar}` - no `email` (the screen never showed it; `/people` already hid it).
|
|
204
|
+
`/threads` and `/count` were handing a member every participant's address.
|
|
205
|
+
|
|
206
|
+
## 1.7.3 (2026-10-13 Tool sweep, upgrades)
|
|
207
|
+
- `allMessages()` now fills `messagesCache.byThread` (a Map from `indexByThread`, admin/lib/messages.js) in the same
|
|
208
|
+
30 s fill; `messagesByThread()` / `threadMessages(id)` read it. Thread list unread counts, GET messages, mark read /
|
|
209
|
+
unread, prune, leave-and-purge and the call chat's `load()` no longer filter the whole site's messages per call.
|
|
210
|
+
Any write still drops the cache (and so the index). `/count` and the unread notice already made one pass over all.
|
|
211
|
+
- The slideovers (New conversation, Edit, Keep for, Managed groups) were already M templates via `openPanel`; the
|
|
212
|
+
screen's `esc` now covers `'` too.
|
|
213
|
+
|
|
214
|
+
## 1.8.0 (2026-10-15 Tool Upgrade, second pass)
|
|
215
|
+
|
|
216
|
+
- **A send never loses or misplaces typing**: `afterSend()` (admin/lib/messages.js, tests/after-send.test.js) decides
|
|
217
|
+
what stays in the box after the POST - typed on after the sent text keeps just the new part, edited otherwise keeps
|
|
218
|
+
everything, unsent attachments stay. If the person moved to another conversation meanwhile, the sent one's draft is
|
|
219
|
+
cleaned instead and the new conversation's box and bubbles are left alone (it used to append the message there and
|
|
220
|
+
empty its box). The sent text never survives as a draft (no double send).
|
|
221
|
+
- Uploads land on the conversation they were started on (`tid`), as a draft if the person moved on.
|
|
222
|
+
- The unfinished new-conversation draft is removed only once the question is answered.
|
|
223
|
+
- Skeletons for the conversation list (first load) and a conversation's messages (`.msx-loading`,
|
|
224
|
+
`.msx-thread-loading`, block elements; core `loading.js` at run time).
|
|
225
|
+
|
|
226
|
+
## 1.9.0 (2026-10-21): free
|
|
227
|
+
|
|
228
|
+
`tier: free`, no `closedSource` - no licence is needed or checked any more (the engine only checks a
|
|
229
|
+
`closedSource` plugin). Included in every hosted plan and free to self-host; not `bundle: false`, so the next
|
|
230
|
+
`make sync-free-tier` ships it in the engine.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* Messages admin screen - see admin/views/messages.js. --dm-* tokens only. */
|
|
2
|
+
.msx { display: grid; grid-template-columns: minmax(16rem, 22rem) 1fr; gap: .75rem; min-height: 32rem; height: calc(100vh - 13rem); }
|
|
3
|
+
.msx [hidden], .msx-form [hidden] { display: none !important; }
|
|
4
|
+
.msx-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); background: var(--dm-surface); display: flex; flex-direction: column; min-height: 0; }
|
|
5
|
+
.msx-side { padding: .6rem; gap: .5rem; }
|
|
6
|
+
.msx-side-head { display: flex; align-items: center; gap: .35rem; }
|
|
7
|
+
.msx-search { width: 100%; }
|
|
8
|
+
.msx-note { margin: 0; padding: .4rem .2rem; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
|
|
9
|
+
.msx-note.is-bad { color: var(--dm-danger); }
|
|
10
|
+
.msx-seg { display: inline-flex; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; flex: 1; }
|
|
11
|
+
.msx-seg button { flex: 1; border: 0; background: none; color: var(--dm-text); padding: .3rem .45rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm); white-space: nowrap; }
|
|
12
|
+
.msx-seg button + button { border-left: 1px solid var(--dm-border); }
|
|
13
|
+
.msx-seg button b { font-weight: 500; opacity: .65; margin-left: .15rem; }
|
|
14
|
+
.msx-seg button:hover { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); }
|
|
15
|
+
.msx-seg button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
|
|
16
|
+
|
|
17
|
+
/* Conversation rows */
|
|
18
|
+
.msx-threads { flex: 1; overflow-y: auto; display: flex; flex-direction: column; min-height: 0; }
|
|
19
|
+
.msx-row { display: flex; align-items: center; gap: .6rem; padding: .5rem .4rem; border-radius: var(--dm-radius-sm); cursor: pointer; }
|
|
20
|
+
.msx-row:hover, .msx-row:focus-visible { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); outline: none; }
|
|
21
|
+
.msx-row.is-active { background: color-mix(in srgb, var(--dm-primary) 12%, transparent); box-shadow: inset 3px 0 0 var(--dm-primary); }
|
|
22
|
+
.msx-row.is-unread .msx-row-title { font-weight: 700; }
|
|
23
|
+
.msx-avatar { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 2.1rem; height: 2.1rem; border-radius: 50%;
|
|
24
|
+
font-size: var(--dm-text-xs); font-weight: 700; color: var(--dm-primary); background: color-mix(in srgb, var(--dm-primary) 14%, transparent); }
|
|
25
|
+
.msx-avatar.is-group { border-radius: var(--dm-radius-md); color: var(--dm-success); background: color-mix(in srgb, var(--dm-success) 14%, transparent); }
|
|
26
|
+
.msx-avatar.is-lg { width: 2.5rem; height: 2.5rem; font-size: var(--dm-text-sm); }
|
|
27
|
+
.msx-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
28
|
+
.msx-row-top { display: flex; align-items: baseline; gap: .4rem; }
|
|
29
|
+
.msx-row-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
|
30
|
+
.msx-row-when { color: var(--dm-text-muted); font-size: var(--dm-text-xs); white-space: nowrap; }
|
|
31
|
+
.msx-row-preview { color: var(--dm-text-muted); font-size: var(--dm-text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
32
|
+
.msx-row-preview .msx-draft { color: var(--dm-warning); font-weight: 600; }
|
|
33
|
+
.msx-chip { display: inline-flex; align-items: center; font-size: var(--dm-text-xs); font-weight: 600; padding: .05rem .45rem; border-radius: 999px; white-space: nowrap;
|
|
34
|
+
background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
|
|
35
|
+
.msx-chip.is-unread { background: var(--dm-primary); color: var(--dm-primary-text); }
|
|
36
|
+
.msx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); line-height: 1; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .3rem; }
|
|
37
|
+
.msx-kebab:hover { color: var(--dm-primary); }
|
|
38
|
+
|
|
39
|
+
/* The open conversation */
|
|
40
|
+
.msx-main { min-width: 0; }
|
|
41
|
+
.msx-main-head { display: flex; align-items: center; gap: .6rem; padding: .6rem .8rem; border-bottom: 1px solid var(--dm-border); }
|
|
42
|
+
.msx-main-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
43
|
+
.msx-main-title strong, .msx-main-title small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
44
|
+
.msx-main-title small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
|
|
45
|
+
.msx-empty-main { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .5rem; color: var(--dm-text-muted); padding: 2rem; text-align: center; }
|
|
46
|
+
.msx-empty-actions { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
|
|
47
|
+
.msx-scroll { flex: 1; overflow-y: auto; padding: .75rem .9rem; min-height: 0; }
|
|
48
|
+
.msx-bubbles { display: flex; flex-direction: column; gap: .35rem; }
|
|
49
|
+
.msx-item { display: flex; flex-direction: column; }
|
|
50
|
+
.msx-day { align-self: center; margin: .5rem 0 .2rem; font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
|
|
51
|
+
.msx-msg { display: flex; align-items: flex-end; gap: .35rem; max-width: 78%; align-self: flex-start; }
|
|
52
|
+
.msx-msg.is-mine { align-self: flex-end; flex-direction: row-reverse; }
|
|
53
|
+
.msx-bubble { padding: .45rem .7rem; border-radius: 1rem; border-bottom-left-radius: .3rem; background: var(--dm-surface-raised, color-mix(in srgb, var(--dm-text) 7%, transparent)); color: var(--dm-text); min-width: 0; }
|
|
54
|
+
.msx-msg.is-mine .msx-bubble { background: var(--dm-primary); color: var(--dm-primary-text); border-bottom-left-radius: 1rem; border-bottom-right-radius: .3rem; }
|
|
55
|
+
.msx-msg.is-deleted .msx-bubble { background: none; border: 1px dashed var(--dm-border); color: var(--dm-text-muted); font-style: italic; }
|
|
56
|
+
.msx-sender { display: block; font-size: var(--dm-text-xs); font-weight: 700; color: var(--dm-primary); margin-bottom: .1rem; }
|
|
57
|
+
.msx-body { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
58
|
+
.msx-meta { display: block; font-size: 10px; opacity: .7; margin-top: .15rem; text-align: right; }
|
|
59
|
+
.msx-atts { display: flex; flex-direction: column; gap: .3rem; margin-top: .35rem; }
|
|
60
|
+
.msx-att-img img { display: block; max-width: 16rem; max-height: 12rem; border-radius: var(--dm-radius-sm); }
|
|
61
|
+
.msx-att-file { display: inline-flex; align-items: center; gap: .3rem; color: inherit; text-decoration: underline; }
|
|
62
|
+
.msx-msg .msx-kebab { opacity: 0; transition: opacity .15s; }
|
|
63
|
+
.msx-msg:hover .msx-kebab, .msx-msg:focus-within .msx-kebab, .msx-msg:focus .msx-kebab { opacity: 1; }
|
|
64
|
+
.msx-msg:focus-visible { outline: 2px solid var(--dm-primary); outline-offset: 2px; border-radius: 1rem; }
|
|
65
|
+
|
|
66
|
+
/* Composer */
|
|
67
|
+
.msx-composer { border-top: 1px solid var(--dm-border); padding: .6rem .8rem; display: flex; flex-direction: column; gap: .35rem; }
|
|
68
|
+
.msx-compose-row { display: flex; gap: .5rem; align-items: flex-end; }
|
|
69
|
+
.msx-input { flex: 1; resize: vertical; min-height: 2.6rem; max-height: 12rem; }
|
|
70
|
+
.msx-compose-actions { display: flex; gap: .3rem; }
|
|
71
|
+
.msx-compose-actions .btn { display: inline-flex; align-items: center; gap: .3rem; }
|
|
72
|
+
.msx-hint { color: var(--dm-text-muted); font-size: var(--dm-text-xs); display: flex; align-items: center; gap: .3rem; }
|
|
73
|
+
.msx-attach-list { display: flex; flex-wrap: wrap; gap: .3rem; }
|
|
74
|
+
.msx-att-chip { display: inline-flex; align-items: center; gap: .3rem; font-size: var(--dm-text-xs); padding: .15rem .5rem; border-radius: 999px;
|
|
75
|
+
background: color-mix(in srgb, var(--dm-primary) 12%, transparent); }
|
|
76
|
+
.msx-att-chip button { border: 0; background: none; cursor: pointer; color: var(--dm-text-muted); padding: 0; font-size: var(--dm-text-sm); }
|
|
77
|
+
|
|
78
|
+
/* Slideover forms */
|
|
79
|
+
.msx-form { display: flex; flex-direction: column; gap: .85rem; }
|
|
80
|
+
.msx-form .form-label { display: flex; align-items: center; gap: .3rem; }
|
|
81
|
+
.msx-people { max-height: 45vh; overflow-y: auto; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .25rem; }
|
|
82
|
+
.msx-person { display: flex; align-items: center; gap: .5rem; padding: .3rem .35rem; border-radius: var(--dm-radius-sm); cursor: pointer; }
|
|
83
|
+
.msx-person:hover { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); }
|
|
84
|
+
.msx-err { color: var(--dm-danger); font-size: var(--dm-text-xs); }
|
|
85
|
+
.msx-hint-block { display: block; color: var(--dm-text-muted); font-size: var(--dm-text-xs); margin-top: .25rem; }
|
|
86
|
+
.msx-actions { display: flex; align-items: center; gap: .4rem; padding-top: .6rem; border-top: 1px solid var(--dm-border); }
|
|
87
|
+
.msx-actions .btn { display: inline-flex; align-items: center; gap: .3rem; }
|
|
88
|
+
.msx-spacer { flex: 1; }
|
|
89
|
+
.msx-group-row { display: flex; align-items: center; gap: .6rem; padding: .45rem .3rem; border-bottom: 1px solid var(--dm-border); }
|
|
90
|
+
.msx-group-row:last-child { border-bottom: 0; }
|
|
91
|
+
|
|
92
|
+
@media (max-width: 820px) {
|
|
93
|
+
.msx { grid-template-columns: 1fr; height: auto; }
|
|
94
|
+
.msx-side { max-height: 40vh; }
|
|
95
|
+
.msx-main { min-height: 30rem; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Calls (1.4) */
|
|
99
|
+
.msx-call-btn { display: inline-flex; align-items: center; gap: .3rem; white-space: nowrap; }
|
|
100
|
+
.msx-callline { align-self: center; display: inline-flex; align-items: center; gap: .5rem; margin: .35rem 0; padding: .25rem .75rem;
|
|
101
|
+
border: 1px solid var(--dm-border); border-radius: 999px; font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
|
|
102
|
+
.msx-rings-dock { position: fixed; right: 1rem; bottom: 1rem; z-index: 1050; display: flex; flex-direction: column; gap: .5rem; width: min(22rem, calc(100vw - 2rem)); pointer-events: none; }
|
|
103
|
+
.msx-rings { display: flex; flex-direction: column; gap: .5rem; }
|
|
104
|
+
.msx-ring { pointer-events: auto; display: flex; align-items: center; gap: .6rem; padding: .6rem .7rem; border-radius: var(--dm-radius-md);
|
|
105
|
+
border: 1px solid var(--dm-border); border-left: 4px solid var(--dm-success); background: var(--dm-surface); color: var(--dm-text);
|
|
106
|
+
box-shadow: 0 6px 24px color-mix(in srgb, var(--dm-text) 18%, transparent); }
|
|
107
|
+
.msx-ring-dot { width: .7rem; height: .7rem; border-radius: 50%; background: var(--dm-success); flex-shrink: 0; animation: msx-ring 1.4s ease-in-out infinite; }
|
|
108
|
+
.msx-ring-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
109
|
+
.msx-ring-main strong, .msx-ring-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
110
|
+
.msx-ring-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
|
|
111
|
+
.msx-ring .btn { display: inline-flex; align-items: center; }
|
|
112
|
+
@keyframes msx-ring { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } }
|
|
113
|
+
@media (prefers-reduced-motion: reduce) { .msx-ring-dot { animation: none; } }
|
|
114
|
+
.dm-plugin-banner-action[data-banner-action="messages-call"] { display: inline-flex; align-items: center; gap: .3rem; }
|
|
115
|
+
|
|
116
|
+
/* Skeleton slots (1.8): blocks, so core skeleton rows get the full width. */
|
|
117
|
+
.msx-loading, .msx-thread-loading { display: block; }
|