@mh-gg/base-plugins 0.1.1-alpha.20260613T085325975Z

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.
Files changed (61) hide show
  1. package/README.md +247 -0
  2. package/package.json +48 -0
  3. package/src/approvals/index.cjs +65 -0
  4. package/src/attachments/index.cjs +75 -0
  5. package/src/calendar/index.cjs +48 -0
  6. package/src/checklists/index.cjs +58 -0
  7. package/src/comments/index.cjs +15 -0
  8. package/src/comments/plugin.cjs +66 -0
  9. package/src/comments/reducer.cjs +81 -0
  10. package/src/comments/schemas.cjs +60 -0
  11. package/src/comments/state.cjs +17 -0
  12. package/src/comments/threads.cjs +44 -0
  13. package/src/comments/views.cjs +27 -0
  14. package/src/composer/capabilities.cjs +19 -0
  15. package/src/composer/compose.cjs +37 -0
  16. package/src/composer/index.cjs +15 -0
  17. package/src/composer/operations.cjs +42 -0
  18. package/src/composer/registry.cjs +155 -0
  19. package/src/composer/selection.cjs +39 -0
  20. package/src/composer/suite.cjs +32 -0
  21. package/src/crdt/client.mjs +207 -0
  22. package/src/crdt/index.cjs +258 -0
  23. package/src/embeds/index.cjs +90 -0
  24. package/src/files/index.cjs +133 -0
  25. package/src/index.cjs +19 -0
  26. package/src/labels/index.cjs +46 -0
  27. package/src/location-pins/index.cjs +142 -0
  28. package/src/markdown/documents/index.cjs +128 -0
  29. package/src/markdown/index.cjs +8 -0
  30. package/src/markdown/parser/index.cjs +127 -0
  31. package/src/markdown/providers/audio.cjs +77 -0
  32. package/src/markdown/providers/cloud.cjs +72 -0
  33. package/src/markdown/providers/developer.cjs +45 -0
  34. package/src/markdown/providers/direct.cjs +49 -0
  35. package/src/markdown/providers/games.cjs +26 -0
  36. package/src/markdown/providers/images.cjs +88 -0
  37. package/src/markdown/providers/index.cjs +97 -0
  38. package/src/markdown/providers/maps.cjs +24 -0
  39. package/src/markdown/providers/productivity.cjs +30 -0
  40. package/src/markdown/providers/res-inspired.cjs +11 -0
  41. package/src/markdown/providers/social.cjs +33 -0
  42. package/src/markdown/providers/video.cjs +139 -0
  43. package/src/markdown/resolve.cjs +87 -0
  44. package/src/media-rooms/index.cjs +244 -0
  45. package/src/presence/index.cjs +193 -0
  46. package/src/reactions/index.cjs +47 -0
  47. package/src/screen-share/index.cjs +84 -0
  48. package/src/shared/constants.cjs +87 -0
  49. package/src/shared/embed.cjs +82 -0
  50. package/src/shared/index.cjs +20 -0
  51. package/src/shared/roles.cjs +5 -0
  52. package/src/shared/scopes.cjs +15 -0
  53. package/src/shared/url.cjs +32 -0
  54. package/src/shared/validation.cjs +31 -0
  55. package/test/composable-plugins.test.cjs +170 -0
  56. package/test/crdt-plugin.test.cjs +168 -0
  57. package/test/embed-autodetect-providers.test.cjs +138 -0
  58. package/test/markdown-media-workflow-plugins.test.cjs +201 -0
  59. package/test/markdown-parser-edge-cases.test.cjs +86 -0
  60. package/test/plugin-structure.test.cjs +69 -0
  61. package/test/shared-plugin-edges.test.cjs +207 -0
