@peers-app/peers-ui 0.18.4 → 0.18.6

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.
@@ -11,25 +11,27 @@ const input_1 = require("../../components/input");
11
11
  const markdown_with_mentions_1 = require("../../components/markdown-with-mentions");
12
12
  const tooltip_1 = require("../../components/tooltip");
13
13
  const hooks_1 = require("../../hooks");
14
- const deviceVersionTagVar = (0, peers_sdk_1.groupDeviceVar)("deviceVersionTag");
15
14
  const PackageInfo = (props) => {
16
15
  const { pkg } = props;
17
- const [activeVersionId] = (0, hooks_1.useObservable)(pkg.qs.activePackageVersionId);
18
- const [versionFollowRange] = (0, hooks_1.useObservable)(pkg.qs.versionFollowRange);
19
16
  const [followVersionTags] = (0, hooks_1.useObservable)(pkg.qs.followVersionTags);
20
- const [deviceTag] = (0, hooks_1.useObservable)(deviceVersionTagVar);
21
17
  const localPathVar = (0, peers_sdk_1.groupDeviceVar)(`packageLocalPath_${pkg.packageId}`, {
22
18
  defaultValue: `${peers_sdk_1.packagesRootDir}/${pkg.name}`,
23
19
  });
24
20
  const [localPath, setLocalPath] = (0, hooks_1.useObservable)(localPathVar);
25
- const [deviceTagDraft, setDeviceTagDraft] = react_1.default.useState(deviceTag || "");
21
+ const [devicePrefs] = (0, hooks_1.useObservable)((0, peers_sdk_1.packagePrefsVar)(pkg.packageId));
22
+ const effective = (0, peers_sdk_1.getEffectivePackagePrefs)(pkg.toJS(), devicePrefs);
23
+ const activeVersionId = effective.activePackageVersionId;
24
+ const deviceFollowTags = devicePrefs?.followTags;
25
+ const isPinned = effective.isPinned;
26
+ const followRange = effective.followRange;
27
+ const [deviceTagDraft, setDeviceTagDraft] = react_1.default.useState(deviceFollowTags || "");
26
28
  const savingRef = react_1.default.useRef(false);
27
29
  react_1.default.useEffect(() => {
28
30
  if (!savingRef.current) {
29
- setDeviceTagDraft(deviceTag || "");
31
+ setDeviceTagDraft(deviceFollowTags || "");
30
32
  }
31
- }, [deviceTag]);
32
- const deviceTagDirty = deviceTagDraft.trim() !== (deviceTag || "");
33
+ }, [deviceFollowTags]);
34
+ const deviceTagDirty = deviceTagDraft.trim() !== (deviceFollowTags || "");
33
35
  const prevDirtyRef = react_1.default.useRef(false);
34
36
  react_1.default.useEffect(() => {
35
37
  if (deviceTagDirty && !prevDirtyRef.current) {
@@ -43,15 +45,12 @@ const PackageInfo = (props) => {
43
45
  if (props.saveDeviceTagRef) {
44
46
  props.saveDeviceTagRef.current = async () => {
45
47
  const val = deviceTagDraft.trim();
46
- if (val !== (deviceTag || "")) {
48
+ if (val !== (deviceFollowTags || "")) {
47
49
  savingRef.current = true;
48
50
  try {
49
- if (val) {
50
- deviceVersionTagVar(val);
51
- }
52
- else {
53
- await deviceVersionTagVar.delete();
54
- }
51
+ await (0, peers_sdk_1.updatePackagePrefs)(pkg.packageId, {
52
+ followTags: val || undefined,
53
+ });
55
54
  }
56
55
  finally {
57
56
  savingRef.current = false;
@@ -71,13 +70,14 @@ const PackageInfo = (props) => {
71
70
  const active = all.find((v) => v.packageVersionId === activeVersionId);
72
71
  if (!active?.version)
73
72
  return "uptodate";
73
+ const effectiveFollowTags = deviceFollowTags || followVersionTags;
74
74
  const parse = (v) => v.split(".").map(Number);
75
75
  const [aMaj, aMin, aPat] = parse(active.version);
76
76
  let highest = null;
77
77
  for (const v of all) {
78
78
  if (!v.version || v.packageVersionId === activeVersionId)
79
79
  continue;
80
- if (!(0, peers_sdk_1.doesTagMatch)(active.versionTag, v.versionTag, followVersionTags, deviceTag))
80
+ if (!(0, peers_sdk_1.doesTagMatch)(active.versionTag, v.versionTag, effectiveFollowTags, undefined))
81
81
  continue;
82
82
  const [maj, min, pat] = parse(v.version);
83
83
  if (maj > aMaj)
@@ -99,16 +99,18 @@ const PackageInfo = (props) => {
99
99
  }
100
100
  }
101
101
  return highest || "uptodate";
102
- }, undefined, [activeVersionId, pkg.packageId, followVersionTags, deviceTag]);
103
- const isPinned = versionFollowRange === "pinned";
102
+ }, undefined, [activeVersionId, pkg.packageId, followVersionTags, deviceFollowTags]);
104
103
  const remoteRepoUrl = pkg.remoteRepo;
105
104
  return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("small", { children: "Name:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.name, className: "form-control mb-3 p-0 ps-2", placeholder: "Package name", title: "Package name", disabled: true }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Local Path:", (0, jsx_runtime_1.jsxs)("small", { children: [(0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: `The local path to the directory containing the package. This will open in VS Code if it's installed otherwise this will open in your native file system` }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-link", onClick: () => {
106
105
  peers_sdk_1.rpcServerCalls.openPackage(localPath || peers_sdk_1.packagesRootDir);
107
106
  }, children: "Open Local" })] })] }), (0, jsx_runtime_1.jsx)("input", { type: "text", className: "form-control mb-3 p-0 ps-2", placeholder: "~/peers/packages/my-package", value: localPath || "", onChange: (e) => setLocalPath(e.target.value) })] }), remoteRepoUrl ? ((0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Source URL:", (0, jsx_runtime_1.jsxs)("small", { children: [(0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: `The remote source of this package. Often a github repository URL or a link to the Peers page for the package.` }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-link", onClick: () => {
108
107
  peers_sdk_1.rpcServerCalls.openLinkInBrowser(remoteRepoUrl);
109
- }, children: "Open Remote" })] })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.remoteRepo, className: "form-control mb-3 p-0 ps-2", disabled: true })] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("small", { children: ["Version:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "The currently active version of this package. Manage versions in the Versions tab." })] }), activeVersion ? ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center mt-1 mb-3", children: [(0, jsx_runtime_1.jsxs)("strong", { className: "me-2", children: ["v", activeVersion.version] }), activeVersion.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ${activeVersion.versionTag === "dev" ? "text-bg-danger" : activeVersion.versionTag.startsWith("beta") ? "text-bg-warning" : "text-bg-success"} me-2`, children: activeVersion.versionTag })), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small me-2", children: activeVersion.packageVersionHash?.substring(0, 8) }), newerLevel === "uptodate" ? ((0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-success", children: "Up to date" })) : newerLevel ? ((0, jsx_runtime_1.jsxs)("span", { className: `badge text-bg-${newerLevel === "major" ? "danger" : newerLevel === "minor" ? "warning" : "info"}`, children: ["Newer ", newerLevel, " version available"] })) : null] })) : ((0, jsx_runtime_1.jsx)("div", { className: "text-muted small mt-1 mb-3", children: "No active version" })), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Auto-Update Range:", (0, jsx_runtime_1.jsx)(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." })] }), (0, jsx_runtime_1.jsxs)("select", { className: "form-select form-select-sm", value: versionFollowRange || "latest", onChange: (e) => {
108
+ }, children: "Open Remote" })] })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.remoteRepo, className: "form-control mb-3 p-0 ps-2", disabled: true })] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("small", { children: ["Version:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "The currently active version of this package. Manage versions in the Versions tab." })] }), activeVersion ? ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center mt-1 mb-3", children: [(0, jsx_runtime_1.jsxs)("strong", { className: "me-2", children: ["v", activeVersion.version] }), activeVersion.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ${activeVersion.versionTag === "dev" ? "text-bg-danger" : activeVersion.versionTag.startsWith("beta") ? "text-bg-warning" : "text-bg-success"} me-2`, children: activeVersion.versionTag })), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small me-2", children: activeVersion.packageVersionHash?.substring(0, 8) }), newerLevel === "uptodate" ? ((0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-success", children: "Up to date" })) : newerLevel ? ((0, jsx_runtime_1.jsxs)("span", { className: `badge text-bg-${newerLevel === "major" ? "danger" : newerLevel === "minor" ? "warning" : "info"}`, children: ["Newer ", newerLevel, " version available"] })) : null] })) : ((0, jsx_runtime_1.jsx)("div", { className: "text-muted small mt-1 mb-3", children: "No active version" })), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Auto-Update Range (this device):", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "Controls which new versions are auto-activated on this device. **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." })] }), (0, jsx_runtime_1.jsxs)("select", { className: "form-select form-select-sm", value: isPinned ? "pinned" : followRange, onChange: async (e) => {
110
109
  const val = e.target.value;
