meta-sovereign 0.49.7 → 0.49.8

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.
@@ -0,0 +1,163 @@
1
+ // A server refusal never goes unseen (R-Z12). Every answer in the uniform
2
+ // `{ ok: false, error, code }` shape is logged to the console once per
3
+ // distinct error; a user-triggered (non-GET) request that fails with a
4
+ // systemic code also raises a localised toast, so a failed send or save is
5
+ // never a silent empty panel. Background reads stay quiet on the page.
6
+
7
+ import React, { useEffect, useState } from 'react';
8
+ import { useT } from './i18n.js';
9
+
10
+ const el = React.createElement;
11
+
12
+ export const API_ERROR_EVENT = 'ms-api-error';
13
+
14
+ // Codes with their own wording (error.code.<code> in every locale).
15
+ export const KNOWN_CODES = [
16
+ 'bad-host',
17
+ 'too-large',
18
+ 'bad-json',
19
+ 'internal',
20
+ 'send-unknown',
21
+ 'unsupported',
22
+ 'unsupported-chat',
23
+ 'captcha',
24
+ 'LOW_DISK',
25
+ 'STORE_WRITE_FAILED',
26
+ 'STORE_FULL',
27
+ 'STORE_LOCKED',
28
+ 'outbox-unavailable',
29
+ 'send-in-doubt',
30
+ 'timeout',
31
+ 'network',
32
+ ];
33
+
34
+ // Never a toast: a feature probe or a chat that cannot do it is explained
35
+ // inline by the panel that asked. Codes outside KNOWN_CODES are the panels'.
36
+ const INLINE_ONLY = new Set(['unsupported', 'unsupported-chat']);
37
+
38
+ const seen = new Set();
39
+
40
+ /** The localised sentence for a server code, else the server's own text. */
41
+ export const errorText = (t, code, fallback = '') => {
42
+ const key = `error.code.${code}`;
43
+ const text = code ? t(key) : key;
44
+ return text === key ? fallback || t('error.code.unknown') : text;
45
+ };
46
+
47
+ /** The `{ code, error }` of a failed answer, or null when it succeeded. */
48
+ export const failureOf = (status, data) => {
49
+ if (
50
+ data?.ok === false ||
51
+ (typeof data?.error === 'string' && status >= 400)
52
+ ) {
53
+ return { code: data.code ?? '', error: String(data.error ?? '') };
54
+ }
55
+ return status >= 500 ? { code: 'internal', error: '' } : null;
56
+ };
57
+
58
+ /** Logs once per distinct error; raises the toast for user-triggered calls. */
59
+ export const reportApiFailure = ({ path, method = 'GET', status, data }) => {
60
+ const failure = failureOf(status, data);
61
+ if (!failure) {
62
+ return null;
63
+ }
64
+ const key = `${method} ${path} ${failure.code} ${failure.error}`;
65
+ if (!seen.has(key)) {
66
+ seen.add(key);
67
+ globalThis.console?.warn?.(
68
+ `[meta-sovereign] ${method} ${path} failed (${status}): ${failure.code} ${failure.error}`.trim()
69
+ );
70
+ }
71
+ if (
72
+ method !== 'GET' &&
73
+ KNOWN_CODES.includes(failure.code) &&
74
+ !INLINE_ONLY.has(failure.code)
75
+ ) {
76
+ globalThis.dispatchEvent?.(
77
+ new CustomEvent(API_ERROR_EVENT, { detail: { ...failure, path } })
78
+ );
79
+ }
80
+ return failure;
81
+ };
82
+
83
+ /** Forgets what was logged (tests). */
84
+ export const resetApiErrors = () => seen.clear();
85
+
86
+ /** The toast, mounted once in the app; Escape or × hides it. */
87
+ export const ApiErrorToast = () => {
88
+ const t = useT();
89
+ const [shown, setShown] = useState(null);
90
+ useEffect(() => {
91
+ const on = (event) => setShown(event.detail);
92
+ globalThis.addEventListener?.(API_ERROR_EVENT, on);
93
+ return () => globalThis.removeEventListener?.(API_ERROR_EVENT, on);
94
+ }, []);
95
+ if (!shown) {
96
+ return null;
97
+ }
98
+ return el(
99
+ 'aside',
100
+ {
101
+ className: 'disk-banner api-error-toast',
102
+ role: 'alert',
103
+ 'data-banner': 'api-error',
104
+ 'data-error-code': shown.code,
105
+ },
106
+ [
107
+ el('span', { key: 'text' }, errorText(t, shown.code, shown.error)),
108
+ el(
109
+ 'button',
110
+ {
111
+ key: 'x',
112
+ type: 'button',
113
+ 'data-action': 'api-error-dismiss',
114
+ onClick: () => setShown(null),
115
+ },
116
+ t('disk.dismiss')
117
+ ),
118
+ ]
119
+ );
120
+ };
121
+
122
+ /** Logs a background failure to the console once per distinct message. */
123
+ export const logOnce = (what, error) => {
124
+ const key = `${what} ${String(error?.message ?? error)}`;
125
+ if (!seen.has(key)) {
126
+ seen.add(key);
127
+ globalThis.console?.warn?.(`[meta-sovereign] ${key}`);
128
+ }
129
+ };
130
+
131
+ /** A list that could not load: what failed, and a button to try again. */
132
+ export const LoadError = ({ error, retry, what }) => {
133
+ const t = useT();
134
+ const code = typeof error === 'object' ? error?.code : '';
135
+ return el(
136
+ 'div',
137
+ {
138
+ className: 'load-error',
139
+ role: 'alert',
140
+ 'data-load-error': what ?? 'list',
141
+ },
142
+ [
143
+ el('p', { key: 'm' }, t('error.load')),
144
+ el(
145
+ 'p',
146
+ { key: 'd', className: 'meta' },
147
+ errorText(t, code, String(error?.message ?? error ?? ''))
148
+ ),
149
+ retry
150
+ ? el(
151
+ 'button',
152
+ {
153
+ key: 'r',
154
+ type: 'button',
155
+ 'data-action': 'load-retry',
156
+ onClick: retry,
157
+ },
158
+ t('error.retry')
159
+ )
160
+ : null,
161
+ ]
162
+ );
163
+ };
@@ -12531,3 +12531,28 @@ a.remote-chip:hover {
12531
12531
  text-transform: none;
12532
12532
  text-decoration: underline;
12533
12533
  }
