mnfst 0.5.163 → 0.5.164
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/lib/manifest.appwrite.auth.js +107 -199
- package/lib/manifest.appwrite.data.js +1 -3
- package/lib/manifest.charts.js +34 -79
- package/lib/manifest.chat.js +515 -0
- package/lib/manifest.code.js +110 -340
- package/lib/manifest.colorpicker.js +252 -551
- package/lib/manifest.combobox.js +73 -91
- package/lib/manifest.components.js +34 -95
- package/lib/manifest.css +0 -132
- package/lib/manifest.data.js +189 -562
- package/lib/manifest.datepicker.js +43 -128
- package/lib/manifest.dropdowns.js +18 -39
- package/lib/manifest.export.js +29 -147
- package/lib/manifest.icons.js +9 -24
- package/lib/manifest.integrity.json +23 -23
- package/lib/manifest.js +62 -126
- package/lib/manifest.localization.js +67 -200
- package/lib/manifest.markdown.js +65 -174
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.payments.js +40 -139
- package/lib/manifest.router.js +24 -73
- package/lib/manifest.status.js +20 -56
- package/lib/manifest.svg.js +12 -30
- package/lib/manifest.tooltips.js +28 -65
- package/lib/manifest.utilities.js +40 -107
- package/lib/manifest.virtual.js +15 -53
- package/package.json +1 -1
- package/lib/manifest.appwrite.presence.js +0 -1650
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/* Manifest Chat — store / engine
|
|
2
|
+
/* By Andrew Matlock under MIT license
|
|
3
|
+
/* https://manifestx.dev
|
|
4
|
+
/*
|
|
5
|
+
/* Handle = reactive conversation view fed by an adapter; drives intents. The
|
|
6
|
+
/* plugin transports/stores nothing — see CHAT-PLUGIN-DESIGN.md.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
(function () {
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
// ---- ordering -----------------------------------------------------------
|
|
13
|
+
// Pending (un-acked) messages have no ts → sort to the tail; _seq tiebreaks.
|
|
14
|
+
function tsKey(m) {
|
|
15
|
+
if (m._optimistic && m.ts == null) return Number.POSITIVE_INFINITY;
|
|
16
|
+
const t = m.ts;
|
|
17
|
+
if (typeof t === 'number') return t;
|
|
18
|
+
if (typeof t === 'string') { const p = Date.parse(t); if (!isNaN(p)) return p; }
|
|
19
|
+
return m._seq || 0;
|
|
20
|
+
}
|
|
21
|
+
function byKey(a, b) {
|
|
22
|
+
const ka = tsKey(a), kb = tsKey(b);
|
|
23
|
+
if (ka !== kb) return ka - kb;
|
|
24
|
+
return (a._seq || 0) - (b._seq || 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalize(m, seq) {
|
|
28
|
+
const body = m.body && typeof m.body === 'object' ? m.body : { text: m.body == null ? '' : String(m.body) };
|
|
29
|
+
return Object.assign({}, m, { body: Object.assign({ text: '' }, body), _seq: m._seq != null ? m._seq : seq });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- tree (render-time projection over replyTo) -------------------------
|
|
33
|
+
// roots[] with recursive .replies; unloaded parent → orphan root.
|
|
34
|
+
// maxDepth re-parents deeper replies onto their level-N ancestor (stops
|
|
35
|
+
// indenting past N) while preserving the node's true depth.
|
|
36
|
+
function buildTree(messages, opts) {
|
|
37
|
+
const maxDepth = opts && typeof opts.maxDepth === 'number' ? opts.maxDepth : Infinity;
|
|
38
|
+
const byId = new Map();
|
|
39
|
+
const nodes = messages.map(m => { const n = Object.assign({}, m, { replies: [], depth: 0, childCount: 0, orphan: false }); byId.set(m.id, n); return n; });
|
|
40
|
+
const roots = [];
|
|
41
|
+
for (const n of nodes) {
|
|
42
|
+
const parent = n.replyTo != null ? byId.get(n.replyTo) : null;
|
|
43
|
+
if (n.replyTo != null && !parent) { n.orphan = true; roots.push(n); continue; }
|
|
44
|
+
if (!parent) { roots.push(n); continue; }
|
|
45
|
+
n.depth = parent.depth + 1;
|
|
46
|
+
let host = parent;
|
|
47
|
+
while (host.depth >= maxDepth && host._clampParent) host = host._clampParent; // re-parent past the cap
|
|
48
|
+
if (host.depth >= maxDepth) n._clampParent = host;
|
|
49
|
+
host.replies.push(n); host.childCount++;
|
|
50
|
+
}
|
|
51
|
+
return roots;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// DFS flatten — convenience for renderers that prefer a flat list + indent.
|
|
55
|
+
function flattenTree(roots, out) {
|
|
56
|
+
out = out || [];
|
|
57
|
+
for (const n of roots) { out.push(n); if (n.replies && n.replies.length) flattenTree(n.replies, out); }
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- handle -------------------------------------------------------------
|
|
62
|
+
function createHandle(adapter, conversationId, opts) {
|
|
63
|
+
opts = opts || {};
|
|
64
|
+
const A = window.Alpine;
|
|
65
|
+
const isAggregate = !!opts.aggregate;
|
|
66
|
+
let seq = 0;
|
|
67
|
+
|
|
68
|
+
const _msgs = []; // plain source of truth
|
|
69
|
+
const _byId = new Map(); // id -> plain msg
|
|
70
|
+
const _participants = new Map(); // id -> participant
|
|
71
|
+
const _typing = new Map(); // id -> participant
|
|
72
|
+
let cursorOlder = null, cursorNewer = null, lastSeen = null, unsub = null;
|
|
73
|
+
|
|
74
|
+
const state = A.reactive({
|
|
75
|
+
messages: [], participants: [], me: null, typing: [],
|
|
76
|
+
status: 'idle', live: false, atStart: false, atEnd: false,
|
|
77
|
+
lastRehome: null, error: null
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function ordered() { return _msgs.slice().sort(byKey); }
|
|
81
|
+
// Fresh per-message snapshots each commit so a keyed x-for re-renders
|
|
82
|
+
// in-place mutations (streaming appends, status) that identity wouldn't trip.
|
|
83
|
+
function commit() { state.messages = ordered().map(m => Object.assign({}, m, { body: Object.assign({}, m.body) })); }
|
|
84
|
+
function commitParticipants() { state.participants = [..._participants.values()]; }
|
|
85
|
+
function commitTyping() { state.typing = [..._typing.values()]; }
|
|
86
|
+
|
|
87
|
+
function upsert(raw) {
|
|
88
|
+
const incoming = normalize(raw, ++seq);
|
|
89
|
+
const prev = incoming.id != null ? _byId.get(incoming.id) : null;
|
|
90
|
+
if (prev) {
|
|
91
|
+
// merge — server echo reconciles body/media/status onto the local copy
|
|
92
|
+
Object.assign(prev.body, incoming.body);
|
|
93
|
+
for (const k of Object.keys(incoming)) if (k !== 'body' && k !== '_seq') prev[k] = incoming[k];
|
|
94
|
+
} else {
|
|
95
|
+
_msgs.push(incoming);
|
|
96
|
+
if (incoming.id != null) _byId.set(incoming.id, incoming);
|
|
97
|
+
seenAuthor(incoming.author);
|
|
98
|
+
}
|
|
99
|
+
lastSeen = incoming.id || lastSeen;
|
|
100
|
+
commit();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function appendPart(id, part) {
|
|
104
|
+
const m = _byId.get(id); if (!m) return;
|
|
105
|
+
if (part && part.text) m.body.text = (m.body.text || '') + part.text;
|
|
106
|
+
m.status = part && part.done ? 'sent' : 'streaming';
|
|
107
|
+
commit();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function seenAuthor(p) { if (p && p.id != null && !_participants.has(p.id)) { _participants.set(p.id, p); commitParticipants(); } }
|
|
111
|
+
function upsertParticipant(p) { if (p && p.id != null) { _participants.set(p.id, Object.assign(_participants.get(p.id) || {}, p)); commitParticipants(); } }
|
|
112
|
+
function removeParticipant(id) { if (_participants.delete(id)) commitParticipants(); }
|
|
113
|
+
|
|
114
|
+
// side: undefined='both'; 'older'/'newer' must NOT clobber the opposite
|
|
115
|
+
// cursor (a directional page reports both, but only its own end advanced).
|
|
116
|
+
function ingestLoad(res, side) {
|
|
117
|
+
if (!res) return;
|
|
118
|
+
(res.participants || []).forEach(upsertParticipant);
|
|
119
|
+
(res.messages || []).forEach(upsert);
|
|
120
|
+
if (side !== 'newer') { if (res.cursorOlder !== undefined) cursorOlder = res.cursorOlder; if (res.atStart !== undefined) state.atStart = res.atStart; }
|
|
121
|
+
if (side !== 'older') { if (res.cursorNewer !== undefined) cursorNewer = res.cursorNewer; if (res.atEnd !== undefined) state.atEnd = res.atEnd; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const handlers = {
|
|
125
|
+
onMessage: (m) => upsert(m), // MAY carry an unseen conversationId — never rejected
|
|
126
|
+
onMessagePart: (id, part) => appendPart(id, part),
|
|
127
|
+
onParticipant: (p, op) => op === 'removed' ? removeParticipant(p && p.id != null ? p.id : p) : upsertParticipant(p),
|
|
128
|
+
onTyping: (pid, on) => { const p = _participants.get(pid) || { id: pid }; if (on) _typing.set(pid, p); else _typing.delete(pid); commitTyping(); },
|
|
129
|
+
onReceipt: (id, status) => { const m = _byId.get(id); if (m) { m.status = status; commit(); } },
|
|
130
|
+
onReaction: (id, reactions) => { const m = _byId.get(id); if (m) { m.reactions = reactions; commit(); } },
|
|
131
|
+
onGap: (hint) => backfill(hint),
|
|
132
|
+
onConnection: (on) => { state.live = !!on; }
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
async function backfill(hint) {
|
|
136
|
+
if (!adapter.load) return;
|
|
137
|
+
const since = (hint && hint.since) || lastSeen;
|
|
138
|
+
try { ingestLoad(await adapter.load(conversationId, { after: since })); } catch (_) { }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function open() {
|
|
142
|
+
state.status = 'loading';
|
|
143
|
+
try {
|
|
144
|
+
state.me = adapter.identity ? adapter.identity() : null;
|
|
145
|
+
if (state.me) seenAuthor(state.me);
|
|
146
|
+
ingestLoad(await adapter.load(conversationId, opts.around ? { around: opts.around } : undefined));
|
|
147
|
+
if (adapter.subscribe) { unsub = adapter.subscribe(conversationId, handlers); state.live = true; }
|
|
148
|
+
state.status = 'ready';
|
|
149
|
+
} catch (e) { state.status = 'error'; state.error = String(e && e.message || e); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function send(draft) {
|
|
153
|
+
const tmp = 'tmp_' + (++seq);
|
|
154
|
+
const body = draft && draft.body ? draft.body : { text: draft && draft.text != null ? draft.text : '' };
|
|
155
|
+
const local = normalize({ id: tmp, conversationId, author: state.me, body, replyTo: draft && draft.replyTo, status: 'pending', ts: null, _optimistic: true }, ++seq);
|
|
156
|
+
_msgs.push(local); _byId.set(tmp, local); commit();
|
|
157
|
+
if (!adapter.send) { local.status = 'failed'; local.statusReason = 'unsupported'; commit(); return; }
|
|
158
|
+
try {
|
|
159
|
+
const ack = await adapter.send(conversationId, Object.assign({}, draft, { body }));
|
|
160
|
+
_byId.delete(tmp);
|
|
161
|
+
local.id = ack.id; local.ts = ack.ts; local.status = 'sent'; local._optimistic = false;
|
|
162
|
+
if (ack.conversationId && ack.conversationId !== local.conversationId) {
|
|
163
|
+
const from = local.conversationId; local.conversationId = ack.conversationId;
|
|
164
|
+
if (!isAggregate) state.lastRehome = { from, to: ack.conversationId }; // single-chat: cockpit must retarget
|
|
165
|
+
}
|
|
166
|
+
_byId.set(local.id, local); commit();
|
|
167
|
+
return ack;
|
|
168
|
+
} catch (e) { local.status = 'failed'; local.statusReason = (e && e.kind) || 'send'; commit(); throw e; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function page(dir) {
|
|
172
|
+
if (!adapter.load) return;
|
|
173
|
+
if (dir === 'older' && (state.atStart || cursorOlder == null)) return;
|
|
174
|
+
if (dir === 'newer' && (state.atEnd || cursorNewer == null)) return;
|
|
175
|
+
ingestLoad(await adapter.load(conversationId, dir === 'older' ? { before: cursorOlder } : { after: cursorNewer }), dir);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handle = {
|
|
179
|
+
__v_skip: true, // keep Alpine from re-proxying the handle when stored in x-data
|
|
180
|
+
id: 'h_' + Math.round(performance.now()) + '_' + (++seq),
|
|
181
|
+
conversationId, isAggregate,
|
|
182
|
+
get messages() { return state.messages; },
|
|
183
|
+
get participants() { return state.participants; },
|
|
184
|
+
get me() { return state.me; },
|
|
185
|
+
get typing() { return state.typing; },
|
|
186
|
+
get status() { return state.status; },
|
|
187
|
+
get live() { return state.live; },
|
|
188
|
+
get atStart() { return state.atStart; },
|
|
189
|
+
get atEnd() { return state.atEnd; },
|
|
190
|
+
get lastRehome() { return state.lastRehome; },
|
|
191
|
+
get error() { return state.error; },
|
|
192
|
+
get can() {
|
|
193
|
+
return {
|
|
194
|
+
send: !!adapter.send, edit: !!adapter.edit, retract: !!adapter.retract, react: !!adapter.react,
|
|
195
|
+
transfer: !!adapter.transfer, addParticipants: !!adapter.addParticipant,
|
|
196
|
+
typingIndicators: !!adapter.setTyping, readReceipts: !!adapter.markRead,
|
|
197
|
+
loadOlder: !!adapter.load, loadNewer: !!adapter.load,
|
|
198
|
+
loadReplies: !!adapter.loadReplies, loadReactions: !!adapter.loadReactions
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
tree(o) { void state.messages; return buildTree(state.messages, o); },
|
|
202
|
+
flatTree(o) { void state.messages; return flattenTree(buildTree(state.messages, o)); },
|
|
203
|
+
send,
|
|
204
|
+
edit: (id, body) => adapter.edit && adapter.edit(conversationId, id, body),
|
|
205
|
+
retract: (id) => adapter.retract && adapter.retract(conversationId, id),
|
|
206
|
+
react: (id, emoji) => adapter.react && adapter.react(conversationId, id, emoji),
|
|
207
|
+
unreact: (id, emoji) => adapter.unreact && adapter.unreact(conversationId, id, emoji),
|
|
208
|
+
transfer: (from, to, role) => adapter.transfer && adapter.transfer(conversationId, from, to, role),
|
|
209
|
+
addParticipant: (p) => adapter.addParticipant && adapter.addParticipant(conversationId, p),
|
|
210
|
+
removeParticipant: (id) => adapter.removeParticipant && adapter.removeParticipant(conversationId, id),
|
|
211
|
+
setTyping: (on) => adapter.setTyping && adapter.setTyping(conversationId, on),
|
|
212
|
+
markRead: (upTo) => adapter.markRead && adapter.markRead(conversationId, upTo),
|
|
213
|
+
loadOlder: () => page('older'),
|
|
214
|
+
loadNewer: () => page('newer'),
|
|
215
|
+
loadReplies: (parentId) => adapter.loadReplies && adapter.loadReplies(conversationId, parentId),
|
|
216
|
+
loadReactions: (id) => adapter.loadReactions && adapter.loadReactions(conversationId, id),
|
|
217
|
+
clearRehome: () => { state.lastRehome = null; },
|
|
218
|
+
close() { try { unsub && unsub(); } catch (_) { } }
|
|
219
|
+
};
|
|
220
|
+
open();
|
|
221
|
+
return handle;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- merge (small-N read projection; e.g. a Case lens) ------------------
|
|
225
|
+
function mergeHandles(handles, opts) {
|
|
226
|
+
const order = (opts && opts.order) || 'ts';
|
|
227
|
+
return {
|
|
228
|
+
__v_skip: true,
|
|
229
|
+
isMerge: true,
|
|
230
|
+
members: handles,
|
|
231
|
+
get messages() {
|
|
232
|
+
const all = [];
|
|
233
|
+
for (const h of handles) for (const m of h.messages) all.push(Object.assign({ _source: h.conversationId }, m));
|
|
234
|
+
return order === 'ts' ? all.sort(byKey) : all;
|
|
235
|
+
},
|
|
236
|
+
get participants() { const seen = new Map(); for (const h of handles) for (const p of h.participants) seen.set(p.id, p); return [...seen.values()]; },
|
|
237
|
+
get status() { return handles.some(h => h.status === 'loading') ? 'loading' : (handles.every(h => h.status === 'ready') ? 'ready' : 'idle'); },
|
|
238
|
+
get live() { return handles.some(h => h.live); },
|
|
239
|
+
can: { send: handles.some(h => h.can.send) },
|
|
240
|
+
send: (draft, target) => { const h = handles.find(x => x.conversationId === target) || handles[0]; return h.send(draft); },
|
|
241
|
+
tree(o) { return buildTree(this.messages, o); },
|
|
242
|
+
close: () => handles.forEach(h => h.close())
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
window.ManifestChatStore = { createHandle, mergeHandles, buildTree, flattenTree };
|
|
247
|
+
})();
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
/* Manifest Chat — reference adapters + registry
|
|
251
|
+
/* By Andrew Matlock under MIT license
|
|
252
|
+
/* https://manifestx.dev
|
|
253
|
+
/*
|
|
254
|
+
/* Seeded in-memory backend with per-conversation and aggregate (fan-out)
|
|
255
|
+
/* views. Exercises the whole contract with no transport, proving a real
|
|
256
|
+
/* adapter (Appwrite, a Cloudflare DO) slots in the same shape.
|
|
257
|
+
*/
|
|
258
|
+
|
|
259
|
+
(function () {
|
|
260
|
+
'use strict';
|
|
261
|
+
|
|
262
|
+
const registry = new Map();
|
|
263
|
+
function register(name, factory) { registry.set(name, factory); }
|
|
264
|
+
function resolve(ref, opts) {
|
|
265
|
+
if (ref && typeof ref === 'object') return ref; // an adapter object passed directly
|
|
266
|
+
const f = registry.get(ref);
|
|
267
|
+
if (!f) throw new Error('chat: unknown adapter "' + ref + '"');
|
|
268
|
+
return typeof f === 'function' ? f(opts) : f;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- the shared in-memory backend --------------------------------------
|
|
272
|
+
// One instance holds all conversations; both adapter views read it.
|
|
273
|
+
function createBackend() {
|
|
274
|
+
let seq = 0;
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
const t = (minsAgo) => now - minsAgo * 60000;
|
|
277
|
+
const id = (p) => p + '_' + (++seq);
|
|
278
|
+
|
|
279
|
+
const me = { id: 'u_me', kind: 'human', role: 'agent', displayName: 'You', color: '#7c3aed' };
|
|
280
|
+
const ana = { id: 'u_ana', kind: 'human', role: 'member', displayName: 'Ana', color: '#0ea5e9' };
|
|
281
|
+
const bo = { id: 'u_bo', kind: 'human', role: 'member', displayName: 'Bo', color: '#16a34a' };
|
|
282
|
+
const bot = { id: 'u_bot', kind: 'agent', role: 'bot', displayName: 'Acme Bot', color: '#d97706' };
|
|
283
|
+
const player = { id: 'c_p7', kind: 'contact', role: 'player', displayName: 'Player 7', color: '#db2777' };
|
|
284
|
+
|
|
285
|
+
// conv: { id, channel, closed, participants[], messages[] }
|
|
286
|
+
const convs = new Map();
|
|
287
|
+
function mk(cid, channel, parts, msgs, closed) {
|
|
288
|
+
convs.set(cid, { id: cid, channel, closed: !!closed, participants: parts, messages: msgs });
|
|
289
|
+
}
|
|
290
|
+
const m = (cid, author, text, minsAgo, extra) =>
|
|
291
|
+
Object.assign({ id: id('m'), conversationId: cid, author, body: { text }, ts: t(minsAgo), status: 'delivered' }, extra || {});
|
|
292
|
+
|
|
293
|
+
// 1:1 AI co-pilot
|
|
294
|
+
mk('dm-ai', 'webchat', [me, bot], [
|
|
295
|
+
m('dm-ai', bot, 'Hi — I can help with your account. What do you need?', 12),
|
|
296
|
+
m('dm-ai', me, 'How many free credits do I get?', 11)
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
// group with reactions + a small reply tree
|
|
300
|
+
const g1 = m('grp-1', ana, 'Ship the v2 doc today?', 30);
|
|
301
|
+
mk('grp-1', 'webchat', [me, ana, bo], [
|
|
302
|
+
g1,
|
|
303
|
+
Object.assign(m('grp-1', bo, '+1, reviewing now', 28), { reactions: [{ emoji: '👍', count: 2, byMe: true }], replyTo: g1.id }),
|
|
304
|
+
Object.assign(m('grp-1', me, 'I’ll cut the release after', 26), { replyTo: g1.id })
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
// forum-style nested thread (multi-level) for c.tree
|
|
308
|
+
const root = m('forum-1', ana, 'Proposal: drop stored `direction`', 200);
|
|
309
|
+
const r1 = Object.assign(m('forum-1', bo, 'Agree — derive from author', 190), { replyTo: root.id });
|
|
310
|
+
const r2 = Object.assign(m('forum-1', me, 'What about group with N of our identities?', 185), { replyTo: r1.id });
|
|
311
|
+
const r3 = Object.assign(m('forum-1', ana, 'Exactly why binary breaks', 180), { replyTo: r2.id });
|
|
312
|
+
const orphan = Object.assign(m('forum-1', bo, '(reply to a post paged out of view)', 175), { replyTo: 'm_paged_away' });
|
|
313
|
+
mk('forum-1', 'webchat', [me, ana, bo], [root, r1, r2, r3, orphan]);
|
|
314
|
+
|
|
315
|
+
// player-7 lifetime history across channels — mostly CLOSED (aggregate lens)
|
|
316
|
+
mk('hist-tg-1', 'telegram', [player, bot], [
|
|
317
|
+
m('hist-tg-1', player, 'deposit stuck', 60 * 24 * 30),
|
|
318
|
+
m('hist-tg-1', bot, 'resolved — refunded', 60 * 24 * 30 + 1, { meta: { authoredBy: 'u_me' } })
|
|
319
|
+
], true);
|
|
320
|
+
mk('hist-em-1', 'email', [player, me], [
|
|
321
|
+
m('hist-em-1', player, 'bonus question', 60 * 24 * 9),
|
|
322
|
+
m('hist-em-1', me, 'applied to your account', 60 * 24 * 9 + 2)
|
|
323
|
+
], true);
|
|
324
|
+
mk('hist-wc-1', 'webchat', [player, bot], [
|
|
325
|
+
m('hist-wc-1', player, 'KYC docs?', 60 * 24 * 2),
|
|
326
|
+
m('hist-wc-1', bot, 'uploaded, thanks', 60 * 24 * 2 + 1)
|
|
327
|
+
], true);
|
|
328
|
+
|
|
329
|
+
return { seq: () => ++seq, id, t, me, ana, bo, bot, player, convs };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const backend = createBackend();
|
|
333
|
+
const subscribers = new Map(); // conversationId -> Set(handlers) (+ 'contact:c_p7' channel)
|
|
334
|
+
|
|
335
|
+
function emit(channel, fn) { const s = subscribers.get(channel); if (s) s.forEach(fn); }
|
|
336
|
+
function deliver(conv, msg) {
|
|
337
|
+
conv.messages.push(msg);
|
|
338
|
+
emit(conv.id, h => h.onMessage && h.onMessage(msg));
|
|
339
|
+
emit('contact:' + backend.player.id, h => h.onMessage && h.onMessage(msg)); // aggregate sees it too
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// page around/before/after an anchor; cursors are just indices here.
|
|
343
|
+
function pageList(list, win, size) {
|
|
344
|
+
win = win || {}; size = size || 20;
|
|
345
|
+
const ids = list.map(x => x.id);
|
|
346
|
+
let start, end;
|
|
347
|
+
if (win.around != null) { const i = Math.max(0, ids.indexOf(win.around)); start = Math.max(0, i - Math.floor(size / 2)); end = Math.min(list.length, start + size); }
|
|
348
|
+
else if (win.before != null) { const i = ids.indexOf(win.before); end = i < 0 ? list.length : i; start = Math.max(0, end - size); }
|
|
349
|
+
else if (win.after != null) { const i = ids.indexOf(win.after); start = i < 0 ? 0 : i + 1; end = Math.min(list.length, start + size); }
|
|
350
|
+
else { start = Math.max(0, list.length - size); end = list.length; }
|
|
351
|
+
return {
|
|
352
|
+
messages: list.slice(start, end),
|
|
353
|
+
cursorOlder: start > 0 ? list[start].id : null,
|
|
354
|
+
cursorNewer: end < list.length ? list[end - 1].id : null,
|
|
355
|
+
atStart: start <= 0, atEnd: end >= list.length
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sub(channel, handlers) {
|
|
360
|
+
if (!subscribers.has(channel)) subscribers.set(channel, new Set());
|
|
361
|
+
subscribers.get(channel).add(handlers);
|
|
362
|
+
setTimeout(() => handlers.onConnection && handlers.onConnection(true), 0);
|
|
363
|
+
return () => { const s = subscribers.get(channel); if (s) s.delete(handlers); };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- per-conversation adapter ------------------------------------------
|
|
367
|
+
function staticAdapter() {
|
|
368
|
+
return {
|
|
369
|
+
identity: () => backend.me,
|
|
370
|
+
async load(cid, win) { const c = backend.convs.get(cid); if (!c) return { messages: [], participants: [] }; const p = pageList(c.messages, win); return Object.assign({ participants: c.participants }, p); },
|
|
371
|
+
subscribe: (cid, handlers) => sub(cid, handlers),
|
|
372
|
+
async send(cid, draft) {
|
|
373
|
+
const c = backend.convs.get(cid);
|
|
374
|
+
// closed-never-reopens: a reply into a closed conversation spawns a new one
|
|
375
|
+
let target = c;
|
|
376
|
+
if (c && c.closed) { const nid = backend.id('cht'); backend.convs.set(nid, { id: nid, channel: c.channel, closed: false, participants: c.participants, messages: [] }); target = backend.convs.get(nid); }
|
|
377
|
+
const msg = { id: backend.id('m'), conversationId: target.id, author: backend.me, body: draft.body, replyTo: draft.replyTo, ts: backend.t(0), status: 'sent' };
|
|
378
|
+
setTimeout(() => deliver(target, msg), 60); // server echo (reconciles media/status by id)
|
|
379
|
+
return { id: msg.id, ts: msg.ts, conversationId: target.id };
|
|
380
|
+
},
|
|
381
|
+
async react(cid, mid, emoji) { const c = backend.convs.get(cid); const msg = c && c.messages.find(x => x.id === mid); if (msg) { msg.reactions = [{ emoji, count: 1, byMe: true, by: [backend.me.id] }]; emit(cid, h => h.onReaction && h.onReaction(mid, msg.reactions)); } return { ok: true }; },
|
|
382
|
+
async addParticipant(cid, p) { const c = backend.convs.get(cid); if (c && !c.participants.some(x => x.id === p.id)) { c.participants.push(p); emit(cid, h => h.onParticipant && h.onParticipant(p, 'added')); } return { ok: true }; },
|
|
383
|
+
async transfer(cid, from, to, role) { emit(cid, h => h.onParticipant && h.onParticipant({ id: to, role: role || 'assignee' }, 'changed')); return { ok: true }; },
|
|
384
|
+
async setTyping(cid, on) { emit(cid, h => h.onTyping && h.onTyping(backend.me.id, on)); },
|
|
385
|
+
async markRead(cid, upTo) { emit(cid, h => h.onReceipt && h.onReceipt(upTo, 'read')); },
|
|
386
|
+
async loadReplies(cid, parentId) { const c = backend.convs.get(cid); return { messages: (c ? c.messages : []).filter(x => x.replyTo === parentId), cursor: null, done: true }; }
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- aggregate adapter (virtual conversationId, fan-out) ----------------
|
|
391
|
+
// Merges a contact's conversations into one stream; subscribes at the
|
|
392
|
+
// CONTACT level so a new conversation's first inbound has an unseen id.
|
|
393
|
+
function aggregateAdapter(opts) {
|
|
394
|
+
const contactId = (opts && opts.contactId) || backend.player.id;
|
|
395
|
+
function memberConvs() { return [...backend.convs.values()].filter(c => c.participants.some(p => p.id === contactId)); }
|
|
396
|
+
function allMsgs() { return memberConvs().flatMap(c => c.messages).sort((a, b) => a.ts - b.ts); }
|
|
397
|
+
return {
|
|
398
|
+
identity: () => backend.me,
|
|
399
|
+
async load(_vid, win) { const list = allMsgs(); const p = pageList(list, win, 12); const seen = new Map(); memberConvs().forEach(c => c.participants.forEach(x => seen.set(x.id, x))); return Object.assign({ participants: [...seen.values()] }, p); },
|
|
400
|
+
subscribe: (_vid, handlers) => sub('contact:' + contactId, handlers),
|
|
401
|
+
async send(_vid, draft) {
|
|
402
|
+
// route by channel; if that channel's latest conv is closed, spawn a new one
|
|
403
|
+
const ch = (draft && draft.viaChannelId) || 'webchat';
|
|
404
|
+
const open = memberConvs().filter(c => c.channel === ch && !c.closed).pop();
|
|
405
|
+
let target = open;
|
|
406
|
+
if (!target) { const nid = backend.id('cht'); backend.convs.set(nid, { id: nid, channel: ch, closed: false, participants: [backend.player, backend.me], messages: [] }); target = backend.convs.get(nid); }
|
|
407
|
+
const msg = { id: backend.id('m'), conversationId: target.id, author: backend.me, body: draft.body, ts: backend.t(0), status: 'sent' };
|
|
408
|
+
setTimeout(() => deliver(target, msg), 60);
|
|
409
|
+
return { id: msg.id, ts: msg.ts, conversationId: target.id };
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---- simulation hooks (button-driven; deterministic for verification) ---
|
|
415
|
+
const sim = {
|
|
416
|
+
// stream an AI reply token-by-token into a conversation
|
|
417
|
+
aiReply(cid, text) {
|
|
418
|
+
const c = backend.convs.get(cid); if (!c) return;
|
|
419
|
+
const mid = backend.id('m');
|
|
420
|
+
const msg = { id: mid, conversationId: cid, author: backend.bot, body: { text: '' }, ts: backend.t(0), status: 'streaming', meta: { authoredBy: 'u_me' } };
|
|
421
|
+
c.messages.push(msg);
|
|
422
|
+
emit(cid, h => h.onMessage && h.onMessage(msg));
|
|
423
|
+
const words = (text || 'Every account starts with 5,000 free credits each month.').split(' ');
|
|
424
|
+
let i = 0;
|
|
425
|
+
const tick = setInterval(() => {
|
|
426
|
+
if (i >= words.length) { clearInterval(tick); emit(cid, h => h.onMessagePart && h.onMessagePart(mid, { text: '', done: true })); return; }
|
|
427
|
+
emit(cid, h => h.onMessagePart && h.onMessagePart(mid, { text: (i ? ' ' : '') + words[i] }));
|
|
428
|
+
i++;
|
|
429
|
+
}, 120);
|
|
430
|
+
},
|
|
431
|
+
// drop the live connection, then reconnect with a gap signal
|
|
432
|
+
disconnect(cid) { emit(cid, h => h.onConnection && h.onConnection(false)); emit('contact:' + backend.player.id, h => h.onConnection && h.onConnection(false)); },
|
|
433
|
+
reconnectWithGap(cid, missed) {
|
|
434
|
+
// a message arrived while "disconnected" — exists in the backend but wasn't pushed
|
|
435
|
+
const c = backend.convs.get(cid);
|
|
436
|
+
if (c) c.messages.push({ id: backend.id('m'), conversationId: cid, author: backend.ana, body: { text: missed || 'message you missed while away' }, ts: backend.t(0), status: 'delivered' });
|
|
437
|
+
emit(cid, h => h.onConnection && h.onConnection(true));
|
|
438
|
+
emit(cid, h => h.onGap && h.onGap({ since: c ? c.messages[c.messages.length - 2].id : null }));
|
|
439
|
+
},
|
|
440
|
+
// a brand-new conversation spawns and delivers live to the aggregate stream
|
|
441
|
+
newInboundOnClosedChannel() {
|
|
442
|
+
const nid = backend.id('cht');
|
|
443
|
+
backend.convs.set(nid, { id: nid, channel: 'telegram', closed: false, participants: [backend.player, backend.bot], messages: [] });
|
|
444
|
+
const c = backend.convs.get(nid);
|
|
445
|
+
deliver(c, { id: backend.id('m'), conversationId: nid, author: backend.player, body: { text: 'new message — fresh conversation' }, ts: backend.t(0), status: 'delivered' });
|
|
446
|
+
return nid;
|
|
447
|
+
},
|
|
448
|
+
backend
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
register('demo', staticAdapter);
|
|
452
|
+
register('demo-aggregate', aggregateAdapter);
|
|
453
|
+
|
|
454
|
+
window.ManifestChatAdapters = { register, resolve, staticAdapter, aggregateAdapter, sim };
|
|
455
|
+
})();
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
/* Manifest Chat — magic + init
|
|
459
|
+
/* By Andrew Matlock under MIT license
|
|
460
|
+
/* https://manifestx.dev
|
|
461
|
+
/*
|
|
462
|
+
/* Registers $chat. Renders nothing — the author drives their UI off the handle.
|
|
463
|
+
/* open(conversationId, { adapter, around?, aggregate? }) · merge(handles, { order })
|
|
464
|
+
/* adapter(name, factory) · flatten(tree)
|
|
465
|
+
*/
|
|
466
|
+
|
|
467
|
+
(function () {
|
|
468
|
+
'use strict';
|
|
469
|
+
|
|
470
|
+
function api() {
|
|
471
|
+
const Store = window.ManifestChatStore;
|
|
472
|
+
const Adapters = window.ManifestChatAdapters;
|
|
473
|
+
return {
|
|
474
|
+
open(conversationId, opts) {
|
|
475
|
+
opts = opts || {};
|
|
476
|
+
const adapter = Adapters.resolve(opts.adapter, opts);
|
|
477
|
+
const aggregate = opts.aggregate || (typeof opts.adapter === 'string' && /aggregate/.test(opts.adapter));
|
|
478
|
+
return Store.createHandle(adapter, conversationId, Object.assign({}, opts, { aggregate }));
|
|
479
|
+
},
|
|
480
|
+
merge(handles, o) { return Store.mergeHandles(handles, o); },
|
|
481
|
+
adapter(name, factory) { if (factory === undefined) return Adapters.resolve(name); Adapters.register(name, factory); },
|
|
482
|
+
flatten(tree) { return Store.flattenTree(tree); },
|
|
483
|
+
get sim() { return Adapters.sim; } // demo/sim hooks; harmless in prod (no callers)
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function registerMagic() {
|
|
488
|
+
if (!window.Alpine || typeof window.Alpine.magic !== 'function') return false;
|
|
489
|
+
if (registerMagic._done) return true;
|
|
490
|
+
// Bind the api once; $chat.* are stable references (handles carry their own reactivity).
|
|
491
|
+
const instance = api();
|
|
492
|
+
window.Alpine.magic('chat', () => instance);
|
|
493
|
+
registerMagic._done = true;
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function ensureInitialized() {
|
|
498
|
+
if (!window.ManifestChatStore || !window.ManifestChatAdapters) return;
|
|
499
|
+
registerMagic();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
window.ensureManifestChatInitialized = ensureInitialized;
|
|
503
|
+
|
|
504
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureInitialized);
|
|
505
|
+
document.addEventListener('alpine:init', ensureInitialized);
|
|
506
|
+
|
|
507
|
+
if (window.Alpine && typeof window.Alpine.magic === 'function') {
|
|
508
|
+
setTimeout(ensureInitialized, 0);
|
|
509
|
+
} else {
|
|
510
|
+
const check = setInterval(() => {
|
|
511
|
+
if (window.Alpine && typeof window.Alpine.magic === 'function') { clearInterval(check); ensureInitialized(); }
|
|
512
|
+
}, 10);
|
|
513
|
+
setTimeout(() => clearInterval(check), 5000);
|
|
514
|
+
}
|
|
515
|
+
})();
|