@planningcenter/topbar 12.5.0-rc.1 → 12.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/topbar.js +405 -45
- package/dist/topbar.js.map +1 -1
- package/dist/topbar.modern.js +405 -45
- package/dist/topbar.modern.js.map +1 -1
- package/dist/topbar.module.js +405 -45
- package/dist/topbar.module.js.map +1 -1
- package/dist/topbar.umd.js +405 -45
- package/dist/topbar.umd.js.map +1 -1
- package/package.json +1 -1
package/dist/topbar.js
CHANGED
|
@@ -3630,7 +3630,7 @@ const store = createStore(getAllPcoMeta());
|
|
|
3630
3630
|
const recentlyVisitableMetadataStore = createStore({
|
|
3631
3631
|
metadata: getRecentlyVisitableMetadata()
|
|
3632
3632
|
});
|
|
3633
|
-
function sync() {
|
|
3633
|
+
function sync$1() {
|
|
3634
3634
|
const nextState = getAllPcoMeta();
|
|
3635
3635
|
if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
|
|
3636
3636
|
const metadata = getRecentlyVisitableMetadata();
|
|
@@ -3640,7 +3640,7 @@ function sync() {
|
|
|
3640
3640
|
});
|
|
3641
3641
|
}
|
|
3642
3642
|
}
|
|
3643
|
-
const observer = new MutationObserver(sync);
|
|
3643
|
+
const observer = new MutationObserver(sync$1);
|
|
3644
3644
|
let hasTriggeredObserve = false;
|
|
3645
3645
|
function observe() {
|
|
3646
3646
|
if (hasTriggeredObserve) return;
|
|
@@ -3651,7 +3651,7 @@ function observe() {
|
|
|
3651
3651
|
// never mutate afterward, so the observer alone would never catch them.
|
|
3652
3652
|
// Re-read once now, since observe() is only called once a consumer is
|
|
3653
3653
|
// actually mounting, which is always after the document has fully parsed.
|
|
3654
|
-
sync();
|
|
3654
|
+
sync$1();
|
|
3655
3655
|
observer.observe(document.head, {
|
|
3656
3656
|
attributes: true,
|
|
3657
3657
|
childList: true,
|
|
@@ -3667,10 +3667,8 @@ function useRecentlyVisitableMetadata() {
|
|
|
3667
3667
|
return shim.useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
|
|
3668
3668
|
}
|
|
3669
3669
|
|
|
3670
|
-
// Entries match the ones home-mobile stores, so one list can sync across web
|
|
3671
|
-
// and mobile.
|
|
3672
|
-
const ENTRY_VERSION = 3;
|
|
3673
3670
|
const MAX_ENTRIES = 50;
|
|
3671
|
+
const MAX_ENTRY_BYTES = 1024;
|
|
3674
3672
|
const MAX_TEXT_LENGTH = 200;
|
|
3675
3673
|
const PRODUCTS$1 = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
|
|
3676
3674
|
// Login has no pages worth returning to.
|
|
@@ -3717,8 +3715,8 @@ function entryKey(entry) {
|
|
|
3717
3715
|
return `page:${entry.app}:${entry.path.split("?")[0]}`;
|
|
3718
3716
|
}
|
|
3719
3717
|
}
|
|
3720
|
-
function
|
|
3721
|
-
if (!isRecord(value) ||
|
|
3718
|
+
function hasEntryShape(value) {
|
|
3719
|
+
if (!isRecord(value) || typeof value.visitedAt !== "string") {
|
|
3722
3720
|
return false;
|
|
3723
3721
|
} else if (value.type === "resource") {
|
|
3724
3722
|
return typeof value.pcrl === "string" && isPcrl(value.pcrl);
|
|
@@ -3728,15 +3726,38 @@ function isEntry(value) {
|
|
|
3728
3726
|
return false;
|
|
3729
3727
|
}
|
|
3730
3728
|
}
|
|
3729
|
+
function utf8Length(value) {
|
|
3730
|
+
return Array.from(value).reduce((length, character) => {
|
|
3731
|
+
var _a;
|
|
3732
|
+
const codePoint = (_a = character.codePointAt(0)) !== null && _a !== void 0 ? _a : 0;
|
|
3733
|
+
if (codePoint < 0x80) {
|
|
3734
|
+
return length + 1;
|
|
3735
|
+
} else if (codePoint < 0x800) {
|
|
3736
|
+
return length + 2;
|
|
3737
|
+
} else if (codePoint < 0x10000) {
|
|
3738
|
+
return length + 3;
|
|
3739
|
+
} else {
|
|
3740
|
+
return length + 4;
|
|
3741
|
+
}
|
|
3742
|
+
}, 0);
|
|
3743
|
+
}
|
|
3744
|
+
function excessBytes(entry) {
|
|
3745
|
+
return utf8Length(JSON.stringify(entry)) - MAX_ENTRY_BYTES;
|
|
3746
|
+
}
|
|
3747
|
+
// user-store-sync rejects a whole write over one entry above its size limit,
|
|
3748
|
+
// so an entry that can't fit isn't one this list can hold.
|
|
3749
|
+
function isEntry(value) {
|
|
3750
|
+
return hasEntryShape(value) && excessBytes(value) <= 0;
|
|
3751
|
+
}
|
|
3731
3752
|
// Cuts on code points so a surrogate pair is never split.
|
|
3732
3753
|
function clampText(value) {
|
|
3733
3754
|
return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
|
|
3734
3755
|
}
|
|
3735
|
-
function
|
|
3756
|
+
function metadataFrom({
|
|
3736
3757
|
details,
|
|
3737
3758
|
type
|
|
3738
3759
|
}) {
|
|
3739
|
-
|
|
3760
|
+
const metadata = {
|
|
3740
3761
|
...(details && {
|
|
3741
3762
|
details: clampText(details)
|
|
3742
3763
|
}),
|
|
@@ -3744,39 +3765,124 @@ function clampMetadata({
|
|
|
3744
3765
|
type: clampText(type)
|
|
3745
3766
|
})
|
|
3746
3767
|
};
|
|
3768
|
+
return Object.keys(metadata).length > 0 ? {
|
|
3769
|
+
metadata
|
|
3770
|
+
} : {};
|
|
3771
|
+
}
|
|
3772
|
+
function pageEntry({
|
|
3773
|
+
app,
|
|
3774
|
+
path,
|
|
3775
|
+
visitedAt
|
|
3776
|
+
}, {
|
|
3777
|
+
details,
|
|
3778
|
+
title,
|
|
3779
|
+
type
|
|
3780
|
+
}) {
|
|
3781
|
+
return {
|
|
3782
|
+
app,
|
|
3783
|
+
...metadataFrom({
|
|
3784
|
+
details,
|
|
3785
|
+
type
|
|
3786
|
+
}),
|
|
3787
|
+
path,
|
|
3788
|
+
title: clampText(title),
|
|
3789
|
+
type: "page",
|
|
3790
|
+
visitedAt
|
|
3791
|
+
};
|
|
3747
3792
|
}
|
|
3748
|
-
function
|
|
3749
|
-
|
|
3750
|
-
|
|
3793
|
+
function texts(entry) {
|
|
3794
|
+
var _a, _b, _c, _d;
|
|
3795
|
+
return {
|
|
3796
|
+
details: (_b = (_a = entry.metadata) === null || _a === void 0 ? void 0 : _a.details) !== null && _b !== void 0 ? _b : "",
|
|
3797
|
+
title: entry.title,
|
|
3798
|
+
type: (_d = (_c = entry.metadata) === null || _c === void 0 ? void 0 : _c.type) !== null && _d !== void 0 ? _d : ""
|
|
3799
|
+
};
|
|
3800
|
+
}
|
|
3801
|
+
function jsonBytes(value) {
|
|
3802
|
+
return utf8Length(JSON.stringify(value)) - 2;
|
|
3803
|
+
}
|
|
3804
|
+
function longestText(entry) {
|
|
3805
|
+
return Object.entries(texts(entry)).reduce((longest, candidate) => jsonBytes(candidate[1]) > jsonBytes(longest[1]) ? candidate : longest);
|
|
3806
|
+
}
|
|
3807
|
+
// Takes whole characters off the end until at least `excess` bytes are gone,
|
|
3808
|
+
// so a surrogate pair is never split.
|
|
3809
|
+
function shortenBy(value, excess, minLength) {
|
|
3810
|
+
var _a;
|
|
3811
|
+
const characters = Array.from(value);
|
|
3812
|
+
let removed = 0;
|
|
3813
|
+
while (characters.length > minLength && removed < excess) {
|
|
3814
|
+
removed += jsonBytes((_a = characters.pop()) !== null && _a !== void 0 ? _a : "");
|
|
3815
|
+
}
|
|
3816
|
+
return characters.join("");
|
|
3817
|
+
}
|
|
3818
|
+
// Trims the longest text field by the overflow, repeating until the entry
|
|
3819
|
+
// fits or the title is down to one character.
|
|
3820
|
+
function trimTexts(entry) {
|
|
3821
|
+
const excess = excessBytes(entry);
|
|
3822
|
+
const [field, value] = longestText(entry);
|
|
3823
|
+
const shortened = shortenBy(value, excess, field === "title" ? 1 : 0);
|
|
3824
|
+
if (excess <= 0 || shortened === value) {
|
|
3825
|
+
return entry;
|
|
3751
3826
|
} else {
|
|
3752
|
-
return {
|
|
3753
|
-
...
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3827
|
+
return trimTexts(pageEntry(entry, {
|
|
3828
|
+
...texts(entry),
|
|
3829
|
+
[field]: shortened
|
|
3830
|
+
}));
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
// The query string is the first thing to go, since it's not part of the
|
|
3834
|
+
// page's identity; what's left is trimmed to fit under the service's limit.
|
|
3835
|
+
function fitPage(entry) {
|
|
3836
|
+
if (excessBytes(entry) <= 0) {
|
|
3837
|
+
return entry;
|
|
3838
|
+
} else {
|
|
3839
|
+
return trimTexts({
|
|
3840
|
+
...entry,
|
|
3841
|
+
path: entry.path.split("?")[0]
|
|
3842
|
+
});
|
|
3759
3843
|
}
|
|
3760
3844
|
}
|
|
3761
3845
|
function toEntry(visit, visitedAt) {
|
|
3762
|
-
|
|
3763
|
-
...
|
|
3764
|
-
version: ENTRY_VERSION,
|
|
3846
|
+
const entry = {
|
|
3847
|
+
...visit,
|
|
3765
3848
|
visitedAt: visitedAt.toISOString()
|
|
3766
3849
|
};
|
|
3850
|
+
if (entry.type === "resource") {
|
|
3851
|
+
return entry;
|
|
3852
|
+
} else {
|
|
3853
|
+
return fitPage(pageEntry(entry, {
|
|
3854
|
+
...texts(entry),
|
|
3855
|
+
title: entry.title.trim()
|
|
3856
|
+
}));
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
// user-store-sync rejects a whole write over one field it doesn't know, so
|
|
3860
|
+
// anything else an entry carries (like the version older entries stored) is
|
|
3861
|
+
// dropped on the way in.
|
|
3862
|
+
function withKnownFields(entry) {
|
|
3863
|
+
if (entry.type === "resource") {
|
|
3864
|
+
return {
|
|
3865
|
+
pcrl: entry.pcrl,
|
|
3866
|
+
type: entry.type,
|
|
3867
|
+
visitedAt: entry.visitedAt
|
|
3868
|
+
};
|
|
3869
|
+
} else {
|
|
3870
|
+
return pageEntry(entry, texts(entry));
|
|
3871
|
+
}
|
|
3767
3872
|
}
|
|
3768
3873
|
// Recents are disposable: anything unexpected reads as an empty list (or, for
|
|
3769
3874
|
// a single bad entry, a list without it) rather than throwing.
|
|
3875
|
+
function entriesFrom(value) {
|
|
3876
|
+
if (Array.isArray(value)) {
|
|
3877
|
+
return value.filter(isEntry).map(withKnownFields);
|
|
3878
|
+
} else {
|
|
3879
|
+
return [];
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3770
3882
|
function parseRecentlyVisited(value) {
|
|
3771
|
-
let parsed = null;
|
|
3772
3883
|
try {
|
|
3773
|
-
|
|
3884
|
+
return entriesFrom(JSON.parse(value !== null && value !== void 0 ? value : "null"));
|
|
3774
3885
|
} catch {
|
|
3775
|
-
parsed = null;
|
|
3776
|
-
}
|
|
3777
|
-
if (Array.isArray(parsed)) {
|
|
3778
|
-
return parsed.filter(isEntry);
|
|
3779
|
-
} else {
|
|
3780
3886
|
return [];
|
|
3781
3887
|
}
|
|
3782
3888
|
}
|
|
@@ -3784,6 +3890,38 @@ function addVisit(entries, entry) {
|
|
|
3784
3890
|
const key = entryKey(entry);
|
|
3785
3891
|
return [entry, ...entries.filter(existing => entryKey(existing) !== key)].slice(0, MAX_ENTRIES);
|
|
3786
3892
|
}
|
|
3893
|
+
// The service may write timestamps with fewer fractional digits than
|
|
3894
|
+
// toISOString, so they're compared as times rather than strings.
|
|
3895
|
+
function visitTime(entry) {
|
|
3896
|
+
return isRecord(entry) && typeof entry.visitedAt === "string" ? Date.parse(entry.visitedAt) || 0 : 0;
|
|
3897
|
+
}
|
|
3898
|
+
function newestFirst(entries) {
|
|
3899
|
+
const seen = new Set();
|
|
3900
|
+
return entries.sort((a, b) => visitTime(b) - visitTime(a)).filter(entry => {
|
|
3901
|
+
if (isEntry(entry)) {
|
|
3902
|
+
const key = entryKey(entry);
|
|
3903
|
+
const isFirst = !seen.has(key);
|
|
3904
|
+
seen.add(key);
|
|
3905
|
+
return isFirst;
|
|
3906
|
+
} else {
|
|
3907
|
+
return true;
|
|
3908
|
+
}
|
|
3909
|
+
}).slice(0, MAX_ENTRIES);
|
|
3910
|
+
}
|
|
3911
|
+
// One list from several, newest visit first wherever it was recorded.
|
|
3912
|
+
function mergeEntries(...lists) {
|
|
3913
|
+
return newestFirst(lists.flat());
|
|
3914
|
+
}
|
|
3915
|
+
// Products pin different topbar versions, so the shared list can hold entries
|
|
3916
|
+
// this build doesn't recognize (a newer app or type). They're kept, as stored,
|
|
3917
|
+
// in what's written back, so an older topbar never erases a newer client's
|
|
3918
|
+
// visits.
|
|
3919
|
+
function mergeStored(stored, local) {
|
|
3920
|
+
return newestFirst([...(Array.isArray(stored) ? stored : []), ...local]);
|
|
3921
|
+
}
|
|
3922
|
+
function isSameList(a, b) {
|
|
3923
|
+
return a.length === b.length && a.every((entry, index) => entryKey(entry) === entryKey(b[index]) && visitTime(entry) === visitTime(b[index]));
|
|
3924
|
+
}
|
|
3787
3925
|
|
|
3788
3926
|
// The app whose URL, in this environment, has the current page's host.
|
|
3789
3927
|
// Reversing pcoUrl handles every environment @planningcenter/url knows about,
|
|
@@ -3894,9 +4032,164 @@ function useCurrentPersonId() {
|
|
|
3894
4032
|
return userId == null ? undefined : String(userId);
|
|
3895
4033
|
}
|
|
3896
4034
|
|
|
3897
|
-
//
|
|
3898
|
-
//
|
|
3899
|
-
|
|
4035
|
+
// Tokens last 15 minutes. Refreshing a minute early keeps one from expiring
|
|
4036
|
+
// between reading a token and the service checking it.
|
|
4037
|
+
const REFRESH_MARGIN_MS = 60000;
|
|
4038
|
+
// A failed mint (a CSRF 422, a 5xx, a body that isn't a token) isn't retried
|
|
4039
|
+
// on every visit and panel open; the product is left alone for a while.
|
|
4040
|
+
const MINT_BACKOFF_MS = 5 * 60000;
|
|
4041
|
+
const MAX_ATTEMPTS = 3;
|
|
4042
|
+
// A product without the endpoint answers 404 for the rest of the session, so
|
|
4043
|
+
// it's asked once per tab rather than on every page.
|
|
4044
|
+
const UNAVAILABLE_UNTIL_KEY = "topbar.user_store_sync.unavailable_until";
|
|
4045
|
+
const END_OF_SESSION = Number.MAX_SAFE_INTEGER;
|
|
4046
|
+
// Per person, so switching connected people never writes one person's visits
|
|
4047
|
+
// with another's token.
|
|
4048
|
+
function tokenKey(personId) {
|
|
4049
|
+
return `topbar.user_store_sync.token.${personId}`;
|
|
4050
|
+
}
|
|
4051
|
+
function readSession(key) {
|
|
4052
|
+
try {
|
|
4053
|
+
return sessionStorage.getItem(key);
|
|
4054
|
+
} catch {
|
|
4055
|
+
return null;
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
function writeSession(key, value) {
|
|
4059
|
+
try {
|
|
4060
|
+
sessionStorage.setItem(key, value);
|
|
4061
|
+
} catch {
|
|
4062
|
+
// Without sessionStorage the next request just mints again.
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
function isUserStoreSyncUnavailable() {
|
|
4066
|
+
return Number(readSession(UNAVAILABLE_UNTIL_KEY)) > Date.now();
|
|
4067
|
+
}
|
|
4068
|
+
function markUnavailable(until) {
|
|
4069
|
+
writeSession(UNAVAILABLE_UNTIL_KEY, String(until));
|
|
4070
|
+
}
|
|
4071
|
+
function isToken(value) {
|
|
4072
|
+
return isRecord(value) && typeof value.token === "string" && typeof value.url === "string" && Number.isFinite(value.expiresAt) && typeof value.readOnly === "boolean";
|
|
4073
|
+
}
|
|
4074
|
+
function cachedToken(personId) {
|
|
4075
|
+
var _a;
|
|
4076
|
+
try {
|
|
4077
|
+
const token = JSON.parse((_a = readSession(tokenKey(personId))) !== null && _a !== void 0 ? _a : "null");
|
|
4078
|
+
if (isToken(token) && token.expiresAt - REFRESH_MARGIN_MS > Date.now()) {
|
|
4079
|
+
return token;
|
|
4080
|
+
} else {
|
|
4081
|
+
return null;
|
|
4082
|
+
}
|
|
4083
|
+
} catch {
|
|
4084
|
+
return null;
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
function tokenFrom(json) {
|
|
4088
|
+
if (isRecord(json)) {
|
|
4089
|
+
const token = {
|
|
4090
|
+
expiresAt: typeof json.expires_at === "string" ? Date.parse(json.expires_at) : NaN,
|
|
4091
|
+
readOnly: json.read_only === true,
|
|
4092
|
+
token: json.token,
|
|
4093
|
+
url: json.url
|
|
4094
|
+
};
|
|
4095
|
+
return isToken(token) ? token : null;
|
|
4096
|
+
} else {
|
|
4097
|
+
return null;
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
async function mintToken(personId) {
|
|
4101
|
+
const {
|
|
4102
|
+
json,
|
|
4103
|
+
ok,
|
|
4104
|
+
response
|
|
4105
|
+
} = await apiRequest(railsCsrfFetch, `${window.location.origin}/user_store_sync/token`, {
|
|
4106
|
+
method: "POST"
|
|
4107
|
+
});
|
|
4108
|
+
const token = ok ? tokenFrom(json) : null;
|
|
4109
|
+
if (token) {
|
|
4110
|
+
writeSession(tokenKey(personId), JSON.stringify(token));
|
|
4111
|
+
} else if ((response === null || response === void 0 ? void 0 : response.status) === 404) {
|
|
4112
|
+
markUnavailable(END_OF_SESSION);
|
|
4113
|
+
} else {
|
|
4114
|
+
markUnavailable(Date.now() + MINT_BACKOFF_MS);
|
|
4115
|
+
}
|
|
4116
|
+
return token;
|
|
4117
|
+
}
|
|
4118
|
+
async function request(personId, key, init = {}, isRetry = false) {
|
|
4119
|
+
var _a;
|
|
4120
|
+
const token = isRetry ? await mintToken(personId) : (_a = cachedToken(personId)) !== null && _a !== void 0 ? _a : await mintToken(personId);
|
|
4121
|
+
if (token) {
|
|
4122
|
+
const response = await fetch(`${token.url}/v1/data/${key}`, {
|
|
4123
|
+
...init,
|
|
4124
|
+
headers: {
|
|
4125
|
+
...init.headers,
|
|
4126
|
+
Authorization: `Bearer ${token.token}`
|
|
4127
|
+
}
|
|
4128
|
+
});
|
|
4129
|
+
if (response.status === 401 && !isRetry) {
|
|
4130
|
+
return request(personId, key, init, true);
|
|
4131
|
+
} else {
|
|
4132
|
+
return {
|
|
4133
|
+
response,
|
|
4134
|
+
token
|
|
4135
|
+
};
|
|
4136
|
+
}
|
|
4137
|
+
} else {
|
|
4138
|
+
return null;
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
// The service answers version 0 for a person with nothing stored yet, and
|
|
4142
|
+
// takes `If-Match: "0"` for the first write. Without a version at all, the
|
|
4143
|
+
// write goes unconditioned.
|
|
4144
|
+
function ifMatch(version) {
|
|
4145
|
+
return typeof version === "number" ? {
|
|
4146
|
+
"If-Match": `"${version}"`
|
|
4147
|
+
} : {};
|
|
4148
|
+
}
|
|
4149
|
+
// Applies `change` to the stored value and writes the result, rereading and
|
|
4150
|
+
// retrying when another tab or device wrote in between. `change` returns null
|
|
4151
|
+
// when there's nothing to write. A read-only token (staff impersonating
|
|
4152
|
+
// someone) only reads, so nothing is written into the person's history.
|
|
4153
|
+
// Resolves to the value now stored, or null when the service couldn't be
|
|
4154
|
+
// reached.
|
|
4155
|
+
async function updateStoredValue(personId, key, change, attemptsLeft = MAX_ATTEMPTS) {
|
|
4156
|
+
const read = await request(personId, key);
|
|
4157
|
+
if (read === null || read === void 0 ? void 0 : read.response.ok) {
|
|
4158
|
+
const {
|
|
4159
|
+
value,
|
|
4160
|
+
version
|
|
4161
|
+
} = await read.response.json();
|
|
4162
|
+
const next = read.token.readOnly ? null : change(value);
|
|
4163
|
+
if (next === null) {
|
|
4164
|
+
return Array.isArray(value) ? value : [];
|
|
4165
|
+
} else {
|
|
4166
|
+
const write = await request(personId, key, {
|
|
4167
|
+
body: JSON.stringify({
|
|
4168
|
+
value: next
|
|
4169
|
+
}),
|
|
4170
|
+
headers: {
|
|
4171
|
+
"Content-Type": "application/json",
|
|
4172
|
+
...ifMatch(version)
|
|
4173
|
+
},
|
|
4174
|
+
method: "PUT"
|
|
4175
|
+
});
|
|
4176
|
+
if (write === null || write === void 0 ? void 0 : write.response.ok) {
|
|
4177
|
+
return next;
|
|
4178
|
+
} else if ((write === null || write === void 0 ? void 0 : write.response.status) === 409 && attemptsLeft > 1) {
|
|
4179
|
+
return updateStoredValue(personId, key, change, attemptsLeft - 1);
|
|
4180
|
+
} else {
|
|
4181
|
+
return null;
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
} else {
|
|
4185
|
+
return null;
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
// Each product keeps its own copy in localStorage, and user-store-sync joins
|
|
4190
|
+
// the copies up across products and devices wherever the product can mint a
|
|
4191
|
+
// token for it.
|
|
4192
|
+
const SYNC_KEY = "recently_viewed";
|
|
3900
4193
|
function storageKey({
|
|
3901
4194
|
env,
|
|
3902
4195
|
personId
|
|
@@ -3915,17 +4208,60 @@ function readRecentlyVisited(scope) {
|
|
|
3915
4208
|
return [];
|
|
3916
4209
|
}
|
|
3917
4210
|
}
|
|
4211
|
+
function writeRecentlyVisited(scope, entries) {
|
|
4212
|
+
try {
|
|
4213
|
+
localStorage.setItem(storageKey(scope), JSON.stringify(entries));
|
|
4214
|
+
changes.setState({
|
|
4215
|
+
version: changes.getState().version + 1
|
|
4216
|
+
});
|
|
4217
|
+
} catch {
|
|
4218
|
+
// Storage can be full or blocked; recents are disposable.
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
async function syncOnce(scope) {
|
|
4222
|
+
try {
|
|
4223
|
+
const synced = await updateStoredValue(scope.personId, SYNC_KEY, stored => {
|
|
4224
|
+
const shared = entriesFrom(stored);
|
|
4225
|
+
const local = readRecentlyVisited(scope);
|
|
4226
|
+
return isSameList(mergeEntries(shared, local), shared) ? null : mergeStored(stored, local);
|
|
4227
|
+
});
|
|
4228
|
+
if (synced) {
|
|
4229
|
+
writeRecentlyVisited(scope, mergeEntries(entriesFrom(synced), readRecentlyVisited(scope)));
|
|
4230
|
+
}
|
|
4231
|
+
} catch {
|
|
4232
|
+
// The service is best-effort; the local copy stands on its own.
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
const syncing = new Map();
|
|
4236
|
+
const syncAgain = new Set();
|
|
4237
|
+
// One sync per list at a time. A visit recorded while one runs schedules a
|
|
4238
|
+
// single follow-up, so it still reaches the service; anything else asking
|
|
4239
|
+
// mid-sync just waits for the one running.
|
|
4240
|
+
function sync(scope, followUp) {
|
|
4241
|
+
const key = storageKey(scope);
|
|
4242
|
+
const running = syncing.get(key);
|
|
4243
|
+
if (isUserStoreSyncUnavailable()) {
|
|
4244
|
+
return Promise.resolve();
|
|
4245
|
+
} else if (running) {
|
|
4246
|
+
if (followUp) syncAgain.add(key);
|
|
4247
|
+
return running;
|
|
4248
|
+
} else {
|
|
4249
|
+
const run = syncOnce(scope).then(() => {
|
|
4250
|
+
syncing.delete(key);
|
|
4251
|
+
return syncAgain.delete(key) ? sync(scope, false) : undefined;
|
|
4252
|
+
});
|
|
4253
|
+
syncing.set(key, run);
|
|
4254
|
+
return run;
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
function syncRecentlyVisited(scope) {
|
|
4258
|
+
return sync(scope, false);
|
|
4259
|
+
}
|
|
3918
4260
|
function recordVisit(visit, scope) {
|
|
3919
4261
|
const entry = toEntry(visit, new Date());
|
|
3920
4262
|
if (isEntry(entry)) {
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
changes.setState({
|
|
3924
|
-
version: changes.getState().version + 1
|
|
3925
|
-
});
|
|
3926
|
-
} catch {
|
|
3927
|
-
// Storage can be full or blocked; recents are disposable.
|
|
3928
|
-
}
|
|
4263
|
+
writeRecentlyVisited(scope, addVisit(readRecentlyVisited(scope), entry));
|
|
4264
|
+
sync(scope, true);
|
|
3929
4265
|
}
|
|
3930
4266
|
}
|
|
3931
4267
|
function useRecentlyVisited({
|
|
@@ -3933,6 +4269,14 @@ function useRecentlyVisited({
|
|
|
3933
4269
|
personId
|
|
3934
4270
|
}) {
|
|
3935
4271
|
const version = shim.useSyncExternalStore(changes.subscribe, () => changes.getState().version);
|
|
4272
|
+
React.useEffect(() => {
|
|
4273
|
+
if (personId) {
|
|
4274
|
+
syncRecentlyVisited({
|
|
4275
|
+
env,
|
|
4276
|
+
personId
|
|
4277
|
+
});
|
|
4278
|
+
}
|
|
4279
|
+
}, [env, personId]);
|
|
3936
4280
|
// `version` isn't read, but listing it re-reads storage after each visit.
|
|
3937
4281
|
return React.useMemo(() => personId ? readRecentlyVisited({
|
|
3938
4282
|
env,
|
|
@@ -4107,28 +4451,44 @@ function useCopyLink() {
|
|
|
4107
4451
|
};
|
|
4108
4452
|
}
|
|
4109
4453
|
|
|
4454
|
+
function indexOf(highlight, items, list) {
|
|
4455
|
+
const pinnedIndex = items.findIndex(({
|
|
4456
|
+
key
|
|
4457
|
+
}) => key === highlight.key);
|
|
4458
|
+
if (highlight.list !== list) {
|
|
4459
|
+
return 0;
|
|
4460
|
+
} else if (pinnedIndex >= 0) {
|
|
4461
|
+
return pinnedIndex;
|
|
4462
|
+
} else {
|
|
4463
|
+
return highlight.index;
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4110
4466
|
// Remembering which list the index belongs to puts the highlight back on the
|
|
4111
4467
|
// first item whenever a new list replaces it, without an effect to reset it.
|
|
4112
|
-
// A list that grows
|
|
4468
|
+
// A list that grows, fills in, or reorders under the same `list` keeps its
|
|
4469
|
+
// highlight on the same item, found again by key.
|
|
4113
4470
|
// Disabled, nothing is highlighted and the keys and pointer leave it alone.
|
|
4114
4471
|
function useResultNavigation(items, list, enabled = true) {
|
|
4115
4472
|
const [highlight, setHighlight] = React.useState({
|
|
4116
4473
|
index: 0,
|
|
4117
4474
|
list
|
|
4118
4475
|
});
|
|
4119
|
-
const highlightedIndex = highlight
|
|
4476
|
+
const highlightedIndex = indexOf(highlight, items, list);
|
|
4120
4477
|
const highlighted = enabled ? items[highlightedIndex] : undefined;
|
|
4121
4478
|
const {
|
|
4122
4479
|
copiedUrl,
|
|
4123
4480
|
copy
|
|
4124
4481
|
} = useCopyLink();
|
|
4125
4482
|
const highlightIndex = React.useCallback(index => {
|
|
4483
|
+
var _a;
|
|
4126
4484
|
if (!enabled) return;
|
|
4127
|
-
|
|
4485
|
+
const key = (_a = items[index]) === null || _a === void 0 ? void 0 : _a.key;
|
|
4486
|
+
setHighlight(current => current.list === list && current.index === index && current.key === key ? current : {
|
|
4128
4487
|
index,
|
|
4488
|
+
key,
|
|
4129
4489
|
list
|
|
4130
4490
|
});
|
|
4131
|
-
}, [enabled, list]);
|
|
4491
|
+
}, [enabled, items, list]);
|
|
4132
4492
|
const handleKeyDown = event => {
|
|
4133
4493
|
if (!enabled) return;
|
|
4134
4494
|
const step = verticalStep(event.key);
|