@velocelab/message 0.0.0-stage → 0.1.2
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/dist/frontend/message.js +254 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +90 -0
- package/dist/translations.d.ts +67 -0
- package/dist/translations.js +15 -0
- package/frontend/index.tsx +39 -0
- package/frontend/page.tsx +37 -0
- package/package.json +17 -4
- package/src/index.ts +57 -0
- package/src/translations.ts +15 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +2 -0
- package/README.md +0 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { Bell as e, Button as t, Check as n, Fragment as r, Send as i, Sheet as a, SheetContent as o, SheetHeader as s, SheetTitle as c, Switch as l, Trash2 as u, api as d, jsx as f, jsxs as p, useEffect as m, useI18n as h, useState as g } from "/dashboard-client.js";
|
|
2
|
+
//#region frontend/page.tsx
|
|
3
|
+
function _() {
|
|
4
|
+
let { language: r, t: a } = h(), [o, s] = g([]), [c, _] = g([]), [v, y] = g([]), b = async () => {
|
|
5
|
+
try {
|
|
6
|
+
s((await d.get("/user/messages")).data.messages || []);
|
|
7
|
+
} catch {
|
|
8
|
+
s([]);
|
|
9
|
+
}
|
|
10
|
+
}, x = async () => {
|
|
11
|
+
let e = await d.get("/user/messages/types");
|
|
12
|
+
_(e.data.types || []), y(e.data.preferences || []);
|
|
13
|
+
};
|
|
14
|
+
m(() => {
|
|
15
|
+
b(), x();
|
|
16
|
+
}, []);
|
|
17
|
+
let S = async (e) => {
|
|
18
|
+
await d.post("/user/messages/read", { id: e }), await b();
|
|
19
|
+
}, C = async (e) => {
|
|
20
|
+
await d.post("/user/messages/delete", { id: e }), await b();
|
|
21
|
+
}, w = async () => {
|
|
22
|
+
await d.post("/user/messages/test", { language: r }), await b();
|
|
23
|
+
}, T = (e, t = "") => v.find((n) => n.plugin === e && n.type === t)?.enabled ?? !0, E = async (e, t, n) => {
|
|
24
|
+
await d.post("/user/messages/preferences", {
|
|
25
|
+
plugin: e,
|
|
26
|
+
type: t,
|
|
27
|
+
enabled: n
|
|
28
|
+
}), await x();
|
|
29
|
+
}, D = [...new Set(c.map((e) => e.plugin))], O = (e) => e === "message" ? a("message.title") : e, k = (e) => e.labelKey ? a(e.labelKey) : e.label, A = new Map(c.map((e) => [e.id, e])), j = o.reduce((e, t) => {
|
|
30
|
+
let n = A.get(t.type), r = `${n?.plugin || t.source}::${n ? k(n) : a("message.other")}`;
|
|
31
|
+
return e[r] = [...e[r] || [], t], e;
|
|
32
|
+
}, {});
|
|
33
|
+
return /* @__PURE__ */ p("div", {
|
|
34
|
+
className: "mx-auto max-w-2xl space-y-6",
|
|
35
|
+
children: [
|
|
36
|
+
/* @__PURE__ */ p("div", {
|
|
37
|
+
className: "flex flex-wrap items-start justify-between gap-4",
|
|
38
|
+
children: [/* @__PURE__ */ p("div", { children: [/* @__PURE__ */ f("h1", {
|
|
39
|
+
className: "text-3xl font-bold",
|
|
40
|
+
children: a("message.title")
|
|
41
|
+
}), /* @__PURE__ */ f("p", {
|
|
42
|
+
className: "mt-2 text-sm text-muted-foreground",
|
|
43
|
+
children: a("message.description")
|
|
44
|
+
})] }), /* @__PURE__ */ p(t, {
|
|
45
|
+
variant: "outline",
|
|
46
|
+
onClick: () => void w(),
|
|
47
|
+
children: [/* @__PURE__ */ f(i, { size: 16 }), a("message.sendTest")]
|
|
48
|
+
})]
|
|
49
|
+
}),
|
|
50
|
+
/* @__PURE__ */ p("section", {
|
|
51
|
+
className: "space-y-3",
|
|
52
|
+
children: [/* @__PURE__ */ f("h2", {
|
|
53
|
+
className: "text-lg font-semibold",
|
|
54
|
+
children: a("message.types")
|
|
55
|
+
}), D.map((e) => /* @__PURE__ */ p("div", {
|
|
56
|
+
className: "rounded-lg border p-4",
|
|
57
|
+
children: [/* @__PURE__ */ p("div", {
|
|
58
|
+
className: "flex items-center justify-between gap-3",
|
|
59
|
+
children: [/* @__PURE__ */ f("h3", {
|
|
60
|
+
className: "min-w-0 break-words font-medium",
|
|
61
|
+
children: O(e)
|
|
62
|
+
}), /* @__PURE__ */ f(l, {
|
|
63
|
+
"aria-label": a("message.enablePlugin", { plugin: O(e) }),
|
|
64
|
+
checked: T(e),
|
|
65
|
+
onCheckedChange: (t) => void E(e, "", t)
|
|
66
|
+
})]
|
|
67
|
+
}), /* @__PURE__ */ f("div", {
|
|
68
|
+
className: "mt-3 divide-y",
|
|
69
|
+
children: c.filter((t) => t.plugin === e).map((t) => /* @__PURE__ */ p("div", {
|
|
70
|
+
className: "flex items-center justify-between gap-3 py-2",
|
|
71
|
+
children: [/* @__PURE__ */ f("span", {
|
|
72
|
+
className: "min-w-0 break-words text-sm text-muted-foreground",
|
|
73
|
+
children: k(t)
|
|
74
|
+
}), /* @__PURE__ */ f(l, {
|
|
75
|
+
"aria-label": a("message.enableType", { type: k(t) }),
|
|
76
|
+
checked: T(e, t.id),
|
|
77
|
+
disabled: !T(e),
|
|
78
|
+
onCheckedChange: (n) => void E(e, t.id, n)
|
|
79
|
+
})]
|
|
80
|
+
}, t.id))
|
|
81
|
+
})]
|
|
82
|
+
}, e))]
|
|
83
|
+
}),
|
|
84
|
+
/* @__PURE__ */ f("div", {
|
|
85
|
+
className: "space-y-5",
|
|
86
|
+
children: o.length === 0 ? /* @__PURE__ */ f("div", {
|
|
87
|
+
className: "rounded-lg border py-16 text-center text-sm text-muted-foreground",
|
|
88
|
+
children: a("message.empty")
|
|
89
|
+
}) : Object.entries(j).map(([r, i]) => {
|
|
90
|
+
let [o, s] = r.split("::");
|
|
91
|
+
return /* @__PURE__ */ p("section", {
|
|
92
|
+
className: "space-y-2",
|
|
93
|
+
children: [/* @__PURE__ */ p("h2", {
|
|
94
|
+
className: "text-sm font-semibold",
|
|
95
|
+
children: [
|
|
96
|
+
O(o),
|
|
97
|
+
" ",
|
|
98
|
+
/* @__PURE__ */ p("span", {
|
|
99
|
+
className: "font-normal text-muted-foreground",
|
|
100
|
+
children: ["/ ", s]
|
|
101
|
+
})
|
|
102
|
+
]
|
|
103
|
+
}), i.map((r) => /* @__PURE__ */ p("div", {
|
|
104
|
+
className: "flex items-center gap-3 rounded-lg border p-4",
|
|
105
|
+
children: [
|
|
106
|
+
/* @__PURE__ */ f("span", {
|
|
107
|
+
className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted",
|
|
108
|
+
children: /* @__PURE__ */ f(e, { size: 16 })
|
|
109
|
+
}),
|
|
110
|
+
/* @__PURE__ */ p("div", {
|
|
111
|
+
className: "min-w-0 flex-1",
|
|
112
|
+
children: [/* @__PURE__ */ f("p", {
|
|
113
|
+
className: "truncate font-medium",
|
|
114
|
+
children: r.title
|
|
115
|
+
}), /* @__PURE__ */ f("p", {
|
|
116
|
+
className: "mt-1 text-sm text-muted-foreground",
|
|
117
|
+
children: r.subtitle || O(r.source)
|
|
118
|
+
})]
|
|
119
|
+
}),
|
|
120
|
+
!r.read && /* @__PURE__ */ f(t, {
|
|
121
|
+
variant: "ghost",
|
|
122
|
+
size: "icon",
|
|
123
|
+
"aria-label": a("message.markRead"),
|
|
124
|
+
title: a("message.markRead"),
|
|
125
|
+
onClick: () => void S(r.id),
|
|
126
|
+
children: /* @__PURE__ */ f(n, { size: 16 })
|
|
127
|
+
}),
|
|
128
|
+
/* @__PURE__ */ f(t, {
|
|
129
|
+
variant: "ghost",
|
|
130
|
+
size: "icon",
|
|
131
|
+
"aria-label": a("message.delete"),
|
|
132
|
+
title: a("message.delete"),
|
|
133
|
+
onClick: () => void C(r.id),
|
|
134
|
+
children: /* @__PURE__ */ f(u, { size: 16 })
|
|
135
|
+
})
|
|
136
|
+
]
|
|
137
|
+
}, r.id))]
|
|
138
|
+
}, r);
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
]
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region frontend/index.tsx
|
|
146
|
+
function v(e) {
|
|
147
|
+
if (!e || typeof window > "u") return !1;
|
|
148
|
+
try {
|
|
149
|
+
let t = new URL(e, window.location.origin), n = new URL(window.location.href), r = (e) => e.length > 1 ? e.replace(/\/+$/, "") : e;
|
|
150
|
+
return t.origin === n.origin && r(t.pathname) === r(n.pathname);
|
|
151
|
+
} catch {
|
|
152
|
+
return !1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function y() {
|
|
156
|
+
let { t: n } = h(), [i, l] = g(!1), [_, y] = g([]), b = async () => {
|
|
157
|
+
try {
|
|
158
|
+
let e = (await d.get("/user/messages")).data.messages || [], t = e.filter((e) => v(e.action?.href));
|
|
159
|
+
t.length && await Promise.allSettled(t.map((e) => d.post("/user/messages/delete", { id: e.id }))), y(e.filter((e) => !v(e.action?.href)));
|
|
160
|
+
} catch {
|
|
161
|
+
y([]);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
m(() => {
|
|
165
|
+
b();
|
|
166
|
+
let e = window.setInterval(() => void b(), 1e4);
|
|
167
|
+
return () => window.clearInterval(e);
|
|
168
|
+
}, []);
|
|
169
|
+
let x = async (e) => {
|
|
170
|
+
e.action?.href ? await d.post("/user/messages/delete", { id: e.id }) : await d.post("/user/messages/read", { id: e.id }), y((t) => t.filter((t) => t.id !== e.id)), l(!1), e.action?.href && window.location.assign(e.action.href);
|
|
171
|
+
}, S = async (e) => {
|
|
172
|
+
await d.post("/user/messages/delete", { id: e }), await b();
|
|
173
|
+
}, C = (t) => /* @__PURE__ */ f(e, { size: 16 });
|
|
174
|
+
return /* @__PURE__ */ p(r, { children: [/* @__PURE__ */ p(t, {
|
|
175
|
+
variant: "ghost",
|
|
176
|
+
size: "icon",
|
|
177
|
+
"aria-label": n("message.title"),
|
|
178
|
+
title: n("message.title"),
|
|
179
|
+
onClick: () => {
|
|
180
|
+
l(!0), b();
|
|
181
|
+
},
|
|
182
|
+
className: "relative",
|
|
183
|
+
children: [/* @__PURE__ */ f(e, { size: 18 }), _.some((e) => !e.read) && /* @__PURE__ */ f("span", { className: "absolute right-1 top-1 h-2 w-2 rounded-full bg-primary" })]
|
|
184
|
+
}), /* @__PURE__ */ f(a, {
|
|
185
|
+
open: i,
|
|
186
|
+
onOpenChange: l,
|
|
187
|
+
children: /* @__PURE__ */ p(o, {
|
|
188
|
+
side: "right",
|
|
189
|
+
className: "w-full sm:max-w-md",
|
|
190
|
+
children: [/* @__PURE__ */ f(s, { children: /* @__PURE__ */ p(c, {
|
|
191
|
+
className: "flex items-center gap-2",
|
|
192
|
+
children: [/* @__PURE__ */ f(e, { size: 18 }), n("message.title")]
|
|
193
|
+
}) }), /* @__PURE__ */ f("div", {
|
|
194
|
+
className: "mt-4 space-y-2 overflow-y-auto",
|
|
195
|
+
children: _.length === 0 ? /* @__PURE__ */ f("div", {
|
|
196
|
+
className: "py-16 text-center text-sm text-muted-foreground",
|
|
197
|
+
children: n("message.empty")
|
|
198
|
+
}) : _.map((e) => /* @__PURE__ */ p("div", {
|
|
199
|
+
className: "flex gap-3 rounded-lg border p-3",
|
|
200
|
+
children: [/* @__PURE__ */ p("button", {
|
|
201
|
+
className: "flex min-w-0 flex-1 gap-3 text-left",
|
|
202
|
+
onClick: () => void x(e),
|
|
203
|
+
children: [/* @__PURE__ */ f("span", {
|
|
204
|
+
className: "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted",
|
|
205
|
+
children: C(e)
|
|
206
|
+
}), /* @__PURE__ */ p("span", {
|
|
207
|
+
className: "min-w-0",
|
|
208
|
+
children: [
|
|
209
|
+
/* @__PURE__ */ f("span", {
|
|
210
|
+
className: "block truncate text-sm font-medium",
|
|
211
|
+
children: e.title
|
|
212
|
+
}),
|
|
213
|
+
/* @__PURE__ */ f("span", {
|
|
214
|
+
className: "mt-1 block text-xs text-muted-foreground",
|
|
215
|
+
children: e.subtitle
|
|
216
|
+
}),
|
|
217
|
+
/* @__PURE__ */ f("span", {
|
|
218
|
+
className: "mt-2 block text-[11px] text-muted-foreground",
|
|
219
|
+
children: n("message.from", { source: e.source === "message" ? n("message.title") : e.source })
|
|
220
|
+
})
|
|
221
|
+
]
|
|
222
|
+
})]
|
|
223
|
+
}), /* @__PURE__ */ f(t, {
|
|
224
|
+
variant: "ghost",
|
|
225
|
+
size: "icon",
|
|
226
|
+
"aria-label": n("message.delete"),
|
|
227
|
+
title: n("message.delete"),
|
|
228
|
+
onClick: () => void S(e.id),
|
|
229
|
+
children: /* @__PURE__ */ f(u, { size: 15 })
|
|
230
|
+
})]
|
|
231
|
+
}, e.id))
|
|
232
|
+
})]
|
|
233
|
+
})
|
|
234
|
+
})] });
|
|
235
|
+
}
|
|
236
|
+
function b(t) {
|
|
237
|
+
t.slot("topbar.actions", y, "message.button", 30), t.page({
|
|
238
|
+
frame: "settings",
|
|
239
|
+
path: "/settings/messages",
|
|
240
|
+
component: _,
|
|
241
|
+
minimumPermissionLevel: 10,
|
|
242
|
+
nav: {
|
|
243
|
+
id: "message.settings",
|
|
244
|
+
labelKey: "message.title",
|
|
245
|
+
icon: e,
|
|
246
|
+
order: 35,
|
|
247
|
+
minimumPermissionLevel: 10,
|
|
248
|
+
scope: "settings",
|
|
249
|
+
group: "general"
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
export { y as MessageButton, b as apply, b as default };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Context, Service } from "yumeri";
|
|
2
|
+
import "@velocelab/dashboard";
|
|
3
|
+
import "@velocelab/database-core";
|
|
4
|
+
import "@velocelab/notification";
|
|
5
|
+
export declare const depend: string[];
|
|
6
|
+
export declare const provide: string[];
|
|
7
|
+
export interface MessageAction {
|
|
8
|
+
href?: string;
|
|
9
|
+
method?: "GET" | "POST" | "PUT" | "DELETE";
|
|
10
|
+
body?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface MessageType {
|
|
13
|
+
id: string;
|
|
14
|
+
plugin: string;
|
|
15
|
+
label: string;
|
|
16
|
+
labelKey?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface MessageInput {
|
|
19
|
+
userId: number;
|
|
20
|
+
type: string;
|
|
21
|
+
dedupeKey: string;
|
|
22
|
+
icon?: string;
|
|
23
|
+
title: string;
|
|
24
|
+
subtitle?: string;
|
|
25
|
+
action?: MessageAction;
|
|
26
|
+
}
|
|
27
|
+
export interface MessageRecord extends MessageInput {
|
|
28
|
+
id: number;
|
|
29
|
+
source: string;
|
|
30
|
+
read: boolean;
|
|
31
|
+
created_at: string;
|
|
32
|
+
updated_at: string;
|
|
33
|
+
}
|
|
34
|
+
interface StoredMessage {
|
|
35
|
+
id?: number;
|
|
36
|
+
user_id: number;
|
|
37
|
+
type?: string;
|
|
38
|
+
dedupe_key: string;
|
|
39
|
+
icon: string;
|
|
40
|
+
title: string;
|
|
41
|
+
subtitle: string;
|
|
42
|
+
action: string;
|
|
43
|
+
source: string;
|
|
44
|
+
read: boolean;
|
|
45
|
+
created_at: string;
|
|
46
|
+
updated_at: string;
|
|
47
|
+
}
|
|
48
|
+
interface MessagePreference {
|
|
49
|
+
id?: number;
|
|
50
|
+
user_id: number;
|
|
51
|
+
plugin: string;
|
|
52
|
+
type: string;
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
}
|
|
55
|
+
declare module "@yumerijs/types" {
|
|
56
|
+
interface Tables {
|
|
57
|
+
messages: StoredMessage;
|
|
58
|
+
message_preferences: MessagePreference;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
declare module "yumeri" {
|
|
62
|
+
interface Components {
|
|
63
|
+
message: MessageService;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export declare class MessageService extends Service {
|
|
67
|
+
private readonly typesById;
|
|
68
|
+
private readonly db;
|
|
69
|
+
private readonly notification;
|
|
70
|
+
constructor(ctx: Context);
|
|
71
|
+
registerType(owner: Context, type: MessageType): void;
|
|
72
|
+
types(): MessageType[];
|
|
73
|
+
preferences(userId: number): Promise<Pick<MessagePreference, keyof MessagePreference>[]>;
|
|
74
|
+
setEnabled(userId: number, plugin: string, type: string, enabled: boolean): Promise<void>;
|
|
75
|
+
send(input: MessageInput): Promise<MessageRecord | undefined>;
|
|
76
|
+
remove(userId: number, id: number): Promise<void>;
|
|
77
|
+
markRead(userId: number, id: number): Promise<void>;
|
|
78
|
+
list(userId: number): Promise<MessageRecord[]>;
|
|
79
|
+
}
|
|
80
|
+
export declare function apply(ctx: Context): Promise<void>;
|
|
81
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Service } from "yumeri";
|
|
2
|
+
import "@velocelab/dashboard";
|
|
3
|
+
import "@velocelab/database-core";
|
|
4
|
+
import "@velocelab/notification";
|
|
5
|
+
import { messageTranslations } from "./translations.js";
|
|
6
|
+
export const depend = ["database", "dashboard", "notification"];
|
|
7
|
+
export const provide = ["message"];
|
|
8
|
+
const currentUser = (session) => session.properties.user?.id;
|
|
9
|
+
const registries = new WeakMap();
|
|
10
|
+
const initialized = new WeakSet();
|
|
11
|
+
const record = (row) => ({ id: Number(row.id), userId: row.user_id, type: row.type || `${row.source}:legacy`, dedupeKey: row.dedupe_key, icon: row.icon, title: row.title, subtitle: row.subtitle, action: row.action ? JSON.parse(row.action) : undefined, source: row.source, read: Boolean(row.read), created_at: row.created_at, updated_at: row.updated_at });
|
|
12
|
+
export class MessageService extends Service {
|
|
13
|
+
typesById;
|
|
14
|
+
db;
|
|
15
|
+
notification;
|
|
16
|
+
constructor(ctx) { super(ctx); const core = ctx.getCore(); this.typesById = registries.get(core) ?? new Map(); registries.set(core, this.typesById); this.db = ctx.component.database; this.notification = ctx.component.notification; }
|
|
17
|
+
registerType(owner, type) { if (this.typesById.has(type.id))
|
|
18
|
+
throw new Error(`Message type already registered: ${type.id}`); this.typesById.set(type.id, type); owner.affect(() => { if (this.typesById.get(type.id) === type)
|
|
19
|
+
this.typesById.delete(type.id); }); }
|
|
20
|
+
types() { return [...this.typesById.values()]; }
|
|
21
|
+
async preferences(userId) { return await this.db.select("message_preferences", { user_id: userId }); }
|
|
22
|
+
async setEnabled(userId, plugin, type, enabled) { if (!this.types().some(item => item.plugin === plugin && (!type || item.id === type)))
|
|
23
|
+
throw Error("Unknown message type"); const key = { user_id: userId, plugin, type }; const existing = await this.db.selectOne("message_preferences", key); if (existing)
|
|
24
|
+
await this.db.update("message_preferences", key, { enabled });
|
|
25
|
+
else
|
|
26
|
+
await this.db.create("message_preferences", { ...key, enabled }); }
|
|
27
|
+
async send(input) { const type = this.typesById.get(input.type); if (!type)
|
|
28
|
+
throw Error(`Message type is not registered: ${input.type}`); const prefs = await this.preferences(input.userId); if (prefs.some(item => item.plugin === type.plugin && !item.enabled && (!item.type || item.type === type.id)))
|
|
29
|
+
return; const now = new Date().toISOString(); const action = input.action ? JSON.stringify(input.action) : ""; const key = { user_id: input.userId, type: type.id, dedupe_key: `${type.id}:${input.dedupeKey}` }; const data = { icon: input.icon || "bell", title: input.title, subtitle: input.subtitle || "", action, source: type.plugin, read: false, updated_at: now }; const existing = await this.db.selectOne("messages", key); let result; if (existing) {
|
|
30
|
+
await this.db.update("messages", { id: existing.id }, data);
|
|
31
|
+
result = record({ ...existing, ...data, type: type.id });
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
result = record(await this.db.create("messages", { ...key, ...data, created_at: now })); await this.notification.send({ userId: input.userId, title: input.title, body: input.subtitle, tag: `message-${type.id}-${input.dedupeKey}`, url: input.action?.href }); return result; }
|
|
35
|
+
async remove(userId, id) { await this.db.remove("messages", { id, user_id: userId }); }
|
|
36
|
+
async markRead(userId, id) { await this.db.update("messages", { id, user_id: userId }, { read: true, updated_at: new Date().toISOString() }); }
|
|
37
|
+
async list(userId) { return (await this.db.select("messages", { user_id: userId })).sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).map(record); }
|
|
38
|
+
}
|
|
39
|
+
export async function apply(ctx) {
|
|
40
|
+
const core = ctx.getCore();
|
|
41
|
+
if (initialized.has(core))
|
|
42
|
+
return;
|
|
43
|
+
for (const [key, translations] of Object.entries(messageTranslations))
|
|
44
|
+
ctx.i18n(key, translations);
|
|
45
|
+
const db = ctx.component.database;
|
|
46
|
+
await db.extend("messages", { id: { type: "integer", autoIncrement: true }, user_id: { type: "integer", nullable: false }, type: "string", dedupe_key: { type: "string", nullable: false }, icon: { type: "string", initial: "bell" }, title: { type: "string", nullable: false }, subtitle: "string", action: "json", source: { type: "string", nullable: false }, read: { type: "boolean", initial: false }, created_at: "timestamp", updated_at: "timestamp" }, { unique: [["user_id", "dedupe_key"]] });
|
|
47
|
+
await db.extend("message_preferences", { id: { type: "integer", autoIncrement: true }, user_id: { type: "integer", nullable: false }, plugin: { type: "string", nullable: false }, type: { type: "string", nullable: false }, enabled: { type: "boolean", initial: true } }, { unique: [["user_id", "plugin", "type"]] });
|
|
48
|
+
if (!ctx.getCore().services.message)
|
|
49
|
+
ctx.registerService("message", MessageService);
|
|
50
|
+
const service = ctx.component.message ?? new MessageService(ctx);
|
|
51
|
+
service.registerType(ctx, { id: "message:test", plugin: "message", label: "测试消息", labelKey: "message.testTitle" });
|
|
52
|
+
ctx.component.dashboard.addEntry({ dev: new URL("../frontend/index.tsx", import.meta.url).pathname, prod: new URL("./frontend/message.js", import.meta.url).pathname, plugin: "message" });
|
|
53
|
+
ctx.route("/api/user/messages").methods("GET").action(async (session) => { const id = currentUser(session); if (id)
|
|
54
|
+
session.respond({ messages: await service.list(id) }, "json"); });
|
|
55
|
+
ctx.route("/api/user/messages/types").methods("GET").action(async (session) => { const id = currentUser(session); if (id)
|
|
56
|
+
session.respond({ types: service.types(), preferences: await service.preferences(id) }, "json"); });
|
|
57
|
+
ctx.route("/api/user/messages/preferences").methods("POST").action(async (session) => { const id = currentUser(session); const body = await session.parseRequestBody(); if (id && typeof body?.plugin === "string" && typeof body?.enabled === "boolean") {
|
|
58
|
+
await service.setEnabled(id, body.plugin, String(body.type || ""), body.enabled);
|
|
59
|
+
session.respond({ ok: true }, "json");
|
|
60
|
+
} });
|
|
61
|
+
ctx.route("/api/user/messages/delete").methods("POST").action(async (session) => { const id = currentUser(session); const body = await session.parseRequestBody(); if (id && Number(body?.id)) {
|
|
62
|
+
await service.remove(id, Number(body.id));
|
|
63
|
+
session.respond({ ok: true }, "json");
|
|
64
|
+
} });
|
|
65
|
+
ctx.route("/api/user/messages/read").methods("POST").action(async (session) => { const id = currentUser(session); const body = await session.parseRequestBody(); if (id && Number(body?.id)) {
|
|
66
|
+
await service.markRead(id, Number(body.id));
|
|
67
|
+
session.respond({ ok: true }, "json");
|
|
68
|
+
} });
|
|
69
|
+
ctx.route("/api/user/messages/test").methods("POST").action(async (session) => { const id = currentUser(session); if (!id) {
|
|
70
|
+
session.status = 401;
|
|
71
|
+
session.respond({ error: "unauthorized" }, "json");
|
|
72
|
+
return;
|
|
73
|
+
} try {
|
|
74
|
+
const body = await session.parseRequestBody();
|
|
75
|
+
const language = body?.language === "en" || body?.language === "ja" ? body.language : "zh";
|
|
76
|
+
const message = await service.send({ userId: id, type: "message:test", dedupeKey: `test-${Date.now()}`, title: messageTranslations["message.testTitle"][language], subtitle: messageTranslations["message.testBody"][language] });
|
|
77
|
+
if (!message) {
|
|
78
|
+
session.status = 403;
|
|
79
|
+
session.respond({ error: "test message type is disabled" }, "json");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
session.respond({ message }, "json");
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
session.status = 400;
|
|
86
|
+
session.respond({ error: error instanceof Error ? error.message : String(error) }, "json");
|
|
87
|
+
} });
|
|
88
|
+
initialized.add(core);
|
|
89
|
+
ctx.affect(() => { initialized.delete(core); });
|
|
90
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export declare const messageTranslations: {
|
|
2
|
+
readonly "message.title": {
|
|
3
|
+
readonly zh: "消息";
|
|
4
|
+
readonly en: "Messages";
|
|
5
|
+
readonly ja: "メッセージ";
|
|
6
|
+
};
|
|
7
|
+
readonly "message.description": {
|
|
8
|
+
readonly zh: "查看和管理来自 Veloce 各功能的消息。";
|
|
9
|
+
readonly en: "View and manage messages from Veloce features.";
|
|
10
|
+
readonly ja: "Veloce の各機能からのメッセージを確認・管理します。";
|
|
11
|
+
};
|
|
12
|
+
readonly "message.empty": {
|
|
13
|
+
readonly zh: "暂无消息";
|
|
14
|
+
readonly en: "No messages";
|
|
15
|
+
readonly ja: "メッセージはありません";
|
|
16
|
+
};
|
|
17
|
+
readonly "message.from": {
|
|
18
|
+
readonly zh: "来自 {source}";
|
|
19
|
+
readonly en: "From {source}";
|
|
20
|
+
readonly ja: "送信元: {source}";
|
|
21
|
+
};
|
|
22
|
+
readonly "message.delete": {
|
|
23
|
+
readonly zh: "删除消息";
|
|
24
|
+
readonly en: "Delete message";
|
|
25
|
+
readonly ja: "メッセージを削除";
|
|
26
|
+
};
|
|
27
|
+
readonly "message.markRead": {
|
|
28
|
+
readonly zh: "标记已读";
|
|
29
|
+
readonly en: "Mark as read";
|
|
30
|
+
readonly ja: "既読にする";
|
|
31
|
+
};
|
|
32
|
+
readonly "message.types": {
|
|
33
|
+
readonly zh: "消息类型";
|
|
34
|
+
readonly en: "Message types";
|
|
35
|
+
readonly ja: "メッセージの種類";
|
|
36
|
+
};
|
|
37
|
+
readonly "message.other": {
|
|
38
|
+
readonly zh: "其他消息";
|
|
39
|
+
readonly en: "Other messages";
|
|
40
|
+
readonly ja: "その他のメッセージ";
|
|
41
|
+
};
|
|
42
|
+
readonly "message.sendTest": {
|
|
43
|
+
readonly zh: "发送测试消息";
|
|
44
|
+
readonly en: "Send test message";
|
|
45
|
+
readonly ja: "テストメッセージを送信";
|
|
46
|
+
};
|
|
47
|
+
readonly "message.testTitle": {
|
|
48
|
+
readonly zh: "测试消息";
|
|
49
|
+
readonly en: "Test message";
|
|
50
|
+
readonly ja: "テストメッセージ";
|
|
51
|
+
};
|
|
52
|
+
readonly "message.testBody": {
|
|
53
|
+
readonly zh: "这是一条测试消息。";
|
|
54
|
+
readonly en: "This is a test message.";
|
|
55
|
+
readonly ja: "これはテストメッセージです。";
|
|
56
|
+
};
|
|
57
|
+
readonly "message.enablePlugin": {
|
|
58
|
+
readonly zh: "启用 {plugin} 的消息";
|
|
59
|
+
readonly en: "Enable messages from {plugin}";
|
|
60
|
+
readonly ja: "{plugin} のメッセージを有効にする";
|
|
61
|
+
};
|
|
62
|
+
readonly "message.enableType": {
|
|
63
|
+
readonly zh: "启用{type}";
|
|
64
|
+
readonly en: "Enable {type}";
|
|
65
|
+
readonly ja: "{type}を有効にする";
|
|
66
|
+
};
|
|
67
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const messageTranslations = {
|
|
2
|
+
"message.title": { zh: "消息", en: "Messages", ja: "メッセージ" },
|
|
3
|
+
"message.description": { zh: "查看和管理来自 Veloce 各功能的消息。", en: "View and manage messages from Veloce features.", ja: "Veloce の各機能からのメッセージを確認・管理します。" },
|
|
4
|
+
"message.empty": { zh: "暂无消息", en: "No messages", ja: "メッセージはありません" },
|
|
5
|
+
"message.from": { zh: "来自 {source}", en: "From {source}", ja: "送信元: {source}" },
|
|
6
|
+
"message.delete": { zh: "删除消息", en: "Delete message", ja: "メッセージを削除" },
|
|
7
|
+
"message.markRead": { zh: "标记已读", en: "Mark as read", ja: "既読にする" },
|
|
8
|
+
"message.types": { zh: "消息类型", en: "Message types", ja: "メッセージの種類" },
|
|
9
|
+
"message.other": { zh: "其他消息", en: "Other messages", ja: "その他のメッセージ" },
|
|
10
|
+
"message.sendTest": { zh: "发送测试消息", en: "Send test message", ja: "テストメッセージを送信" },
|
|
11
|
+
"message.testTitle": { zh: "测试消息", en: "Test message", ja: "テストメッセージ" },
|
|
12
|
+
"message.testBody": { zh: "这是一条测试消息。", en: "This is a test message.", ja: "これはテストメッセージです。" },
|
|
13
|
+
"message.enablePlugin": { zh: "启用 {plugin} 的消息", en: "Enable messages from {plugin}", ja: "{plugin} のメッセージを有効にする" },
|
|
14
|
+
"message.enableType": { zh: "启用{type}", en: "Enable {type}", ja: "{type}を有効にする" },
|
|
15
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Bell, Trash2 } from "lucide-react"
|
|
2
|
+
import { useEffect, useState } from "react"
|
|
3
|
+
import type { DashboardContext } from "@velocelab/dashboard/frontend"
|
|
4
|
+
import { api, Button, Sheet, SheetContent, SheetHeader, SheetTitle } from "@velocelab/dashboard/frontend"
|
|
5
|
+
import MessageSettings from "./page"
|
|
6
|
+
import { useI18n } from "@/lib/i18n"
|
|
7
|
+
|
|
8
|
+
type Message = { id:number; icon?:string; title:string; subtitle?:string; source:string; read:boolean; action?:{href?:string; method?:string; body?:Record<string,unknown>} }
|
|
9
|
+
|
|
10
|
+
function targetsCurrentPath(href?: string) {
|
|
11
|
+
if (!href || typeof window === "undefined") return false
|
|
12
|
+
try {
|
|
13
|
+
const target = new URL(href, window.location.origin)
|
|
14
|
+
const current = new URL(window.location.href)
|
|
15
|
+
const normalize = (pathname: string) => pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname
|
|
16
|
+
return target.origin === current.origin && normalize(target.pathname) === normalize(current.pathname)
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function MessageButton() {
|
|
23
|
+
const { t } = useI18n()
|
|
24
|
+
const [open,setOpen]=useState(false); const [messages,setMessages]=useState<Message[]>([])
|
|
25
|
+
const load=async()=>{try{const response=await api.get("/user/messages"); const next=(response.data.messages||[]) as Message[]; const reached=next.filter((message) => targetsCurrentPath(message.action?.href)); if (reached.length) await Promise.allSettled(reached.map((message) => api.post("/user/messages/delete",{id:message.id}))); setMessages(next.filter((message) => !targetsCurrentPath(message.action?.href)))}catch{setMessages([])}}
|
|
26
|
+
useEffect(() => { void load(); const timer = window.setInterval(() => void load(), 10000); return () => window.clearInterval(timer) }, [])
|
|
27
|
+
const activate=async(message:Message)=>{if (message.action?.href) await api.post("/user/messages/delete",{id:message.id}); else await api.post("/user/messages/read",{id:message.id}); setMessages((current) => current.filter((item) => item.id !== message.id)); setOpen(false); if(message.action?.href) window.location.assign(message.action.href)}
|
|
28
|
+
const remove=async(id:number)=>{await api.post("/user/messages/delete",{id}); await load()}
|
|
29
|
+
const icon=(_message:Message)=><Bell size={16}/>
|
|
30
|
+
return <>
|
|
31
|
+
<Button variant="ghost" size="icon" aria-label={t("message.title")} title={t("message.title")} onClick={()=>{setOpen(true); void load()}} className="relative"><Bell size={18}/>{messages.some(m=>!m.read)&&<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"/>}</Button>
|
|
32
|
+
<Sheet open={open} onOpenChange={setOpen}><SheetContent side="right" className="w-full sm:max-w-md"><SheetHeader><SheetTitle className="flex items-center gap-2"><Bell size={18}/>{t("message.title")}</SheetTitle></SheetHeader><div className="mt-4 space-y-2 overflow-y-auto">{messages.length===0?<div className="py-16 text-center text-sm text-muted-foreground">{t("message.empty")}</div>:messages.map(message=><div key={message.id} className="flex gap-3 rounded-lg border p-3"><button className="flex min-w-0 flex-1 gap-3 text-left" onClick={()=>void activate(message)}><span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">{icon(message)}</span><span className="min-w-0"><span className="block truncate text-sm font-medium">{message.title}</span><span className="mt-1 block text-xs text-muted-foreground">{message.subtitle}</span><span className="mt-2 block text-[11px] text-muted-foreground">{t("message.from", { source: message.source === "message" ? t("message.title") : message.source })}</span></span></button><Button variant="ghost" size="icon" aria-label={t("message.delete")} title={t("message.delete")} onClick={()=>void remove(message.id)}><Trash2 size={15}/></Button></div>)}</div></SheetContent></Sheet>
|
|
33
|
+
</>
|
|
34
|
+
}
|
|
35
|
+
export function apply(ctx: DashboardContext) {
|
|
36
|
+
ctx.slot("topbar.actions", MessageButton, "message.button", 30)
|
|
37
|
+
ctx.page({ frame: "settings", path: "/settings/messages", component: MessageSettings, minimumPermissionLevel: 10, nav: { id: "message.settings", labelKey: "message.title", icon: Bell, order: 35, minimumPermissionLevel: 10, scope: "settings", group: "general" } })
|
|
38
|
+
}
|
|
39
|
+
export default apply
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Bell, Check, Send, Trash2 } from "lucide-react"
|
|
2
|
+
import { useEffect, useState } from "react"
|
|
3
|
+
import { api, Button, Switch } from "@velocelab/dashboard/frontend"
|
|
4
|
+
import { useI18n } from "@/lib/i18n"
|
|
5
|
+
|
|
6
|
+
type Message = { id: number; title: string; subtitle?: string; source: string; type: string; read: boolean }
|
|
7
|
+
type MessageType = { id: string; plugin: string; label: string; labelKey?: string }
|
|
8
|
+
|
|
9
|
+
export default function MessageSettings() {
|
|
10
|
+
const { language, t } = useI18n()
|
|
11
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
12
|
+
const [types, setTypes] = useState<MessageType[]>([])
|
|
13
|
+
const [preferences, setPreferences] = useState<Array<{ plugin: string; type: string; enabled: boolean }>>([])
|
|
14
|
+
const load = async () => { try { setMessages((await api.get("/user/messages")).data.messages || []) } catch { setMessages([]) } }
|
|
15
|
+
const loadTypes = async () => { const response = await api.get("/user/messages/types"); setTypes(response.data.types || []); setPreferences(response.data.preferences || []) }
|
|
16
|
+
useEffect(() => { void load(); void loadTypes() }, [])
|
|
17
|
+
const markRead = async (id: number) => { await api.post("/user/messages/read", { id }); await load() }
|
|
18
|
+
const remove = async (id: number) => { await api.post("/user/messages/delete", { id }); await load() }
|
|
19
|
+
const test = async () => { await api.post("/user/messages/test", { language }); await load() }
|
|
20
|
+
const enabled = (plugin: string, type = "") => preferences.find(item => item.plugin === plugin && item.type === type)?.enabled ?? true
|
|
21
|
+
const setEnabled = async (plugin: string, type: string, value: boolean) => { await api.post("/user/messages/preferences", { plugin, type, enabled: value }); await loadTypes() }
|
|
22
|
+
const groups = [...new Set(types.map(type => type.plugin))]
|
|
23
|
+
const pluginLabel = (plugin: string) => plugin === "message" ? t("message.title") : plugin
|
|
24
|
+
const typeLabel = (type: MessageType) => type.labelKey ? t(type.labelKey) : type.label
|
|
25
|
+
const typeById = new Map(types.map(type => [type.id, type]))
|
|
26
|
+
const messageGroups = messages.reduce<Record<string, Message[]>>((result, message) => {
|
|
27
|
+
const type = typeById.get(message.type)
|
|
28
|
+
const key = `${type?.plugin || message.source}::${type ? typeLabel(type) : t("message.other")}`
|
|
29
|
+
result[key] = [...(result[key] || []), message]
|
|
30
|
+
return result
|
|
31
|
+
}, {})
|
|
32
|
+
return <div className="mx-auto max-w-2xl space-y-6">
|
|
33
|
+
<div className="flex flex-wrap items-start justify-between gap-4"><div><h1 className="text-3xl font-bold">{t("message.title")}</h1><p className="mt-2 text-sm text-muted-foreground">{t("message.description")}</p></div><Button variant="outline" onClick={() => void test()}><Send size={16} />{t("message.sendTest")}</Button></div>
|
|
34
|
+
<section className="space-y-3"><h2 className="text-lg font-semibold">{t("message.types")}</h2>{groups.map(plugin => <div key={plugin} className="rounded-lg border p-4"><div className="flex items-center justify-between gap-3"><h3 className="min-w-0 break-words font-medium">{pluginLabel(plugin)}</h3><Switch aria-label={t("message.enablePlugin", { plugin: pluginLabel(plugin) })} checked={enabled(plugin)} onCheckedChange={value => void setEnabled(plugin, "", value)} /></div><div className="mt-3 divide-y">{types.filter(type => type.plugin === plugin).map(type => <div key={type.id} className="flex items-center justify-between gap-3 py-2"><span className="min-w-0 break-words text-sm text-muted-foreground">{typeLabel(type)}</span><Switch aria-label={t("message.enableType", { type: typeLabel(type) })} checked={enabled(plugin, type.id)} disabled={!enabled(plugin)} onCheckedChange={value => void setEnabled(plugin, type.id, value)} /></div>)}</div></div>)}</section>
|
|
35
|
+
<div className="space-y-5">{messages.length === 0 ? <div className="rounded-lg border py-16 text-center text-sm text-muted-foreground">{t("message.empty")}</div> : Object.entries(messageGroups).map(([key, items]) => { const [plugin, label] = key.split("::"); return <section key={key} className="space-y-2"><h2 className="text-sm font-semibold">{pluginLabel(plugin)} <span className="font-normal text-muted-foreground">/ {label}</span></h2>{items.map(message => <div key={message.id} className="flex items-center gap-3 rounded-lg border p-4"><span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted"><Bell size={16} /></span><div className="min-w-0 flex-1"><p className="truncate font-medium">{message.title}</p><p className="mt-1 text-sm text-muted-foreground">{message.subtitle || pluginLabel(message.source)}</p></div>{!message.read && <Button variant="ghost" size="icon" aria-label={t("message.markRead")} title={t("message.markRead")} onClick={() => void markRead(message.id)}><Check size={16} /></Button>}<Button variant="ghost" size="icon" aria-label={t("message.delete")} title={t("message.delete")} onClick={() => void remove(message.id)}><Trash2 size={16} /></Button></div>)}</section> })}</div>
|
|
36
|
+
</div>
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velocelab/message",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"dev": "src/index.ts",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "../../node_modules/.bin/tsc -p tsconfig.json && ../../node_modules/.bin/vite build"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@velocelab/dashboard": "^0.1.5",
|
|
13
|
+
"@velocelab/database-core": "^0.1.1",
|
|
14
|
+
"@velocelab/notification": "^0.1.2"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"yumeri": "^3.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Context, Core, Database, Service, Session } from "yumeri";
|
|
2
|
+
import "@velocelab/dashboard";
|
|
3
|
+
import "@velocelab/database-core";
|
|
4
|
+
import "@velocelab/notification";
|
|
5
|
+
import { messageTranslations } from "./translations.js";
|
|
6
|
+
|
|
7
|
+
export const depend = ["database", "dashboard", "notification"];
|
|
8
|
+
export const provide = ["message"];
|
|
9
|
+
export interface MessageAction { href?: string; method?: "GET" | "POST" | "PUT" | "DELETE"; body?: Record<string, unknown> }
|
|
10
|
+
export interface MessageType { id: string; plugin: string; label: string; labelKey?: string }
|
|
11
|
+
export interface MessageInput { userId: number; type: string; dedupeKey: string; icon?: string; title: string; subtitle?: string; action?: MessageAction }
|
|
12
|
+
export interface MessageRecord extends MessageInput { id: number; source: string; read: boolean; created_at: string; updated_at: string }
|
|
13
|
+
interface StoredMessage { id?: number; user_id: number; type?: string; dedupe_key: string; icon: string; title: string; subtitle: string; action: string; source: string; read: boolean; created_at: string; updated_at: string }
|
|
14
|
+
interface MessagePreference { id?: number; user_id: number; plugin: string; type: string; enabled: boolean }
|
|
15
|
+
interface NotificationService { send(input: { userId: number; title: string; body?: string; tag?: string; url?: string }): Promise<void> }
|
|
16
|
+
declare module "@yumerijs/types" { interface Tables { messages: StoredMessage; message_preferences: MessagePreference } }
|
|
17
|
+
declare module "yumeri" { interface Components { message: MessageService } }
|
|
18
|
+
const currentUser = (session: Session) => (session.properties.user as { id?: number } | undefined)?.id;
|
|
19
|
+
const registries = new WeakMap<Core, Map<string, MessageType>>();
|
|
20
|
+
const initialized = new WeakSet<Core>();
|
|
21
|
+
const record = (row: StoredMessage): MessageRecord => ({ id: Number(row.id), userId: row.user_id, type: row.type || `${row.source}:legacy`, dedupeKey: row.dedupe_key, icon: row.icon, title: row.title, subtitle: row.subtitle, action: row.action ? JSON.parse(row.action) : undefined, source: row.source, read: Boolean(row.read), created_at: row.created_at, updated_at: row.updated_at });
|
|
22
|
+
|
|
23
|
+
export class MessageService extends Service {
|
|
24
|
+
private readonly typesById: Map<string, MessageType>;
|
|
25
|
+
private readonly db: Database;
|
|
26
|
+
private readonly notification: NotificationService;
|
|
27
|
+
constructor(ctx: Context) { super(ctx); const core = ctx.getCore(); this.typesById = registries.get(core) ?? new Map(); registries.set(core, this.typesById); this.db = ctx.component.database as Database; this.notification = ctx.component.notification as NotificationService; }
|
|
28
|
+
registerType(owner: Context, type: MessageType) { if (this.typesById.has(type.id)) throw new Error(`Message type already registered: ${type.id}`); this.typesById.set(type.id, type); owner.affect(() => { if (this.typesById.get(type.id) === type) this.typesById.delete(type.id); }); }
|
|
29
|
+
types() { return [...this.typesById.values()]; }
|
|
30
|
+
async preferences(userId: number) { return await this.db.select("message_preferences", { user_id: userId }); }
|
|
31
|
+
async setEnabled(userId: number, plugin: string, type: string, enabled: boolean) { if (!this.types().some(item => item.plugin === plugin && (!type || item.id === type))) throw Error("Unknown message type"); const key = { user_id: userId, plugin, type }; const existing = await this.db.selectOne("message_preferences", key); if (existing) await this.db.update("message_preferences", key, { enabled }); else await this.db.create("message_preferences", { ...key, enabled }); }
|
|
32
|
+
async send(input: MessageInput): Promise<MessageRecord | undefined> { const type = this.typesById.get(input.type); if (!type) throw Error(`Message type is not registered: ${input.type}`); const prefs = await this.preferences(input.userId); if (prefs.some(item => item.plugin === type.plugin && !item.enabled && (!item.type || item.type === type.id))) return; const now = new Date().toISOString(); const action = input.action ? JSON.stringify(input.action) : ""; const key = { user_id: input.userId, type: type.id, dedupe_key: `${type.id}:${input.dedupeKey}` }; const data = { icon: input.icon || "bell", title: input.title, subtitle: input.subtitle || "", action, source: type.plugin, read: false, updated_at: now }; const existing = await this.db.selectOne("messages", key); let result: MessageRecord; if (existing) { await this.db.update("messages", { id: existing.id }, data); result = record({ ...existing, ...data, type: type.id }); } else result = record(await this.db.create("messages", { ...key, ...data, created_at: now })); await this.notification.send({ userId: input.userId, title: input.title, body: input.subtitle, tag: `message-${type.id}-${input.dedupeKey}`, url: input.action?.href }); return result; }
|
|
33
|
+
async remove(userId: number, id: number) { await this.db.remove("messages", { id, user_id: userId }); }
|
|
34
|
+
async markRead(userId: number, id: number) { await this.db.update("messages", { id, user_id: userId }, { read: true, updated_at: new Date().toISOString() }); }
|
|
35
|
+
async list(userId: number) { return (await this.db.select("messages", { user_id: userId })).sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).map(record); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function apply(ctx: Context) {
|
|
39
|
+
const core = ctx.getCore();
|
|
40
|
+
if (initialized.has(core)) return;
|
|
41
|
+
for (const [key, translations] of Object.entries(messageTranslations)) ctx.i18n(key, translations);
|
|
42
|
+
const db = ctx.component.database as Database;
|
|
43
|
+
await db.extend("messages", { id: { type: "integer", autoIncrement: true }, user_id: { type: "integer", nullable: false }, type: "string", dedupe_key: { type: "string", nullable: false }, icon: { type: "string", initial: "bell" }, title: { type: "string", nullable: false }, subtitle: "string", action: "json", source: { type: "string", nullable: false }, read: { type: "boolean", initial: false }, created_at: "timestamp", updated_at: "timestamp" }, { unique: [["user_id", "dedupe_key"]] });
|
|
44
|
+
await db.extend("message_preferences", { id: { type: "integer", autoIncrement: true }, user_id: { type: "integer", nullable: false }, plugin: { type: "string", nullable: false }, type: { type: "string", nullable: false }, enabled: { type: "boolean", initial: true } }, { unique: [["user_id", "plugin", "type"]] });
|
|
45
|
+
if (!(ctx.getCore() as Core & { services: Record<string, unknown> }).services.message) ctx.registerService("message", MessageService);
|
|
46
|
+
const service = (ctx.component.message as MessageService | undefined) ?? new MessageService(ctx);
|
|
47
|
+
service.registerType(ctx, { id: "message:test", plugin: "message", label: "测试消息", labelKey: "message.testTitle" });
|
|
48
|
+
ctx.component.dashboard.addEntry({ dev: new URL("../frontend/index.tsx", import.meta.url).pathname, prod: new URL("./frontend/message.js", import.meta.url).pathname, plugin: "message" });
|
|
49
|
+
ctx.route("/api/user/messages").methods("GET").action(async session => { const id = currentUser(session); if (id) session.respond({ messages: await service.list(id) }, "json"); });
|
|
50
|
+
ctx.route("/api/user/messages/types").methods("GET").action(async session => { const id = currentUser(session); if (id) session.respond({ types: service.types(), preferences: await service.preferences(id) }, "json"); });
|
|
51
|
+
ctx.route("/api/user/messages/preferences").methods("POST").action(async session => { const id = currentUser(session); const body = await session.parseRequestBody() as any; if (id && typeof body?.plugin === "string" && typeof body?.enabled === "boolean") { await service.setEnabled(id, body.plugin, String(body.type || ""), body.enabled); session.respond({ ok: true }, "json"); } });
|
|
52
|
+
ctx.route("/api/user/messages/delete").methods("POST").action(async session => { const id = currentUser(session); const body = await session.parseRequestBody() as any; if (id && Number(body?.id)) { await service.remove(id, Number(body.id)); session.respond({ ok: true }, "json"); } });
|
|
53
|
+
ctx.route("/api/user/messages/read").methods("POST").action(async session => { const id = currentUser(session); const body = await session.parseRequestBody() as any; if (id && Number(body?.id)) { await service.markRead(id, Number(body.id)); session.respond({ ok: true }, "json"); } });
|
|
54
|
+
ctx.route("/api/user/messages/test").methods("POST").action(async session => { const id = currentUser(session); if (!id) { session.status = 401; session.respond({ error: "unauthorized" }, "json"); return; } try { const body = await session.parseRequestBody() as { language?: string } | undefined; const language = body?.language === "en" || body?.language === "ja" ? body.language : "zh"; const message = await service.send({ userId: id, type: "message:test", dedupeKey: `test-${Date.now()}`, title: messageTranslations["message.testTitle"][language], subtitle: messageTranslations["message.testBody"][language] }); if (!message) { session.status = 403; session.respond({ error: "test message type is disabled" }, "json"); return; } session.respond({ message }, "json"); } catch (error) { session.status = 400; session.respond({ error: error instanceof Error ? error.message : String(error) }, "json"); } });
|
|
55
|
+
initialized.add(core);
|
|
56
|
+
ctx.affect(() => { initialized.delete(core); });
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const messageTranslations = {
|
|
2
|
+
"message.title": { zh: "消息", en: "Messages", ja: "メッセージ" },
|
|
3
|
+
"message.description": { zh: "查看和管理来自 Veloce 各功能的消息。", en: "View and manage messages from Veloce features.", ja: "Veloce の各機能からのメッセージを確認・管理します。" },
|
|
4
|
+
"message.empty": { zh: "暂无消息", en: "No messages", ja: "メッセージはありません" },
|
|
5
|
+
"message.from": { zh: "来自 {source}", en: "From {source}", ja: "送信元: {source}" },
|
|
6
|
+
"message.delete": { zh: "删除消息", en: "Delete message", ja: "メッセージを削除" },
|
|
7
|
+
"message.markRead": { zh: "标记已读", en: "Mark as read", ja: "既読にする" },
|
|
8
|
+
"message.types": { zh: "消息类型", en: "Message types", ja: "メッセージの種類" },
|
|
9
|
+
"message.other": { zh: "其他消息", en: "Other messages", ja: "その他のメッセージ" },
|
|
10
|
+
"message.sendTest": { zh: "发送测试消息", en: "Send test message", ja: "テストメッセージを送信" },
|
|
11
|
+
"message.testTitle": { zh: "测试消息", en: "Test message", ja: "テストメッセージ" },
|
|
12
|
+
"message.testBody": { zh: "这是一条测试消息。", en: "This is a test message.", ja: "これはテストメッセージです。" },
|
|
13
|
+
"message.enablePlugin": { zh: "启用 {plugin} 的消息", en: "Enable messages from {plugin}", ja: "{plugin} のメッセージを有効にする" },
|
|
14
|
+
"message.enableType": { zh: "启用{type}", en: "Enable {type}", ja: "{type}を有効にする" },
|
|
15
|
+
} as const
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"extends":"../../tsconfig.json","compilerOptions":{"rootDir":"src","outDir":"dist"},"include":["src"]}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { dashboardClientUrl as client } from "../dashboard/client-version";
|
|
2
|
+
export default defineConfig({ define:{"process.env.NODE_ENV":JSON.stringify("production")}, plugins:[react()], resolve:{alias:[{find:"react/jsx-runtime",replacement:client},{find:"react/jsx-dev-runtime",replacement:client},{find:"react-dom/client",replacement:client},{find:"react-dom",replacement:client},{find:"react-router-dom",replacement:client},{find:"@tanstack/react-query",replacement:client},{find:"lucide-react",replacement:client},{find:"react",replacement:client},{find:"@velocelab/dashboard/frontend",replacement:client},{find:/^@\/(components|lib|hooks)(?:\/.*)?$/,replacement:client},{find:"@",replacement:"frontend"}]},build:{outDir:"dist/frontend",rollupOptions:{external:[client]},lib:{entry:"frontend/index.tsx",formats:["es"],fileName:()=>"message.js"}}});
|
package/README.md
DELETED