jattac.react.recents
Version:
A React component and utility for tracking and displaying recent browser locations and URLs.
347 lines (327 loc) • 19.3 kB
JavaScript
;
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 uniqueByUrl = [...new Map(validRecents.map((item) => [item.url, item])).values()];
return uniqueByUrl;
});
}
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 = "@keyframes Recents-module_fadeIn__fxi5P {\n from {\n opacity: 0;\n transform: translateY(-10px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Mobile-first base styles */\n.Recents-module_recentsContainer__87baP {\n padding: 12px;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica,\n Arial, sans-serif;\n animation: Recents-module_fadeIn__fxi5P 0.5s ease-in-out;\n width: 100%;\n box-sizing: border-box;\n}\n\n.Recents-module_recentsTitle__t8LqW {\n font-size: 1.2em;\n font-weight: 600;\n margin-bottom: 16px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.Recents-module_removeAllButton__30r2o {\n background-color: #f44336;\n color: white;\n border: none;\n padding: 6px 10px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 0.8rem;\n transition: background-color 0.3s ease;\n}\n\n.Recents-module_recentsList__9h8x4 {\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.Recents-module_recentItem__AIFny {\n display: flex;\n flex-wrap: wrap; /* Allow items to wrap */\n align-items: center;\n padding: 10px 4px;\n margin-bottom: 8px;\n border-radius: 6px;\n transition: background-color 0.3s ease;\n}\n\n.Recents-module_recentItem__AIFny:hover {\n background-color: #f5f5f5;\n}\n\n.Recents-module_favicon__nMqe3 {\n width: 20px;\n height: 20px;\n margin-right: 12px;\n flex-shrink: 0;\n}\n\n.Recents-module_recentLink__LnBqE {\n color: #333;\n text-decoration: none;\n font-size: 1rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* Take up most of the space, but leave room for the button */\n flex-basis: calc(100% - 80px);\n flex-grow: 1;\n}\n\n.Recents-module_recentLink__LnBqE:hover {\n text-decoration: underline;\n}\n\n.Recents-module_timeLabel__Paq1c {\n font-size: 0.8rem;\n color: #666;\n flex-basis: 100%; /* Make time take full width on new line */\n margin-top: 4px;\n padding-left: 32px; /* Align with text, not favicon */\n}\n\n.Recents-module_removeButton__Ugt7l {\n background: transparent;\n border: none;\n cursor: pointer;\n color: #9e9e9e;\n padding: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 50%;\n transition: background-color 0.3s ease, color 0.3s ease;\n margin-left: auto; /* Pushes button to the far right */\n}\n\n.Recents-module_removeButton__Ugt7l:hover {\n background-color: #e0e0e0;\n color: #d32f2f;\n}\n\n.Recents-module_noRecents__OySGm {\n text-align: center;\n padding: 32px;\n border: 2px dashed #e0e0e0;\n border-radius: 8px;\n}\n\n.Recents-module_noRecentsText__kT8tq {\n font-size: 1rem;\n color: #666;\n}\n\n/* Desktop-aware styles */\n@media (min-width: 768px) {\n .Recents-module_recentsContainer__87baP {\n max-width: 600px;\n margin: 0 auto;\n padding: 16px;\n }\n\n .Recents-module_recentsTitle__t8LqW {\n font-size: 1.25em;\n }\n\n .Recents-module_removeAllButton__30r2o {\n padding: 6px 12px;\n font-size: 0.875rem;\n }\n\n .Recents-module_recentItem__AIFny {\n flex-wrap: nowrap; /* Prevent wrapping on desktop */\n }\n\n .Recents-module_recentLink__LnBqE {\n flex-basis: auto; /* Reset basis */\n }\n\n .Recents-module_timeLabel__Paq1c {\n flex-basis: auto; /* Reset basis */\n margin-top: 0;\n padding-left: 0;\n margin-left: 16px;\n font-size: 0.875rem;\n }\n\n .Recents-module_removeButton__Ugt7l {\n margin-left: 16px;\n }\n}\n";
var styles = {"recentsContainer":"Recents-module_recentsContainer__87baP","fadeIn":"Recents-module_fadeIn__fxi5P","recentsTitle":"Recents-module_recentsTitle__t8LqW","removeAllButton":"Recents-module_removeAllButton__30r2o","recentsList":"Recents-module_recentsList__9h8x4","recentItem":"Recents-module_recentItem__AIFny","favicon":"Recents-module_favicon__nMqe3","recentLink":"Recents-module_recentLink__LnBqE","timeLabel":"Recents-module_timeLabel__Paq1c","removeButton":"Recents-module_removeButton__Ugt7l","noRecents":"Recents-module_noRecents__OySGm","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 getFaviconUrl = (url) => {
try {
const urlObj = new URL(url);
const domain = urlObj.hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
}
catch (error) {
console.error("Invalid URL:", url);
return ""; // Return a path to a default/placeholder icon if you have one
}
};
const TrashIcon = () => (React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" },
React.createElement("polyline", { points: "3 6 5 6 21 6" }),
React.createElement("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }),
React.createElement("line", { x1: "10", y1: "11", x2: "10", y2: "17" }),
React.createElement("line", { x1: "14", y1: "11", x2: "14", y2: "17" })));
const Recents = ({ onBeforeRemove, namespace, maxRecents = 10, }) => {
const [recents, setRecents] = React.useState([]);
const tracker = React.useMemo(() => {
return new OnBrowserLocationTracker({
maxRecents: maxRecents,
namespace: namespace,
});
}, [maxRecents, namespace]);
React.useEffect(() => {
const loadRecents = () => __awaiter(void 0, void 0, void 0, function* () {
const storedRecents = yield tracker.getRecentsAsync();
setRecents(storedRecents);
});
loadRecents();
}, [tracker]);
const handleRemoveRecent = (recent) => {
const shouldRemove = onBeforeRemove ? onBeforeRemove([recent]) : true;
if (!shouldRemove)
return;
setRecents((prevRecents) => prevRecents.filter((item) => item.url !== recent.url));
tracker.removeRecent(recent.url);
};
const handleRemoveAll = () => {
const shouldRemoveAll = onBeforeRemove ? onBeforeRemove(recents) : true;
if (!shouldRemoveAll)
return;
setRecents([]);
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!"))) : (React.createElement("ul", { className: styles.recentsList }, recents.map((item, index) => (React.createElement("li", { key: index, className: styles.recentItem },
React.createElement("img", { src: getFaviconUrl(item.url), alt: "favicon", className: styles.favicon }),
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" },
React.createElement(TrashIcon, null)))))))));
};
exports.OnBrowserLocationTracker = OnBrowserLocationTracker;
exports.Recents = Recents;
//# sourceMappingURL=index.js.map