Initial commit: AutoTrader Marker Firefox extension
Mark AutoTrader listings as 'not wanted' on both search results and detail pages. Includes the unlisted signing / auto-update pipeline (web-ext + dist/updates.json polled by Firefox). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.web-ext-artifacts/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# AutoTrader Marker
|
||||||
|
|
||||||
|
Firefox extension to mark AutoTrader (autotrader.co.uk) listings as "not wanted"
|
||||||
|
so the ones you haven't reviewed yet stand out.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Search results**: adds a `✗ Not wanted` button to the top-left of every
|
||||||
|
listing card. Click and the card fades to ~30 % opacity with a red dashed
|
||||||
|
outline.
|
||||||
|
- **Detail page** (`/car-details/{id}`): adds a floating `✗ Not wanted` button
|
||||||
|
in the bottom-right corner. If you land on a page you've already dismissed,
|
||||||
|
a red banner appears at the top with an **Undo** action.
|
||||||
|
- Browser-action popup shows the count, lets you toggle **Hide dismissed
|
||||||
|
listings** (fully collapse them on search results instead of fade),
|
||||||
|
export/import the list as JSON, or clear everything.
|
||||||
|
- State persists in `browser.storage.local` and syncs across open tabs via
|
||||||
|
`storage.onChanged`.
|
||||||
|
|
||||||
|
## Temporary install (for development)
|
||||||
|
|
||||||
|
1. `about:debugging#/runtime/this-firefox` → **Load Temporary Add-on…**
|
||||||
|
2. Select `manifest.json`. Changes require clicking **Reload** on that page.
|
||||||
|
3. Temporary add-ons vanish on Firefox restart.
|
||||||
|
|
||||||
|
## Persistent install via unlisted signing (auto-updates)
|
||||||
|
|
||||||
|
Once signed and installed this way, Firefox auto-updates installed copies by
|
||||||
|
polling `dist/updates.json` in the Gitea repo roughly once a day.
|
||||||
|
|
||||||
|
### One-time setup
|
||||||
|
|
||||||
|
1. **Create the Gitea repo** at <https://git.nocker.cloud/tony/autotrader-marker>
|
||||||
|
(must be pullable over HTTPS without auth so Firefox can fetch
|
||||||
|
`dist/updates.json` and the XPIs).
|
||||||
|
2. **Initial commit and push**:
|
||||||
|
```bash
|
||||||
|
cd autotrader-marker
|
||||||
|
git init && git branch -m main
|
||||||
|
git remote add origin https://git.nocker.cloud/tony/autotrader-marker.git
|
||||||
|
npm install
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial commit"
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
3. **Get AMO JWT credentials** at
|
||||||
|
<https://addons.mozilla.org/en-US/developers/addon/api/key/>. Save them
|
||||||
|
somewhere safe (macOS Keychain or Infisical `/autotrader-marker`).
|
||||||
|
4. **Export** the creds before running any release:
|
||||||
|
```bash
|
||||||
|
export WEB_EXT_API_KEY='user:12345:67'
|
||||||
|
export WEB_EXT_API_SECRET='abcdef0123456789...'
|
||||||
|
```
|
||||||
|
5. **Cut the first signed release** (from the repo root, on your Mac):
|
||||||
|
```bash
|
||||||
|
bin/release.sh 0.1.0 # or: bin/release.sh patch
|
||||||
|
git add manifest.json package.json dist/
|
||||||
|
git commit -m "Release v0.1.0"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
6. **Install the signed XPI once** on your host Firefox:
|
||||||
|
- Open `about:addons` → ⚙ (gear) → **Install Add-on From File…**
|
||||||
|
- Select `dist/autotrader-marker-0.1.0.xpi`
|
||||||
|
- Firefox keeps it installed and checks for updates automatically.
|
||||||
|
|
||||||
|
### Subsequent releases
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit code, then:
|
||||||
|
bin/release.sh patch # 0.1.0 → 0.1.1
|
||||||
|
# (or 'minor' / 'major' / an explicit version like '1.2.3')
|
||||||
|
|
||||||
|
git add manifest.json package.json dist/
|
||||||
|
git commit -m "Release v$(node -p "require('./manifest.json').version")"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Firefox polls for updates on its own schedule (~daily). To trigger immediately:
|
||||||
|
`about:addons` → ⚙ → **Check for Updates**.
|
||||||
|
|
||||||
|
### What `bin/release.sh` does
|
||||||
|
|
||||||
|
1. Bumps the version in `manifest.json` and `package.json`.
|
||||||
|
2. Runs `web-ext sign --channel=unlisted` against AMO. Mozilla validates and
|
||||||
|
signs the XPI (usually seconds for an unlisted submission).
|
||||||
|
3. Copies the signed XPI to `dist/autotrader-marker-<version>.xpi`.
|
||||||
|
4. Appends the new version to `dist/updates.json` so Firefox can discover it.
|
||||||
|
5. Prints next-step git commands — it does **not** push automatically.
|
||||||
|
|
||||||
|
If `web-ext sign` prints a lint ERROR about `MANIFEST_UPDATE_URL`, that's
|
||||||
|
expected and doesn't block signing: the rule only applies to AMO-listed
|
||||||
|
add-ons, and `--channel=unlisted` proceeds past it.
|
||||||
|
|
||||||
|
## Package a static zip (for `about:debugging` without signing)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import zipfile
|
||||||
|
with zipfile.ZipFile('../autotrader-marker.zip', 'w', zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for f in ['manifest.json','content.js','content.css','popup.html','popup.css','popup.js','icon.svg']:
|
||||||
|
z.write(f)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## DOM contract (verified 2026-04)
|
||||||
|
|
||||||
|
The content script relies on these AutoTrader selectors. If they change, update
|
||||||
|
`content.js`:
|
||||||
|
|
||||||
|
| What | Selector |
|
||||||
|
|------|----------|
|
||||||
|
| Listing card | `li[data-testid^="id-"]` (id is `id-{listingId}`) |
|
||||||
|
| Search result title | `[data-testid="search-listing-title"]` |
|
||||||
|
| Detail page id | URL path `/car-details/{digits}` |
|
||||||
|
| Detail page title | `h1` (falls back to `document.title`) |
|
||||||
|
|
||||||
|
The primary id sources are the `data-testid` on the `<li>` for search results
|
||||||
|
and the URL path for detail pages — no href parsing needed in the happy path.
|
||||||
|
The detail-page UI uses a fixed-position FAB, so it doesn't depend on the
|
||||||
|
page's layout at all.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `manifest.json` | MV3 manifest, Firefox-specific settings, `update_url` → Gitea |
|
||||||
|
| `content.js` | Injects buttons, manages dismissal state, MutationObserver for infinite scroll, SPA-nav aware |
|
||||||
|
| `content.css` | Button styling + dismissed-card fade/hide rules |
|
||||||
|
| `popup.html` / `popup.css` / `popup.js` | Toolbar popup UI |
|
||||||
|
| `icon.svg` | Single SVG used for toolbar + addon listing |
|
||||||
|
| `bin/release.sh` | Version bump + sign + updates.json refresh |
|
||||||
|
| `dist/updates.json` | Served to Firefox for update polling |
|
||||||
|
| `dist/*.xpi` | Signed extension bundles referenced by `updates.json` |
|
||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Release a new signed XPI, update dist/updates.json, and prepare a commit.
|
||||||
|
# Requires: WEB_EXT_API_KEY, WEB_EXT_API_SECRET (AMO JWT credentials).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bin/release.sh patch # 0.1.0 -> 0.1.1
|
||||||
|
# bin/release.sh minor # 0.1.0 -> 0.2.0
|
||||||
|
# bin/release.sh major # 0.1.0 -> 1.0.0
|
||||||
|
# bin/release.sh 1.2.3 # explicit version
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUMP="${1:-patch}"
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
ADDON_ID="autotrader-marker@tony.codes"
|
||||||
|
RAW_BASE="https://git.nocker.cloud/tony/autotrader-marker/raw/branch/main/dist"
|
||||||
|
|
||||||
|
: "${WEB_EXT_API_KEY:?WEB_EXT_API_KEY is required (AMO JWT issuer)}"
|
||||||
|
: "${WEB_EXT_API_SECRET:?WEB_EXT_API_SECRET is required (AMO JWT secret)}"
|
||||||
|
|
||||||
|
# --- Compute next version ---
|
||||||
|
CURRENT=$(node -p "require('./manifest.json').version")
|
||||||
|
if [[ "$BUMP" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
NEXT="$BUMP"
|
||||||
|
else
|
||||||
|
NEXT=$(node -e "
|
||||||
|
const [maj,min,pat] = require('./manifest.json').version.split('.').map(Number);
|
||||||
|
const bump = process.argv[1];
|
||||||
|
let v;
|
||||||
|
if (bump === 'major') v = [maj+1, 0, 0];
|
||||||
|
else if (bump === 'minor') v = [maj, min+1, 0];
|
||||||
|
else v = [maj, min, pat+1];
|
||||||
|
console.log(v.join('.'));
|
||||||
|
" "$BUMP")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Bumping $CURRENT → $NEXT"
|
||||||
|
|
||||||
|
# --- Write version to manifest.json and package.json ---
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
for (const f of ['manifest.json','package.json']) {
|
||||||
|
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
|
||||||
|
j.version = process.argv[1];
|
||||||
|
fs.writeFileSync(f, JSON.stringify(j, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
" "$NEXT"
|
||||||
|
|
||||||
|
# --- Sign via AMO ---
|
||||||
|
ARTIFACTS_DIR="$REPO_ROOT/.web-ext-artifacts"
|
||||||
|
rm -rf "$ARTIFACTS_DIR"
|
||||||
|
mkdir -p "$ARTIFACTS_DIR"
|
||||||
|
|
||||||
|
echo "→ Signing via AMO (unlisted channel)"
|
||||||
|
npx --yes web-ext sign \
|
||||||
|
--source-dir="$REPO_ROOT" \
|
||||||
|
--artifacts-dir="$ARTIFACTS_DIR" \
|
||||||
|
--channel=unlisted \
|
||||||
|
--api-key="$WEB_EXT_API_KEY" \
|
||||||
|
--api-secret="$WEB_EXT_API_SECRET" \
|
||||||
|
--ignore-files "node_modules/**" "dist/**" "bin/**" "package.json" "package-lock.json" "README.md" ".gitignore"
|
||||||
|
|
||||||
|
# --- Move signed XPI to dist/ with a predictable name ---
|
||||||
|
SIGNED_XPI=$(find "$ARTIFACTS_DIR" -name '*.xpi' -type f | head -1)
|
||||||
|
if [[ -z "$SIGNED_XPI" ]]; then
|
||||||
|
echo "ERROR: no signed XPI produced — check AMO output above" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET="dist/autotrader-marker-$NEXT.xpi"
|
||||||
|
mkdir -p dist
|
||||||
|
cp "$SIGNED_XPI" "$TARGET"
|
||||||
|
echo "→ Signed XPI: $TARGET"
|
||||||
|
|
||||||
|
# --- Regenerate dist/updates.json (keep all historical versions) ---
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = 'dist/updates.json';
|
||||||
|
const j = fs.existsSync(path) ? JSON.parse(fs.readFileSync(path, 'utf8')) : { addons: {} };
|
||||||
|
const addonId = process.argv[1];
|
||||||
|
const version = process.argv[2];
|
||||||
|
const rawBase = process.argv[3];
|
||||||
|
j.addons = j.addons || {};
|
||||||
|
j.addons[addonId] = j.addons[addonId] || { updates: [] };
|
||||||
|
const updates = j.addons[addonId].updates.filter(u => u.version !== version);
|
||||||
|
updates.push({ version, update_link: rawBase + '/autotrader-marker-' + version + '.xpi' });
|
||||||
|
// Sort ascending by semver (Firefox picks the highest applicable)
|
||||||
|
updates.sort((a, b) => {
|
||||||
|
const pa = a.version.split('.').map(Number);
|
||||||
|
const pb = b.version.split('.').map(Number);
|
||||||
|
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
j.addons[addonId].updates = updates;
|
||||||
|
fs.writeFileSync(path, JSON.stringify(j, null, 2) + '\n');
|
||||||
|
" "$ADDON_ID" "$NEXT" "$RAW_BASE"
|
||||||
|
|
||||||
|
echo "→ dist/updates.json updated"
|
||||||
|
|
||||||
|
# --- Summarise for the developer ---
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
✔ Release $NEXT built and signed.
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Review the diff:
|
||||||
|
git status
|
||||||
|
git diff
|
||||||
|
2. Commit and push:
|
||||||
|
git add manifest.json package.json dist/
|
||||||
|
git commit -m "Release v$NEXT"
|
||||||
|
git push origin main
|
||||||
|
3. Firefox will auto-update installed copies within ~24h
|
||||||
|
(or via about:addons → gear → "Check for Updates").
|
||||||
|
|
||||||
|
Signed XPI: $TARGET
|
||||||
|
EOF
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
/* AutoTrader Marker — injected styles */
|
||||||
|
|
||||||
|
.atm-card {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font: 600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(30, 30, 30, 0.82);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||||
|
transition: background-color 120ms ease, transform 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-btn:hover {
|
||||||
|
background: rgba(200, 30, 30, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-btn[aria-pressed="true"] {
|
||||||
|
background: rgba(40, 120, 60, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dismissed cards: faded + strikethrough in default mode */
|
||||||
|
.atm-card.atm-dismissed {
|
||||||
|
opacity: 0.32;
|
||||||
|
filter: grayscale(0.6);
|
||||||
|
transition: opacity 150ms ease, filter 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-card.atm-dismissed:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
filter: grayscale(0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-card.atm-dismissed::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px dashed rgba(200, 30, 30, 0.6);
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide mode: fully collapse dismissed cards */
|
||||||
|
html.atm-hide-dismissed .atm-card.atm-dismissed {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Detail page: floating action button --- */
|
||||||
|
|
||||||
|
.atm-fab {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
z-index: 2147483647;
|
||||||
|
padding: 12px 18px;
|
||||||
|
font: 600 14px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
background: #c41e1e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
transition: background-color 120ms ease, transform 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-fab:hover {
|
||||||
|
background: #a61919;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-fab.atm-fab-dismissed {
|
||||||
|
background: #2f8547;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-fab.atm-fab-dismissed:hover {
|
||||||
|
background: #276e3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-fab:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Detail page: top banner when already dismissed --- */
|
||||||
|
|
||||||
|
.atm-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2147483646;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font: 600 13px/1.3 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
background: #c41e1e;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-banner.atm-banner-visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-banner-undo {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atm-banner-undo:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
// AutoTrader Marker — content script
|
||||||
|
// Attaches a "Not wanted" button to each listing card and stores dismissals in browser.storage.local.
|
||||||
|
//
|
||||||
|
// DOM contract (verified against autotrader.co.uk 2026-04):
|
||||||
|
// Listing card: <li data-testid="id-{listingId}">
|
||||||
|
// Title: <a data-testid="search-listing-title">
|
||||||
|
// Detail link: <a href="/car-details/{listingId}?...">
|
||||||
|
// The `data-testid="id-*"` attribute carries the listing id directly, which is
|
||||||
|
// more robust than parsing anchor hrefs. Anchor-walk fallback kept for edge
|
||||||
|
// cases (featured/sponsored slots that render differently).
|
||||||
|
|
||||||
|
const api = typeof browser !== "undefined" ? browser : chrome;
|
||||||
|
|
||||||
|
const STORAGE_KEY = "dismissedListings";
|
||||||
|
const SETTINGS_KEY = "settings";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
dismissed: {},
|
||||||
|
hideDismissed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- storage ---
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
const res = await api.storage.local.get([STORAGE_KEY, SETTINGS_KEY]);
|
||||||
|
state.dismissed = res[STORAGE_KEY] || {};
|
||||||
|
state.hideDismissed = !!(res[SETTINGS_KEY] && res[SETTINGS_KEY].hideDismissed);
|
||||||
|
applyHideMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDismissed(id, title) {
|
||||||
|
state.dismissed[id] = { ts: Date.now(), title: title || "" };
|
||||||
|
await api.storage.local.set({ [STORAGE_KEY]: state.dismissed });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearDismissed(id) {
|
||||||
|
delete state.dismissed[id];
|
||||||
|
await api.storage.local.set({ [STORAGE_KEY]: state.dismissed });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHideMode() {
|
||||||
|
document.documentElement.classList.toggle("atm-hide-dismissed", state.hideDismissed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- listing detection ---
|
||||||
|
|
||||||
|
function extractIdFromLi(li) {
|
||||||
|
// Primary: data-testid="id-202601279443591"
|
||||||
|
const testid = li.getAttribute("data-testid") || "";
|
||||||
|
const m = testid.match(/^id-(\d+)$/);
|
||||||
|
if (m) return m[1];
|
||||||
|
// Secondary: the li's own id attribute uses same pattern
|
||||||
|
const liId = li.id || "";
|
||||||
|
const m2 = liId.match(/^id-(\d+)$/);
|
||||||
|
if (m2) return m2[1];
|
||||||
|
// Last resort: derive from a /car-details/ anchor inside
|
||||||
|
const a = li.querySelector('a[href*="/car-details/"]');
|
||||||
|
if (a) {
|
||||||
|
const m3 = a.getAttribute("href").match(/\/car-details\/(\d+)/);
|
||||||
|
if (m3) return m3[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTitle(card) {
|
||||||
|
const t = card.querySelector('[data-testid="search-listing-title"]');
|
||||||
|
if (t) return t.textContent.trim().slice(0, 200);
|
||||||
|
const h = card.querySelector("h2, h3");
|
||||||
|
return h ? h.textContent.trim().slice(0, 200) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAllCards() {
|
||||||
|
// Primary selector — verified stable on autotrader.co.uk
|
||||||
|
const lis = document.querySelectorAll('li[data-testid^="id-"]');
|
||||||
|
const cards = new Map();
|
||||||
|
for (const li of lis) {
|
||||||
|
const id = extractIdFromLi(li);
|
||||||
|
if (id) cards.set(li, id);
|
||||||
|
}
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI injection ---
|
||||||
|
|
||||||
|
function decorateCard(card, id) {
|
||||||
|
if (card.dataset.atmId === id && card.querySelector(":scope > .atm-btn")) return;
|
||||||
|
card.dataset.atmId = id;
|
||||||
|
card.classList.add("atm-card");
|
||||||
|
|
||||||
|
// Remove stale button
|
||||||
|
const existing = card.querySelector(":scope > .atm-btn");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "atm-btn";
|
||||||
|
btn.addEventListener("click", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (state.dismissed[id]) {
|
||||||
|
await clearDismissed(id);
|
||||||
|
} else {
|
||||||
|
await setDismissed(id, extractTitle(card));
|
||||||
|
}
|
||||||
|
refreshCardState(card, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(btn);
|
||||||
|
refreshCardState(card, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCardState(card, id) {
|
||||||
|
const dismissed = !!state.dismissed[id];
|
||||||
|
card.classList.toggle("atm-dismissed", dismissed);
|
||||||
|
const btn = card.querySelector(":scope > .atm-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = dismissed ? "✓ Dismissed — undo" : "✗ Not wanted";
|
||||||
|
btn.setAttribute("aria-pressed", dismissed ? "true" : "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateAll() {
|
||||||
|
const cards = findAllCards();
|
||||||
|
for (const [card, id] of cards) {
|
||||||
|
decorateCard(card, id);
|
||||||
|
}
|
||||||
|
// Also refresh any previously-decorated cards whose state may have changed
|
||||||
|
document.querySelectorAll(".atm-card[data-atm-id]").forEach((card) => {
|
||||||
|
if (!cards.has(card)) refreshCardState(card, card.dataset.atmId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- observers ---
|
||||||
|
|
||||||
|
let rafPending = false;
|
||||||
|
function scheduleDecorate() {
|
||||||
|
if (rafPending) return;
|
||||||
|
rafPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rafPending = false;
|
||||||
|
decorateAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mo = new MutationObserver(() => scheduleDecorate());
|
||||||
|
|
||||||
|
function startObserving() {
|
||||||
|
mo.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- detail page ---
|
||||||
|
// URL pattern: /car-details/{digits}. A fixed FAB + optional top banner
|
||||||
|
// doesn't depend on the page layout, so it's resilient to redesigns.
|
||||||
|
|
||||||
|
function getDetailListingId() {
|
||||||
|
const m = location.pathname.match(/^\/car-details\/(\d+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDetailTitle() {
|
||||||
|
const h1 = document.querySelector("h1");
|
||||||
|
if (h1 && h1.textContent.trim()) return h1.textContent.trim().slice(0, 200);
|
||||||
|
return (document.title || "").replace(/\s*\|\s*AutoTrader.*$/i, "").trim().slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDetailUI(id) {
|
||||||
|
let fab = document.getElementById("atm-fab");
|
||||||
|
if (!fab) {
|
||||||
|
fab = document.createElement("button");
|
||||||
|
fab.id = "atm-fab";
|
||||||
|
fab.type = "button";
|
||||||
|
fab.className = "atm-fab";
|
||||||
|
fab.addEventListener("click", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.dismissed[id]) {
|
||||||
|
await clearDismissed(id);
|
||||||
|
} else {
|
||||||
|
await setDismissed(id, extractDetailTitle());
|
||||||
|
}
|
||||||
|
refreshDetailUI(id);
|
||||||
|
});
|
||||||
|
document.body.appendChild(fab);
|
||||||
|
}
|
||||||
|
|
||||||
|
let banner = document.getElementById("atm-banner");
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement("div");
|
||||||
|
banner.id = "atm-banner";
|
||||||
|
banner.className = "atm-banner";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "atm-banner-label";
|
||||||
|
const undo = document.createElement("button");
|
||||||
|
undo.type = "button";
|
||||||
|
undo.className = "atm-banner-undo";
|
||||||
|
undo.textContent = "Undo";
|
||||||
|
undo.addEventListener("click", async () => {
|
||||||
|
await clearDismissed(id);
|
||||||
|
refreshDetailUI(id);
|
||||||
|
});
|
||||||
|
banner.appendChild(label);
|
||||||
|
banner.appendChild(undo);
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshDetailUI(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDetailUI(id) {
|
||||||
|
const dismissed = !!state.dismissed[id];
|
||||||
|
const fab = document.getElementById("atm-fab");
|
||||||
|
if (fab) {
|
||||||
|
fab.textContent = dismissed ? "✓ Dismissed — undo" : "✗ Not wanted";
|
||||||
|
fab.setAttribute("aria-pressed", dismissed ? "true" : "false");
|
||||||
|
fab.classList.toggle("atm-fab-dismissed", dismissed);
|
||||||
|
}
|
||||||
|
const banner = document.getElementById("atm-banner");
|
||||||
|
if (banner) {
|
||||||
|
banner.classList.toggle("atm-banner-visible", dismissed);
|
||||||
|
const label = banner.querySelector(".atm-banner-label");
|
||||||
|
if (label) label.textContent = "You marked this car as not wanted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- React to storage changes (popup / other tabs) ---
|
||||||
|
api.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area !== "local") return;
|
||||||
|
if (changes[STORAGE_KEY]) {
|
||||||
|
state.dismissed = changes[STORAGE_KEY].newValue || {};
|
||||||
|
document.querySelectorAll(".atm-card[data-atm-id]").forEach((card) => {
|
||||||
|
refreshCardState(card, card.dataset.atmId);
|
||||||
|
});
|
||||||
|
const detailId = getDetailListingId();
|
||||||
|
if (detailId) refreshDetailUI(detailId);
|
||||||
|
}
|
||||||
|
if (changes[SETTINGS_KEY]) {
|
||||||
|
state.hideDismissed = !!(changes[SETTINGS_KEY].newValue && changes[SETTINGS_KEY].newValue.hideDismissed);
|
||||||
|
applyHideMode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- bootstrap + SPA navigation ---
|
||||||
|
|
||||||
|
function removeDetailUI() {
|
||||||
|
document.getElementById("atm-fab")?.remove();
|
||||||
|
document.getElementById("atm-banner")?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDetailId = null;
|
||||||
|
function applyPageMode() {
|
||||||
|
const detailId = getDetailListingId();
|
||||||
|
if (detailId) {
|
||||||
|
currentDetailId = detailId;
|
||||||
|
ensureDetailUI(detailId);
|
||||||
|
} else {
|
||||||
|
if (currentDetailId) {
|
||||||
|
removeDetailUI();
|
||||||
|
currentDetailId = null;
|
||||||
|
}
|
||||||
|
decorateAll();
|
||||||
|
startObserving();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchUrlChanges() {
|
||||||
|
let lastUrl = location.href;
|
||||||
|
const check = () => {
|
||||||
|
if (location.href !== lastUrl) {
|
||||||
|
lastUrl = location.href;
|
||||||
|
applyPageMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Cover both history API and back/forward
|
||||||
|
const wrap = (fn) => function () {
|
||||||
|
const ret = fn.apply(this, arguments);
|
||||||
|
queueMicrotask(check);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
history.pushState = wrap(history.pushState);
|
||||||
|
history.replaceState = wrap(history.replaceState);
|
||||||
|
window.addEventListener("popstate", check);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
await loadState();
|
||||||
|
applyPageMode();
|
||||||
|
watchUrlChanges();
|
||||||
|
})();
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"addons": {
|
||||||
|
"autotrader-marker@tony.codes": {
|
||||||
|
"updates": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#1e1e1e"/>
|
||||||
|
<path d="M18 22 L46 22 L42 42 L22 42 Z" fill="#c41e1e" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<circle cx="26" cy="48" r="3" fill="#fff"/>
|
||||||
|
<circle cx="38" cy="48" r="3" fill="#fff"/>
|
||||||
|
<path d="M20 22 L44 22 M22 30 L42 30" stroke="#fff" stroke-width="1.5" opacity="0.4"/>
|
||||||
|
<path d="M16 16 L48 48 M48 16 L16 48" stroke="#fff" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 529 B |
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "AutoTrader Marker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Mark AutoTrader listings as 'not wanted' so you can spot the ones you haven't reviewed yet.",
|
||||||
|
"permissions": ["storage"],
|
||||||
|
"host_permissions": [
|
||||||
|
"*://*.autotrader.co.uk/*"
|
||||||
|
],
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["*://*.autotrader.co.uk/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"css": ["content.css"],
|
||||||
|
"run_at": "document_idle",
|
||||||
|
"all_frames": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_title": "AutoTrader Marker",
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": "icon.svg"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"48": "icon.svg",
|
||||||
|
"96": "icon.svg",
|
||||||
|
"128": "icon.svg"
|
||||||
|
},
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "autotrader-marker@tony.codes",
|
||||||
|
"strict_min_version": "115.0",
|
||||||
|
"update_url": "https://git.nocker.cloud/tony/autotrader-marker/raw/branch/main/dist/updates.json",
|
||||||
|
"data_collection_permissions": {
|
||||||
|
"required": ["none"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4114
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "autotrader-marker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Firefox extension that marks AutoTrader listings as 'not wanted'.",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "web-ext lint --source-dir=. --ignore-files=node_modules dist bin package.json package-lock.json README.md .gitignore",
|
||||||
|
"release:patch": "bin/release.sh patch",
|
||||||
|
"release:minor": "bin/release.sh minor",
|
||||||
|
"release:major": "bin/release.sh major"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"web-ext": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--fg: #1a1a1a;
|
||||||
|
--bg: #fff;
|
||||||
|
--muted: #666;
|
||||||
|
--border: #e4e4e4;
|
||||||
|
--accent: #c41e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--fg: #f2f2f2;
|
||||||
|
--bg: #1e1e1e;
|
||||||
|
--muted: #9a9a9a;
|
||||||
|
--border: #3a3a3a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 300px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font: 13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 90px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--fg);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
min-height: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>AutoTrader Marker</title>
|
||||||
|
<link rel="stylesheet" href="popup.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>AutoTrader Marker</h1>
|
||||||
|
<p class="sub" id="count">—</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="hideToggle" />
|
||||||
|
<span>Hide dismissed listings</span>
|
||||||
|
</label>
|
||||||
|
<p class="hint">When off, dismissed cars are faded so you can still see them.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="actions">
|
||||||
|
<button id="exportBtn" type="button">Export JSON</button>
|
||||||
|
<button id="importBtn" type="button">Import JSON</button>
|
||||||
|
<input type="file" id="importFile" accept="application/json" hidden />
|
||||||
|
<button id="clearBtn" type="button" class="danger">Clear all</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p id="status" class="status"></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const api = typeof browser !== "undefined" ? browser : chrome;
|
||||||
|
|
||||||
|
const STORAGE_KEY = "dismissedListings";
|
||||||
|
const SETTINGS_KEY = "settings";
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
count: document.getElementById("count"),
|
||||||
|
hideToggle: document.getElementById("hideToggle"),
|
||||||
|
exportBtn: document.getElementById("exportBtn"),
|
||||||
|
importBtn: document.getElementById("importBtn"),
|
||||||
|
importFile: document.getElementById("importFile"),
|
||||||
|
clearBtn: document.getElementById("clearBtn"),
|
||||||
|
status: document.getElementById("status"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
els.status.textContent = msg;
|
||||||
|
if (msg) setTimeout(() => (els.status.textContent = ""), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const res = await api.storage.local.get([STORAGE_KEY, SETTINGS_KEY]);
|
||||||
|
const dismissed = res[STORAGE_KEY] || {};
|
||||||
|
const settings = res[SETTINGS_KEY] || {};
|
||||||
|
const n = Object.keys(dismissed).length;
|
||||||
|
els.count.textContent = n === 1 ? "1 listing dismissed" : `${n} listings dismissed`;
|
||||||
|
els.hideToggle.checked = !!settings.hideDismissed;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.hideToggle.addEventListener("change", async () => {
|
||||||
|
const res = await api.storage.local.get(SETTINGS_KEY);
|
||||||
|
const settings = res[SETTINGS_KEY] || {};
|
||||||
|
settings.hideDismissed = els.hideToggle.checked;
|
||||||
|
await api.storage.local.set({ [SETTINGS_KEY]: settings });
|
||||||
|
});
|
||||||
|
|
||||||
|
els.clearBtn.addEventListener("click", async () => {
|
||||||
|
const res = await api.storage.local.get(STORAGE_KEY);
|
||||||
|
const n = Object.keys(res[STORAGE_KEY] || {}).length;
|
||||||
|
if (n === 0) {
|
||||||
|
setStatus("Nothing to clear.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = confirm(`Clear all ${n} dismissals? This can't be undone.`);
|
||||||
|
if (!ok) return;
|
||||||
|
await api.storage.local.set({ [STORAGE_KEY]: {} });
|
||||||
|
await refresh();
|
||||||
|
setStatus("Cleared.");
|
||||||
|
});
|
||||||
|
|
||||||
|
els.exportBtn.addEventListener("click", async () => {
|
||||||
|
const res = await api.storage.local.get(STORAGE_KEY);
|
||||||
|
const data = res[STORAGE_KEY] || {};
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
a.href = url;
|
||||||
|
a.download = `autotrader-marker-${stamp}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus("Exported.");
|
||||||
|
});
|
||||||
|
|
||||||
|
els.importBtn.addEventListener("click", () => els.importFile.click());
|
||||||
|
els.importFile.addEventListener("change", async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("Expected an object keyed by listing id");
|
||||||
|
}
|
||||||
|
const res = await api.storage.local.get(STORAGE_KEY);
|
||||||
|
const merged = { ...(res[STORAGE_KEY] || {}), ...parsed };
|
||||||
|
await api.storage.local.set({ [STORAGE_KEY]: merged });
|
||||||
|
await refresh();
|
||||||
|
setStatus(`Imported ${Object.keys(parsed).length} entries.`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Import failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
els.importFile.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === "local" && (changes[STORAGE_KEY] || changes[SETTINGS_KEY])) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
Reference in New Issue
Block a user