summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/auth.js24
-rw-r--r--app/lib/router.js5
-rw-r--r--app/lib/static.js4
-rw-r--r--app/note-store.js4
-rw-r--r--app/public/api.js91
-rw-r--r--app/public/index.html12
-rw-r--r--app/public/main.js188
-rw-r--r--app/public/note-ponys.html102
-rw-r--r--app/public/ponys.js1
-rw-r--r--app/public/sock.js5
-rw-r--r--app/public/utils.js10
11 files changed, 298 insertions, 148 deletions
diff --git a/app/auth.js b/app/auth.js
index 7859a24..4d5c1cb 100644
--- a/app/auth.js
+++ b/app/auth.js
@@ -1,5 +1,5 @@
-import {readFile as rf, writeFile as wf} from "node:fs/promises";
-import {readFileSync, rename} from "node:fs";
+import {readFile as rf, writeFile as wf, rename} from "node:fs/promises";
+import {readFileSync} from "node:fs";
import {randomBytes} from "node:crypto";
import argon2 from "argon2";
@@ -37,10 +37,10 @@ function debounce(fn, interval=100) {
function loadJSONSync(path) {
const obj = {};
try {
- Object.assign(obj, JSON.parse(readFileSync(path)));
+ Object.assign(obj, JSON.parse(readFileSync(path, "utf8")));
} catch(e) {
console.log(Date.now()+` Error loading ${path}, creating fallback empty set`);
- rename(path, path+".bad."+Date.now(), err => {});
+ rename(path, path+".bad."+Date.now()).catch(() => {}); // TODO make synchronous?
}
return obj;
}
@@ -165,7 +165,7 @@ const checkReferer = req => {
if (req.headers["referer"] && !req.headers["referer"].includes(DOMAIN))
console.log(Date.now()+" [WARN] Unexpected HTTP Referer: "+req.headers["referer"]); };
-async function login(req, res, match, data) {
+async function signIn(req, res, match, data) {
const currentToken = parseCookies(req)?.token;
if (currentToken || !data.username || !data.password) return err400(res);
const error = {success:false, msg:"Bad username or password"};
@@ -175,7 +175,7 @@ async function login(req, res, match, data) {
if (!uid) return sendError(res); // User doesn't exist
let user;
- try { user = JSON.parse(await rf(`private/${uid}.json`));
+ try { user = JSON.parse(await rf(`private/${uid}.json`, "utf8"));
} catch (e) { return err500(res); } // Can't load user data
checkReferer(req);
@@ -206,7 +206,7 @@ async function login(req, res, match, data) {
} else return err500(res);
}
-function logout(req, res) {
+function signOut(req, res) {
const token = parseCookies(req)?.token;
const tokenData = getToken(token);
if (tokenData) {
@@ -235,7 +235,7 @@ function changePassword(req, res, match, data) {
if (!uid) return token? err401(res) : sendError(req);
let user;
- try { user = JSON.parse(await rf(`private/${uid}.json`));
+ try { user = JSON.parse(await rf(`private/${uid}.json`, "utf8"));
} catch (e) { return err500(res); }
checkReferer(req);
@@ -261,7 +261,7 @@ function changePassword(req, res, match, data) {
await wf(`private/${uid}.json`, JSON.stringify(user));
if (!res.getHeader("Set-Cookie")) { // Might have been renewed by authed()
- if (token) renewToken(res, token, fingerprint, 0);
+ if (token) renewToken(res, token, collectFingerprint(req), 0);
else setTokenCookie(res, createToken(uid,
Object.assign(collectFingerprint(req), {
sessionID: newSessionID(),
@@ -286,7 +286,7 @@ async function changeUsernameReq(req, res, match, data) {
const sendError = res => { rateLimitIP(req, 2, 8); sj(res, error); };
let user;
- try { user = JSON.parse(await rf(`private/${req.uid}.json`));
+ try { user = JSON.parse(await rf(`private/${req.uid}.json`, "utf8"));
} catch (e) { return err500(res); }
const pass = user.password;
@@ -419,8 +419,8 @@ export function authed(fn) { return rateLimit((req, res, ...rest) => {
}); }
export const attach = (app) => { // TODO make endpoints RESTier?
- app.jpost("/login", rateLimit(login)); // {username, password[, keepSession]} -> {success[, msg][, mustChangePassword]}
- app.post("/logout", rateLimit(logout));
+ app.jpost("/sign-in", rateLimit(signIn)); // {username, password[, keepSession]} -> {success[, msg][, mustChangePassword]}
+ app.post("/sign-out", rateLimit(signOut));
app.jpost("/change-password", rateLimit(changePassword)); // {password, newPassword[, username[, keepSession]]} -> {success[, msg]}
app.jpost("/change-username", authed(changeUsernameReq)); // {newUsername, password} -> {success[, msg]}
app.get("/session-list", authed(sessionList)); // -> {active:[{id:<sessionID>, ...}, ...], recent:[...]}
diff --git a/app/lib/router.js b/app/lib/router.js
index 186e6ad..7d30ccf 100644
--- a/app/lib/router.js
+++ b/app/lib/router.js
@@ -1,4 +1,4 @@
-import {parse as parseURL} from "node:url";
+import {URL} from "node:url";
const degroup = path => Object.assign(path, path.groups);
export default class Router {
@@ -7,7 +7,7 @@ export default class Router {
}
route(req, res) {
- const pathname = parseURL(req.url).pathname;
+ const pathname = new URL(req.url, "file:").pathname; // TODO double-check
return this.routes.some(route => {
const isMatch = route.method === req.method && route.re.test(pathname);
if (isMatch) route.cb(req, res, degroup(route.re.exec(pathname)));
@@ -30,6 +30,7 @@ export default class Router {
}
jpost(re, cb, max) {
+ // TODO check req content-type? set accepts?
this.gpost(re, (req, res, match, data) => {
try {
data = JSON.parse(data);
diff --git a/app/lib/static.js b/app/lib/static.js
index 26fc231..08c877f 100644
--- a/app/lib/static.js
+++ b/app/lib/static.js
@@ -1,5 +1,5 @@
import {normalize, extname} from "node:path";
-import {parse as parseURL} from "node:url";
+import {URL} from "node:url";
import fs from "node:fs";
const mimeTypes = {
@@ -46,7 +46,7 @@ export default class Static {
return;
}
- const pathname = parseURL(req.url).pathname;
+ const pathname = new URL(req.url, "file:").pathname; // TODO double-check
const sane = normalize(pathname).replace(/^(\.\.\/)+/, "");
let path = `${this.root}${sane}`; //Path.join(__dirname, sane);
diff --git a/app/note-store.js b/app/note-store.js
index eeebf28..8ad54db 100644
--- a/app/note-store.js
+++ b/app/note-store.js
@@ -21,7 +21,7 @@ async function newNote(req, res) {
async function getNote(req, res, match) {
console.log(Date.now()+` Getting note ${req.uid}:${match.noteID}`);
const noteFile = `${NOTE_DIR}/${req.uid}/${match.noteID}.md`;
- const content = await rf(noteFile, "UTF-8");
+ const content = await rf(noteFile, "utf8");
sj(res, {id:match.noteID, content});
}
@@ -29,7 +29,7 @@ async function setNote(req, res, match, data) {
console.log(Date.now()+` Setting note ${req.uid}:${match.noteID}`);
if (match.noteID !== data.id) return err400(res);
const noteFile = `${NOTE_DIR}/${req.uid}/${match.noteID}.md`;
- await wf(noteFile, data.content, "UTF-8");
+ await wf(noteFile, data.content, "utf8");
sj(res, {});
}
diff --git a/app/public/api.js b/app/public/api.js
new file mode 100644
index 0000000..6321909
--- /dev/null
+++ b/app/public/api.js
@@ -0,0 +1,91 @@
+export async function getNote(id) {
+ const res = await fetch(`${id}`);
+ const note = await res.json();
+ //console.log(note);
+ return note;
+}
+
+export async function getList() {
+ const res = await fetch("/list");
+ const list = await res.json();
+ //console.log(list);
+
+ return Promise.all(list.map(id => getNote(id)));
+}
+
+export async function saveNote(id, content) {
+ const res = await fetch("/"+id, {
+ method: "POST",
+ body: JSON.stringify({id, content}),
+ });
+
+ const note = await res.json();
+ //console.log(note);
+}
+
+export async function newNote() {
+ const res = await fetch("/new", {
+ method: "POST",
+ });
+
+ const note = await res.json();
+ //console.log(note);
+ return note;
+}
+
+export async function getUserData() {
+ const res = await fetch(`/user`);
+ const user = await res.json();
+ //console.log(user);
+ return user;
+}
+
+export async function getUserSessions() {
+ const res = await fetch(`/session-list`);
+ const sessions = await res.json();
+ //console.log(sessions);
+ return sessions;
+}
+
+export async function signIn({username, password, keepSession}) {
+ const res = await fetch("/sign-in", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({username, password, keepSession})
+ });
+
+ if (res.ok) return res.json();
+ return {code: res.status, error: res.statusText}; // TODO
+}
+
+export async function changePassword({username, password, newPassword, keepSession}) {
+ const res = await fetch("/change-password", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({username, password, newPassword, keepSession})
+ });
+
+ return res.json();
+}
+
+export async function changeUsername({newUsername, password}) {
+ const res = await fetch("/change-username", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({newUsername, password})
+ });
+
+ return res.json();
+}
+
+export async function signOut() {
+ const res = await fetch("/sign-out", {
+ method: "POST",
+ });
+}
+
+export async function signOutEverywhere() {
+ const res = await fetch("/deauth-all", {
+ method: "POST",
+ });
+}
diff --git a/app/public/index.html b/app/public/index.html
index 593e6b3..d900888 100644
--- a/app/public/index.html
+++ b/app/public/index.html
@@ -89,8 +89,8 @@
<input type="text" id="new-username" name="newUsername" placeholder="New username" autocomplete="none" autocorrect="off" autocapitalize="off" spellcheck="false">
<button id="change-username">Change username</button>
<br>
- <button id="log-out">Log out</button>
- <button id="log-out-everywhere">Log out everywhere</button>
+ <button id="sign-out">Sign out</button>
+ <button id="sign-out-everywhere">Sign out everywhere</button>
<br>
<!--input type="text" inputmode="search" id="search" name="search" placeholder="Search…" list="search-hints"-->
<input type="search" id="search" name="search" placeholder="Search…" list="search-hints" incremental>
@@ -110,7 +110,11 @@
<button class="toolbar-fab" id="add-btn"></button>
</div>
- <!--script src="sock.js"></script-->
- <script src="main.js"></script>
+ <script type="module">
+ import Ponys from "./ponys.js";
+ Ponys.defineAll();
+ </script>
+ <template name="note-card" src="./note-ponys.html"></template>
+ <script type="module" src="main.js"></script>
</body>
</html>
diff --git a/app/public/main.js b/app/public/main.js
index 5746142..a9119b4 100644
--- a/app/public/main.js
+++ b/app/public/main.js
@@ -1,6 +1,8 @@
-document.addEventListener("DOMContentLoaded", async () => { "use strict";
+import { getNote, getList, saveNote, newNote, getUserData, getUserSessions,
+ signIn, changePassword, changeUsername, signOut, signOutEverywhere } from "./api.js";
+
+document.addEventListener("DOMContentLoaded", async () => {
-const secure = location.protocol === "https:";
const $ = (s,c) => (c||document).querySelector(s);
function $$(x,y,z,a){a=(z||document).querySelectorAll(x);if(typeof y=="function")[].forEach.call(a,y);return a}
function m(a,b,c){c=document;b=c.createElement(b||"p");b.innerHTML=a.trim();for(a=c.createDocumentFragment();c=b.firstChild;)a.appendChild(c);return a.firstChild}
@@ -16,6 +18,7 @@ function debounce(fn, delay) {
}
}
+//const secure = location.protocol === "https:";
//sock.init(`ws${secure?"s":""}://${location.host}/ws`);
//sock.on("hello", e => {
// console.log("hello", e);
@@ -27,79 +30,37 @@ function debounce(fn, delay) {
// .then(() => console.log("Service worker registered"));
//}
-function drawNote(note) {
- const el = m(`<div class="card-shadow"><div class="card">
- <!--h3>${note.id}</h3-->
- <textarea>${note.content.trim()}</textarea>
- </div></div>`);
-
- const ta = $("textarea", el)
-
- ta.addEventListener("input",
- debounce(() => { saveNote(note.id, ta.value); }, 500));
-
- function resizeTextarea() { // TODO simplify
- el.style.height = (el.scrollHeight) + "px";
- ta.style.height = ""; ta.style.height = (ta.scrollHeight) + "px";
- el.style.height = "";
- }
- ta.addEventListener("input", resizeTextarea);
- ta.addEventListener("focus", resizeTextarea);
- window.addEventListener("resize", resizeTextarea);
- setTimeout(resizeTextarea);
+//console.log("outside", $("note-card").textContent);
+//$("note-card").addEventListener("edit", () => console.log("edit event fired"));
+function drawNote(note) {
+ //const el = m(`<div class="card-shadow"><div class="card">
+ // <!--h3>${note.id}</h3-->
+ // <textarea>${note.content.trim()}</textarea>
+ //</div></div>`);
+
+ //const ta = $("textarea", el)
+
+ //ta.addEventListener("input",
+ // debounce(() => { saveNote(note.id, ta.value); }, 500));
+
+ //function resizeTextarea() { // TODO simplify
+ // el.style.height = (el.scrollHeight) + "px";
+ // ta.style.height = ""; ta.style.height = (ta.scrollHeight) + "px";
+ // el.style.height = "";
+ //}
+ //ta.addEventListener("input", resizeTextarea);
+ //ta.addEventListener("focus", resizeTextarea);
+ //window.addEventListener("resize", resizeTextarea);
+ //setTimeout(resizeTextarea);
+
+ const el = m(`<note-card data-id="${note.id}">${note.content.trim()}</note-card>`);
$("#notes").appendChild(el);
}
-async function getNote(id) {
- const res = await fetch(`${id}`);
- const note = await res.json();
- console.log(note);
- drawNote(note);
-}
-
-async function getList() {
- const res = await fetch("/list");
- const list = await res.json();
- console.log(list);
-
- return Promise.all(list.map(id => getNote(id)));
-}
-
-async function saveNote(id, content) {
- const res = await fetch("/"+id, {
- method: "POST",
- body: JSON.stringify({id, content}),
- });
-
- const note = await res.json();
- console.log(note);
-}
-
-async function newNote() {
- const res = await fetch("/new", {
- method: "POST",
- });
-
- const note = await res.json();
- console.log(note);
-
- drawNote(note);
-}
-
-$("#add-btn").addEventListener("click", newNote);
-
-async function getUserData() {
- const res = await fetch(`/user`);
- const user = await res.json();
- console.log(user);
-}
-
-async function getUserSessions() {
- const res = await fetch(`/session-list`);
- const sessions = await res.json();
- console.log(sessions);
-}
+$("#add-btn").addEventListener("click", async () => {
+ drawNote(await newNote());
+});
function showSignIn() {
const hash = location.hash.slice(2).replace(/^sign-in(\/to\/)?/,"");
@@ -131,32 +92,27 @@ $("#sign-in-basic-go").addEventListener("click", async e => {
inputs.forEach(e => e.disabled = true);
$("#sign-in-form").classList.add("loading");
- const res = await fetch("/login", {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify({
- username: $("#sign-in-username").value,
- password: $("#sign-in-password").value,
- keepSession: $("#sign-in-keep-session").checked,
- })
+ const res = await signIn({
+ username: $("#sign-in-username").value,
+ password: $("#sign-in-password").value,
+ keepSession: $("#sign-in-keep-session").checked,
});
inputs.forEach(e => e.disabled = false);
$("#sign-in-form").classList.remove("loading");
- if (!res.ok) {
- $("#sign-in-error").textContent = res.status+" "+res.statusText;
+ if (res.error) {
+ $("#sign-in-error").textContent = res.code+" "+res.error;
return;
}
- const json = await res.json();
- console.log(json);
+ console.log(res);
- if (!json.success) {
- $("#sign-in-error").textContent = json.msg;
+ if (!res.success) {
+ $("#sign-in-error").textContent = res.msg;
return;
}
- if (json.mustChangePassword) {
+ if (res.mustChangePassword) {
alert("Must change password (TODO)");
// TODO
return;
@@ -167,66 +123,54 @@ $("#sign-in-basic-go").addEventListener("click", async e => {
// TODO load location
getUserData();
- getList();
+ const noteList = await getList();
+ for (const note of noteList) drawNote(note);
$("#sign-in-dialog").close();
});
$("#change-password").addEventListener("click", async () => {
- const res = await fetch("/change-password", {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify({
- username: $("#username").value, // TODO
- password: $("#password").value, // TODO
- newPassword: $("#new-password").value,
- keepSession: $("#keep-session").checked,
- })
+ const res = await changePassword({
+ username: $("#username").value, // TODO
+ password: $("#password").value, // TODO
+ newPassword: $("#new-password").value,
+ keepSession: $("#keep-session").checked,
});
- const json = await res.json();
- console.log(json);
+ console.log(res);
getUserData();
});
$("#change-username").addEventListener("click", async () => {
- const res = await fetch("/change-username", {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify({
- newUsername: $("#new-username").value,
- password: $("#password").value, // TODO
- })
+ const res = await changeUsername({
+ newUsername: $("#new-username").value,
+ password: $("#password").value, // TODO
});
- const json = await res.json();
- console.log(json);
+ console.log(res);
getUserData();
});
-$("#log-out").addEventListener("click", async () => {
- const res = await fetch("/logout", {
- method: "POST",
- });
-
- //const json = await res.json();
- //console.log(json);
- console.log("Logged out");
+$("#sign-out").addEventListener("click", async () => {
+ await signOut();
+ console.log("Signed out");
$("#sign-in-dialog").showModal();
});
-$("#log-out-everywhere").addEventListener("click", async () => {
- const res = await fetch("/deauth-all", {
- method: "POST",
- });
-
- console.log("Logged out everywhere");
+$("#sign-out-everywhere").addEventListener("click", async () => {
+ signOutEverywhere();
+ console.log("Signed out everywhere");
showSignIn();
});
-try { await getUserData(); await getList(); await getUserSessions(); } catch(e) { showSignIn(); }
+try {
+ await getUserData();
+ const noteList = await getList();
+ for (const note of noteList) drawNote(note);
+ await getUserSessions();
+} catch(e) { showSignIn(); }
(() => {
let prevScroll = 0;
diff --git a/app/public/note-ponys.html b/app/public/note-ponys.html
new file mode 100644
index 0000000..bcbe7ed
--- /dev/null
+++ b/app/public/note-ponys.html
@@ -0,0 +1,102 @@
+<div class="card-shadow">
+ <div class="card">
+ <!--h3>${note.id}</h3-->
+ <textarea rows=1></textarea>
+ </div>
+</div>
+
+<script setup>
+import { debounce } from "./utils.js";
+import { saveNote } from "./api.js";
+export default class extends HTMLElement {
+ #resizeFn;
+
+ connectedCallback() {
+ const ta = this.$("textarea");
+ ta.value = this.textContent;
+
+ ta.addEventListener("input", debounce(() => { // TODO ensure good debounce behavior
+ saveNote(this.dataset.id, ta.value);
+ }, 500));
+
+ //ta.addEventListener("input", () => this.dispatchEvent(new Event("edit")));
+
+ this.#resizeFn = () => this.resizeTextarea();
+ ta.addEventListener("input", this.#resizeFn);
+ ta.addEventListener("focus", this.#resizeFn);
+ window.addEventListener("resize", this.#resizeFn);
+ setTimeout(this.#resizeFn);
+ }
+
+ disconnectedCallback() {
+ window.removeEventListener("resize", this.#resizeFn);
+ }
+
+ resizeTextarea() { // TODO simplify?
+ const cs = this.$(".card-shadow");
+ const ta = this.$("textarea");
+ cs.style.height = (cs.scrollHeight) + "px";
+ ta.style.height = "";
+ ta.style.height = (ta.scrollHeight) + "px";
+ cs.style.height = "";
+ }
+}
+</script>
+
+<style>
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+textarea {
+ font-family: inherit;
+}
+/* TODO :target? */
+.card-shadow {
+ position: relative;
+ margin: 16px auto;
+ max-width: 5.5in;
+ border-radius: 24px;
+ box-shadow: 0 2px 16px rgba(0,0,0,.05), 0 2px 4px rgba(0,0,0,.1);
+ transition: box-shadow .2s;
+}
+.card-shadow:hover,
+.card-shadow:focus-within {
+ box-shadow: 0 2px 16px rgba(0,0,0,.15), 0 2px 4px rgba(0,0,0,.15);
+}
+.card-shadow::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ box-shadow: 0 0 0 1px rgba(255,255,255,.2) inset;
+ border-radius: 24px;
+ pointer-events: none;
+ transition: box-shadow .2s;
+}
+.card-shadow:hover::after,
+.card-shadow:focus-within::after {
+ box-shadow: 0 0 0 2px rgba(255,255,255,.25) inset;
+}
+.card {
+ /*padding-bottom: 16px;*/
+ border-radius: 24px;
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ overflow: hidden;
+}
+
+.card > textarea { /* TODO */
+ display: block;
+ width: 100%;
+ padding: 16px 24px;
+ border: none;
+ resize: none;
+ outline: none;
+ font-size: 16px;
+ line-height: 24px;
+ letter-spacing: .3px;
+ background: transparent;
+ color: inherit;
+}
+</style>
diff --git a/app/public/ponys.js b/app/public/ponys.js
new file mode 100644
index 0000000..adc54ab
--- /dev/null
+++ b/app/public/ponys.js
@@ -0,0 +1 @@
+/* ponys v0.3.6 */export default class{static define(t,r,n,o=""){if(!r.content){let e=document.createElement("template");e.innerHTML=r,r=e}let l=(r=r.content).querySelector("script[setup]")||r.querySelector("script");return import("data:text/javascript;base64,"+btoa(l?.text?.replace(/(?<=(import|from)\s*?("|'))\.{0,2}\/.*?[^\\](?=\2)/g,(e=>new URL(e,new URL(o,location.origin)))))).then((o=>{l?.remove();class c extends(o.default||HTMLElement){constructor(){super();let t=this;try{t=t.attachShadow({mode:"open"})}catch{}this.$=e=>t.querySelector(e),this.$$=e=>t.querySelectorAll(e);let n=r.cloneNode(!0);e(this,n),t.append(n)}}return customElements.define(t,c,n),c}))}static defineAll(e=document){return Promise.allSettled([...e.querySelectorAll("template[name]")].map((e=>{let t={};for(let{name:r,value:n}of e.attributes)t[r]=n;return t.src?this.import(t.name,t.src,t):this.define(t.name,e,t)})))}static import(e,t,r){return fetch(t).then((e=>e.ok?e.text():Promise.reject(Error(t)))).then((n=>this.define(e,n,r,t)))}}function e(t,r){for(let n of r.children)n.host=t,n.$=t.$,n.$$=t.$$,e(t,n)}
diff --git a/app/public/sock.js b/app/public/sock.js
index b4905e5..72e00ec 100644
--- a/app/public/sock.js
+++ b/app/public/sock.js
@@ -1,4 +1,3 @@
-const sock = (() => { "use strict";
const refresh = () => setTimeout(() => location.reload(), 1e3);
let ws, pingTimeout;
@@ -12,7 +11,7 @@ const handlers = {
}],
};
-const sock = {
+export const sock = {
init: url => {
ws = new WebSocket(url);
ws.addEventListener("close", refresh);
@@ -39,5 +38,3 @@ const sock = {
else prequeue.push([type, data]);
},
};
-
-return sock })();
diff --git a/app/public/utils.js b/app/public/utils.js
new file mode 100644
index 0000000..2e08012
--- /dev/null
+++ b/app/public/utils.js
@@ -0,0 +1,10 @@
+export function debounce(fn, delay) {
+ let timeout = null;
+ return (...args) => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ timeout = null;
+ fn.apply(null, args);
+ }, delay||500);
+ }
+}