12534
+
12535
+ /* A list that could not load, with a retry button (R-Z12). */
12536
+ .load-error {
12537
+ margin: 1rem;
12538
+ padding: 0.75rem 1rem;
12539
+ border: 1px solid var(--border);
12540
+ border-left: 4px solid var(--accent);
12541
+ border-radius: 8px;
12542
+ background: var(--bg-elev);
12543
+ }
12544
+
12545
+ /* The error toast floats above any open dialog so a refusal is never hidden. */
12546
+ .api-error-toast {
12547
+ position: fixed;
12548
+ top: 0.75rem;
12549
+ left: 50%;
12550
+ transform: translateX(-50%);
12551
+ z-index: 10001;
12552
+ max-width: min(36rem, calc(100vw - 2rem));
12553
+ margin: 0;
12554
+ box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
12555
+ }
12556
+ main[data-view='chat'] > .api-error-toast {
12557
+ margin: 0;
12558
+ }
package/js/src/web/app.js CHANGED
@@ -26,6 +26,8 @@ import { useAppearance, useApplyAppearance } from './appearance.js';
26
26
  import { WebModeBanner } from './web-mode-banner.js';
27
27
  import { DiskBanner } from './disk-banner.js';
28
28
  import { SchemaBanner } from './schema-banner.js';
29
+ import { ApiErrorToast } from './api-errors.js';
30
+ import { installDialogFocus } from './dialog-focus.js';
29
31
  import { shouldAutoOpen, useAnyConnection } from './tutorial-start.js';
30
32
  import { Icon } from './icons.js';
31
33
  import { ModeBadge } from './telegram-online.js';
@@ -207,6 +209,7 @@ const App = () => {
207
209
  [
208
210
  el(DiskBanner, { key: 'disk', api }),
209
211
  el(SchemaBanner, { key: 'schema', api }),
212
+ el(ApiErrorToast, { key: 'api-error' }),
210
213
  el(WebModeBanner, {
211
214
  key: 'web-mode',
212
215
  online,
@@ -257,4 +260,5 @@ const Root = () => {
257
260
 
258
261
  const mount = document.getElementById('app');
259
262
  installClientLog({ send: api.clientLog });
263
+ installDialogFocus();
260
264
  createRoot(mount).render(el(Root));