111
- pkg.versionFollowRange = val === "latest" ? undefined : val;
112
- }, children: [(0, jsx_runtime_1.jsx)("option", { value: "latest", children: "Latest (auto-update to newest)" }), (0, jsx_runtime_1.jsx)("option", { value: "minor", children: "Minor (same major version)" }), (0, jsx_runtime_1.jsx)("option", { value: "patch", children: "Patch (same major.minor version)" }), (0, jsx_runtime_1.jsx)("option", { value: "pinned", children: "Pinned (no auto-updates)" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: `mb-3 ${isPinned ? "opacity-50" : ""}`, children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Follow Version Tags:", (0, jsx_runtime_1.jsx)(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`." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.followVersionTags, className: "form-control form-control-sm p-0 ps-2", placeholder: `Following "${activeVersion?.versionTag || "stable"}"`, disabled: isPinned })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-2", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Device-Specific Version Tag:", (0, jsx_runtime_1.jsx)(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." })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-2", children: [(0, jsx_runtime_1.jsx)("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) }), deviceTagDraft && ((0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-outline-secondary", title: "Clear device tag override", onClick: () => setDeviceTagDraft(""), children: (0, jsx_runtime_1.jsx)("i", { className: "bi bi-x-lg" }) }))] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("small", { children: ["Description:", (0, jsx_runtime_1.jsx)(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.` })] }), (0, jsx_runtime_1.jsx)(markdown_with_mentions_1.MarkdownWithMentions, { content: pkg.description || "" })] })] }));
110
+ await (0, peers_sdk_1.updatePackagePrefs)(pkg.packageId, {
111
+ pinned: val === "pinned",
112
+ followRange: val === "pinned" ? undefined : val,
113
+ });
114
+ }, children: [(0, jsx_runtime_1.jsx)("option", { value: "latest", children: "Latest (auto-update to newest)" }), (0, jsx_runtime_1.jsx)("option", { value: "minor", children: "Minor (same major version)" }), (0, jsx_runtime_1.jsx)("option", { value: "patch", children: "Patch (same major.minor version)" }), (0, jsx_runtime_1.jsx)("option", { value: "pinned", children: "Pinned (no auto-updates)" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: `mb-3 ${isPinned ? "opacity-50" : ""}`, children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Follow Version Tags (this device):", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "Which version tags to auto-activate on this device. Leave empty to follow the group's tag policy. Use `*` for any tag, or a comma-separated list like `stable,beta`." })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-2", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", className: "form-control form-control-sm p-0 ps-2", placeholder: `Following "${activeVersion?.versionTag || "stable"}"`, value: deviceTagDraft, onChange: (e) => setDeviceTagDraft(e.target.value), disabled: isPinned }), deviceTagDraft && ((0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-outline-secondary", title: "Clear device tag override", onClick: () => setDeviceTagDraft(""), children: (0, jsx_runtime_1.jsx)("i", { className: "bi bi-x-lg" }) }))] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("small", { children: ["Description:", (0, jsx_runtime_1.jsx)(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.` })] }), (0, jsx_runtime_1.jsx)(markdown_with_mentions_1.MarkdownWithMentions, { content: pkg.description || "" })] })] }));
113
115
  };
114
116
  exports.PackageInfo = PackageInfo;
