@peers-app/peers-ui 0.9.4 → 0.10.1

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.
@@ -10,11 +10,13 @@ const loading_indicator_1 = require("../../components/loading-indicator");
10
10
  const save_button_1 = require("../../components/save-button");
11
11
  const tabs_1 = require("../../components/tabs");
12
12
  const package_info_1 = require("./package-info");
13
+ const package_versions_1 = require("./package-versions");
13
14
  const hooks_1 = require("../../hooks");
14
15
  const input_1 = require("../../components/input");
15
16
  const tabs_state_1 = require("../../tabs-layout/tabs-state");
16
17
  const PackageDetails = (props) => {
17
18
  const refresh = (0, hooks_1.useObservableState)(Date.now());
19
+ const saveDeviceTagRef = react_1.default.useRef(null);
18
20
  const pkg = (0, hooks_1.usePromise)(async () => {
19
21
  const pkg = await (0, peers_sdk_1.Packages)().get(props.packageId);
20
22
  if (!pkg) {
@@ -62,13 +64,20 @@ const PackageDetails = (props) => {
62
64
  react_1.default.createElement("h4", null,
63
65
  react_1.default.createElement(input_1.Input, { key: pkg.packageId, className: 'border border-0', style: { width: '100%', outline: 'none', backgroundColor: 'transparent' }, value: pkg.qs.name }))),
64
66
  react_1.default.createElement("div", null,
65
- react_1.default.createElement(save_button_1.SaveButton, { key: pkg.packageId, doc: pkg, addActions: [
67
+ react_1.default.createElement(save_button_1.SaveButton, { key: pkg.packageId, doc: pkg, onClick: async () => {
68
+ await pkg.save();
69
+ await saveDeviceTagRef.current?.();
70
+ }, addActions: [
66
71
  ...addActions
67
72
  ] }))),
68
73
  react_1.default.createElement(tabs_1.Tabs, { key: pkg.packageId, tabs: [
69
74
  {
70
75
  name: 'Info', content: react_1.default.createElement(tabs_1.ScreenTabBody, null,
71
- react_1.default.createElement(package_info_1.PackageInfo, { pkg: pkg }))
76
+ react_1.default.createElement(package_info_1.PackageInfo, { pkg: pkg, saveDeviceTagRef: saveDeviceTagRef }))
77
+ },
78
+ {
79
+ name: 'Versions', content: react_1.default.createElement(tabs_1.ScreenTabBody, null,
80
+ react_1.default.createElement(package_versions_1.PackageVersionsList, { pkg: pkg }))
72
81
  },
73
82
  {
74
83
  name: 'Components', content: react_1.default.createElement(tabs_1.ScreenTabBody, null, "TODO - show all of the different components in the package")
@@ -2,4 +2,5 @@ import React from "react";
2
2
  import { IDoc, IPackage } from "@peers-app/peers-sdk";
3
3
  export declare const PackageInfo: (props: {
4
4
  pkg: IDoc<IPackage>;
5
+ saveDeviceTagRef?: React.MutableRefObject<(() => Promise<void>) | null>;
5
6
  }) => React.JSX.Element;
@@ -9,8 +9,87 @@ const peers_sdk_1 = require("@peers-app/peers-sdk");
9
9
  const markdown_with_mentions_1 = require("../../components/markdown-with-mentions");
10
10
  const tooltip_1 = require("../../components/tooltip");
11
11
  const input_1 = require("../../components/input");
12
+ const hooks_1 = require("../../hooks");
13
+ const deviceVersionTagVar = (0, peers_sdk_1.groupDeviceVar)('deviceVersionTag');
12
14
  const PackageInfo = (props) => {
13
15
  const { pkg } = props;
16
+ const [activeVersionId] = (0, hooks_1.useObservable)(pkg.qs.activePackageVersionId);
17
+ const [versionFollowRange] = (0, hooks_1.useObservable)(pkg.qs.versionFollowRange);
18
+ const [followVersionTags] = (0, hooks_1.useObservable)(pkg.qs.followVersionTags);
19
+ const [deviceTag] = (0, hooks_1.useObservable)(deviceVersionTagVar);
20
+ const [deviceTagDraft, setDeviceTagDraft] = react_1.default.useState(deviceTag || '');
21
+ const savingRef = react_1.default.useRef(false);
22
+ react_1.default.useEffect(() => {
23
+ if (!savingRef.current) {
24
+ setDeviceTagDraft(deviceTag || '');
25
+ }
26
+ }, [deviceTag]);
27
+ const deviceTagDirty = deviceTagDraft.trim() !== (deviceTag || '');
28
+ const prevDirtyRef = react_1.default.useRef(false);
29
+ react_1.default.useEffect(() => {
30
+ if (deviceTagDirty && !prevDirtyRef.current) {
31
+ pkg.q((pkg.q() || 0) + 1);
32
+ }
33
+ else if (!deviceTagDirty && prevDirtyRef.current) {
34
+ pkg.q(Math.max(0, (pkg.q() || 0) - 1));
35
+ }
36
+ prevDirtyRef.current = deviceTagDirty;
37
+ }, [deviceTagDirty]);
38
+ if (props.saveDeviceTagRef) {
39
+ props.saveDeviceTagRef.current = async () => {
40
+ const val = deviceTagDraft.trim();
41
+ if (val !== (deviceTag || '')) {
42
+ savingRef.current = true;
43
+ try {
44
+ if (val) {
45
+ deviceVersionTagVar(val);
46
+ }
47
+ else {
48
+ await deviceVersionTagVar.delete();
49
+ }
50
+ }
51
+ finally {
52
+ savingRef.current = false;
53
+ }
54
+ }
55
+ };
56
+ }
57
+ const activeVersion = (0, hooks_1.usePromise)(async () => {
58
+ if (!activeVersionId)
59
+ return null;
60
+ return (0, peers_sdk_1.PackageVersions)().get(activeVersionId);
61
+ }, undefined, [activeVersionId]);
62
+ const newerLevel = (0, hooks_1.usePromise)(async () => {
63
+ if (!activeVersionId)
64
+ return 'uptodate';
65
+ const all = await (0, peers_sdk_1.PackageVersions)().list({ packageId: pkg.packageId });
66
+ const active = all.find(v => v.packageVersionId === activeVersionId);
67
+ if (!active?.version)
68
+ return 'uptodate';
69
+ const parse = (v) => v.split('.').map(Number);
70
+ const [aMaj, aMin, aPat] = parse(active.version);
71
+ let highest = null;
72
+ for (const v of all) {
73
+ if (!v.version || v.packageVersionId === activeVersionId)
74
+ continue;
75
+ const [maj, min, pat] = parse(v.version);
76
+ if (maj > aMaj)
77
+ return 'major';
78
+ if (maj === aMaj && min > aMin) {
79
+ highest = 'minor';
80
+ continue;
81
+ }
82
+ if (maj === aMaj && min === aMin && pat > aPat && !highest) {
83
+ highest = 'patch';
84
+ continue;
85
+ }
86
+ if (maj === aMaj && min === aMin && pat === aPat && (v.createdAt || '') > (active.createdAt || '') && !highest) {
87
+ highest = 'patch';
88
+ }
89
+ }
90
+ return highest || 'uptodate';
91
+ }, undefined, [activeVersionId, pkg.packageId]);
92
+ const isPinned = versionFollowRange === 'pinned';
14
93
  return (react_1.default.createElement("div", null,
15
94
  react_1.default.createElement("small", null, "Name:"),
16
95
  react_1.default.createElement(input_1.Input, { value: pkg.qs.name, className: "form-control mb-3 p-0 ps-2", placeholder: "Package name", title: "Package name", disabled: true }),
@@ -37,6 +116,46 @@ const PackageInfo = (props) => {
37
116
  react_1.default.createElement("small", null,
38
117
  "Description:",
39
118
  react_1.default.createElement(tooltip_1.Tooltip, { markdownContent: `This should be edited in the package's README.md. It will automatically update when you restart Peers or reload the package.` })),
40
- react_1.default.createElement(markdown_with_mentions_1.MarkdownWithMentions, { content: pkg.description || '' }))));
119
+ react_1.default.createElement(markdown_with_mentions_1.MarkdownWithMentions, { content: pkg.description || '' })),
120
+ react_1.default.createElement("div", { className: "mt-2" },
121
+ react_1.default.createElement("hr", null),
122
+ react_1.default.createElement("small", null,
123
+ "Version:",
124
+ react_1.default.createElement(tooltip_1.Tooltip, { markdownContent: "The currently active version of this package. Manage versions in the Versions tab." })),
125
+ activeVersion ? (react_1.default.createElement("div", { className: "d-flex align-items-center mt-1 mb-3" },
126
+ react_1.default.createElement("strong", { className: "me-2" },
127
+ "v",
128
+ activeVersion.version),
129
+ activeVersion.versionTag && (react_1.default.createElement("span", { className: `badge ${activeVersion.versionTag.startsWith('beta') ? 'text-bg-warning' : 'text-bg-info'} me-2` }, activeVersion.versionTag)),
130
+ react_1.default.createElement("code", { className: "text-muted small me-2" }, activeVersion.packageVersionHash?.substring(0, 8)),
131
+ newerLevel === 'uptodate' ? (react_1.default.createElement("span", { className: "badge text-bg-success" }, "Up to date")) : newerLevel ? (react_1.default.createElement("span", { className: `badge text-bg-${newerLevel === 'major' ? 'danger' : newerLevel === 'minor' ? 'warning' : 'info'}` },
132
+ "Newer ",
133
+ newerLevel,
134
+ " version available")) : null)) : (react_1.default.createElement("div", { className: "text-muted small mt-1 mb-3" }, "No active version")),
135
+ react_1.default.createElement("div", { className: "mb-3" },
136
+ react_1.default.createElement("small", null,
137
+ "Auto-Update Range:",
138
+ react_1.default.createElement(tooltip_1.Tooltip, { markdownContent: "Controls which new versions are auto-activated. **Pinned** = never auto-update. **Patch** = same major.minor (e.g. 1.2.x). **Minor** = same major (e.g. 1.x.x). **Latest** = always auto-update to newest." })),
139
+ react_1.default.createElement("select", { className: "form-select form-select-sm", value: versionFollowRange || 'latest', onChange: (e) => {
140
+ const val = e.target.value;
141
+ pkg.versionFollowRange = val === 'latest' ? undefined : val;
142
+ } },
143
+ react_1.default.createElement("option", { value: "latest" }, "Latest (auto-update to newest)"),
144
+ react_1.default.createElement("option", { value: "minor" }, "Minor (same major version)"),
145
+ react_1.default.createElement("option", { value: "patch" }, "Patch (same major.minor version)"),
146
+ react_1.default.createElement("option", { value: "pinned" }, "Pinned (no auto-updates)"))),
147
+ react_1.default.createElement("div", { className: `mb-3 ${isPinned ? 'opacity-50' : ''}` },
148
+ react_1.default.createElement("small", null,
149
+ "Follow Version Tags:",
150
+ react_1.default.createElement(tooltip_1.Tooltip, { markdownContent: "Which version tags to auto-activate. Leave empty for **current** (follows the active version's tag). Use `*` for any tag, or a comma-separated list like `stable,prod`." })),
151
+ react_1.default.createElement(input_1.Input, { value: pkg.qs.followVersionTags, className: "form-control form-control-sm p-0 ps-2", placeholder: `Following "${activeVersion?.versionTag || 'stable'}"`, disabled: isPinned })),
152
+ react_1.default.createElement("div", { className: "mb-2" },
153
+ react_1.default.createElement("small", null,
154
+ "Device-Specific Version Tag:",
155
+ react_1.default.createElement(tooltip_1.Tooltip, { markdownContent: "Override the tag policy for this device only. For example, set to `beta` to test pre-release versions on this device without affecting other devices or group members. This is not synced." })),
156
+ react_1.default.createElement("div", { className: "d-flex align-items-center gap-2" },
157
+ react_1.default.createElement("input", { type: "text", className: "form-control form-control-sm p-0 ps-2", placeholder: "e.g. beta", value: deviceTagDraft, onChange: (e) => setDeviceTagDraft(e.target.value) }),
158
+ deviceTagDraft && (react_1.default.createElement("button", { className: "btn btn-sm btn-outline-secondary", title: "Clear device tag override", onClick: () => setDeviceTagDraft('') },
159
+ react_1.default.createElement("i", { className: "bi bi-x-lg" }))))))));
41
160
  };
42
161
  exports.PackageInfo = PackageInfo;
@@ -43,6 +43,20 @@ const tooltip_1 = require("../../components/tooltip");
43
43
  const globals_1 = require("../../globals");
44
44
  const hooks_1 = require("../../hooks");
45
45
  const ui_loader_1 = require("../../ui-router/ui-loader");
46
+ const PackageVersionBadge = ({ activePackageVersionId }) => {
47
+ const pv = (0, hooks_1.usePromise)(async () => {
48
+ if (!activePackageVersionId)
49
+ return null;
50
+ return (0, peers_sdk_1.PackageVersions)().get(activePackageVersionId);
51
+ }, undefined, [activePackageVersionId]);
52
+ if (!pv)
53
+ return null;
54
+ return (react_1.default.createElement("span", { className: "ms-2" },
55
+ react_1.default.createElement("small", { className: "text-muted" },
56
+ "v",
57
+ pv.version),
58
+ pv.versionTag && (react_1.default.createElement("span", { className: `badge ms-1 ${pv.versionTag.startsWith('beta') ? 'text-bg-warning' : 'text-bg-info'}`, style: { fontSize: '0.65em' } }, pv.versionTag))));
59
+ };
46
60
  const PackageList = () => {
47
61
  const [searchTextObs] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)(''));
48
62
  const [searchText] = (0, hooks_1.useObservable)(searchTextObs);
@@ -157,6 +171,7 @@ const PackageList = () => {
157
171
  react_1.default.createElement("i", { className: "bi bi-box-fill" }),
158
172
  "\u00A0\u00A0",
159
173
  react_1.default.createElement("a", { href: `#packages/${pkg.packageId}` }, pkg.name),
174
+ react_1.default.createElement(PackageVersionBadge, { activePackageVersionId: pkg.activePackageVersionId }),
160
175
  react_1.default.createElement(tooltip_1.Tooltip, { markdownContent: pkg.description, positions: ['bottom', 'top', 'right', 'left'] })));
161
176
  });
162
177
  }, loadingIndicator: react_1.default.createElement("div", { className: "d-flex justify-content-center", style: { height: 200 } },
@@ -0,0 +1,5 @@
1
+ import React from "react";
2
+ import { IDoc, IPackage } from "@peers-app/peers-sdk";
3
+ export declare const PackageVersionsList: (props: {
4
+ pkg: IDoc<IPackage>;
5
+ }) => React.JSX.Element;
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.PackageVersionsList = void 0;
37
+ const react_1 = __importStar(require("react"));
38
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
39
+ const hooks_1 = require("../../hooks");
40
+ function formatDate(iso) {
41
+ try {
42
+ const d = new Date(iso);
43
+ const now = new Date();
44
+ const diffMs = now.getTime() - d.getTime();
45
+ const diffMins = Math.floor(diffMs / 60000);
46
+ if (diffMins < 1)
47
+ return 'just now';
48
+ if (diffMins < 60)
49
+ return `${diffMins}m ago`;
50
+ const diffHours = Math.floor(diffMins / 60);
51
+ if (diffHours < 24)
52
+ return `${diffHours}h ago`;
53
+ const diffDays = Math.floor(diffHours / 24);
54
+ if (diffDays < 30)
55
+ return `${diffDays}d ago`;
56
+ return d.toLocaleDateString();
57
+ }
58
+ catch {
59
+ return iso;
60
+ }
61
+ }
62
+ function tagBadgeClass(tag) {
63
+ if (!tag)
64
+ return 'text-bg-secondary';
65
+ if (tag.startsWith('beta'))
66
+ return 'text-bg-warning';
67
+ if (tag === 'stable')
68
+ return 'text-bg-info';
69
+ return 'text-bg-secondary';
70
+ }
71
+ const PackageVersionsList = (props) => {
72
+ const { pkg } = props;
73
+ const [activeVersionId] = (0, hooks_1.useObservable)(pkg.qs.activePackageVersionId);
74
+ const [versionFollowRange] = (0, hooks_1.useObservable)(pkg.qs.versionFollowRange);
75
+ const refreshKey = (0, hooks_1.useObservableState)(0);
76
+ const [activating, setActivating] = (0, react_1.useState)(null);
77
+ const [deleting, setDeleting] = (0, react_1.useState)(null);
78
+ const [pinning, setPinning] = (0, react_1.useState)(false);
79
+ const isPinned = versionFollowRange === 'pinned';
80
+ const versions = (0, hooks_1.usePromise)(async () => {
81
+ const all = await (0, peers_sdk_1.PackageVersions)().list({ packageId: pkg.packageId });
82
+ const parse = (v) => v.split('.').map(Number);
83
+ const sorted = all.sort((a, b) => {
84
+ const [aMaj, aMin, aPat] = parse(a.version || '0.0.0');
85
+ const [bMaj, bMin, bPat] = parse(b.version || '0.0.0');
86
+ if (bMaj !== aMaj)
87
+ return bMaj - aMaj;
88
+ if (bMin !== aMin)
89
+ return bMin - aMin;
90
+ if (bPat !== aPat)
91
+ return bPat - aPat;
92
+ return (b.createdAt || '').localeCompare(a.createdAt || '');
93
+ });
94
+ const uniqueCreatorIds = [...new Set(sorted.map(pv => pv.createdBy).filter(Boolean))];
95
+ const userMap = new Map();
96
+ await Promise.all(uniqueCreatorIds.map(async (uid) => {
97
+ try {
98
+ const user = await (0, peers_sdk_1.Users)().get(uid, { useCache: true });
99
+ if (user?.name)
100
+ userMap.set(uid, user.name);
101
+ }
102
+ catch { }
103
+ }));
104
+ return { sorted, userMap };
105
+ }, undefined, [pkg.packageId, refreshKey()]);
106
+ async function activateVersion(pv) {
107
+ setActivating(pv.packageVersionId);
108
+ try {
109
+ const current = await (0, peers_sdk_1.Packages)().get(pkg.packageId);
110
+ if (current) {
111
+ current.activePackageVersionId = pv.packageVersionId;
112
+ await (0, peers_sdk_1.Packages)().signAndSave(current);
113
+ await pkg.load();
114
+ refreshKey(refreshKey() + 1);
115
+ }
116
+ }
117
+ catch (err) {
118
+ alert(`Failed to activate version: ${err}`);
119
+ }
120
+ finally {
121
+ setActivating(null);
122
+ }
123
+ }
124
+ async function deleteVersion(pv) {
125
+ if (!confirm(`Delete version v${pv.version} (${pv.packageVersionHash?.substring(0, 8)})?`))
126
+ return;
127
+ setDeleting(pv.packageVersionId);
128
+ try {
129
+ await (0, peers_sdk_1.PackageVersions)().delete(pv.packageVersionId);
130
+ refreshKey(refreshKey() + 1);
131
+ }
132
+ catch (err) {
133
+ alert(`Failed to delete version: ${err}`);
134
+ }
135
+ finally {
136
+ setDeleting(null);
137
+ }
138
+ }
139
+ async function pinVersion() {
140
+ setPinning(true);
141
+ try {
142
+ const current = await (0, peers_sdk_1.Packages)().get(pkg.packageId);
143
+ if (current) {
144
+ current.versionFollowRange = 'pinned';
145
+ await (0, peers_sdk_1.Packages)().signAndSave(current);
146
+ await pkg.load();
147
+ }
148
+ }
149
+ catch (err) {
150
+ alert(`Failed to pin version: ${err}`);
151
+ }
152
+ finally {
153
+ setPinning(false);
154
+ }
155
+ }
156
+ if (!versions) {
157
+ return react_1.default.createElement("div", { className: "text-center p-3" },
158
+ react_1.default.createElement("div", { className: "spinner-border spinner-border-sm" }));
159
+ }
160
+ const { sorted, userMap } = versions;
161
+ if (sorted.length === 0) {
162
+ return (react_1.default.createElement("div", { className: "text-muted text-center p-4" },
163
+ react_1.default.createElement("i", { className: "bi bi-tag me-1" }),
164
+ "No versions found for this package."));
165
+ }
166
+ return (react_1.default.createElement("div", null,
167
+ react_1.default.createElement("small", { className: "text-muted" },
168
+ sorted.length,
169
+ " version",
170
+ sorted.length !== 1 ? 's' : ''),
171
+ react_1.default.createElement("div", { className: "list-group mt-2" }, sorted.map(pv => {
172
+ const isActive = pv.packageVersionId === activeVersionId;
173
+ const creatorName = userMap.get(pv.createdBy);
174
+ return (react_1.default.createElement("div", { key: pv.packageVersionId, className: `list-group-item ${isActive ? 'list-group-item-success' : ''}` },
175
+ react_1.default.createElement("div", { className: "d-flex align-items-center justify-content-between" },
176
+ react_1.default.createElement("div", null,
177
+ react_1.default.createElement("strong", { className: "me-2" },
178
+ "v",
179
+ pv.version),
180
+ pv.versionTag && (react_1.default.createElement("span", { className: `badge ${tagBadgeClass(pv.versionTag)} me-2` }, pv.versionTag)),
181
+ react_1.default.createElement("code", { className: "text-muted small me-2" }, pv.packageVersionHash?.substring(0, 8)),
182
+ react_1.default.createElement("small", { className: "text-muted me-2" }, formatDate(pv.createdAt)),
183
+ creatorName && (react_1.default.createElement("small", { className: "text-muted" },
184
+ "by ",
185
+ creatorName))),
186
+ react_1.default.createElement("div", { className: "d-flex gap-1" },
187
+ isActive ? (react_1.default.createElement(react_1.default.Fragment, null,
188
+ react_1.default.createElement("button", { className: "btn btn-sm btn-outline-secondary", disabled: isPinned || pinning, onClick: () => pinVersion(), title: isPinned ? 'Already pinned' : 'Pin to this version (disable auto-updates)' },
189
+ pinning ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm" })) : (react_1.default.createElement("i", { className: "bi bi-pin-fill" })),
190
+ isPinned ? ' Pinned' : ' Pin'),
191
+ react_1.default.createElement("span", { className: "badge text-bg-success align-self-center" }, "active"))) : (react_1.default.createElement("button", { className: "btn btn-sm btn-outline-primary", disabled: activating === pv.packageVersionId, onClick: () => activateVersion(pv) },
192
+ activating === pv.packageVersionId ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm me-1" })) : (react_1.default.createElement("i", { className: "bi bi-check2-circle me-1" })),
193
+ "Activate")),
194
+ !isActive && (react_1.default.createElement("button", { className: "btn btn-sm btn-outline-danger", disabled: deleting === pv.packageVersionId, onClick: () => deleteVersion(pv) }, deleting === pv.packageVersionId ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm" })) : (react_1.default.createElement("i", { className: "bi bi-trash" }))))))));
195
+ }))));
196
+ };
197
+ exports.PackageVersionsList = PackageVersionsList;
@@ -106,7 +106,4 @@ exports.systemPackage = {
106
106
  localPath: '',
107
107
  disabled: false,
108
108
  signature: '',
109
- packageBundleFileId: '',
110
- packageBundleFileHash: '',
111
- // System apps use existing router instead of separate bundle files
112
109
  };
@@ -6,19 +6,16 @@ export declare const allPackages: import("@peers-app/peers-sdk").Observable<{
6
6
  createdBy: string;
7
7
  packageId: string;
8
8
  localPath: string;
9
- packageBundleFileId: string;
10
- packageBundleFileHash: string;
11
9
  disabled?: boolean | undefined;
12
- remoteRepo?: string | undefined;
13
10
  appNavs?: {
14
11
  name: string;
15
12
  iconClassName: string;
16
13
  navigationPath: string;
17
14
  displayName?: string | undefined;
18
15
  }[] | undefined;
19
- routesBundleFileId?: string | undefined;
20
- routesBundleFileHash?: string | undefined;
21
- uiBundleFileId?: string | undefined;
22
- uiBundleFileHash?: string | undefined;
16
+ remoteRepo?: string | undefined;
17
+ activePackageVersionId?: string | undefined;
18
+ versionFollowRange?: "pinned" | "patch" | "minor" | "latest" | undefined;
19
+ followVersionTags?: string | undefined;
23
20
  }[]>;
24
21
  export declare const loadAllRoutes: () => Promise<true | undefined>;
@@ -9,10 +9,10 @@ const loadAllRoutes = async () => {
9
9
  if (allRoutesLoaded)
10
10
  return;
11
11
  allRoutesLoaded = true;
12
- // Filter packages that have UI bundles (routesBundleFileId or uiBundleFileId)
12
+ // Filter packages that have an active version
13
13
  let packagesWithUI = await (0, peers_sdk_1.Packages)().list({
14
14
  disabled: { $ne: true },
15
- routesBundleFileId: { $exists: true },
15
+ activePackageVersionId: { $exists: true },
16
16
  });
17
17
  (0, exports.allPackages)(packagesWithUI);
18
18
  await Promise.all(packagesWithUI.map(pkg => loadRoutesBundle(pkg)));
@@ -58,14 +58,21 @@ function loadRoutesBundle(pkg, forceRefresh) {
58
58
  console.log(`waiting for registerPeersUIRoute to be defined on the window object`);
59
59
  await new Promise((resolve) => setTimeout(resolve, 20));
60
60
  }
61
+ let routesBundleFileId;
62
+ if (pkg.activePackageVersionId) {
63
+ try {
64
+ const pv = await (0, peers_sdk_1.PackageVersions)().get(pkg.activePackageVersionId);
65
+ routesBundleFileId = pv?.routesBundleFileId;
66
+ }
67
+ catch { /* no version record */ }
68
+ }
61
69
  let bundleCode = '';
62
- if (pkg.routesBundleFileId) {
63
- bundleCode = await peers_sdk_1.rpcServerCalls.getFileContents(pkg.routesBundleFileId);
70
+ if (routesBundleFileId) {
71
+ bundleCode = await peers_sdk_1.rpcServerCalls.getFileContents(routesBundleFileId);
64
72
  }
65
73
  if (bundleCode) {
66
74
  const exportRoutes = (peerRoutes) => {
67
75
  peerRoutes.routes.forEach(route => {
68
- // TODO maybe add package that this came from
69
76
  window.registerPeersUIRoute(route);
70
77
  });
71
78
  };
@@ -73,8 +80,8 @@ function loadRoutesBundle(pkg, forceRefresh) {
73
80
  bundleFunction(exportRoutes);
74
81
  return {};
75
82
  }
76
- else {
77
- console.warn(`Routes bundle file not found for ${pkg.name} (fileId: ${pkg.routesBundleFileId})`);
83
+ else if (routesBundleFileId) {
84
+ console.warn(`Routes bundle file not found for ${pkg.name} (fileId: ${routesBundleFileId})`);
78
85
  }
79
86
  return null;
80
87
  }
@@ -227,28 +227,35 @@ const UILoader = (args) => {
227
227
  const uiLoadingPromises = {};
228
228
  // Check if we're running in a React Native WebView (has injectUIBundle available)
229
229
  const isReactNativeWebView = typeof window.ReactNativeWebView !== 'undefined';
230
+ async function resolveUiBundleFileId(pkg) {
231
+ if (!pkg.activePackageVersionId)
232
+ return undefined;
233
+ try {
234
+ const pv = await (0, peers_sdk_1.PackageVersions)().get(pkg.activePackageVersionId);
235
+ return pv?.uiBundleFileId;
236
+ }
237
+ catch {
238
+ return undefined;
239
+ }
240
+ }
230
241
  function loadUIBundle(pkg, forceRefresh) {
231
- // Dynamically import the bundle
232
242
  let importPromise = uiLoadingPromises[pkg.packageId];
233
243
  if (!importPromise || forceRefresh) {
234
244
  const sTime = Date.now();
235
245
  console.log(`loading ui bundle for ${pkg.name}`);
236
246
  importPromise = new Promise(async (resolve, reject) => {
237
247
  try {
238
- if (!pkg.uiBundleFileId) {
248
+ const uiBundleFileId = await resolveUiBundleFileId(pkg);
249
+ if (!uiBundleFileId) {
239
250
  resolve();
240
251
  return;
241
252
  }
242
- // Use fast injection path for React Native WebView
243
253
  if (isReactNativeWebView && peers_sdk_1.rpcServerCalls.injectUIBundle) {
244
- // Set up listeners for bundle load completion
245
254
  const _window = window;
246
255
  _window.__peersUIs = _window.__peersUIs || {};
247
256
  const loadPromise = new Promise((resolveLoad, rejectLoad) => {
248
- const fileId = pkg.uiBundleFileId;
249
257
  _window.__peersUIBundleLoaded = (loadedFileId) => {
250
- if (loadedFileId === fileId) {
251
- // Copy loaded UIs to our local registry
258
+ if (loadedFileId === uiBundleFileId) {
252
259
  Object.keys(_window.__peersUIs || {}).forEach(peersUIId => {
253
260
  peersUIs[peersUIId] = _window.__peersUIs[peersUIId];
254
261
  });
@@ -256,18 +263,17 @@ function loadUIBundle(pkg, forceRefresh) {
256
263
  }
257
264
  };
258
265
  _window.__peersUIBundleError = (errorFileId, errorMsg) => {
259
- if (errorFileId === fileId) {
266
+ if (errorFileId === uiBundleFileId) {
260
267
  rejectLoad(new Error(errorMsg));
261
268
  }
262
269
  };
263
270
  });
264
- await peers_sdk_1.rpcServerCalls.injectUIBundle(pkg.uiBundleFileId);
271
+ await peers_sdk_1.rpcServerCalls.injectUIBundle(uiBundleFileId);
265
272
  await loadPromise;
266
273
  console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms`);
267
274
  }
268
275
  else {
269
- // Fallback: use postMessage-based getFileContents (slower for large bundles)
270
- let bundleCode = await peers_sdk_1.rpcServerCalls.getFileContents(pkg.uiBundleFileId);
276
+ let bundleCode = await peers_sdk_1.rpcServerCalls.getFileContents(uiBundleFileId);
271
277
  if (bundleCode) {
272
278
  const exportUIs = (peerUIs) => {
273
279
  peerUIs?.uis?.forEach(ui => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.9.4",
3
+ "version": "0.10.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -26,7 +26,7 @@
26
26
  "test:coverage": "jest --coverage"
27
27
  },
28
28
  "peerDependencies": {
29
- "@peers-app/peers-sdk": "^0.9.4",
29
+ "@peers-app/peers-sdk": "^0.10.1",
30
30
  "bootstrap": "^5.3.3",
31
31
  "react": "^18.0.0",
32
32
  "react-dom": "^18.0.0"
@@ -37,7 +37,7 @@
37
37
  "@babel/preset-react": "^7.24.1",
38
38
  "@babel/preset-typescript": "^7.27.1",
39
39
  "@electron/rebuild": "^3.6.0",
40
- "@peers-app/peers-sdk": "0.9.4",
40
+ "@peers-app/peers-sdk": "0.10.1",
41
41
  "@testing-library/dom": "^10.4.0",
42
42
  "@testing-library/jest-dom": "^6.6.3",
43
43
  "@testing-library/react": "^16.3.0",
@@ -4,6 +4,7 @@ import { LoadingIndicator } from "../../components/loading-indicator";
4
4
  import { ISaveButtonProps, SaveButton } from "../../components/save-button";
5
5
  import { ScreenTabBody, Tabs } from "../../components/tabs";
6
6
  import { PackageInfo } from "./package-info";
7
+ import { PackageVersionsList } from "./package-versions";
7
8
  import { useObservableState, usePromise } from "../../hooks";
8
9
  import { Input } from "../../components/input";
9
10
  import { updateActiveTabTitle } from "../../tabs-layout/tabs-state";
@@ -16,6 +17,7 @@ interface IProps {
16
17
  export const PackageDetails = (props: IProps) => {
17
18
 
18
19
  const refresh = useObservableState(Date.now());
20
+ const saveDeviceTagRef = React.useRef<(() => Promise<void>) | null>(null);
19
21
 
20
22
  const pkg = usePromise(async () => {
21
23
  const pkg = await Packages().get(props.packageId);
@@ -80,6 +82,10 @@ export const PackageDetails = (props: IProps) => {
80
82
  <SaveButton
81
83
  key={pkg.packageId}
82
84
  doc={pkg}
85
+ onClick={async () => {
86
+ await pkg.save();
87
+ await saveDeviceTagRef.current?.();
88
+ }}
83
89
  addActions={[
84
90
  ...addActions
85
91
  ]}
@@ -93,7 +99,13 @@ export const PackageDetails = (props: IProps) => {
93
99
  {
94
100
  name: 'Info', content:
95
101
  <ScreenTabBody>
96
- <PackageInfo pkg={pkg} />
102
+ <PackageInfo pkg={pkg} saveDeviceTagRef={saveDeviceTagRef} />
103
+ </ScreenTabBody>
104
+ },
105
+ {
106
+ name: 'Versions', content:
107
+ <ScreenTabBody>
108
+ <PackageVersionsList pkg={pkg} />
97
109
  </ScreenTabBody>
98
110
  },
99
111
  {
@@ -1,11 +1,85 @@
1
1
  import React from "react";
2
- import { rpcServerCalls, IDoc, IPackage, packagesRootDir } from "@peers-app/peers-sdk";
2
+ import { rpcServerCalls, IDoc, IPackage, IPackageVersion, PackageVersions, packagesRootDir, groupDeviceVar } from "@peers-app/peers-sdk";
3
3
  import { MarkdownWithMentions } from "../../components/markdown-with-mentions";
4
4
  import { Tooltip } from "../../components/tooltip";
5
5
  import { Input } from "../../components/input";
6
+ import { useObservable, usePromise } from "../../hooks";
6
7
 
7
- export const PackageInfo = (props: { pkg: IDoc<IPackage> }) => {
8
+ const deviceVersionTagVar = groupDeviceVar<string | undefined>('deviceVersionTag');
9
+
10
+ export const PackageInfo = (props: {
11
+ pkg: IDoc<IPackage>,
12
+ saveDeviceTagRef?: React.MutableRefObject<(() => Promise<void>) | null>,
13
+ }) => {
8
14
  const { pkg } = props;
15
+ const [activeVersionId] = useObservable(pkg.qs.activePackageVersionId);
16
+ const [versionFollowRange] = useObservable(pkg.qs.versionFollowRange);
17
+ const [followVersionTags] = useObservable(pkg.qs.followVersionTags);
18
+ const [deviceTag] = useObservable(deviceVersionTagVar);
19
+
20
+ const [deviceTagDraft, setDeviceTagDraft] = React.useState(deviceTag || '');
21
+ const savingRef = React.useRef(false);
22
+
23
+ React.useEffect(() => {
24
+ if (!savingRef.current) {
25
+ setDeviceTagDraft(deviceTag || '');
26
+ }
27
+ }, [deviceTag]);
28
+
29
+ const deviceTagDirty = deviceTagDraft.trim() !== (deviceTag || '');
30
+ const prevDirtyRef = React.useRef(false);
31
+ React.useEffect(() => {
32
+ if (deviceTagDirty && !prevDirtyRef.current) {
33
+ pkg.q((pkg.q() || 0) + 1);
34
+ } else if (!deviceTagDirty && prevDirtyRef.current) {
35
+ pkg.q(Math.max(0, (pkg.q() || 0) - 1));
36
+ }
37
+ prevDirtyRef.current = deviceTagDirty;
38
+ }, [deviceTagDirty]);
39
+
40
+ if (props.saveDeviceTagRef) {
41
+ props.saveDeviceTagRef.current = async () => {
42
+ const val = deviceTagDraft.trim();
43
+ if (val !== (deviceTag || '')) {
44
+ savingRef.current = true;
45
+ try {
46
+ if (val) {
47
+ deviceVersionTagVar(val);
48
+ } else {
49
+ await deviceVersionTagVar.delete();
50
+ }
51
+ } finally {
52
+ savingRef.current = false;
53
+ }
54
+ }
55
+ };
56
+ }
57
+
58
+ const activeVersion = usePromise(async () => {
59
+ if (!activeVersionId) return null;
60
+ return PackageVersions().get(activeVersionId) as Promise<IPackageVersion | null>;
61
+ }, undefined, [activeVersionId]);
62
+
63
+ const newerLevel = usePromise(async (): Promise<'major' | 'minor' | 'patch' | 'uptodate'> => {
64
+ if (!activeVersionId) return 'uptodate';
65
+ const all = await PackageVersions().list({ packageId: pkg.packageId });
66
+ const active = all.find(v => v.packageVersionId === activeVersionId);
67
+ if (!active?.version) return 'uptodate';
68
+ const parse = (v: string) => v.split('.').map(Number);
69
+ const [aMaj, aMin, aPat] = parse(active.version);
70
+ let highest: 'major' | 'minor' | 'patch' | null = null;
71
+ for (const v of all) {
72
+ if (!v.version || v.packageVersionId === activeVersionId) continue;
73
+ const [maj, min, pat] = parse(v.version);
74
+ if (maj > aMaj) return 'major';
75
+ if (maj === aMaj && min > aMin) { highest = 'minor'; continue; }
76
+ if (maj === aMaj && min === aMin && pat > aPat && !highest) { highest = 'patch'; continue; }
77
+ if (maj === aMaj && min === aMin && pat === aPat && (v.createdAt || '') > (active.createdAt || '') && !highest) { highest = 'patch'; }
78
+ }
79
+ return highest || 'uptodate';
80
+ }, undefined, [activeVersionId, pkg.packageId]);
81
+
82
+ const isPinned = versionFollowRange === 'pinned';
9
83
 
10
84
  return (
11
85
  <div>
@@ -66,18 +140,97 @@ export const PackageInfo = (props: { pkg: IDoc<IPackage> }) => {
66
140
  Description:
67
141
  <Tooltip markdownContent={`This should be edited in the package's README.md. It will automatically update when you restart Peers or reload the package.`} />
68
142
  </small>
69
- {/* <div className="border rounded border-dark-subtle p-1">
70
- <MarkdownWithMentions
71
- content={pkg.description || ''}
72
- />
73
- </div> */}
74
-
75
143
  <MarkdownWithMentions
76
144
  content={pkg.description || ''}
77
145
  />
78
146
  </div>
79
147
 
148
+ <div className="mt-2">
149
+ <hr />
150
+ <small>
151
+ Version:
152
+ <Tooltip markdownContent="The currently active version of this package. Manage versions in the Versions tab." />
153
+ </small>
154
+ {activeVersion ? (
155
+ <div className="d-flex align-items-center mt-1 mb-3">
156
+ <strong className="me-2">v{activeVersion.version}</strong>
157
+ {activeVersion.versionTag && (
158
+ <span className={`badge ${activeVersion.versionTag.startsWith('beta') ? 'text-bg-warning' : 'text-bg-info'} me-2`}>
159
+ {activeVersion.versionTag}
160
+ </span>
161
+ )}
162
+ <code className="text-muted small me-2">{activeVersion.packageVersionHash?.substring(0, 8)}</code>
163
+ {newerLevel === 'uptodate' ? (
164
+ <span className="badge text-bg-success">Up to date</span>
165
+ ) : newerLevel ? (
166
+ <span className={`badge text-bg-${newerLevel === 'major' ? 'danger' : newerLevel === 'minor' ? 'warning' : 'info'}`}>
167
+ Newer {newerLevel} version available
168
+ </span>
169
+ ) : null}
170
+ </div>
171
+ ) : (
172
+ <div className="text-muted small mt-1 mb-3">No active version</div>
173
+ )}
174
+
175
+ <div className="mb-3">
176
+ <small>
177
+ Auto-Update Range:
178
+ <Tooltip markdownContent="Controls which new versions are auto-activated. **Pinned** = never auto-update. **Patch** = same major.minor (e.g. 1.2.x). **Minor** = same major (e.g. 1.x.x). **Latest** = always auto-update to newest." />
179
+ </small>
180
+ <select
181
+ className="form-select form-select-sm"
182
+ value={versionFollowRange || 'latest'}
183
+ onChange={(e) => {
184
+ const val = e.target.value as 'pinned' | 'patch' | 'minor' | 'latest';
185
+ pkg.versionFollowRange = val === 'latest' ? undefined : val;
186
+ }}
187
+ >
188
+ <option value="latest">Latest (auto-update to newest)</option>
189
+ <option value="minor">Minor (same major version)</option>
190
+ <option value="patch">Patch (same major.minor version)</option>
191
+ <option value="pinned">Pinned (no auto-updates)</option>
192
+ </select>
193
+ </div>
194
+
195
+ <div className={`mb-3 ${isPinned ? 'opacity-50' : ''}`}>
196
+ <small>
197
+ Follow Version Tags:
198
+ <Tooltip markdownContent="Which version tags to auto-activate. Leave empty for **current** (follows the active version's tag). Use `*` for any tag, or a comma-separated list like `stable,prod`." />
199
+ </small>
200
+ <Input
201
+ value={pkg.qs.followVersionTags}
202
+ className="form-control form-control-sm p-0 ps-2"
203
+ placeholder={`Following "${activeVersion?.versionTag || 'stable'}"`}
204
+ disabled={isPinned}
205
+ />
206
+ </div>
207
+
208
+ <div className="mb-2">
209
+ <small>
210
+ Device-Specific Version Tag:
211
+ <Tooltip markdownContent="Override the tag policy for this device only. For example, set to `beta` to test pre-release versions on this device without affecting other devices or group members. This is not synced." />
212
+ </small>
213
+ <div className="d-flex align-items-center gap-2">
214
+ <input
215
+ type="text"
216
+ className="form-control form-control-sm p-0 ps-2"
217
+ placeholder="e.g. beta"
218
+ value={deviceTagDraft}
219
+ onChange={(e) => setDeviceTagDraft(e.target.value)}
220
+ />
221
+ {deviceTagDraft && (
222
+ <button
223
+ className="btn btn-sm btn-outline-secondary"
224
+ title="Clear device tag override"
225
+ onClick={() => setDeviceTagDraft('')}
226
+ >
227
+ <i className="bi bi-x-lg"></i>
228
+ </button>
229
+ )}
230
+ </div>
231
+ </div>
232
+ </div>
233
+
80
234
  </div>
81
235
  )
82
236
  }
83
-
@@ -1,13 +1,35 @@
1
- import { ICursorIterable, IPackage, observable, Packages, packagesRootDir, rpcServerCalls } from "@peers-app/peers-sdk";
1
+ import { ICursorIterable, IPackage, observable, Packages, PackageVersions, packagesRootDir, rpcServerCalls } from "@peers-app/peers-sdk";
2
2
  import React, { useEffect, useState } from 'react';
3
3
  import { Input } from "../../components/input";
4
4
  import { LazyList } from "../../components/lazy-list";
5
5
  import { LoadingIndicator } from '../../components/loading-indicator';
6
6
  import { Tooltip } from '../../components/tooltip';
7
7
  import { isDesktop, mainContentPath } from '../../globals';
8
- import { useObservable, useObservableState } from "../../hooks";
8
+ import { useObservable, useObservableState, usePromise } from "../../hooks";
9
9
  import { registerInternalPeersUI } from "../../ui-router/ui-loader";
10
10
 
11
+ const PackageVersionBadge = ({ activePackageVersionId }: { activePackageVersionId?: string }) => {
12
+ const pv = usePromise(async () => {
13
+ if (!activePackageVersionId) return null;
14
+ return PackageVersions().get(activePackageVersionId);
15
+ }, undefined, [activePackageVersionId]);
16
+
17
+ if (!pv) return null;
18
+
19
+ return (
20
+ <span className="ms-2">
21
+ <small className="text-muted">v{pv.version}</small>
22
+ {pv.versionTag && (
23
+ <span className={`badge ms-1 ${pv.versionTag.startsWith('beta') ? 'text-bg-warning' : 'text-bg-info'}`}
24
+ style={{ fontSize: '0.65em' }}
25
+ >
26
+ {pv.versionTag}
27
+ </span>
28
+ )}
29
+ </span>
30
+ );
31
+ };
32
+
11
33
  export const PackageList = () => {
12
34
  const [searchTextObs] = useState(() => observable(''));
13
35
  const [searchText] = useObservable(searchTextObs);
@@ -146,6 +168,7 @@ export const PackageList = () => {
146
168
  <a href={`#packages/${pkg.packageId}`}>
147
169
  {pkg.name}
148
170
  </a>
171
+ <PackageVersionBadge activePackageVersionId={pkg.activePackageVersionId} />
149
172
  <Tooltip
150
173
  markdownContent={pkg.description}
151
174
  positions={['bottom', 'top', 'right', 'left']}
@@ -0,0 +1,204 @@
1
+ import React, { useState } from "react";
2
+ import { IDoc, IPackage, IPackageVersion, Packages, PackageVersions, Users } from "@peers-app/peers-sdk";
3
+ import { useObservable, useObservableState, usePromise } from "../../hooks";
4
+
5
+ function formatDate(iso: string): string {
6
+ try {
7
+ const d = new Date(iso);
8
+ const now = new Date();
9
+ const diffMs = now.getTime() - d.getTime();
10
+ const diffMins = Math.floor(diffMs / 60000);
11
+ if (diffMins < 1) return 'just now';
12
+ if (diffMins < 60) return `${diffMins}m ago`;
13
+ const diffHours = Math.floor(diffMins / 60);
14
+ if (diffHours < 24) return `${diffHours}h ago`;
15
+ const diffDays = Math.floor(diffHours / 24);
16
+ if (diffDays < 30) return `${diffDays}d ago`;
17
+ return d.toLocaleDateString();
18
+ } catch {
19
+ return iso;
20
+ }
21
+ }
22
+
23
+ function tagBadgeClass(tag?: string): string {
24
+ if (!tag) return 'text-bg-secondary';
25
+ if (tag.startsWith('beta')) return 'text-bg-warning';
26
+ if (tag === 'stable') return 'text-bg-info';
27
+ return 'text-bg-secondary';
28
+ }
29
+
30
+ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
31
+ const { pkg } = props;
32
+ const [activeVersionId] = useObservable(pkg.qs.activePackageVersionId);
33
+ const [versionFollowRange] = useObservable(pkg.qs.versionFollowRange);
34
+ const refreshKey = useObservableState(0);
35
+ const [activating, setActivating] = useState<string | null>(null);
36
+ const [deleting, setDeleting] = useState<string | null>(null);
37
+ const [pinning, setPinning] = useState(false);
38
+ const isPinned = versionFollowRange === 'pinned';
39
+
40
+ const versions = usePromise(async () => {
41
+ const all = await PackageVersions().list({ packageId: pkg.packageId });
42
+ const parse = (v: string) => v.split('.').map(Number);
43
+ const sorted = all.sort((a, b) => {
44
+ const [aMaj, aMin, aPat] = parse(a.version || '0.0.0');
45
+ const [bMaj, bMin, bPat] = parse(b.version || '0.0.0');
46
+ if (bMaj !== aMaj) return bMaj - aMaj;
47
+ if (bMin !== aMin) return bMin - aMin;
48
+ if (bPat !== aPat) return bPat - aPat;
49
+ return (b.createdAt || '').localeCompare(a.createdAt || '');
50
+ });
51
+ const uniqueCreatorIds = [...new Set(sorted.map(pv => pv.createdBy).filter(Boolean))];
52
+ const userMap = new Map<string, string>();
53
+ await Promise.all(uniqueCreatorIds.map(async (uid) => {
54
+ try {
55
+ const user = await Users().get(uid, { useCache: true });
56
+ if (user?.name) userMap.set(uid, user.name);
57
+ } catch {}
58
+ }));
59
+ return { sorted, userMap };
60
+ }, undefined, [pkg.packageId, refreshKey()]);
61
+
62
+ async function activateVersion(pv: IPackageVersion) {
63
+ setActivating(pv.packageVersionId);
64
+ try {
65
+ const current = await Packages().get(pkg.packageId);
66
+ if (current) {
67
+ current.activePackageVersionId = pv.packageVersionId;
68
+ await Packages().signAndSave(current);
69
+ await pkg.load();
70
+ refreshKey(refreshKey() + 1);
71
+ }
72
+ } catch (err) {
73
+ alert(`Failed to activate version: ${err}`);
74
+ } finally {
75
+ setActivating(null);
76
+ }
77
+ }
78
+
79
+ async function deleteVersion(pv: IPackageVersion) {
80
+ if (!confirm(`Delete version v${pv.version} (${pv.packageVersionHash?.substring(0, 8)})?`)) return;
81
+ setDeleting(pv.packageVersionId);
82
+ try {
83
+ await PackageVersions().delete(pv.packageVersionId);
84
+ refreshKey(refreshKey() + 1);
85
+ } catch (err) {
86
+ alert(`Failed to delete version: ${err}`);
87
+ } finally {
88
+ setDeleting(null);
89
+ }
90
+ }
91
+
92
+ async function pinVersion() {
93
+ setPinning(true);
94
+ try {
95
+ const current = await Packages().get(pkg.packageId);
96
+ if (current) {
97
+ current.versionFollowRange = 'pinned';
98
+ await Packages().signAndSave(current);
99
+ await pkg.load();
100
+ }
101
+ } catch (err) {
102
+ alert(`Failed to pin version: ${err}`);
103
+ } finally {
104
+ setPinning(false);
105
+ }
106
+ }
107
+
108
+ if (!versions) {
109
+ return <div className="text-center p-3"><div className="spinner-border spinner-border-sm" /></div>;
110
+ }
111
+
112
+ const { sorted, userMap } = versions;
113
+
114
+ if (sorted.length === 0) {
115
+ return (
116
+ <div className="text-muted text-center p-4">
117
+ <i className="bi bi-tag me-1"></i>
118
+ No versions found for this package.
119
+ </div>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <div>
125
+ <small className="text-muted">{sorted.length} version{sorted.length !== 1 ? 's' : ''}</small>
126
+ <div className="list-group mt-2">
127
+ {sorted.map(pv => {
128
+ const isActive = pv.packageVersionId === activeVersionId;
129
+ const creatorName = userMap.get(pv.createdBy);
130
+ return (
131
+ <div
132
+ key={pv.packageVersionId}
133
+ className={`list-group-item ${isActive ? 'list-group-item-success' : ''}`}
134
+ >
135
+ <div className="d-flex align-items-center justify-content-between">
136
+ <div>
137
+ <strong className="me-2">v{pv.version}</strong>
138
+ {pv.versionTag && (
139
+ <span className={`badge ${tagBadgeClass(pv.versionTag)} me-2`}>
140
+ {pv.versionTag}
141
+ </span>
142
+ )}
143
+ <code className="text-muted small me-2">
144
+ {pv.packageVersionHash?.substring(0, 8)}
145
+ </code>
146
+ <small className="text-muted me-2">{formatDate(pv.createdAt)}</small>
147
+ {creatorName && (
148
+ <small className="text-muted">
149
+ by {creatorName}
150
+ </small>
151
+ )}
152
+ </div>
153
+ <div className="d-flex gap-1">
154
+ {isActive ? (<>
155
+ <button
156
+ className="btn btn-sm btn-outline-secondary"
157
+ disabled={isPinned || pinning}
158
+ onClick={() => pinVersion()}
159
+ title={isPinned ? 'Already pinned' : 'Pin to this version (disable auto-updates)'}
160
+ >
161
+ {pinning ? (
162
+ <span className="spinner-border spinner-border-sm" />
163
+ ) : (
164
+ <i className="bi bi-pin-fill"></i>
165
+ )}
166
+ {isPinned ? ' Pinned' : ' Pin'}
167
+ </button>
168
+ <span className="badge text-bg-success align-self-center">active</span>
169
+ </>) : (
170
+ <button
171
+ className="btn btn-sm btn-outline-primary"
172
+ disabled={activating === pv.packageVersionId}
173
+ onClick={() => activateVersion(pv)}
174
+ >
175
+ {activating === pv.packageVersionId ? (
176
+ <span className="spinner-border spinner-border-sm me-1" />
177
+ ) : (
178
+ <i className="bi bi-check2-circle me-1"></i>
179
+ )}
180
+ Activate
181
+ </button>
182
+ )}
183
+ {!isActive && (
184
+ <button
185
+ className="btn btn-sm btn-outline-danger"
186
+ disabled={deleting === pv.packageVersionId}
187
+ onClick={() => deleteVersion(pv)}
188
+ >
189
+ {deleting === pv.packageVersionId ? (
190
+ <span className="spinner-border spinner-border-sm" />
191
+ ) : (
192
+ <i className="bi bi-trash"></i>
193
+ )}
194
+ </button>
195
+ )}
196
+ </div>
197
+ </div>
198
+ </div>
199
+ );
200
+ })}
201
+ </div>
202
+ </div>
203
+ );
204
+ };
@@ -93,7 +93,4 @@ export const systemPackage: IPackage = {
93
93
  localPath: '',
94
94
  disabled: false,
95
95
  signature: '',
96
- packageBundleFileId: '',
97
- packageBundleFileHash: '',
98
- // System apps use existing router instead of separate bundle files
99
96
  };
@@ -1,4 +1,4 @@
1
- import { debounceByArgs, IPackage, IPeersPackageRoutes, observable, Packages, rpcServerCalls } from "@peers-app/peers-sdk";
1
+ import { debounceByArgs, IPackage, IPeersPackageRoutes, observable, Packages, PackageVersions, rpcServerCalls } from "@peers-app/peers-sdk";
2
2
  import "../ui-defaults";
3
3
 
4
4
  export const allPackages = observable<IPackage[]>([]);
@@ -7,10 +7,10 @@ let allRoutesLoaded = false;
7
7
  export const loadAllRoutes = async () => {
8
8
  if (allRoutesLoaded) return;
9
9
  allRoutesLoaded = true;
10
- // Filter packages that have UI bundles (routesBundleFileId or uiBundleFileId)
10
+ // Filter packages that have an active version
11
11
  let packagesWithUI = await Packages().list({
12
12
  disabled: { $ne: true },
13
- routesBundleFileId: { $exists: true },
13
+ activePackageVersionId: { $exists: true },
14
14
  });
15
15
  allPackages(packagesWithUI);
16
16
  await Promise.all(
@@ -60,14 +60,22 @@ function loadRoutesBundle(pkg: IPackage, forceRefresh?: boolean): Promise<any> {
60
60
  console.log(`waiting for registerPeersUIRoute to be defined on the window object`);
61
61
  await new Promise((resolve) => setTimeout(resolve, 20));
62
62
  }
63
+
64
+ let routesBundleFileId: string | undefined;
65
+ if (pkg.activePackageVersionId) {
66
+ try {
67
+ const pv = await PackageVersions().get(pkg.activePackageVersionId);
68
+ routesBundleFileId = pv?.routesBundleFileId;
69
+ } catch { /* no version record */ }
70
+ }
71
+
63
72
  let bundleCode = '';
64
- if (pkg.routesBundleFileId) {
65
- bundleCode = await rpcServerCalls.getFileContents(pkg.routesBundleFileId);
73
+ if (routesBundleFileId) {
74
+ bundleCode = await rpcServerCalls.getFileContents(routesBundleFileId);
66
75
  }
67
76
  if (bundleCode) {
68
77
  const exportRoutes = (peerRoutes: IPeersPackageRoutes) => {
69
78
  peerRoutes.routes.forEach(route => {
70
- // TODO maybe add package that this came from
71
79
  (window as any).registerPeersUIRoute(route);
72
80
  });
73
81
  }
@@ -76,8 +84,8 @@ function loadRoutesBundle(pkg: IPackage, forceRefresh?: boolean): Promise<any> {
76
84
  bundleFunction(exportRoutes);
77
85
 
78
86
  return {};
79
- } else {
80
- console.warn(`Routes bundle file not found for ${pkg.name} (fileId: ${pkg.routesBundleFileId})`);
87
+ } else if (routesBundleFileId) {
88
+ console.warn(`Routes bundle file not found for ${pkg.name} (fileId: ${routesBundleFileId})`);
81
89
  }
82
90
  return null;
83
91
  } catch (err) {
@@ -1,4 +1,4 @@
1
- import { debounceByArgs, getUserContext, IPackage, IPeersPackageUIs, IPeersUI, IPeersUIRoute, newid, Packages, rpcServerCalls, toJSON, UIContext, zodAnyObject } from "@peers-app/peers-sdk";
1
+ import { debounceByArgs, getUserContext, IPackage, IPeersPackageUIs, IPeersUI, IPeersUIRoute, newid, Packages, PackageVersions, rpcServerCalls, toJSON, UIContext, zodAnyObject } from "@peers-app/peers-sdk";
2
2
  import { orderBy } from "lodash";
3
3
  import React, { Component, useEffect } from "react";
4
4
  import { LoadingIndicator } from "../components/loading-indicator";
@@ -225,31 +225,34 @@ const uiLoadingPromises: Record<string, Promise<any>> = {};
225
225
  // Check if we're running in a React Native WebView (has injectUIBundle available)
226
226
  const isReactNativeWebView = typeof (window as any).ReactNativeWebView !== 'undefined';
227
227
 
228
+ async function resolveUiBundleFileId(pkg: IPackage): Promise<string | undefined> {
229
+ if (!pkg.activePackageVersionId) return undefined;
230
+ try {
231
+ const pv = await PackageVersions().get(pkg.activePackageVersionId);
232
+ return pv?.uiBundleFileId;
233
+ } catch { return undefined; }
234
+ }
235
+
228
236
  function loadUIBundle(pkg: IPackage, forceRefresh?: boolean) {
229
- // Dynamically import the bundle
230
237
  let importPromise: Promise<any> = uiLoadingPromises[pkg.packageId];
231
238
  if (!importPromise || forceRefresh) {
232
239
  const sTime = Date.now();
233
240
  console.log(`loading ui bundle for ${pkg.name}`);
234
241
  importPromise = new Promise<void>(async (resolve, reject) => {
235
242
  try {
236
- if (!pkg.uiBundleFileId) {
243
+ const uiBundleFileId = await resolveUiBundleFileId(pkg);
244
+ if (!uiBundleFileId) {
237
245
  resolve();
238
246
  return;
239
247
  }
240
248
 
241
- // Use fast injection path for React Native WebView
242
249
  if (isReactNativeWebView && rpcServerCalls.injectUIBundle) {
243
- // Set up listeners for bundle load completion
244
250
  const _window = window as any;
245
251
  _window.__peersUIs = _window.__peersUIs || {};
246
252
 
247
253
  const loadPromise = new Promise<void>((resolveLoad, rejectLoad) => {
248
- const fileId = pkg.uiBundleFileId!;
249
-
250
254
  _window.__peersUIBundleLoaded = (loadedFileId: string) => {
251
- if (loadedFileId === fileId) {
252
- // Copy loaded UIs to our local registry
255
+ if (loadedFileId === uiBundleFileId) {
253
256
  Object.keys(_window.__peersUIs || {}).forEach(peersUIId => {
254
257
  peersUIs[peersUIId] = _window.__peersUIs[peersUIId];
255
258
  });
@@ -258,18 +261,17 @@ function loadUIBundle(pkg: IPackage, forceRefresh?: boolean) {
258
261
  };
259
262
 
260
263
  _window.__peersUIBundleError = (errorFileId: string, errorMsg: string) => {
261
- if (errorFileId === fileId) {
264
+ if (errorFileId === uiBundleFileId) {
262
265
  rejectLoad(new Error(errorMsg));
263
266
  }
264
267
  };
265
268
  });
266
269
 
267
- await rpcServerCalls.injectUIBundle(pkg.uiBundleFileId);
270
+ await rpcServerCalls.injectUIBundle(uiBundleFileId);
268
271
  await loadPromise;
269
272
  console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms`);
270
273
  } else {
271
- // Fallback: use postMessage-based getFileContents (slower for large bundles)
272
- let bundleCode = await rpcServerCalls.getFileContents(pkg.uiBundleFileId);
274
+ let bundleCode = await rpcServerCalls.getFileContents(uiBundleFileId);
273
275
  if (bundleCode) {
274
276
  const exportUIs = (peerUIs: IPeersPackageUIs) => {
275
277
  peerUIs?.uis?.forEach(ui => {