@@ -0,0 +1,244 @@
1
+ const {
2
+ allow,
3
+ activity,
4
+ actorName,
5
+ array,
6
+ boolean,
7
+ createOperationSchemaDescriptor,
8
+ defineHostPlugin,
9
+ deny,
10
+ entityId,
11
+ object,
12
+ optionalString,
13
+ readonlyState,
14
+ string,
15
+ memberOrBetter,
16
+ moderatorOrBetter,
17
+ BASE_PLUGIN_VERSION,
18
+ MEDIA_ROOMS_PLUGIN_ID
19
+ } = require("../shared/index.cjs");
20
+
21
+ function parseMediaRoomsState(state) {
22
+ const value = object(state, "media rooms state");
23
+ object(value.rooms, "rooms");
24
+ array(value.activity, "activity");
25
+ return value;
26
+ }
27
+ function mediaRoomCreatePayload(payload) {
28
+ const value = object(payload, "media.room.create payload");
29
+ return { name: string(value.name, "name", 100), allowsVideo: value.allowsVideo !== false, group: optionalString(value.group, "group", 80), scopeType: optionalString(value.scopeType, "scopeType", 80), scopeId: optionalString(value.scopeId, "scopeId", 160), roleAccess: parseRoleAccess(value.roleAccess) };
30
+ }
31
+ function mediaRoomIdPayload(payload) {
32
+ const value = object(payload, "media room payload");
33
+ return { roomId: string(value.roomId, "roomId") };
34
+ }
35
+ function mediaRoomJoinPayload(payload) {
36
+ const value = object(payload, "media.room.join payload");
37
+ const media = value.media && typeof value.media === "object" ? value.media : {};
38
+ return { roomId: string(value.roomId, "roomId"), media: { audio: media.audio !== false, video: Boolean(media.video), screen: Boolean(media.screen) } };
39
+ }
40
+ function mediaRoomUpdatePayload(payload) {
41
+ const value = object(payload, "media.room.update payload");
42
+ return { roomId: string(value.roomId, "roomId"), locked: value.locked === undefined ? undefined : boolean(value.locked), allowsVideo: value.allowsVideo === undefined ? undefined : boolean(value.allowsVideo), spotlightMemberId: optionalString(value.spotlightMemberId, "spotlightMemberId", 120), name: optionalString(value.name, "name", 100), group: value.group === undefined ? undefined : optionalString(value.group, "group", 80), roleAccess: value.roleAccess === undefined ? undefined : parseRoleAccess(value.roleAccess) };
43
+ }
44
+ const mediaRoomsStateSchemaDescriptor = { plugin: MEDIA_ROOMS_PLUGIN_ID, shape: ["rooms", "activity"] };
45
+ const roleAccessPayloadSchema = { type: "record", values: { type: "enum", values: ["editor", "readonly"] } };
46
+ const mediaPayloadSchema = {
47
+ type: "object",
48
+ required: {},
49
+ optional: {
50
+ audio: { type: "boolean", default: true },
51
+ video: { type: "boolean", default: false },
52
+ screen: { type: "boolean", default: false }
53
+ },
54
+ additional: false
55
+ };
56
+ const mediaRoomsOperationSchemaDescriptor = {
57
+ "media.room.create": {
58
+ required: { name: { type: "string", max: 100 } },
59
+ optional: {
60
+ allowsVideo: { type: "boolean", default: true },
61
+ group: { type: "string", max: 80, nullable: true, nonEmpty: false },
62
+ scopeType: { type: "string", max: 80 },
63
+ scopeId: { type: "string", max: 160 },
64
+ roleAccess: roleAccessPayloadSchema
65
+ },
66
+ authorize: { roles: ["moderator"] }
67
+ },
68
+ "media.room.join": { required: { roomId: { type: "string", max: 200 } }, optional: { media: mediaPayloadSchema }, authorize: { roles: ["member"] } },
69
+ "media.room.leave": { required: { roomId: { type: "string", max: 200 } }, authorize: { roles: ["member"] } },
70
+ "media.room.update": {
71
+ required: { roomId: { type: "string", max: 200 } },
72
+ optional: { locked: { type: "boolean" }, allowsVideo: { type: "boolean" }, spotlightMemberId: { type: "string", max: 120 }, name: { type: "string", max: 100 }, group: { type: "string", max: 80, nullable: true, nonEmpty: false }, roleAccess: roleAccessPayloadSchema },
73
+ authorize: { roles: ["moderator"] }
74
+ },
75
+ "media.room.archive": { required: { roomId: { type: "string", max: 200 } }, authorize: { roles: ["moderator"] } }
76
+ };
77
+ function mediaRoomParticipantCount(room) { return Object.keys(room.participants || {}).length; }
78
+
79
+ function normalizeAccessLevel(value) {
80
+ if (value === "editor" || value === "edit" || value === "member") return "editor";
81
+ if (value === "readonly" || value === "read-only" || value === "read" || value === "viewer" || value === "view") return "readonly";
82
+ return undefined;
83
+ }
84
+
85
+ function parseRoleAccess(value) {
86
+ if (value === undefined || value === null) return {};
87
+ const input = object(value, "roleAccess");
88
+ const access = {};
89
+ for (const [roleId, level] of Object.entries(input)) {
90
+ const cleanRoleId = optionalString(roleId, "roleAccess role", 80);
91
+ const cleanLevel = normalizeAccessLevel(level);
92
+ if (cleanRoleId && cleanLevel) access[cleanRoleId] = cleanLevel;
93
+ }
94
+ return access;
95
+ }
96
+
97
+ function actorRoleTags(ctx, actor = {}) {
98
+ const tags = new Set([actor.role].filter(Boolean));
99
+ for (const pluginState of Object.values(ctx.roomState?.plugins || {})) {
100
+ const assignment = pluginState?.memberRoles?.[actor.memberId];
101
+ if (!assignment || typeof assignment !== "object") continue;
102
+ if (typeof assignment.roleId === "string") tags.add(assignment.roleId);
103
+ if (Array.isArray(assignment.roleIds)) {
104
+ for (const roleId of assignment.roleIds) if (typeof roleId === "string" && roleId) tags.add(roleId);
105
+ }
106
+ }
107
+ return tags;
108
+ }
109
+
110
+ function roomAccessForActor(ctx, room, actor = {}) {
111
+ if (moderatorOrBetter(actor)) return "editor";
112
+ const access = parseRoleAccess(room.roleAccess || {});
113
+ const entries = Object.entries(access);
114
+ if (entries.length === 0) return "editor";
115
+ const tags = actorRoleTags(ctx, actor);
116
+ let level = "hidden";
117
+ for (const [roleId, roleLevel] of entries) {
118
+ if (!tags.has(roleId)) continue;
119
+ if (roleLevel === "editor") return "editor";
120
+ if (roleLevel === "readonly") level = "readonly";
121
+ }
122
+ return level;
123
+ }
124
+
125
+ function canViewRoom(ctx, room, actor) {
126
+ return roomAccessForActor(ctx, room, actor) !== "hidden";
127
+ }
128
+
129
+ function canJoinRoom(ctx, room, actor) {
130
+ return roomAccessForActor(ctx, room, actor) === "editor";
131
+ }
132
+
133
+ function configuredRoomId(value, index) {
134
+ if (typeof value === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(value)) return value;
135
+ return `default_media_room_${index + 1}`;
136
+ }
137
+
138
+ function configuredString(value, fallback, max) {
139
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback;
140
+ }
141
+
142
+ function resolveRoomValue(value, ctx) {
143
+ return value === "$room.id" ? ctx.room.id : value;
144
+ }
145
+
146
+ function defaultRoomsFromConfig(ctx) {
147
+ const defaults = Array.isArray(ctx.plugin.config?.defaultRooms) ? ctx.plugin.config.defaultRooms : [];
148
+ const rooms = {};
149
+ defaults.forEach((item, index) => {
150
+ if (!item || typeof item !== "object") return;
151
+ const id = configuredRoomId(item.id, index);
152
+ rooms[id] = {
153
+ id,
154
+ name: configuredString(item.name, "Event room", 100),
155
+ group: optionalString(item.group, "group", 80),
156
+ allowsVideo: item.allowsVideo !== false,
157
+ scopeType: configuredString(resolveRoomValue(item.scopeType, ctx), undefined, 80),
158
+ scopeId: configuredString(resolveRoomValue(item.scopeId, ctx), undefined, 160),
159
+ roleAccess: parseRoleAccess(item.roleAccess),
160
+ createdBy: "system",
161
+ createdAt: ctx.now,
162
+ locked: false,
163
+ participants: {}
164
+ };
165
+ });
166
+ return rooms;
167
+ }
168
+
169
+ const mediaRoomsHostPlugin = defineHostPlugin({
170
+ id: MEDIA_ROOMS_PLUGIN_ID,
171
+ version: BASE_PLUGIN_VERSION,
172
+ meta: { name: "Reusable Voice And Video Rooms" },
173
+ capabilities: { requires: ["room.state", "room.roles"], provides: ["media.voice", "media.video", "media.rooms"] },
174
+ stateSchemaDescriptor: mediaRoomsStateSchemaDescriptor,
175
+ operationSchemaDescriptor: createOperationSchemaDescriptor(MEDIA_ROOMS_PLUGIN_ID, BASE_PLUGIN_VERSION, mediaRoomsOperationSchemaDescriptor),
176
+ schemas: {
177
+ state: { parse: parseMediaRoomsState },
178
+ operations: {
179
+ "media.room.create": { parse: mediaRoomCreatePayload },
180
+ "media.room.join": { parse: mediaRoomJoinPayload },
181
+ "media.room.leave": { parse: mediaRoomIdPayload },
182
+ "media.room.update": { parse: mediaRoomUpdatePayload },
183
+ "media.room.archive": { parse: mediaRoomIdPayload }
184
+ },
185
+ publicView: { parse: readonlyState },
186
+ queries: { roomDirectory: { parse: readonlyState } }
187
+ },
188
+ async createInitialState(ctx) { return { rooms: defaultRoomsFromConfig(ctx), activity: [] }; },
189
+ authorize(_ctx, op) {
190
+ if (["media.room.join", "media.room.leave"].includes(op.type)) return memberOrBetter(op.actor) ? allow() : deny("Members only");
191
+ if (["media.room.create", "media.room.update", "media.room.archive"].includes(op.type)) return moderatorOrBetter(op.actor) ? allow() : deny("Moderators only");
192
+ return deny("Unsupported media operation");
193
+ },
194
+ async reduce(_ctx, state, op) {
195
+ if (op.type === "media.room.create") {
196
+ const room = { id: entityId("media_room", op), name: op.payload.name, group: op.payload.group, allowsVideo: op.payload.allowsVideo !== false, scopeType: op.payload.scopeType, scopeId: op.payload.scopeId, roleAccess: op.payload.roleAccess || {}, createdBy: op.actor.memberId, createdAt: op.createdAt, locked: false, participants: {} };
197
+ return { ...state, rooms: { ...state.rooms, [room.id]: room }, activity: activity(state, op, `${actorName(op.actor)} created ${room.name}`) };
198
+ }
199
+ const room = state.rooms[op.payload.roomId];
200
+ if (!room || room.archivedAt) throw new Error(`Media room ${op.payload.roomId} not found`);
201
+ if (op.type === "media.room.join") {
202
+ if (!canViewRoom(_ctx, room, op.actor)) throw new Error("Media room is not available for this role");
203
+ if (!canJoinRoom(_ctx, room, op.actor)) throw new Error("Media room is read-only for this role");
204
+ if (room.locked && !moderatorOrBetter(op.actor)) throw new Error("Media room is locked");
205
+ const media = { ...op.payload.media, video: Boolean(room.allowsVideo && op.payload.media?.video) };
206
+ const participant = { memberId: op.actor.memberId, name: actorName(op.actor), media, joinedAt: op.createdAt };
207
+ return { ...state, rooms: { ...state.rooms, [room.id]: { ...room, participants: { ...room.participants, [op.actor.memberId]: participant } } }, activity: activity(state, op, `${participant.name} joined ${room.name}`) };
208
+ }
209
+ if (op.type === "media.room.leave") {
210
+ const participants = { ...room.participants };
211
+ delete participants[op.actor.memberId];
212
+ return { ...state, rooms: { ...state.rooms, [room.id]: { ...room, participants } }, activity: activity(state, op, `${actorName(op.actor)} left ${room.name}`) };
213
+ }
214
+ if (op.type === "media.room.update") {
215
+ const updated = { ...room };
216
+ if (op.payload.locked !== undefined) updated.locked = op.payload.locked;
217
+ if (op.payload.allowsVideo !== undefined) updated.allowsVideo = op.payload.allowsVideo;
218
+ if (op.payload.spotlightMemberId !== undefined) updated.spotlightMemberId = op.payload.spotlightMemberId;
219
+ if (op.payload.name !== undefined) updated.name = op.payload.name;
220
+ if (op.payload.group !== undefined) updated.group = op.payload.group;
221
+ if (op.payload.roleAccess !== undefined) updated.roleAccess = op.payload.roleAccess;
222
+ return { ...state, rooms: { ...state.rooms, [room.id]: updated }, activity: activity(state, op, `${actorName(op.actor)} updated ${updated.name}`) };
223
+ }
224
+ if (op.type === "media.room.archive") {
225
+ return { ...state, rooms: { ...state.rooms, [room.id]: { ...room, archivedAt: op.createdAt, participants: {} } }, activity: activity(state, op, `${actorName(op.actor)} archived ${room.name}`) };
226
+ }
227
+ return state;
228
+ },
229
+ getPublicView(ctx, state, actor) {
230
+ return { rooms: Object.values(state.rooms).filter((room) => !room.archivedAt && canViewRoom(ctx, room, actor)).map((room) => ({ ...room, participantCount: mediaRoomParticipantCount(room) })) };
231
+ },
232
+ queries: {
233
+ roomDirectory(ctx, state, _input, actor) {
234
+ return Object.values(state.rooms).filter((room) => !room.archivedAt && canViewRoom(ctx, room, actor)).map((room) => ({ ...room, participantCount: mediaRoomParticipantCount(room) })).sort((a, b) => a.name.localeCompare(b.name));
235
+ }
236
+ }
237
+ });
238
+
239
+ module.exports = {
240
+ MEDIA_ROOMS_PLUGIN_ID,
241
+ mediaRoomsHostPlugin,
242
+ mediaRoomsOperationSchemaDescriptor,
243
+ mediaRoomsStateSchemaDescriptor
244
+ };
@@ -0,0 +1,193 @@
1
+ const {
2
+ allow,
3
+ activity,
4
+ actorName,
5
+ array,
6
+ clone,
7
+ createOperationSchemaDescriptor,
8
+ defineHostPlugin,
9
+ deny,
10
+ enumValue,
11
+ number,
12
+ object,
13
+ readonlyState,
14
+ memberOrBetter,
15
+ moderatorOrBetter,
16
+ BASE_PLUGIN_VERSION,
17
+ PRESENCE_PLUGIN_ID,
18
+ PRESENCE_STATUSES
19
+ } = require("../shared/index.cjs");
20
+
21
+ const MAX_PRESENCE_MEMBERS = 500;
22
+ const MAX_PRESENCE_ACTIVITY = 250;
23
+ const PRESENCE_STALE_AFTER_MS = 45_000;
24
+ const RESERVED_MEMBER_IDS = new Set(["__proto__", "prototype", "constructor"]);
25
+
26
+ function presenceText(value, name, max, required = false) {
27
+ if (value === undefined || value === null || value === "") {
28
+ if (required) throw new Error(`${name} must be a non-empty string`);
29
+ return null;
30
+ }
31
+ if (typeof value !== "string") throw new Error(`${name} must be a string`);
32
+ const trimmed = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
33
+ if (!trimmed) {
34
+ if (required) throw new Error(`${name} must be a non-empty string`);
35
+ return null;
36
+ }
37
+ if (trimmed.length > max) throw new Error(`${name} is too long`);
38
+ return trimmed;
39
+ }
40
+
41
+ function presenceMemberId(value, name) {
42
+ const memberId = presenceText(value, name, 120, true);
43
+ if (RESERVED_MEMBER_IDS.has(memberId)) throw new Error(`${name} is reserved`);
44
+ return memberId;
45
+ }
46
+
47
+ function parsePresenceMember(key, member) {
48
+ const value = object(member, `members.${key}`);
49
+ const memberId = presenceMemberId(value.memberId ?? key, `members.${key}.memberId`);
50
+ if (memberId !== key) throw new Error(`members.${key}.memberId must match its key`);
51
+ const updatedAt = number(value.updatedAt, `members.${key}.updatedAt`);
52
+ const parsed = {
53
+ memberId,
54
+ name: presenceText(value.name, `members.${key}.name`, 80) || memberId,
55
+ status: enumValue(value.status, `members.${key}.status`, PRESENCE_STATUSES),
56
+ activity: presenceText(value.activity, `members.${key}.activity`, 160),
57
+ location: presenceText(value.location, `members.${key}.location`, 120),
58
+ updatedAt
59
+ };
60
+ if (value.lastPingAt !== undefined) parsed.lastPingAt = number(value.lastPingAt, `members.${key}.lastPingAt`);
61
+ return parsed;
62
+ }
63
+
64
+ function parsePresenceState(state) {
65
+ const value = object(state, "presence state");
66
+ const entries = Object.entries(object(value.members, "members"));
67
+ if (entries.length > MAX_PRESENCE_MEMBERS) throw new Error(`members must include ${MAX_PRESENCE_MEMBERS} or fewer entries`);
68
+ const members = {};
69
+ for (const [key, member] of entries) {
70
+ const memberId = presenceMemberId(key, `members.${key}`);
71
+ members[memberId] = parsePresenceMember(memberId, member);
72
+ }
73
+ return { members, activity: array(value.activity, "activity").slice(-MAX_PRESENCE_ACTIVITY) };
74
+ }
75
+ function presenceUpdatePayload(payload) {
76
+ const value = object(payload, "presence.update payload");
77
+ return {
78
+ status: enumValue(value.status, "status", PRESENCE_STATUSES),
79
+ activity: presenceText(value.activity, "activity", 160),
80
+ location: presenceText(value.location, "location", 120)
81
+ };
82
+ }
83
+ function presenceClearPayload(payload) {
84
+ const value = object(payload || {}, "presence.clear payload");
85
+ return { memberId: value.memberId === undefined || value.memberId === null || value.memberId === "" ? null : presenceMemberId(value.memberId, "memberId") };
86
+ }
87
+ function presencePingPayload(payload) {
88
+ const value = object(payload || {}, "presence.ping payload");
89
+ return { status: value.status === undefined ? null : enumValue(value.status, "status", PRESENCE_STATUSES) };
90
+ }
91
+ function presenceActorOperation(op, memberId) {
92
+ return { ...op, actor: { ...op.actor, memberId } };
93
+ }
94
+ function presenceNow(ctx) {
95
+ const value = Number(ctx?.now);
96
+ return Number.isFinite(value) ? value : Date.now();
97
+ }
98
+ function canSeeInvisible(actor, memberId) {
99
+ return actor?.memberId === memberId || moderatorOrBetter(actor);
100
+ }
101
+ function effectivePresence(member, actor, now) {
102
+ const pingAt = Number(member.lastPingAt);
103
+ const stale = member.status !== "offline" && Number.isFinite(pingAt) && now - pingAt > PRESENCE_STALE_AFTER_MS;
104
+ const status = stale ? "offline" : member.status;
105
+ if (status === "invisible" && !canSeeInvisible(actor, member.memberId)) return undefined;
106
+ return { ...member, status, declaredStatus: member.status, stale, visible: status !== "offline" && status !== "invisible" };
107
+ }
108
+ function projectedPresenceMembers(state, actor, now) {
109
+ const members = {};
110
+ for (const member of Object.values(parsePresenceState(state).members)) {
111
+ const projected = effectivePresence(member, actor, now);
112
+ if (projected) members[projected.memberId] = projected;
113
+ }
114
+ return members;
115
+ }
116
+ const presenceStateSchemaDescriptor = { plugin: PRESENCE_PLUGIN_ID, shape: ["members", "activity"] };
117
+ const presenceOperationSchemaDescriptor = {
118
+ "presence.update": { required: ["status"], optional: ["activity", "location"], authorize: { roles: ["member"] } },
119
+ "presence.ping": { optional: ["status"], authorize: { roles: ["member"] } },
120
+ "presence.clear": { optional: ["memberId"], authorize: { roles: ["member"] } }
121
+ };
122
+ const presenceHostPlugin = defineHostPlugin({
123
+ id: PRESENCE_PLUGIN_ID,
124
+ version: BASE_PLUGIN_VERSION,
125
+ meta: { name: "Reusable Presence" },
126
+ capabilities: { requires: ["room.state", "room.roles"], provides: ["presence.status", "presence.activity"] },
127
+ stateSchemaDescriptor: presenceStateSchemaDescriptor,
128
+ operationSchemaDescriptor: createOperationSchemaDescriptor(PRESENCE_PLUGIN_ID, BASE_PLUGIN_VERSION, presenceOperationSchemaDescriptor),
129
+ schemas: {
130
+ state: { parse: parsePresenceState },
131
+ operations: { "presence.update": { parse: presenceUpdatePayload }, "presence.ping": { parse: presencePingPayload }, "presence.clear": { parse: presenceClearPayload } },
132
+ publicView: { parse: readonlyState },
133
+ queries: { onlineMembers: { parse: readonlyState } }
134
+ },
135
+ async createInitialState() { return { members: {}, activity: [] }; },
136
+ authorize(_ctx, op) {
137
+ if (op.type === "presence.update" || op.type === "presence.ping") return memberOrBetter(op.actor) ? allow() : deny("Members only");
138
+ if (op.type === "presence.clear") {
139
+ const actorMemberId = presenceMemberId(op.actor.memberId, "actor.memberId");
140
+ return (moderatorOrBetter(op.actor) || !op.payload.memberId || op.payload.memberId === actorMemberId) ? allow() : deny("Can only clear your own presence");
141
+ }
142
+ return deny("Unsupported presence operation");
143
+ },
144
+ async reduce(_ctx, state, op) {
145
+ if (op.type === "presence.update") {
146
+ const memberId = presenceMemberId(op.actor.memberId, "actor.memberId");
147
+ const operation = presenceActorOperation(op, memberId);
148
+ const presence = { memberId, name: actorName(operation.actor), status: op.payload.status, activity: op.payload.activity, location: op.payload.location, updatedAt: op.createdAt };
149
+ return { ...state, members: { ...state.members, [memberId]: presence }, activity: activity(state, operation, `${presence.name} is ${presence.status}`) };
150
+ }
151
+ if (op.type === "presence.ping") {
152
+ const memberId = presenceMemberId(op.actor.memberId, "actor.memberId");
153
+ const existing = state.members[memberId];
154
+ const status = op.payload.status || existing?.status || "online";
155
+ const presence = {
156
+ memberId,
157
+ name: actorName(op.actor),
158
+ status,
159
+ activity: existing?.activity || null,
160
+ location: existing?.location || null,
161
+ updatedAt: existing && status === existing.status ? existing.updatedAt : op.createdAt,
162
+ lastPingAt: op.createdAt
163
+ };
164
+ return { ...state, members: { ...state.members, [memberId]: presence } };
165
+ }
166
+ if (op.type === "presence.clear") {
167
+ const actorMemberId = presenceMemberId(op.actor.memberId, "actor.memberId");
168
+ const memberId = op.payload.memberId || actorMemberId;
169
+ const operation = presenceActorOperation(op, actorMemberId);
170
+ const members = { ...state.members };
171
+ delete members[memberId];
172
+ return { ...state, members, activity: activity(state, operation, `${actorName(operation.actor)} cleared presence for ${memberId}`) };
173
+ }
174
+ return state;
175
+ },
176
+ getPublicView(ctx, state, actor = ctx?.actor) {
177
+ const members = projectedPresenceMembers(state, actor, presenceNow(ctx));
178
+ return { members, onlineCount: Object.values(members).filter((member) => member.visible).length };
179
+ },
180
+ queries: {
181
+ onlineMembers(ctx, state, _input, actor = ctx?.actor) {
182
+ return Object.values(projectedPresenceMembers(state, actor, presenceNow(ctx))).filter((member) => member.visible).sort((a, b) => a.name.localeCompare(b.name));
183
+ }
184
+ }
185
+ });
186
+
187
+ module.exports = {
188
+ PRESENCE_PLUGIN_ID,
189
+ PRESENCE_STATUSES,
190
+ presenceHostPlugin,
191
+ presenceOperationSchemaDescriptor,
192
+ presenceStateSchemaDescriptor
193
+ };
@@ -0,0 +1,47 @@
1
+ const { REACTIONS_PLUGIN_ID, REACTIONS_PLUGIN_KEY } = require("../shared/constants.cjs");
2
+ const {
3
+ allow,
4
+ activity,
5
+ actorName,
6
+ array,
7
+ createOperationSchemaDescriptor,
8
+ defineHostPlugin,
9
+ deny,
10
+ entityId,
11
+ memberOrBetter,
12
+ moderatorOrBetter,
13
+ object,
14
+ readonlyState,
15
+ scopeKey,
16
+ scopePayload,
17
+ string,
18
+ BASE_PLUGIN_VERSION
19
+ } = require("../shared/index.cjs");
20
+
21
+ const reactionsHostPlugin = defineHostPlugin({
22
+ id: REACTIONS_PLUGIN_ID,
23
+ version: BASE_PLUGIN_VERSION,
24
+ meta: { name: "Reusable Scoped Reactions" },
25
+ capabilities: { requires: ["room.state", "room.roles"], provides: ["reactions.scoped", "reactions.emoji"] },
26
+ stateSchemaDescriptor: { plugin: REACTIONS_PLUGIN_ID, shape: ["reactions", "activity"] },
27
+ operationSchemaDescriptor: createOperationSchemaDescriptor(REACTIONS_PLUGIN_ID, BASE_PLUGIN_VERSION, { "reaction.toggle": { required: ["scopeType", "scopeId", "emoji"], authorize: { roles: ["member"] } } }),
28
+ schemas: {
29
+ state: { parse(value) { const state = object(value, "reactions state"); object(state.reactions, "reactions"); array(state.activity, "activity"); return state; } },
30
+ operations: { "reaction.toggle": { parse(payload) { const value = object(payload, "reaction.toggle payload"); return { ...scopePayload(value), emoji: string(value.emoji, "emoji", 32) }; } } },
31
+ publicView: { parse: readonlyState },
32
+ queries: { reactionsForScope: { parse: readonlyState } }
33
+ },
34
+ async createInitialState() { return { reactions: {}, activity: [] }; },
35
+ authorize(_ctx, op) { return op.type === "reaction.toggle" ? (memberOrBetter(op.actor) ? allow() : deny("Members only")) : deny("Unsupported reaction operation"); },
36
+ async reduce(_ctx, state, op) {
37
+ const key = scopeKey(op.payload.scopeType, op.payload.scopeId);
38
+ const scoped = state.reactions[key] || { scopeType: op.payload.scopeType, scopeId: op.payload.scopeId, reactions: {} };
39
+ const members = new Set(scoped.reactions[op.payload.emoji] || []);
40
+ if (members.has(op.actor.memberId)) members.delete(op.actor.memberId); else members.add(op.actor.memberId);
41
+ return { ...state, reactions: { ...state.reactions, [key]: { ...scoped, reactions: { ...scoped.reactions, [op.payload.emoji]: [...members].sort() }, updatedAt: op.createdAt } } };
42
+ },
43
+ getPublicView(_ctx, state) { return state; },
44
+ queries: { reactionsForScope(_ctx, state, input = {}) { const scoped = scopePayload(input, "reactionsForScope input"); return state.reactions[scopeKey(scoped.scopeType, scoped.scopeId)] || { ...scoped, reactions: {} }; } }
45
+ });
46
+
47
+ module.exports = { REACTIONS_PLUGIN_ID, REACTIONS_PLUGIN_KEY, reactionsHostPlugin };
@@ -0,0 +1,84 @@
1
+ const {
2
+ allow,
3
+ activity,
4
+ actorName,
5
+ array,
6
+ createOperationSchemaDescriptor,
7
+ defineHostPlugin,
8
+ deny,
9
+ entityId,
10
+ object,
11
+ optionalString,
12
+ readonlyState,
13
+ string,
14
+ memberOrBetter,
15
+ moderatorOrBetter,
16
+ BASE_PLUGIN_VERSION,
17
+ SCREEN_SHARE_PLUGIN_ID
18
+ } = require("../shared/index.cjs");
19
+
20
+ function parseScreenShareState(state) {
21
+ const value = object(state, "screen share state");
22
+ object(value.shares, "shares");
23
+ array(value.activity, "activity");
24
+ return value;
25
+ }
26
+ function screenShareStartPayload(payload) {
27
+ const value = object(payload, "screenshare.start payload");
28
+ return { scopeType: string(value.scopeType, "scopeType", 80), scopeId: string(value.scopeId, "scopeId", 160), roomId: optionalString(value.roomId, "roomId", 160), title: optionalString(value.title, "title", 160) };
29
+ }
30
+ function screenShareStopPayload(payload) {
31
+ const value = object(payload, "screenshare.stop payload");
32
+ return { shareId: string(value.shareId, "shareId") };
33
+ }
34
+ const screenShareStateSchemaDescriptor = { plugin: SCREEN_SHARE_PLUGIN_ID, shape: ["shares", "activity"] };
35
+ const screenShareOperationSchemaDescriptor = {
36
+ "screenshare.start": { required: ["scopeType", "scopeId"], optional: ["roomId", "title"], authorize: { roles: ["member"] } },
37
+ "screenshare.stop": { required: ["shareId"], authorize: { roles: ["member"] } }
38
+ };
39
+ const screenShareHostPlugin = defineHostPlugin({
40
+ id: SCREEN_SHARE_PLUGIN_ID,
41
+ version: BASE_PLUGIN_VERSION,
42
+ meta: { name: "Reusable Screen Share Sessions" },
43
+ capabilities: { requires: ["room.state", "room.roles"], provides: ["media.screen-share", "media.presentation"] },
44
+ stateSchemaDescriptor: screenShareStateSchemaDescriptor,
45
+ operationSchemaDescriptor: createOperationSchemaDescriptor(SCREEN_SHARE_PLUGIN_ID, BASE_PLUGIN_VERSION, screenShareOperationSchemaDescriptor),
46
+ schemas: {
47
+ state: { parse: parseScreenShareState },
48
+ operations: { "screenshare.start": { parse: screenShareStartPayload }, "screenshare.stop": { parse: screenShareStopPayload } },
49
+ publicView: { parse: readonlyState },
50
+ queries: { activeShares: { parse: readonlyState } }
51
+ },
52
+ async createInitialState() { return { shares: {}, activity: [] }; },
53
+ authorize(_ctx, op) {
54
+ if (op.type === "screenshare.start") return memberOrBetter(op.actor) ? allow() : deny("Members only");
55
+ if (op.type === "screenshare.stop") return memberOrBetter(op.actor) ? allow() : deny("Members only");
56
+ return deny("Unsupported screen share operation");
57
+ },
58
+ async reduce(_ctx, state, op) {
59
+ if (op.type === "screenshare.start") {
60
+ const share = { id: entityId("share", op), scopeType: op.payload.scopeType, scopeId: op.payload.scopeId, roomId: op.payload.roomId, title: op.payload.title || "Screen share", presenterId: op.actor.memberId, presenterName: actorName(op.actor), startedAt: op.createdAt };
61
+ return { ...state, shares: { ...state.shares, [share.id]: share }, activity: activity(state, op, `${share.presenterName} started ${share.title}`) };
62
+ }
63
+ if (op.type === "screenshare.stop") {
64
+ const share = state.shares[op.payload.shareId];
65
+ if (!share || share.stoppedAt) throw new Error(`Screen share ${op.payload.shareId} not found`);
66
+ if (!moderatorOrBetter(op.actor) && share.presenterId !== op.actor.memberId) throw new Error("Only presenter or moderators can stop a screen share");
67
+ return { ...state, shares: { ...state.shares, [share.id]: { ...share, stoppedAt: op.createdAt, stoppedBy: op.actor.memberId } }, activity: activity(state, op, `${actorName(op.actor)} stopped ${share.title}`) };
68
+ }
69
+ return state;
70
+ },
71
+ getPublicView(_ctx, state) { return { shares: Object.values(state.shares).filter((share) => !share.stoppedAt) }; },
72
+ queries: {
73
+ activeShares(_ctx, state, input = {}) {
74
+ return Object.values(state.shares).filter((share) => !share.stoppedAt && (!input.scopeType || share.scopeType === input.scopeType) && (!input.scopeId || share.scopeId === input.scopeId));
75
+ }
76
+ }
77
+ });
78
+
79
+ module.exports = {
80
+ SCREEN_SHARE_PLUGIN_ID,
81
+ screenShareHostPlugin,
82
+ screenShareOperationSchemaDescriptor,
83
+ screenShareStateSchemaDescriptor
84
+ };
@@ -0,0 +1,87 @@
1
+ const BASE_PLUGINS_PACKAGE = "@mh-gg/base-plugins";
2
+ const BASE_PLUGIN_VERSION = "1.0.0";
3
+
4
+ const COMMENTS_PLUGIN_ID = "com.matterhorn.examples.plugins.comments";
5
+ const PRESENCE_PLUGIN_ID = "com.matterhorn.examples.plugins.presence";
6
+ const MEDIA_ROOMS_PLUGIN_ID = "com.matterhorn.examples.plugins.media-rooms";
7
+ const SCREEN_SHARE_PLUGIN_ID = "com.matterhorn.examples.plugins.screen-share";
8
+ const MARKDOWN_PLUGIN_ID = "com.matterhorn.examples.plugins.markdown";
9
+ const EMBEDS_PLUGIN_ID = "com.matterhorn.examples.plugins.embeds";
10
+ const ATTACHMENTS_PLUGIN_ID = "com.matterhorn.examples.plugins.attachments";
11
+ const FILES_PLUGIN_ID = "com.matterhorn.examples.plugins.files";
12
+ const REACTIONS_PLUGIN_ID = "com.matterhorn.examples.plugins.reactions";
13
+ const LABELS_PLUGIN_ID = "com.matterhorn.examples.plugins.labels";
14
+ const APPROVALS_PLUGIN_ID = "com.matterhorn.examples.plugins.approvals";
15
+ const CHECKLISTS_PLUGIN_ID = "com.matterhorn.examples.plugins.checklists";
16
+ const CALENDAR_PLUGIN_ID = "com.matterhorn.examples.plugins.calendar";
17
+ const LOCATION_PINS_PLUGIN_ID = "com.matterhorn.examples.plugins.location-pins";
18
+ const CRDT_PLUGIN_ID = "com.matterhorn.examples.plugins.crdt";
19
+
20
+ const COMMENTS_PLUGIN_KEY = "comments";
21
+ const PRESENCE_PLUGIN_KEY = "presence";
22
+ const MEDIA_ROOMS_PLUGIN_KEY = "mediaRooms";
23
+ const SCREEN_SHARE_PLUGIN_KEY = "screenShare";
24
+ const MARKDOWN_PLUGIN_KEY = "markdown";
25
+ const EMBEDS_PLUGIN_KEY = "embeds";
26
+ const ATTACHMENTS_PLUGIN_KEY = "attachments";
27
+ const FILES_PLUGIN_KEY = "files";
28
+ const REACTIONS_PLUGIN_KEY = "reactions";
29
+ const LABELS_PLUGIN_KEY = "labels";
30
+ const APPROVALS_PLUGIN_KEY = "approvals";
31
+ const CHECKLISTS_PLUGIN_KEY = "checklists";
32
+ const CALENDAR_PLUGIN_KEY = "calendar";
33
+ const LOCATION_PINS_PLUGIN_KEY = "locationPins";
34
+ const CRDT_PLUGIN_KEY = "crdt";
35
+
36
+ const PRESENCE_STATUSES = Object.freeze(["online", "away", "busy", "invisible", "offline"]);
37
+
38
+ const MAX_MARKDOWN_BYTES = 64 * 1024;
39
+ const MAX_EMBED_URL_BYTES = 4096;
40
+ const MAX_ATTACHMENTS = 250;
41
+ const MAX_FILES = 100;
42
+ const MAX_FILE_EVENT_BYTES = 2 * 1024 * 1024;
43
+ const MAX_CRDT_EVENT_BYTES = 256 * 1024;
44
+ const MAX_CRDT_UPDATES_PER_DOCUMENT = 5000;
45
+
46
+ module.exports = {
47
+ APPROVALS_PLUGIN_ID,
48
+ APPROVALS_PLUGIN_KEY,
49
+ ATTACHMENTS_PLUGIN_ID,
50
+ ATTACHMENTS_PLUGIN_KEY,
51
+ CALENDAR_PLUGIN_ID,
52
+ CALENDAR_PLUGIN_KEY,
53
+ CHECKLISTS_PLUGIN_ID,
54
+ CHECKLISTS_PLUGIN_KEY,
55
+ COMMENTS_PLUGIN_ID,
56
+ COMMENTS_PLUGIN_KEY,
57
+ CRDT_PLUGIN_ID,
58
+ CRDT_PLUGIN_KEY,
59
+ EMBEDS_PLUGIN_ID,
60
+ EMBEDS_PLUGIN_KEY,
61
+ FILES_PLUGIN_ID,
62
+ FILES_PLUGIN_KEY,
63
+ BASE_PLUGIN_VERSION,
64
+ BASE_PLUGINS_PACKAGE,
65
+ LABELS_PLUGIN_ID,
66
+ LABELS_PLUGIN_KEY,
67
+ LOCATION_PINS_PLUGIN_ID,
68
+ LOCATION_PINS_PLUGIN_KEY,
69
+ MARKDOWN_PLUGIN_ID,
70
+ MARKDOWN_PLUGIN_KEY,
71
+ MAX_ATTACHMENTS,
72
+ MAX_FILE_EVENT_BYTES,
73
+ MAX_FILES,
74
+ MAX_CRDT_EVENT_BYTES,
75
+ MAX_CRDT_UPDATES_PER_DOCUMENT,
76
+ MAX_EMBED_URL_BYTES,
77
+ MAX_MARKDOWN_BYTES,
78
+ MEDIA_ROOMS_PLUGIN_ID,
79
+ MEDIA_ROOMS_PLUGIN_KEY,
80
+ PRESENCE_PLUGIN_ID,
81
+ PRESENCE_PLUGIN_KEY,
82
+ PRESENCE_STATUSES,
83
+ REACTIONS_PLUGIN_ID,
84
+ REACTIONS_PLUGIN_KEY,
85
+ SCREEN_SHARE_PLUGIN_ID,
86
+ SCREEN_SHARE_PLUGIN_KEY
87
+ };