@@ -40,13 +40,14 @@ function tagBadgeClass(tag) {
40
40
  }
41
41
  const PackageVersionsList = (props) => {
42
42
  const { pkg } = props;
43
- const [activeVersionId] = (0, hooks_1.useObservable)(pkg.qs.activePackageVersionId);
44
- const [versionFollowRange] = (0, hooks_1.useObservable)(pkg.qs.versionFollowRange);
45
43
  const refreshKey = (0, hooks_1.useObservableState)(0);
46
44
  const [activating, setActivating] = (0, react_1.useState)(null);
47
45
  const [deleting, setDeleting] = (0, react_1.useState)(null);
48
46
  const [pinning, setPinning] = (0, react_1.useState)(false);
49
- const isPinned = versionFollowRange === "pinned";
47
+ const [devicePrefs] = (0, hooks_1.useObservable)((0, peers_sdk_1.packagePrefsVar)(pkg.packageId));
48
+ const effective = (0, peers_sdk_1.getEffectivePackagePrefs)(pkg.toJS(), devicePrefs);
49
+ const effectiveActiveVersionId = effective.activePackageVersionId;
50
+ const isPinned = effective.isPinned;
50
51
  const versions = (0, hooks_1.usePromise)(async () => {
51
52
  const all = await (0, peers_sdk_1.PackageVersions)().list({ packageId: pkg.packageId });
52
53
  const parse = (v) => v.split(".").map(Number);
@@ -76,16 +77,23 @@ const PackageVersionsList = (props) => {
76
77
  async function activateVersion(pv) {
77
78
  setActivating(pv.packageVersionId);
78
79
  try {
79
- const current = await (0, peers_sdk_1.Packages)().get(pkg.packageId);
80
- if (current) {
81
- current.activePackageVersionId = pv.packageVersionId;
82
- await (0, peers_sdk_1.Packages)().signAndSave(current);
83
- await pkg.load();
84
- refreshKey(refreshKey() + 1);
80
+ await (0, peers_sdk_1.updatePackagePrefs)(pkg.packageId, {
81
+ activePackageVersionId: pv.packageVersionId,
82
+ });
83
+ // For non-dev versions, also update the group default so other devices
84
+ // can discover this as the recommended version.
85
+ if (pv.versionTag !== "dev") {
86
+ const current = await (0, peers_sdk_1.Packages)().get(pkg.packageId);
87
+ if (current) {
88
+ current.activePackageVersionId = pv.packageVersionId;
89
+ await (0, peers_sdk_1.Packages)().signAndSave(current);
90
+ }
85
91
  }
92
+ await pkg.load();
93
+ refreshKey(refreshKey() + 1);
86
94
  }
87
95
  catch (err) {
88
- alert(`Failed to activate version: ${err}`);
96
+ alert(`Failed to activate version: ${err instanceof Error ? err.message : String(err)}`);
89
97
  }
90
98
  finally {
91
99
  setActivating(null);
@@ -100,7 +108,7 @@ const PackageVersionsList = (props) => {
100
108
  refreshKey(refreshKey() + 1);
101
109
  }
102
110
  catch (err) {
103
- alert(`Failed to delete version: ${err}`);
111
+ alert(`Failed to delete version: ${err instanceof Error ? err.message : String(err)}`);
104
112
  }
105
113
  finally {
106
114
  setDeleting(null);
@@ -122,7 +130,7 @@ const PackageVersionsList = (props) => {
122
130
  refreshKey(refreshKey() + 1);
123
131
  }
124
132
  catch (err) {
125
- alert(`Failed to promote version: ${err}`);
133
+ alert(`Failed to promote version: ${err instanceof Error ? err.message : String(err)}`);
126
134
  }
127
135
  }
128
136
  async function updateVersion(pv, newSemver) {
@@ -133,21 +141,17 @@ const PackageVersionsList = (props) => {
133
141
  refreshKey(refreshKey() + 1);
134
142
  }
135
143
  catch (err) {
136
- alert(`Failed to update version: ${err}`);
144
+ alert(`Failed to update version: ${err instanceof Error ? err.message : String(err)}`);
137
145
  }
138
146
  }
139
147
  async function pinVersion() {
140
148
  setPinning(true);
141
149
  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
- }
150
+ await (0, peers_sdk_1.updatePackagePrefs)(pkg.packageId, { pinned: true });
151
+ await pkg.load();
148
152
  }
149
153
  catch (err) {
150
- alert(`Failed to pin version: ${err}`);
154
+ alert(`Failed to pin version: ${err instanceof Error ? err.message : String(err)}`);
151
155
  }
152
156
  finally {
153
157
  setPinning(false);
@@ -160,7 +164,7 @@ const PackageVersionsList = (props) => {
160
164
  if (sorted.length === 0) {
161
165
  return ((0, jsx_runtime_1.jsxs)("div", { className: "text-muted text-center p-4", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-tag me-1" }), "No versions found for this package."] }));
162
166
  }
163
- return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("small", { className: "text-muted", children: [sorted.length, " version", sorted.length !== 1 ? "s" : ""] }), (0, jsx_runtime_1.jsx)("div", { className: "list-group mt-2", children: sorted.map((pv) => ((0, jsx_runtime_1.jsx)(VersionRow, { pv: pv, isActive: pv.packageVersionId === activeVersionId, isPinned: isPinned, pinning: pinning, activating: activating, deleting: deleting, userMap: userMap, onActivate: activateVersion, onDelete: deleteVersion, onPin: pinVersion, onPromote: promoteVersion, onUpdateVersion: updateVersion }, pv.packageVersionId))) })] }));
167
+ return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("small", { className: "text-muted", children: [sorted.length, " version", sorted.length !== 1 ? "s" : ""] }), (0, jsx_runtime_1.jsx)("div", { className: "list-group mt-2", children: sorted.map((pv) => ((0, jsx_runtime_1.jsx)(VersionRow, { pv: pv, isActive: pv.packageVersionId === effectiveActiveVersionId, isPinned: isPinned, pinning: pinning, activating: activating, deleting: deleting, userMap: userMap, onActivate: activateVersion, onDelete: deleteVersion, onPin: pinVersion, onPromote: promoteVersion, onUpdateVersion: updateVersion }, pv.packageVersionId))) })] }));
164
168
  };
165
169
  exports.PackageVersionsList = PackageVersionsList;
166
170
  /**
@@ -5,32 +5,89 @@ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
5
  require("../ui-defaults");
6
6
  exports.allPackages = (0, peers_sdk_1.observable)([]);
7
7
  let allRoutesLoaded = false;
8
+ const prefsSubscribed = new Set();
9
+ function subscribeToPrefs(packageId) {
10
+ if (prefsSubscribed.has(packageId))
11
+ return;
12
+ prefsSubscribed.add(packageId);
13
+ const pvar = (0, peers_sdk_1.packagePrefsVar)(packageId);
14
+ let lastPvId = pvar()?.activePackageVersionId;
15
+ pvar.subscribe(async (prefs) => {
16
+ const newPvId = prefs?.activePackageVersionId;
17
+ if (newPvId && newPvId !== lastPvId) {
18
+ lastPvId = newPvId;
19
+ const pkg = await (0, peers_sdk_1.Packages)().get(packageId);
20
+ if (pkg) {
21
+ const enrichedPkg = await enrichWithPvAppNavs(pkg);
22
+ const _allPackages = (0, exports.allPackages)();
23
+ const idx = _allPackages.findIndex((p) => p.packageId === packageId);
24
+ if (idx >= 0) {
25
+ _allPackages[idx] = enrichedPkg;
26
+ (0, exports.allPackages)([..._allPackages]);
27
+ }
28
+ }
29
+ reloadPackage(packageId);
30
+ }
31
+ });
32
+ }
33
+ /**
34
+ * Enrich an IPackage with appNavs from the device's active PV record.
35
+ * Falls back to `pkg.appNavs` for backward compat with older data.
36
+ */
37
+ async function enrichWithPvAppNavs(pkg) {
38
+ let pvId = pkg.activePackageVersionId;
39
+ try {
40
+ const pvar = (0, peers_sdk_1.packagePrefsVar)(pkg.packageId);
41
+ await pvar.loadingPromise;
42
+ pvId = pvar()?.activePackageVersionId ?? pvId;
43
+ }
44
+ catch {
45
+ /* use group default */
46
+ }
47
+ if (!pvId)
48
+ return pkg;
49
+ try {
50
+ const pv = await (0, peers_sdk_1.PackageVersions)().get(pvId);
51
+ if (pv?.appNavs) {
52
+ return { ...pkg, appNavs: pv.appNavs };
53
+ }
54
+ }
55
+ catch {
56
+ /* fall through to pkg.appNavs */
57
+ }
58
+ return pkg;
59
+ }
8
60
  const loadAllRoutes = async () => {
9
61
  if (allRoutesLoaded)
10
62
  return;
11
63
  allRoutesLoaded = true;
12
- // Filter packages that have an active version
13
64
  const packagesWithUI = await (0, peers_sdk_1.Packages)().list({
14
65
  disabled: { $ne: true },
15
66
  activePackageVersionId: { $exists: true },
16
67
  });
17
- (0, exports.allPackages)(packagesWithUI);
18
- await Promise.all(packagesWithUI.map((pkg) => loadRoutesBundle(pkg)));
68
+ const enriched = await Promise.all(packagesWithUI.map(enrichWithPvAppNavs));
69
+ (0, exports.allPackages)(enriched);
70
+ await Promise.all(enriched.map((pkg) => loadRoutesBundle(pkg)));
71
+ for (const pkg of enriched) {
72
+ subscribeToPrefs(pkg.packageId);
73
+ }
19
74
  (0, peers_sdk_1.Packages)().dataChanged.subscribe(async (evt) => {
75
+ const enrichedPkg = await enrichWithPvAppNavs(evt.dataObject);
20
76
  const _allPackages = (0, exports.allPackages)();
21
77
  if (evt.op === "insert") {
22
- _allPackages.push(evt.dataObject);
78
+ _allPackages.push(enrichedPkg);
79
+ subscribeToPrefs(evt.dataObject.packageId);
23
80
  }
24
81
  else if (evt.op === "delete") {
25
- const idx = (0, exports.allPackages)().findIndex((p) => p.packageId === evt.dataObject.packageId);
82
+ const idx = _allPackages.findIndex((p) => p.packageId === evt.dataObject.packageId);
26
83
  if (idx >= 0) {
27
84
  _allPackages.splice(idx, 1);
28
85
  }
29
86
  }
30
87
  else {
31
- const idx = (0, exports.allPackages)().findIndex((p) => p.packageId === evt.dataObject.packageId);
88
+ const idx = _allPackages.findIndex((p) => p.packageId === evt.dataObject.packageId);
32
89
  if (idx >= 0) {
33
- _allPackages[idx] = evt.dataObject;
90
+ _allPackages[idx] = enrichedPkg;
34
91
  }
35
92
  }
36
93
  (0, exports.allPackages)([..._allPackages]);
@@ -59,10 +116,19 @@ function loadRoutesBundle(pkg, forceRefresh) {
59
116
  console.log(`waiting for registerPeersUIRoute to be defined on the window object`);
60
117
  await new Promise((resolve) => setTimeout(resolve, 20));
61
118
  }
119
+ let pvId = pkg.activePackageVersionId;
120
+ try {
121
+ const pvar = (0, peers_sdk_1.packagePrefsVar)(pkg.packageId);
122
+ await pvar.loadingPromise;
123
+ pvId = pvar()?.activePackageVersionId ?? pvId;
124
+ }
125
+ catch {
126
+ /* use group default */
127
+ }
62
128
  let routesBundleFileId;
63
- if (pkg.activePackageVersionId) {
129
+ if (pvId) {
64
130
  try {
65
- const pv = await (0, peers_sdk_1.PackageVersions)().get(pkg.activePackageVersionId);
131
+ const pv = await (0, peers_sdk_1.PackageVersions)().get(pvId);
66
132
  routesBundleFileId = pv?.routesBundleFileId;
67
133
  }
68
134
  catch {
@@ -190,10 +190,19 @@ const uiLoadingPromises = {};
190
190
  // Check if we're running in a React Native WebView (has injectUIBundle available)
191
191
  const isReactNativeWebView = typeof window.ReactNativeWebView !== "undefined";
192
192
  async function resolveUiBundleFileId(pkg) {
193
- if (!pkg.activePackageVersionId)
193
+ let pvId = pkg.activePackageVersionId;
194
+ try {
195
+ const pvar = (0, peers_sdk_1.packagePrefsVar)(pkg.packageId);
196
+ await pvar.loadingPromise;
197
+ pvId = pvar()?.activePackageVersionId ?? pvId;
198
+ }
199
+ catch {
200
+ /* use group default */
201
+ }
202
+ if (!pvId)
194
203
  return undefined;
195
204
  try {
196
- const pv = await (0, peers_sdk_1.PackageVersions)().get(pkg.activePackageVersionId);
205
+ const pv = await (0, peers_sdk_1.PackageVersions)().get(pvId);
197
206
  return pv?.uiBundleFileId;
198
207
  }
199
208
  catch {
@@ -415,12 +424,31 @@ const reloadPackage = (0, peers_sdk_1.debounceByArgs)(async (packageId) => {
415
424
  (0, globals_1.packageReloaded)(Date.now());
416
425
  }
417
426
  }, 200);
427
+ const uiPrefsSubscribed = new Set();
428
+ function subscribeToPrefs(packageId) {
429
+ if (uiPrefsSubscribed.has(packageId))
430
+ return;
431
+ uiPrefsSubscribed.add(packageId);
432
+ const pvar = (0, peers_sdk_1.packagePrefsVar)(packageId);
433
+ let lastPvId = pvar()?.activePackageVersionId;
434
+ pvar.subscribe((prefs) => {
435
+ const newPvId = prefs?.activePackageVersionId;
436
+ if (newPvId && newPvId !== lastPvId) {
437
+ lastPvId = newPvId;
438
+ reloadPackage(packageId);
439
+ }
440
+ });
441
+ }
418
442
  (0, peers_sdk_1.getUserContext)().then((_userContext) => {
443
+ for (const packageId of Object.keys(uiLoadingPromises)) {
444
+ subscribeToPrefs(packageId);
445
+ }
419
446
  (0, peers_sdk_1.Packages)().dataChanged.subscribe(async (evt) => {
420
447
  const pkg = evt.dataObject;
421
448
  const loadingPromise = uiLoadingPromises[pkg.packageId];
422
449
  if (!loadingPromise)
423
450
  return;
451
+ subscribeToPrefs(pkg.packageId);
424
452
  reloadPackage(pkg.packageId);
425
453
  });
426
454
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.18.4",
3
+ "version": "0.18.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.18.4",
31
+ "@peers-app/peers-sdk": "^0.18.6",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-env": "^7.24.5",
40
40
  "@babel/preset-react": "^7.24.1",
41
41
  "@babel/preset-typescript": "^7.27.1",
42
- "@peers-app/peers-sdk": "0.18.4",
42
+ "@peers-app/peers-sdk": "0.18.6",
43
43
  "@testing-library/dom": "^10.4.0",
44
44
  "@testing-library/jest-dom": "^6.6.3",
45
45
  "@testing-library/react": "^16.3.0",
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  doesTagMatch,
3
+ getEffectivePackagePrefs,
3
4
  groupDeviceVar,
4
5
  type IDoc,
5
6
  type IPackage,
6
7
  type IPackageVersion,
7
8
  PackageVersions,
9
+ packagePrefsVar,
8
10
  packagesRootDir,
9
11
  rpcServerCalls,
12
+ updatePackagePrefs,
10
13
  } from "@peers-app/peers-sdk";
11
14
  import React from "react";
12
15
  import { Input } from "../../components/input";
@@ -14,33 +17,35 @@ import { MarkdownWithMentions } from "../../components/markdown-with-mentions";
14
17
  import { Tooltip } from "../../components/tooltip";
15
18
  import { useObservable, usePromise } from "../../hooks";
16
19
 
17
- const deviceVersionTagVar = groupDeviceVar<string | undefined>("deviceVersionTag");
18
-
19
20
  export const PackageInfo = (props: {
20
21
  pkg: IDoc<IPackage>;
21
22
  saveDeviceTagRef?: React.MutableRefObject<(() => Promise<void>) | null>;
22
23
  }) => {
23
24
  const { pkg } = props;
24
- const [activeVersionId] = useObservable(pkg.qs.activePackageVersionId);
25
- const [versionFollowRange] = useObservable(pkg.qs.versionFollowRange);
26
25
  const [followVersionTags] = useObservable(pkg.qs.followVersionTags);
27
- const [deviceTag] = useObservable(deviceVersionTagVar);
28
26
 
29
27
  const localPathVar = groupDeviceVar<string>(`packageLocalPath_${pkg.packageId}`, {
30
28
  defaultValue: `${packagesRootDir}/${pkg.name}`,
31
29
  });
32
30
  const [localPath, setLocalPath] = useObservable(localPathVar);
33
31
 
34
- const [deviceTagDraft, setDeviceTagDraft] = React.useState(deviceTag || "");
32
+ const [devicePrefs] = useObservable(packagePrefsVar(pkg.packageId));
33
+ const effective = getEffectivePackagePrefs(pkg.toJS(), devicePrefs);
34
+ const activeVersionId = effective.activePackageVersionId;
35
+ const deviceFollowTags = devicePrefs?.followTags;
36
+ const isPinned = effective.isPinned;
37
+ const followRange = effective.followRange;
38
+
39
+ const [deviceTagDraft, setDeviceTagDraft] = React.useState(deviceFollowTags || "");
35
40
  const savingRef = React.useRef(false);
36
41
 
37
42
  React.useEffect(() => {
38
43
  if (!savingRef.current) {
39
- setDeviceTagDraft(deviceTag || "");
44
+ setDeviceTagDraft(deviceFollowTags || "");
40
45
  }
41
- }, [deviceTag]);
46
+ }, [deviceFollowTags]);
42
47
 
43
- const deviceTagDirty = deviceTagDraft.trim() !== (deviceTag || "");
48
+ const deviceTagDirty = deviceTagDraft.trim() !== (deviceFollowTags || "");
44
49
  const prevDirtyRef = React.useRef(false);
45
50
  React.useEffect(() => {
46
51
  if (deviceTagDirty && !prevDirtyRef.current) {
@@ -54,14 +59,12 @@ export const PackageInfo = (props: {
54
59
  if (props.saveDeviceTagRef) {
55
60
  props.saveDeviceTagRef.current = async () => {
56
61
  const val = deviceTagDraft.trim();
57
- if (val !== (deviceTag || "")) {
62
+ if (val !== (deviceFollowTags || "")) {
58
63
  savingRef.current = true;
59
64
  try {
60
- if (val) {
61
- deviceVersionTagVar(val);
62
- } else {
63
- await deviceVersionTagVar.delete();
64
- }
65
+ await updatePackagePrefs(pkg.packageId, {
66
+ followTags: val || undefined,
67
+ });
65
68
  } finally {
66
69
  savingRef.current = false;
67
70
  }
@@ -84,12 +87,14 @@ export const PackageInfo = (props: {
84
87
  const all = await PackageVersions().list({ packageId: pkg.packageId });
85
88
  const active = all.find((v) => v.packageVersionId === activeVersionId);
86
89
  if (!active?.version) return "uptodate";
90
+ const effectiveFollowTags = deviceFollowTags || followVersionTags;
87
91
  const parse = (v: string) => v.split(".").map(Number);
88
92
  const [aMaj, aMin, aPat] = parse(active.version);
89
93
  let highest: "major" | "minor" | "patch" | null = null;
90
94
  for (const v of all) {
91
95
  if (!v.version || v.packageVersionId === activeVersionId) continue;
92
- if (!doesTagMatch(active.versionTag, v.versionTag, followVersionTags, deviceTag)) continue;
96
+ if (!doesTagMatch(active.versionTag, v.versionTag, effectiveFollowTags, undefined))
97
+ continue;
93
98
  const [maj, min, pat] = parse(v.version);
94
99
  if (maj > aMaj) return "major";
95
100
  if (maj === aMaj && min > aMin) {
@@ -113,10 +118,9 @@ export const PackageInfo = (props: {
113
118
  return highest || "uptodate";
114
119
  },
115
120
  undefined,
116
- [activeVersionId, pkg.packageId, followVersionTags, deviceTag],
121
+ [activeVersionId, pkg.packageId, followVersionTags, deviceFollowTags],
117
122
  );
118
123
 
119
- const isPinned = versionFollowRange === "pinned";
120
124
  const remoteRepoUrl = pkg.remoteRepo;
121
125
 
122
126
  return (
@@ -213,15 +217,18 @@ export const PackageInfo = (props: {
213
217
 
214
218
  <div className="mb-3">
215
219
  <small>
216
- Auto-Update Range:
217
- <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." />
220
+ Auto-Update Range (this device):
221
+ <Tooltip markdownContent="Controls which new versions are auto-activated on this device. **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." />
218
222
  </small>
219
223
  <select
220
224
  className="form-select form-select-sm"
221
- value={versionFollowRange || "latest"}
222
- onChange={(e) => {
225
+ value={isPinned ? "pinned" : followRange}
226
+ onChange={async (e) => {
223
227
  const val = e.target.value as "pinned" | "patch" | "minor" | "latest";
224
- pkg.versionFollowRange = val === "latest" ? undefined : val;
228
+ await updatePackagePrefs(pkg.packageId, {
229
+ pinned: val === "pinned",
230
+ followRange: val === "pinned" ? undefined : val,
231
+ });
225
232
  }}
226
233
  >
227
234
  <option value="latest">Latest (auto-update to newest)</option>
@@ -233,29 +240,17 @@ export const PackageInfo = (props: {
233
240
 
234
241
  <div className={`mb-3 ${isPinned ? "opacity-50" : ""}`}>
235
242
  <small>
236
- Follow Version Tags:
237
- <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`." />
238
- </small>
239
- <Input
240
- value={pkg.qs.followVersionTags}
241
- className="form-control form-control-sm p-0 ps-2"
242
- placeholder={`Following "${activeVersion?.versionTag || "stable"}"`}
243
- disabled={isPinned}
244
- />
245
- </div>
246
-
247
- <div className="mb-2">
248
- <small>
249
- Device-Specific Version Tag:
250
- <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." />
243
+ Follow Version Tags (this device):
244
+ <Tooltip markdownContent="Which version tags to auto-activate on this device. Leave empty to follow the group's tag policy. Use `*` for any tag, or a comma-separated list like `stable,beta`." />
251
245
  </small>
252
246
  <div className="d-flex align-items-center gap-2">
253
247
  <input
254
248
  type="text"
255
249
  className="form-control form-control-sm p-0 ps-2"
256
- placeholder="e.g. beta"
250
+ placeholder={`Following "${activeVersion?.versionTag || "stable"}"`}
257
251
  value={deviceTagDraft}
258
252
  onChange={(e) => setDeviceTagDraft(e.target.value)}
253
+ disabled={isPinned}
259
254
  />
260
255
  {deviceTagDraft && (
261
256
  <button
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  computePackageVersionHash,
3
+ getEffectivePackagePrefs,
3
4
  getMe,
4
5
  type IDoc,
5
6
  type IPackage,
6
7
  type IPackageVersion,
7
8
  Packages,
8
9
  PackageVersions,
10
+ packagePrefsVar,
9
11
  Users,
12
+ updatePackagePrefs,
10
13
  } from "@peers-app/peers-sdk";
11
14
  import { useState } from "react";
12
15
  import { useObservable, useObservableState, usePromise } from "../../hooks";
@@ -39,13 +42,15 @@ function tagBadgeClass(tag?: string): string {
39
42
 
40
43
  export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
41
44
  const { pkg } = props;
42
- const [activeVersionId] = useObservable(pkg.qs.activePackageVersionId);
43
- const [versionFollowRange] = useObservable(pkg.qs.versionFollowRange);
44
45
  const refreshKey = useObservableState(0);
45
46
  const [activating, setActivating] = useState<string | null>(null);
46
47
  const [deleting, setDeleting] = useState<string | null>(null);
47
48
  const [pinning, setPinning] = useState(false);
48
- const isPinned = versionFollowRange === "pinned";
49
+
50
+ const [devicePrefs] = useObservable(packagePrefsVar(pkg.packageId));
51
+ const effective = getEffectivePackagePrefs(pkg.toJS(), devicePrefs);
52
+ const effectiveActiveVersionId = effective.activePackageVersionId;
53
+ const isPinned = effective.isPinned;
49
54
 
50
55
  const versions = usePromise(
51
56
  async () => {
@@ -78,15 +83,23 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
78
83
  async function activateVersion(pv: IPackageVersion) {
79
84
  setActivating(pv.packageVersionId);
80
85
  try {
81
- const current = await Packages().get(pkg.packageId);
82
- if (current) {
83
- current.activePackageVersionId = pv.packageVersionId;
84
- await Packages().signAndSave(current);
85
- await pkg.load();
86
- refreshKey(refreshKey() + 1);
86
+ await updatePackagePrefs(pkg.packageId, {
87
+ activePackageVersionId: pv.packageVersionId,
88
+ });
89
+
90
+ // For non-dev versions, also update the group default so other devices
91
+ // can discover this as the recommended version.
92
+ if (pv.versionTag !== "dev") {
93
+ const current = await Packages().get(pkg.packageId);
94
+ if (current) {
95
+ current.activePackageVersionId = pv.packageVersionId;
96
+ await Packages().signAndSave(current);
97
+ }
87
98
  }
88
- } catch (err) {
89
- alert(`Failed to activate version: ${err}`);
99
+ await pkg.load();
100
+ refreshKey(refreshKey() + 1);
101
+ } catch (err: unknown) {
102
+ alert(`Failed to activate version: ${err instanceof Error ? err.message : String(err)}`);
90
103
  } finally {
91
104
  setActivating(null);
92
105
  }
@@ -99,8 +112,8 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
99
112
  try {
100
113
  await PackageVersions().delete(pv.packageVersionId);
101
114
  refreshKey(refreshKey() + 1);
102
- } catch (err) {
103
- alert(`Failed to delete version: ${err}`);
115
+ } catch (err: unknown) {
116
+ alert(`Failed to delete version: ${err instanceof Error ? err.message : String(err)}`);
104
117
  } finally {
105
118
  setDeleting(null);
106
119
  }
@@ -126,8 +139,8 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
126
139
  const updated = { ...pv, versionTag: newTag, packageVersionHash: pvHash, history };
127
140
  await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
128
141
  refreshKey(refreshKey() + 1);
129
- } catch (err) {
130
- alert(`Failed to promote version: ${err}`);
142
+ } catch (err: unknown) {
143
+ alert(`Failed to promote version: ${err instanceof Error ? err.message : String(err)}`);
131
144
  }
132
145
  }
133
146
 
@@ -143,22 +156,18 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
143
156
  const updated = { ...pv, version: newSemver, packageVersionHash: pvHash };
144
157
  await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
145
158
  refreshKey(refreshKey() + 1);
146
- } catch (err) {
147
- alert(`Failed to update version: ${err}`);
159
+ } catch (err: unknown) {
160
+ alert(`Failed to update version: ${err instanceof Error ? err.message : String(err)}`);
148
161
  }
149
162
  }
150
163
 
151
164
  async function pinVersion() {
152
165
  setPinning(true);
153
166
  try {
154
- const current = await Packages().get(pkg.packageId);
155
- if (current) {
156
- current.versionFollowRange = "pinned";
157
- await Packages().signAndSave(current);
158
- await pkg.load();
159
- }
160
- } catch (err) {
161
- alert(`Failed to pin version: ${err}`);
167
+ await updatePackagePrefs(pkg.packageId, { pinned: true });
168
+ await pkg.load();
169
+ } catch (err: unknown) {
170
+ alert(`Failed to pin version: ${err instanceof Error ? err.message : String(err)}`);
162
171
  } finally {
163
172
  setPinning(false);
164
173
  }
@@ -193,7 +202,7 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
193
202
  <VersionRow
194
203
  key={pv.packageVersionId}
195
204
  pv={pv}
196
- isActive={pv.packageVersionId === activeVersionId}
205
+ isActive={pv.packageVersionId === effectiveActiveVersionId}
197
206
  isPinned={isPinned}
198
207
  pinning={pinning}
199
208
  activating={activating}
@@ -6,6 +6,7 @@ import {
6
6
  observable,
7
7
  Packages,
8
8
  PackageVersions,
9
+ packagePrefsVar,
9
10
  rpcServerCalls,
10
11
  } from "@peers-app/peers-sdk";
11
12
  import "../ui-defaults";
@@ -13,30 +14,87 @@ import "../ui-defaults";
13
14
  export const allPackages = observable<IPackage[]>([]);
14
15
  let allRoutesLoaded = false;
15
16
 
17
+ const prefsSubscribed = new Set<string>();
18
+
19
+ function subscribeToPrefs(packageId: string) {
20
+ if (prefsSubscribed.has(packageId)) return;
21
+ prefsSubscribed.add(packageId);
22
+ const pvar = packagePrefsVar(packageId);
23
+ let lastPvId = pvar()?.activePackageVersionId;
24
+ pvar.subscribe(async (prefs) => {
25
+ const newPvId = prefs?.activePackageVersionId;
26
+ if (newPvId && newPvId !== lastPvId) {
27
+ lastPvId = newPvId;
28
+ const pkg = await Packages().get(packageId);
29
+ if (pkg) {
30
+ const enrichedPkg = await enrichWithPvAppNavs(pkg);
31
+ const _allPackages = allPackages();
32
+ const idx = _allPackages.findIndex((p) => p.packageId === packageId);
33
+ if (idx >= 0) {
34
+ _allPackages[idx] = enrichedPkg;
35
+ allPackages([..._allPackages]);
36
+ }
37
+ }
38
+ reloadPackage(packageId);
39
+ }
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Enrich an IPackage with appNavs from the device's active PV record.
45
+ * Falls back to `pkg.appNavs` for backward compat with older data.
46
+ */
47
+ async function enrichWithPvAppNavs(pkg: IPackage): Promise<IPackage> {
48
+ let pvId = pkg.activePackageVersionId;
49
+ try {
50
+ const pvar = packagePrefsVar(pkg.packageId);
51
+ await pvar.loadingPromise;
52
+ pvId = pvar()?.activePackageVersionId ?? pvId;
53
+ } catch {
54
+ /* use group default */
55
+ }
56
+ if (!pvId) return pkg;
57
+ try {
58
+ const pv = await PackageVersions().get(pvId);
59
+ if (pv?.appNavs) {
60
+ return { ...pkg, appNavs: pv.appNavs };
61
+ }
62
+ } catch {
63
+ /* fall through to pkg.appNavs */
64
+ }
65
+ return pkg;
66
+ }
67
+
16
68
  export const loadAllRoutes = async () => {
17
69
  if (allRoutesLoaded) return;
18
70
  allRoutesLoaded = true;
19
- // Filter packages that have an active version
20
71
  const packagesWithUI = await Packages().list({
21
72
  disabled: { $ne: true },
22
73
  activePackageVersionId: { $exists: true },
23
74
  });
24
- allPackages(packagesWithUI);
25
- await Promise.all(packagesWithUI.map((pkg) => loadRoutesBundle(pkg)));
75
+ const enriched = await Promise.all(packagesWithUI.map(enrichWithPvAppNavs));
76
+ allPackages(enriched);
77
+ await Promise.all(enriched.map((pkg) => loadRoutesBundle(pkg)));
78
+
79
+ for (const pkg of enriched) {
80
+ subscribeToPrefs(pkg.packageId);
81
+ }
26
82
 
27
83
  Packages().dataChanged.subscribe(async (evt) => {
84
+ const enrichedPkg = await enrichWithPvAppNavs(evt.dataObject);
28
85
  const _allPackages = allPackages();
29
86
  if (evt.op === "insert") {
30
- _allPackages.push(evt.dataObject);
87
+ _allPackages.push(enrichedPkg);
88
+ subscribeToPrefs(evt.dataObject.packageId);
31
89
  } else if (evt.op === "delete") {
32
- const idx = allPackages().findIndex((p) => p.packageId === evt.dataObject.packageId);
90
+ const idx = _allPackages.findIndex((p) => p.packageId === evt.dataObject.packageId);
33
91
  if (idx >= 0) {
34
92
  _allPackages.splice(idx, 1);
35
93
  }
36
94
  } else {
37
- const idx = allPackages().findIndex((p) => p.packageId === evt.dataObject.packageId);
95
+ const idx = _allPackages.findIndex((p) => p.packageId === evt.dataObject.packageId);
38
96
  if (idx >= 0) {
39
- _allPackages[idx] = evt.dataObject;
97
+ _allPackages[idx] = enrichedPkg;
40
98
  }
41
99
  }
42
100
  allPackages([..._allPackages]);
@@ -72,10 +130,19 @@ function loadRoutesBundle(pkg: IPackage, forceRefresh?: boolean): Promise<unknow
72
130
  await new Promise((resolve) => setTimeout(resolve, 20));
73
131
  }
74
132
 
133
+ let pvId = pkg.activePackageVersionId;
134
+ try {
135
+ const pvar = packagePrefsVar(pkg.packageId);
136
+ await pvar.loadingPromise;
137
+ pvId = pvar()?.activePackageVersionId ?? pvId;
138
+ } catch {
139
+ /* use group default */
140
+ }
141
+
75
142
  let routesBundleFileId: string | undefined;
76
- if (pkg.activePackageVersionId) {
143
+ if (pvId) {
77
144
  try {
78
- const pv = await PackageVersions().get(pkg.activePackageVersionId);
145
+ const pv = await PackageVersions().get(pvId);
79
146
  routesBundleFileId = pv?.routesBundleFileId;
80
147
  } catch {
81
148
  /* no version record */
@@ -8,6 +8,7 @@ import {
8
8
  newid,
9
9
  Packages,
10
10
  PackageVersions,
11
+ packagePrefsVar,
11
12
  rpcServerCalls,
12
13
  toJSON,
13
14
  type UIContext,
@@ -283,9 +284,17 @@ const isReactNativeWebView =
283
284
  typeof (window as Window & { ReactNativeWebView?: unknown }).ReactNativeWebView !== "undefined";
284
285
 
285
286
  async function resolveUiBundleFileId(pkg: IPackage): Promise<string | undefined> {
286
- if (!pkg.activePackageVersionId) return undefined;
287
+ let pvId = pkg.activePackageVersionId;
287
288
  try {
288
- const pv = await PackageVersions().get(pkg.activePackageVersionId);
289
+ const pvar = packagePrefsVar(pkg.packageId);
290
+ await pvar.loadingPromise;
291
+ pvId = pvar()?.activePackageVersionId ?? pvId;
292
+ } catch {
293
+ /* use group default */
294
+ }
295
+ if (!pvId) return undefined;
296
+ try {
297
+ const pv = await PackageVersions().get(pvId);
289
298
  return pv?.uiBundleFileId;
290
299
  } catch {
291
300
  return undefined;
@@ -628,11 +637,32 @@ const reloadPackage = debounceByArgs(async (packageId: string) => {
628
637
  }
629
638
  }, 200);
630
639
 
640
+ const uiPrefsSubscribed = new Set<string>();
641
+
642
+ function subscribeToPrefs(packageId: string) {
643
+ if (uiPrefsSubscribed.has(packageId)) return;
644
+ uiPrefsSubscribed.add(packageId);
645
+ const pvar = packagePrefsVar(packageId);
646
+ let lastPvId = pvar()?.activePackageVersionId;
647
+ pvar.subscribe((prefs) => {
648
+ const newPvId = prefs?.activePackageVersionId;
649
+ if (newPvId && newPvId !== lastPvId) {
650
+ lastPvId = newPvId;
651
+ reloadPackage(packageId);
652
+ }
653
+ });
654
+ }
655
+
631
656
  getUserContext().then((_userContext) => {
657
+ for (const packageId of Object.keys(uiLoadingPromises)) {
658
+ subscribeToPrefs(packageId);
659
+ }
660
+
632
661
  Packages().dataChanged.subscribe(async (evt) => {
633
662
  const pkg = evt.dataObject;
634
663
  const loadingPromise = uiLoadingPromises[pkg.packageId];
635
664
  if (!loadingPromise) return;
665
+ subscribeToPrefs(pkg.packageId);
636
666
  reloadPackage(pkg.packageId);
637
667
  });
638
668
  });