mirror of
https://git.coom.tech/fuckjannies/lolipiss.git
synced 2024-06-26 12:42:38 +00:00
Support double embeds
This commit is contained in:
parent
19d5fb17ed
commit
27eaa92c82
|
@ -1,7 +1,7 @@
|
|||
// ==UserScript==
|
||||
// @name PNGExtraEmbed
|
||||
// @namespace https://coom.tech/
|
||||
// @version 0.100
|
||||
// @version 0.98
|
||||
// @description uhh
|
||||
// @author You
|
||||
// @match https://boards.4channel.org/*
|
||||
|
|
110
main.user.js
110
main.user.js
|
@ -1,7 +1,7 @@
|
|||
// ==UserScript==
|
||||
// @name PNGExtraEmbed
|
||||
// @namespace https://coom.tech/
|
||||
// @version 0.100
|
||||
// @version 0.98
|
||||
// @description uhh
|
||||
// @author You
|
||||
// @match https://boards.4channel.org/*
|
||||
|
@ -10924,7 +10924,6 @@
|
|||
}));
|
||||
var appState = writable({
|
||||
isCatalog: false,
|
||||
is4chanX: false,
|
||||
foundPosts: []
|
||||
});
|
||||
appState.subscribe((v) => {
|
||||
|
@ -11300,30 +11299,30 @@
|
|||
xmlhttprequest(gmopt);
|
||||
});
|
||||
}
|
||||
function GM_fetch(...[url, opt, lisn]) {
|
||||
function blobTo(to, blob) {
|
||||
if (to == "arrayBuffer" && blob.arrayBuffer)
|
||||
return blob.arrayBuffer();
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = function(event) {
|
||||
if (!event)
|
||||
return;
|
||||
if (to == "base64")
|
||||
resolve(event.target.result);
|
||||
else
|
||||
resolve(event.target.result);
|
||||
};
|
||||
if (to == "arrayBuffer")
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
else if (to == "base64")
|
||||
fileReader.readAsDataURL(blob);
|
||||
else if (to == "text")
|
||||
fileReader.readAsText(blob, "utf-8");
|
||||
function blobTo(to, blob) {
|
||||
if (to == "arrayBuffer" && blob.arrayBuffer)
|
||||
return blob.arrayBuffer();
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = function(event) {
|
||||
if (!event)
|
||||
return;
|
||||
if (to == "base64")
|
||||
resolve(event.target.result);
|
||||
else
|
||||
reject("unknown to");
|
||||
});
|
||||
}
|
||||
resolve(event.target.result);
|
||||
};
|
||||
if (to == "arrayBuffer")
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
else if (to == "base64")
|
||||
fileReader.readAsDataURL(blob);
|
||||
else if (to == "text")
|
||||
fileReader.readAsText(blob, "utf-8");
|
||||
else
|
||||
reject("unknown to");
|
||||
});
|
||||
}
|
||||
function GM_fetch(...[url, opt, lisn]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const gmopt = {
|
||||
url: url.toString(),
|
||||
|
@ -11427,19 +11426,6 @@
|
|||
preview_url: e.preview_url,
|
||||
tags: e.tags.split(" ")
|
||||
}))
|
||||
},
|
||||
{
|
||||
name: "ATFbooru",
|
||||
domain: "booru.allthefallen.moe",
|
||||
endpoint: "/posts.json?tags=md5:",
|
||||
quirks: (a) => a.map((e) => ({
|
||||
source: e.source,
|
||||
page: `https://booru.allthefallen.moe/posts/${e.id}`,
|
||||
ext: e.file_url.substr(e.file_url.lastIndexOf(".") + 1),
|
||||
full_url: e.file_url,
|
||||
preview_url: e.preview_url,
|
||||
tags: e.tag_string.split(" ")
|
||||
}))
|
||||
}
|
||||
];
|
||||
var black = /* @__PURE__ */ new Set();
|
||||
|
@ -11449,7 +11435,7 @@
|
|||
sources = new Set(s.sources);
|
||||
});
|
||||
var cache = {};
|
||||
var findFileFrom = async (b, hex, abort) => {
|
||||
var findFileFrom = async (b, hex) => {
|
||||
try {
|
||||
if (b.domain in cache && hex in cache[b.domain])
|
||||
return cache[b.domain][hex];
|
||||
|
@ -11509,7 +11495,7 @@
|
|||
skip: true,
|
||||
extract: extract4,
|
||||
has_embed: has_embed4,
|
||||
match: (fn) => !!fn.match(/^[0-9a-fA-F]{32}\.....?/)
|
||||
match: (fn) => !!fn.match(/^[0-9a-fA-F]{32}\.....?$/)
|
||||
};
|
||||
|
||||
// src/App.svelte
|
||||
|
@ -15575,9 +15561,14 @@
|
|||
post.setAttribute("data-processed", "true");
|
||||
};
|
||||
var startup = async () => {
|
||||
if (typeof window["FCX"] != "undefined")
|
||||
appState.set({ ...cappState, is4chanX: true });
|
||||
await Promise.all([...document.querySelectorAll(".postContainer")].filter((e) => e.textContent?.includes("191 KB")).map((e) => processPost(e)));
|
||||
document.addEventListener("ThreadUpdate", async (e) => {
|
||||
const newPosts = e.detail.newPosts;
|
||||
for (const post of newPosts) {
|
||||
const postContainer = document.getElementById("pc" + post.substring(post.indexOf(".") + 1));
|
||||
processPost(postContainer);
|
||||
}
|
||||
});
|
||||
const mo = new MutationObserver((reco) => {
|
||||
for (const rec of reco)
|
||||
if (rec.type == "childList")
|
||||
|
@ -15609,7 +15600,7 @@
|
|||
document.body.append(scrollHost);
|
||||
appState.set({
|
||||
...cappState,
|
||||
isCatalog: !!document.querySelector(".catalog-small") || !!location.pathname.match(/\/catalog$/)
|
||||
isCatalog: !!document.querySelector(".catalog-small")
|
||||
});
|
||||
await Promise.all(posts.map((e) => processPost(e)));
|
||||
};
|
||||
|
@ -15620,38 +15611,15 @@
|
|||
});
|
||||
};
|
||||
document.addEventListener("4chanXInitFinished", startup);
|
||||
document.addEventListener("ThreadUpdate", async (e) => {
|
||||
const newPosts = e.detail.newPosts;
|
||||
for (const post of newPosts) {
|
||||
const postContainer = document.getElementById("pc" + post.substring(post.indexOf(".") + 1));
|
||||
processPost(postContainer);
|
||||
}
|
||||
});
|
||||
if (cappState.is4chanX) {
|
||||
const qr = window["QR"];
|
||||
const show = qr.show.bind(qr);
|
||||
qr.show = (...args) => {
|
||||
show(...args);
|
||||
document.dispatchEvent(new CustomEvent("QRDialogCreation", {
|
||||
detail: document.getElementById("quickReply")
|
||||
}));
|
||||
};
|
||||
}
|
||||
document.addEventListener("QRDialogCreation", (e) => {
|
||||
const a = document.createElement("a");
|
||||
const target = e.target;
|
||||
const bts = target.querySelector("#qr-filename-container");
|
||||
const i = document.createElement("i");
|
||||
i.className = "fa fa-magnet";
|
||||
const a = document.createElement("a");
|
||||
a.appendChild(i);
|
||||
a.title = "Embed File (Select a file before...)";
|
||||
let target;
|
||||
if (cappState.is4chanX) {
|
||||
i.innerText = "\u{1F9F2}";
|
||||
target = e.detail;
|
||||
target.querySelector("input[type=submit]")?.insertAdjacentElement("beforebegin", a);
|
||||
} else {
|
||||
target = e.target;
|
||||
target.querySelector("#qr-filename-container")?.appendChild(a);
|
||||
}
|
||||
bts?.appendChild(a);
|
||||
a.onclick = async (e2) => {
|
||||
const file = await getSelectedFile();
|
||||
if (!file)
|
||||
|
@ -15692,7 +15660,7 @@
|
|||
};
|
||||
input.click();
|
||||
};
|
||||
}, { once: !cappState.is4chanX });
|
||||
}, { once: true });
|
||||
var customStyles = document.createElement("style");
|
||||
customStyles.appendChild(document.createTextNode(global_default));
|
||||
document.documentElement.insertBefore(customStyles, null);
|
||||
|
|
|
@ -247,11 +247,9 @@
|
|||
<div
|
||||
class:contract={contracted}
|
||||
class="place"
|
||||
|
||||
on:click={e => e.preventDefault()}
|
||||
on:auxclick={e => e.preventDefault()}
|
||||
on:mousedown={bepis}
|
||||
|
||||
on:mouseover={hoverStart}
|
||||
on:mouseout={hoverStop}
|
||||
on:mousemove={hoverUpdate}
|
||||
|
@ -260,11 +258,7 @@
|
|||
>
|
||||
{#if isImage}
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img
|
||||
bind:this={imgElem}
|
||||
alt={file.filename}
|
||||
src={furl || url}
|
||||
/>
|
||||
<img bind:this={imgElem} alt={file.filename} src={furl || url} />
|
||||
{/if}
|
||||
{#if isAudio}
|
||||
<audio
|
||||
|
@ -279,7 +273,7 @@
|
|||
{#if isVideo}
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<video loop={$settings.loop} bind:this={videoElem} src={furl || url} />
|
||||
<video loop={$settings.loop} bind:this={videoElem} src={furl || url} />
|
||||
<!-- assoom videos will never be loaded from thumbnails -->
|
||||
{/if}
|
||||
</div>
|
||||
|
|
23
src/Embeddings.svelte
Normal file
23
src/Embeddings.svelte
Normal file
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { EmbeddedFile } from './main';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Embedding from './Embedding.svelte';
|
||||
|
||||
export const dispatch = createEventDispatcher();
|
||||
|
||||
export let files: EmbeddedFile[]
|
||||
export let id: string = '';
|
||||
|
||||
let children: {[k in number]: Embedding}= {}
|
||||
export async function bepis(ev: MouseEvent) {
|
||||
for (let child of Object.values(children))
|
||||
child.bepis(ev);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each files as file, i}
|
||||
<Embedding bind:this={children[i]} {id} {file} />
|
||||
{/each}
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -1,14 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import type Embedding from './Embedding.svelte';
|
||||
import type Embeddings from './Embeddings.svelte';
|
||||
|
||||
import type { EmbeddedFile } from './main';
|
||||
|
||||
import { settings } from './stores'
|
||||
|
||||
export let id = ''
|
||||
export let file: EmbeddedFile;
|
||||
export let inst: Embedding;
|
||||
export let files: EmbeddedFile[];
|
||||
export let inst: Embedding | Embeddings;
|
||||
|
||||
let isVideo = false
|
||||
|
||||
|
@ -23,7 +24,7 @@ import type { EmbeddedFile } from './main';
|
|||
}
|
||||
const isNotChrome = !navigator.userAgent.includes("Chrome/");
|
||||
|
||||
async function downloadFile() {
|
||||
async function downloadFile(file: EmbeddedFile) {
|
||||
const a = document.createElement("a") as HTMLAnchorElement;
|
||||
document.body.appendChild(a);
|
||||
a.style.display = 'none';
|
||||
|
@ -45,9 +46,10 @@ import type { EmbeddedFile } from './main';
|
|||
class="fa clickable"
|
||||
/>
|
||||
{/if}
|
||||
{#each files as file}
|
||||
<span
|
||||
title={file.filename}
|
||||
on:click={downloadFile}
|
||||
on:click={() => downloadFile(file)}
|
||||
class="fa fa-download clickable"
|
||||
/>
|
||||
{#if file.source}
|
||||
|
@ -75,6 +77,7 @@ import type { EmbeddedFile } from './main';
|
|||
>[PEE contract]</a
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<style scoped>
|
||||
.clickable {
|
||||
|
|
|
@ -61,4 +61,6 @@ div.hasext .catalog-host img {
|
|||
|
||||
.fileThumb.filehost {
|
||||
margin-left: 0 !important;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
|
220
src/main.ts
220
src/main.ts
|
@ -12,7 +12,8 @@ import { GM_fetch, GM_head, headerStringToObject } from "./requests";
|
|||
import App from "./App.svelte";
|
||||
import ScrollHighlighter from "./ScrollHighlighter.svelte";
|
||||
import SettingsButton from './SettingsButton.svelte';
|
||||
import Embedding from './Embedding.svelte';
|
||||
//import Embedding from './Embedding.svelte';
|
||||
import Embeddings from './Embeddings.svelte';
|
||||
import EyeButton from './EyeButton.svelte';
|
||||
|
||||
export interface ImageProcessor {
|
||||
|
@ -82,41 +83,40 @@ type EmbeddedFileWithoutPreview = {
|
|||
|
||||
export type EmbeddedFile = EmbeddedFileWithPreview | EmbeddedFileWithoutPreview;
|
||||
|
||||
const processImage = async (src: string, fn: string, hex: string): Promise<[EmbeddedFile, boolean] | undefined> => {
|
||||
const proc = processors.find(e => e.match(fn));
|
||||
if (!proc)
|
||||
return;
|
||||
if (proc.skip) {
|
||||
// skip file downloading, file is referenced from the filename
|
||||
// basically does things like filtering out blacklisted tags
|
||||
const md5 = Buffer.from(hex, 'base64');
|
||||
if (await proc.has_embed(md5, fn) === true)
|
||||
return [await proc.extract(md5, fn), true];
|
||||
return;
|
||||
}
|
||||
const iter = streamRemote(src);
|
||||
if (!iter)
|
||||
return;
|
||||
let cumul = Buffer.alloc(0);
|
||||
let found: boolean | undefined;
|
||||
let chunk: ReadableStreamDefaultReadResult<Buffer> = { done: true };
|
||||
do {
|
||||
const { value, done } = await iter.next(found === false);
|
||||
if (done) {
|
||||
chunk = { done: true } as ReadableStreamDefaultReadDoneResult;
|
||||
} else {
|
||||
chunk = { done: false, value } as ReadableStreamDefaultReadValueResult<Buffer>;
|
||||
const processImage = async (src: string, fn: string, hex: string): Promise<([EmbeddedFile, boolean] | undefined)[]> => {
|
||||
return Promise.all(processors.filter(e => e.match(fn)).map(async proc => {
|
||||
if (proc.skip) {
|
||||
// skip file downloading, file is referenced from the filename
|
||||
// basically does things like filtering out blacklisted tags
|
||||
const md5 = Buffer.from(hex, 'base64');
|
||||
if (await proc.has_embed(md5, fn) === true)
|
||||
return [await proc.extract(md5, fn), true] as [EmbeddedFile, boolean];
|
||||
return;
|
||||
}
|
||||
if (!done)
|
||||
cumul = Buffer.concat([cumul, value!]);
|
||||
found = await proc.has_embed(cumul);
|
||||
} while (found !== false && !chunk.done);
|
||||
await iter.next(false);
|
||||
if (found === false) {
|
||||
//console.log(`Gave up on ${src} after downloading ${cumul.byteLength} bytes...`);
|
||||
return;
|
||||
}
|
||||
return [await proc.extract(cumul), false];
|
||||
const iter = streamRemote(src);
|
||||
if (!iter)
|
||||
return;
|
||||
let cumul = Buffer.alloc(0);
|
||||
let found: boolean | undefined;
|
||||
let chunk: ReadableStreamDefaultReadResult<Buffer> = { done: true };
|
||||
do {
|
||||
const { value, done } = await iter.next(found === false);
|
||||
if (done) {
|
||||
chunk = { done: true } as ReadableStreamDefaultReadDoneResult;
|
||||
} else {
|
||||
chunk = { done: false, value } as ReadableStreamDefaultReadValueResult<Buffer>;
|
||||
}
|
||||
if (!done)
|
||||
cumul = Buffer.concat([cumul, value!]);
|
||||
found = await proc.has_embed(cumul);
|
||||
} while (found !== false && !chunk.done);
|
||||
await iter.next(false);
|
||||
if (found === false) {
|
||||
//console.log(`Gave up on ${src} after downloading ${cumul.byteLength} bytes...`);
|
||||
return;
|
||||
}
|
||||
return [await proc.extract(cumul), false] as [EmbeddedFile, boolean];
|
||||
}));
|
||||
};
|
||||
|
||||
const textToElement = <T = HTMLElement>(s: string) =>
|
||||
|
@ -127,81 +127,13 @@ const processPost = async (post: HTMLDivElement) => {
|
|||
const origlink = post.querySelector('.file-info > a[target*="_blank"]') as HTMLAnchorElement;
|
||||
if (!thumb || !origlink)
|
||||
return;
|
||||
const res2 = await processImage(origlink.href,
|
||||
let res2 = await processImage(origlink.href,
|
||||
(origlink.querySelector('.fnfull') || origlink).textContent || '',
|
||||
post.querySelector("[data-md5]")?.getAttribute('data-md5') || '');
|
||||
if (!res2)
|
||||
res2 = res2?.filter(e => e);
|
||||
if (!res2 || res2.length == 0)
|
||||
return;
|
||||
const [res, external] = res2;
|
||||
const replyBox = post.querySelector('.post');
|
||||
if (external)
|
||||
replyBox?.classList.add('hasext');
|
||||
else
|
||||
replyBox?.classList.add('hasembed');
|
||||
|
||||
if (!cappState.foundPosts.includes(replyBox as HTMLElement))
|
||||
cappState.foundPosts.push(replyBox as HTMLElement);
|
||||
appState.set(cappState);
|
||||
|
||||
const isCatalog = replyBox?.classList.contains('catalog-post');
|
||||
// add buttons
|
||||
if (!isCatalog) {
|
||||
const ft = post.querySelector('div.file') as HTMLDivElement;
|
||||
const info = post.querySelector("span.file-info") as HTMLSpanElement;
|
||||
|
||||
const filehost: HTMLElement | null = ft.querySelector('.filehost');
|
||||
const eyehost: HTMLElement | null = info.querySelector('.eyehost');
|
||||
const imgcont = filehost || document.createElement('div');
|
||||
const eyecont = eyehost || document.createElement('span');
|
||||
|
||||
if (!filehost) {
|
||||
ft.append(imgcont);
|
||||
imgcont.classList.add("fileThumb");
|
||||
imgcont.classList.add("filehost");
|
||||
} else {
|
||||
imgcont.innerHTML = '';
|
||||
}
|
||||
if (!eyehost) {
|
||||
info.append(eyecont);
|
||||
eyecont.classList.add("eyehost");
|
||||
} else {
|
||||
eyecont.innerHTML = '';
|
||||
}
|
||||
const id = ~~(Math.random() * 20000000);
|
||||
const emb = new Embedding({
|
||||
target: imgcont,
|
||||
props: {
|
||||
file: res,
|
||||
id: '' + id
|
||||
}
|
||||
});
|
||||
new EyeButton({
|
||||
target: eyecont,
|
||||
props: {
|
||||
file: res,
|
||||
inst: emb,
|
||||
id: '' + id
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const opFile = post.querySelector('.catalog-link');
|
||||
const ahem = opFile?.querySelector('.catalog-host');
|
||||
const imgcont = ahem || document.createElement('div');
|
||||
imgcont.className = "catalog-host";
|
||||
if (ahem) {
|
||||
imgcont.innerHTML = '';
|
||||
}
|
||||
const emb = new Embedding({
|
||||
target: imgcont,
|
||||
props: {
|
||||
file: res
|
||||
}
|
||||
});
|
||||
if (!ahem)
|
||||
opFile?.append(imgcont);
|
||||
}
|
||||
|
||||
post.setAttribute('data-processed', "true");
|
||||
processAttachments(post, res2?.filter(e => e) as [EmbeddedFile, boolean][]);
|
||||
};
|
||||
|
||||
const startup = async () => {
|
||||
|
@ -261,7 +193,7 @@ const getSelectedFile = () => {
|
|||
};
|
||||
|
||||
//if (cappState!.is4chanX)
|
||||
document.addEventListener('4chanXInitFinished', startup);
|
||||
document.addEventListener('4chanXInitFinished', startup);
|
||||
/*else {
|
||||
document.addEventListener("QRGetFile", (e) => {
|
||||
const qr = document.getElementById('qrFile') as HTMLInputElement | null;
|
||||
|
@ -357,6 +289,78 @@ customStyles.appendChild(document.createTextNode(globalCss));
|
|||
|
||||
document.documentElement.insertBefore(customStyles, null);
|
||||
|
||||
function processAttachments(post: HTMLDivElement, ress: [EmbeddedFile, boolean][]) {
|
||||
const replyBox = post.querySelector('.post');
|
||||
const external = ress[0][1];
|
||||
if (external)
|
||||
replyBox?.classList.add('hasext');
|
||||
else
|
||||
replyBox?.classList.add('hasembed');
|
||||
|
||||
if (!cappState.foundPosts.includes(replyBox as HTMLElement))
|
||||
cappState.foundPosts.push(replyBox as HTMLElement);
|
||||
appState.set(cappState);
|
||||
|
||||
const isCatalog = replyBox?.classList.contains('catalog-post');
|
||||
// add buttons
|
||||
if (!isCatalog) {
|
||||
const ft = post.querySelector('div.file') as HTMLDivElement;
|
||||
const info = post.querySelector("span.file-info") as HTMLSpanElement;
|
||||
|
||||
const filehost: HTMLElement | null = ft.querySelector('.filehost');
|
||||
const eyehost: HTMLElement | null = info.querySelector('.eyehost');
|
||||
const imgcont = filehost || document.createElement('div');
|
||||
const eyecont = eyehost || document.createElement('span');
|
||||
|
||||
if (!filehost) {
|
||||
ft.append(imgcont);
|
||||
imgcont.classList.add("fileThumb");
|
||||
imgcont.classList.add("filehost");
|
||||
} else {
|
||||
imgcont.innerHTML = '';
|
||||
}
|
||||
if (!eyehost) {
|
||||
info.append(eyecont);
|
||||
eyecont.classList.add("eyehost");
|
||||
} else {
|
||||
eyecont.innerHTML = '';
|
||||
}
|
||||
const id = ~~(Math.random() * 20000000);
|
||||
const emb = new Embeddings({
|
||||
target: imgcont,
|
||||
props: {
|
||||
files: ress.map(e => e[0]),
|
||||
id: '' + id
|
||||
}
|
||||
});
|
||||
new EyeButton({
|
||||
target: eyecont,
|
||||
props: {
|
||||
files: ress.map(e => e[0]),
|
||||
inst: emb,
|
||||
id: '' + id
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const opFile = post.querySelector('.catalog-link');
|
||||
const ahem = opFile?.querySelector('.catalog-host');
|
||||
const imgcont = ahem || document.createElement('div');
|
||||
imgcont.className = "catalog-host";
|
||||
if (ahem) {
|
||||
imgcont.innerHTML = '';
|
||||
}
|
||||
const emb = new Embeddings({
|
||||
target: imgcont,
|
||||
props: {
|
||||
files: ress.map(e => e[0])
|
||||
}
|
||||
});
|
||||
if (!ahem)
|
||||
opFile?.append(imgcont);
|
||||
}
|
||||
|
||||
post.setAttribute('data-processed', "true");
|
||||
}
|
||||
//if ((window as any)['pagemode']) {
|
||||
// onload = () => {
|
||||
// console.log("loaded");
|
||||
|
|
|
@ -35,10 +35,6 @@ export const appState = writable({
|
|||
foundPosts: [] as HTMLElement[]
|
||||
});
|
||||
|
||||
appState.subscribe(v => {
|
||||
console.log(v);
|
||||
});
|
||||
|
||||
settings.subscribe(newVal => {
|
||||
localSet('settings', newVal);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue
Block a user