jattac.react.recents
Version:
A React component and utility for tracking and displaying recent browser locations and URLs.
327 lines (308 loc) • 17.2 kB
JavaScript
'use strict';
var React = require('react');
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
const defaultNamespace = 'global';
class OnBrowserLocationTracker {
constructor(config) {
this.storageKey = 'recents';
this.titleObserver = null;
this.config = config;
if (!OnBrowserLocationTracker._namespace) {
if (this.config.namespace) {
OnBrowserLocationTracker._namespace = this.config.namespace;
}
else {
OnBrowserLocationTracker._namespace = defaultNamespace;
}
}
}
static get namespace() {
if (this._namespace) {
return this._namespace;
}
else {
return defaultNamespace;
}
}
get fullStorageKey() {
return `${OnBrowserLocationTracker.namespace}_${this.storageKey}`;
}
getStoredRecents() {
return __awaiter(this, void 0, void 0, function* () {
const data = localStorage.getItem(this.fullStorageKey);
const result = (data ? JSON.parse(data) : []);
const validRecents = result
.filter((a) => this.isValid(a))
.sort((a, b) => new Date(b.lastVisited).getTime() - new Date(a.lastVisited).getTime());
const uniqueByDisplayLabel = [...new Map(validRecents.map((item) => [item.windowTitle, item])).values()];
return uniqueByDisplayLabel;
});
}
isValid(location) {
return location.url && location.windowTitle ? true : false;
}
saveRecents(recents) {
return __awaiter(this, void 0, void 0, function* () {
const validRecents = recents.filter((a) => this.isValid(a));
localStorage.setItem(this.fullStorageKey, JSON.stringify(validRecents));
});
}
shouldTrack(location) {
var _a;
if (!this.config.dontTrack)
return true; // If no dontTrack rules, always track
const { withPrefix, withSuffix, contains } = this.config.dontTrack;
// Helper function to check a condition
const matchesCondition = (value, patterns) => {
if (!value || !patterns)
return false;
return patterns.some((pattern) => {
const { urlOrTitle: title, url } = pattern;
return (title && value.startsWith(title)) || (url && value.startsWith(url));
});
};
const matchables = [withPrefix, withSuffix, contains];
for (let i = 0; i < matchables.length; i++) {
const specificMatchable = (_a = matchables[i]) !== null && _a !== void 0 ? _a : [];
const foundMatch = matchesCondition(location.url, specificMatchable) || matchesCondition(location.windowTitle, specificMatchable);
if (foundMatch) {
return false;
}
}
return true;
}
trackVisit(location) {
return __awaiter(this, void 0, void 0, function* () {
location.url = this.processUrlForTracking(location.url);
if (!this.shouldTrack(location))
return; // Skip tracking if location matches dontTrack rules
const recents = yield this.getStoredRecents();
// Remove any existing entry for the same URL
const updatedRecents = recents.filter((r) => r.url !== location.url);
// Add the new location to the top
updatedRecents.unshift(location);
// Enforce the limit
if (updatedRecents.length > this.config.maxRecents) {
updatedRecents.pop();
}
yield this.saveRecents(updatedRecents);
});
}
getRecentsAsync() {
return __awaiter(this, void 0, void 0, function* () {
return yield this.getStoredRecents();
});
}
removeRecent(url) {
return __awaiter(this, void 0, void 0, function* () {
const recents = yield this.getStoredRecents();
const updatedRecents = recents.filter((recent) => recent.url !== url);
yield this.saveRecents(updatedRecents);
});
}
clearRecents() {
return __awaiter(this, void 0, void 0, function* () {
localStorage.removeItem(this.fullStorageKey);
});
}
getCurrentLocation() {
// Ensure document is ready before accessing title
if (!window.location.href)
return null;
// Check if the document is fully loaded before accessing <title>
if (document.readyState === 'complete') {
this.observeTitleChange();
}
else {
// If the document is not fully loaded, listen for the load event
window.addEventListener('load', () => this.observeTitleChange());
}
return null; // MutationObserver will handle updates
}
/**
* Processes the URL for tracking, taking into account the disregardQueryStrings configuration.
* @param url The URL to process.
* @returns The processed URL.
*/
processUrlForTracking(url) {
if (!this.config.disregardQueryStrings)
return url;
const urlObj = new URL(url);
const path = urlObj.pathname;
this.config.disregardQueryStrings.forEach(({ path: disregardPath, queryStrings }) => {
if (path.startsWith(disregardPath)) {
queryStrings.forEach((queryString) => {
urlObj.searchParams.delete(queryString);
});
}
});
return urlObj.toString();
}
observeTitleChange() {
const titleElement = document.querySelector('title');
// Fallback if the title is not available
if (!titleElement) {
// If no title tag found, observe the <head> element instead
const headElement = document.querySelector('head');
if (!headElement)
return;
if (!this.titleObserver) {
this.titleObserver = new MutationObserver(() => {
const title = document.title;
this.updateLocation(title);
});
// Observe changes in the <head> element to detect changes in the title
this.titleObserver.observe(headElement, {
childList: true, // Watch for changes in child elements like <title>
subtree: true, // Watch all descendants, including the title tag
});
}
}
else {
// Directly update if the <title> is available
const title = document.title;
this.updateLocation(title);
}
}
updateLocation(title) {
return __awaiter(this, void 0, void 0, function* () {
const maxTitleLength = 45;
if (!title)
return;
// Truncate title if it exceeds max length and add ellipses
if (title.length > maxTitleLength) {
title = title.substring(0, maxTitleLength) + '...';
}
const location = {
windowTitle: title,
url: window.location.href,
lastVisited: new Date().toISOString(),
};
// Track the visit and persist the location
yield this.trackVisit(location);
});
}
}
OnBrowserLocationTracker._namespace = '';
function styleInject(css, ref) {
if ( ref === void 0 ) ref = {};
var insertAt = ref.insertAt;
if (typeof document === 'undefined') { return; }
var head = document.head || document.getElementsByTagName('head')[0];
var style = document.createElement('style');
style.type = 'text/css';
if (insertAt === 'top') {
if (head.firstChild) {
head.insertBefore(style, head.firstChild);
} else {
head.appendChild(style);
}
} else {
head.appendChild(style);
}
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
}
var css_248z = ".Recents-module_recentsContainer__87baP {\r\n font-family: \"Arial\", sans-serif;\r\n padding: 1.5rem;\r\n border: 1px solid #e5e5e5;\r\n border-radius: 8px;\r\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\r\n background: #fff;\r\n max-width: 600px;\r\n margin: 0 auto;\r\n}\r\n\r\n.Recents-module_recentsTitle__t8LqW {\r\n font-size: 1.5rem;\r\n font-weight: bold;\r\n margin-bottom: 1rem;\r\n color: #333;\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n}\r\n\r\n.Recents-module_removeAllButton__30r2o {\r\n background-color: #e53935;\r\n color: #fff;\r\n border: none;\r\n padding: 0.5rem 1rem;\r\n border-radius: 4px;\r\n cursor: pointer;\r\n font-size: 0.9rem;\r\n transition: background-color 0.2s ease;\r\n}\r\n\r\n.Recents-module_removeAllButton__30r2o:hover {\r\n background-color: #d32f2f;\r\n}\r\n\r\n.Recents-module_recentItem__AIFny {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n margin-bottom: 1rem;\r\n opacity: 0;\r\n transform: translateY(10px);\r\n animation: Recents-module_fadeIn__fxi5P 0.3s ease forwards;\r\n}\r\n\r\n.Recents-module_recentItem__AIFny:last-child {\r\n margin-bottom: 0;\r\n}\r\n\r\n.Recents-module_recentLink__LnBqE {\r\n text-decoration: none;\r\n color: #0073e6;\r\n font-size: 1rem;\r\n flex: 1;\r\n margin-right: 1rem;\r\n transition: color 0.2s ease;\r\n}\r\n\r\n.Recents-module_recentLink__LnBqE:hover {\r\n color: #005bb5;\r\n}\r\n\r\n.Recents-module_timeLabel__Paq1c {\r\n font-size: 0.9rem;\r\n color: #666;\r\n margin-right: 1rem;\r\n}\r\n\r\n.Recents-module_removeButton__Ugt7l {\r\n background-color: transparent;\r\n color: #e53935;\r\n border: none;\r\n font-size: 1rem;\r\n cursor: pointer;\r\n padding: 0.2rem 0.5rem;\r\n transition: color 0.2s ease;\r\n}\r\n\r\n.Recents-module_removeButton__Ugt7l:hover {\r\n color: #d32f2f;\r\n}\r\n\r\n.Recents-module_noRecents__OySGm {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n padding: 2rem 1rem;\r\n text-align: center;\r\n}\r\n\r\n.Recents-module_noRecentsIcon__YSMTH {\r\n width: 48px;\r\n height: 48px;\r\n color: #ccc;\r\n margin-bottom: 1rem;\r\n}\r\n\r\n.Recents-module_noRecentsText__kT8tq {\r\n font-size: 1rem;\r\n color: #666;\r\n line-height: 1.5;\r\n}\r\n\r\n@keyframes Recents-module_fadeIn__fxi5P {\r\n to {\r\n opacity: 1;\r\n transform: translateY(0);\r\n }\r\n}\r\n";
var styles = {"recentsContainer":"Recents-module_recentsContainer__87baP","recentsTitle":"Recents-module_recentsTitle__t8LqW","removeAllButton":"Recents-module_removeAllButton__30r2o","recentItem":"Recents-module_recentItem__AIFny","fadeIn":"Recents-module_fadeIn__fxi5P","recentLink":"Recents-module_recentLink__LnBqE","timeLabel":"Recents-module_timeLabel__Paq1c","removeButton":"Recents-module_removeButton__Ugt7l","noRecents":"Recents-module_noRecents__OySGm","noRecentsIcon":"Recents-module_noRecentsIcon__YSMTH","noRecentsText":"Recents-module_noRecentsText__kT8tq"};
styleInject(css_248z);
const formatFriendlyTime = (isoDate) => {
const date = new Date(isoDate);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
const diffInMinutes = Math.floor(diffInSeconds / 60);
const diffInHours = Math.floor(diffInSeconds / 3600);
const diffInDays = Math.floor(diffInSeconds / 86400);
const diffInWeeks = Math.floor(diffInSeconds / 604800);
const diffInMonths = Math.floor(diffInSeconds / 2592000);
if (diffInSeconds < 60)
return `${diffInSeconds} seconds ago`;
if (diffInMinutes < 60)
return `${diffInMinutes} minutes ago`;
if (diffInHours < 24)
return `${diffInHours} hours ago`;
if (diffInDays === 1)
return "Yesterday";
if (diffInDays < 7)
return `${diffInDays} days ago`;
if (diffInWeeks < 4)
return `${diffInWeeks} weeks ago`;
if (diffInMonths < 12)
return `${diffInMonths} months ago`;
return date.toLocaleDateString(); // Return a more standard date format if it's more than a year ago
};
// NoRecents.svg - a cleaner, modern icon
const NoRecents = () => (React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 64 64", width: "64", height: "64", fill: "none" },
React.createElement("g", null,
React.createElement("path", { fill: "#888", d: "M32 8C18.746 8 8 18.746 8 32s10.746 24 24 24 24-10.746 24-24-10.746-24-24-24zm0 44c-11.046 0-20-8.954-20-20s8.954-20 20-20 20 8.954 20 20-8.954 20-20 20z" }),
React.createElement("path", { fill: "#888", d: "M32 12c1.657 0 3 1.343 3 3s-1.343 3-3 3-3-1.343-3-3 1.343-3 3-3zm-9 10c1.657 0 3 1.343 3 3s-1.343 3-3 3-3-1.343-3-3 1.343-3 3-3zm18 0c1.657 0 3 1.343 3 3s-1.343 3-3 3-3-1.343-3-3 1.343-3 3-3z" }))));
const Recents = ({ onBeforeRemove, namespace }) => {
const [recents, setRecents] = React.useState([]);
React.useEffect(() => {
const tracker = new OnBrowserLocationTracker({ maxRecents: 10, namespace: namespace });
const loadRecents = () => __awaiter(void 0, void 0, void 0, function* () {
const storedRecents = yield tracker.getRecentsAsync();
setRecents(storedRecents);
});
loadRecents();
}, []);
const handleRemoveRecent = (recent) => {
const shouldRemove = onBeforeRemove ? onBeforeRemove([recent]) : true;
if (!shouldRemove)
return;
setRecents((prevRecents) => prevRecents.filter((item) => item.url !== recent.url));
// Update tracker storage if needed
const tracker = new OnBrowserLocationTracker({ maxRecents: 10 });
tracker.removeRecent(recent.url);
};
const handleRemoveAll = () => {
const shouldRemoveAll = onBeforeRemove ? onBeforeRemove(recents) : true;
if (!shouldRemoveAll)
return;
setRecents([]);
// Update tracker storage if needed
const tracker = new OnBrowserLocationTracker({ maxRecents: 10 });
tracker.clearRecents();
};
return (React.createElement("div", { className: styles.recentsContainer },
React.createElement("h2", { className: styles.recentsTitle },
"Recently Visited",
recents.length > 0 && (React.createElement("button", { onClick: handleRemoveAll, className: styles.removeAllButton, title: "Remove all recents" }, "Clear All"))),
recents.length === 0 ? (React.createElement("div", { className: styles.noRecents },
React.createElement(NoRecents, null),
React.createElement("p", { className: styles.noRecentsText }, "No recent activity yet. Start browsing to see your recent history here!"))) : (recents.map((item, index) => (React.createElement("div", { key: index, className: styles.recentItem },
React.createElement("a", { href: item.url, className: styles.recentLink }, item.windowTitle),
React.createElement("span", { className: styles.timeLabel }, formatFriendlyTime(item.lastVisited)),
React.createElement("button", { onClick: () => handleRemoveRecent(item), className: styles.removeButton, title: "Remove this recent" }, "\u2715")))))));
};
exports.OnBrowserLocationTracker = OnBrowserLocationTracker;
exports.Recents = Recents;
//# sourceMappingURL=index.js.map