diff --git a/package-lock.json b/package-lock.json
index 2c8ad199..8482b2b1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,9 @@
"dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
+ "fast-deep-equal": "~3.1.3",
"history": "~5.3.0",
+ "idb-keyval": "~6.2.0",
"just-debounce-it": "~3.2.0",
"masto": "~5.2.0",
"mem": "~9.0.2",
@@ -3259,8 +3261,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-glob": {
"version": "3.2.12",
@@ -3625,6 +3626,14 @@
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true
},
+ "node_modules/idb-keyval": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz",
+ "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==",
+ "dependencies": {
+ "safari-14-idb-fix": "^3.0.0"
+ }
+ },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -4822,6 +4831,11 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/safari-14-idb-fix": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz",
+ "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog=="
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -8132,8 +8146,7 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-glob": {
"version": "3.2.12",
@@ -8411,6 +8424,14 @@
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true
},
+ "idb-keyval": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz",
+ "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==",
+ "requires": {
+ "safari-14-idb-fix": "^3.0.0"
+ }
+ },
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -9267,6 +9288,11 @@
"queue-microtask": "^1.2.2"
}
},
+ "safari-14-idb-fix": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz",
+ "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog=="
+ },
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
diff --git a/package.json b/package.json
index 59cd64ab..a8d23cfe 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,9 @@
"dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
+ "fast-deep-equal": "~3.1.3",
"history": "~5.3.0",
+ "idb-keyval": "~6.2.0",
"just-debounce-it": "~3.2.0",
"masto": "~5.2.0",
"mem": "~9.0.2",
diff --git a/src/app.jsx b/src/app.jsx
index 8cc0e587..bd4f553c 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -11,6 +11,7 @@ import { useSnapshot } from 'valtio';
import Account from './components/account';
import Compose from './components/compose';
+import Drafts from './components/drafts';
import Loader from './components/loader';
import Modal from './components/modal';
import Home from './pages/home';
@@ -280,6 +281,17 @@ function App() {
)}
+ {!!snapStates.showDrafts && (
+ {
+ if (e.target === e.currentTarget) {
+ states.showDrafts = false;
+ }
+ }}
+ >
+
+
+ )}
>
);
}
diff --git a/src/components/compose.jsx b/src/components/compose.jsx
index 143a1f24..47071d26 100644
--- a/src/components/compose.jsx
+++ b/src/components/compose.jsx
@@ -1,6 +1,7 @@
import './compose.css';
import '@github/text-expander-element';
+import equal from 'fast-deep-equal';
import { forwardRef } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -10,12 +11,14 @@ import { useSnapshot } from 'valtio';
import supportedLanguages from '../data/status-supported-languages';
import urlRegex from '../data/url-regex';
+import db from '../utils/db';
import emojifyText from '../utils/emojify-text';
import openCompose from '../utils/open-compose';
import states from '../utils/states';
import store from '../utils/store';
-import { getCurrentAccount } from '../utils/store-utils';
+import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils';
import useDebouncedCallback from '../utils/useDebouncedCallback';
+import useInterval from '../utils/useInterval';
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
@@ -81,7 +84,7 @@ function Compose({
}) {
console.warn('RENDER COMPOSER');
const [uiState, setUIState] = useState('default');
- const UID = useRef(uid());
+ const UID = useRef(draftStatus?.uid || uid());
console.log('Compose UID', UID.current);
const currentAccount = getCurrentAccount();
@@ -178,7 +181,6 @@ function Compose({
}
if (draftStatus) {
const {
- uid,
status,
spoilerText,
visibility,
@@ -187,7 +189,6 @@ function Compose({
poll,
mediaAttachments,
} = draftStatus;
- UID.current = uid;
const composablePoll = !!poll?.options && {
...poll,
options: poll.options.map((o) => o?.title || o),
@@ -348,6 +349,72 @@ function Compose({
},
);
+ const prevBackgroundDraft = useRef({});
+ const draftKey = () => {
+ const ns = getCurrentAccountNS();
+ return `${ns}#${UID.current}`;
+ };
+ const saveUnsavedDraft = () => {
+ // Not enabling this for editing status
+ // I don't think this warrant a draft mode for a status that's already posted
+ // Maybe it could be a big edit change but it should be rare
+ if (editStatus) return;
+ const key = draftKey();
+ const backgroundDraft = {
+ key,
+ replyTo: replyToStatus
+ ? {
+ /* Smaller payload of replyToStatus. Reasons:
+ - No point storing whole thing
+ - Could have media attachments
+ - Could be deleted/edited later
+ */
+ id: replyToStatus.id,
+ account: {
+ id: replyToStatus.account.id,
+ username: replyToStatus.account.username,
+ acct: replyToStatus.account.acct,
+ },
+ }
+ : null,
+ draftStatus: {
+ uid: UID.current,
+ status: textareaRef.current.value,
+ spoilerText: spoilerTextRef.current.value,
+ visibility,
+ language,
+ sensitive,
+ poll,
+ mediaAttachments,
+ },
+ };
+ if (!equal(backgroundDraft, prevBackgroundDraft.current) && !canClose()) {
+ console.debug('not equal', backgroundDraft, prevBackgroundDraft.current);
+ db.drafts
+ .set(key, {
+ ...backgroundDraft,
+ state: 'unsaved',
+ updatedAt: Date.now(),
+ })
+ .then(() => {
+ console.debug('DRAFT saved', key, backgroundDraft);
+ })
+ .catch((e) => {
+ console.error('DRAFT failed', key, e);
+ });
+ prevBackgroundDraft.current = structuredClone(backgroundDraft);
+ }
+ };
+ useInterval(saveUnsavedDraft, 5000); // background save every 5s
+ useEffect(() => {
+ saveUnsavedDraft();
+ // If unmounted, means user discarded the draft
+ // Also means pop-out 🙈, but it's okay because the pop-out will persist the ID and re-create the draft
+ return () => {
+ db.drafts.del(draftKey());
+ };
+ }, []);
+
return (
@@ -383,7 +450,6 @@ function Compose({
// );
const newWin = openCompose({
- uid: UID.current,
editStatus,
replyToStatus,
draftStatus: {
@@ -473,7 +539,7 @@ function Compose({
mediaAttachments,
},
};
- window.opener.__COMPOSE__ = passData;
+ window.opener.__COMPOSE__ = passData; // Pass it here instead of `showCompose` due to some weird proxy issue again
window.opener.__STATES__.showCompose = true;
},
});
diff --git a/src/components/drafts.css b/src/components/drafts.css
new file mode 100644
index 00000000..2b093b8c
--- /dev/null
+++ b/src/components/drafts.css
@@ -0,0 +1,94 @@
+.drafts-list {
+ margin: 1em 0;
+ padding: 0;
+ list-style: none;
+}
+.drafts-list > li {
+ margin: 8px 0 16px;
+ padding: 0;
+}
+
+.mini-draft-meta {
+ font-size: 80%;
+ justify-content: space-between;
+ align-items: center;
+ display: flex;
+ padding: 8px 0;
+}
+.mini-draft-meta * {
+ vertical-align: middle;
+}
+
+button.draft-item {
+ display: block;
+ width: 100%;
+ border: 0;
+ border-radius: 8px;
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ border: 1px solid var(--link-faded-color);
+ text-align: left;
+ padding: 0;
+}
+button.draft-item:is(:hover, :focus) {
+ border-color: var(--link-color);
+ box-shadow: 0 0 0 3px var(--link-faded-color);
+ filter: none !important;
+}
+
+.mini-draft {
+ display: flex;
+ gap: 0 8px;
+ font-size: 90%;
+ padding: 8px;
+}
+
+.mini-draft-aside {
+ width: 64px;
+ aspect-ratio: 1 / 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--bg-faded-color);
+ border-radius: 4px;
+ flex-shrink: 0;
+ border: 1px solid var(--outline-color);
+}
+.mini-draft-aside.has-image {
+ background-image: var(--bg-image);
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+}
+.mini-draft-aside.has-image > span {
+ background-color: var(--bg-faded-blur-color);
+ backdrop-filter: blur(8px);
+ padding: 4px 8px;
+ border-radius: 32px;
+}
+.mini-draft-aside.has-image > span * {
+ vertical-align: middle;
+}
+
+.mini-draft-main {
+ flex-grow: 1;
+}
+
+.mini-draft-spoiler,
+.mini-draft-status {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: -webkit-box;
+ display: box;
+ -webkit-box-orient: vertical;
+ box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ line-height: 1.1;
+}
+.mini-draft-spoiler + .mini-draft-status {
+ border-top: 1px dashed var(--text-insignificant-color);
+ padding-top: 4px;
+ margin-top: 4px;
+ color: var(--text-insignificant-color);
+}
diff --git a/src/components/drafts.jsx b/src/components/drafts.jsx
new file mode 100644
index 00000000..96aca1ed
--- /dev/null
+++ b/src/components/drafts.jsx
@@ -0,0 +1,240 @@
+import './drafts.css';
+
+import { useEffect, useMemo, useReducer, useState } from 'react';
+
+import db from '../utils/db';
+import states from '../utils/states';
+import { getCurrentAccountNS } from '../utils/store-utils';
+
+import Icon from './icon';
+import Loader from './loader';
+
+function Drafts() {
+ const [uiState, setUIState] = useState('default');
+ const [drafts, setDrafts] = useState([]);
+ const [reloadCount, reload] = useReducer((c) => c + 1, 0);
+
+ useEffect(() => {
+ setUIState('loading');
+ (async () => {
+ try {
+ const keys = await db.drafts.keys();
+ if (keys.length) {
+ const ns = getCurrentAccountNS();
+ const ownKeys = keys.filter((key) => key.startsWith(ns));
+ if (ownKeys.length) {
+ const drafts = await db.drafts.getMany(ownKeys);
+ drafts.sort(
+ (a, b) =>
+ new Date(b.updatedAt).getTime() -
+ new Date(a.updatedAt).getTime(),
+ );
+ setDrafts(drafts);
+ } else {
+ setDrafts([]);
+ }
+ } else {
+ setDrafts([]);
+ }
+ setUIState('default');
+ } catch (e) {
+ console.error(e);
+ setUIState('error');
+ }
+ })();
+ }, [reloadCount]);
+
+ const hasDrafts = drafts?.length > 0;
+
+ return (
+
+
+
+ {hasDrafts ? (
+ <>
+
+ {drafts.map((draft) => {
+ const { updatedAt, key, draftStatus, replyTo } = draft;
+ const currentYear = new Date().getFullYear();
+ const updatedAtDate = new Date(updatedAt);
+ return (
+ -
+
+
+ {' '}
+
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ >
+ ) : (
+ No drafts found.
+ )}
+
+
+ );
+}
+
+function MiniDraft({ draft }) {
+ const { draftStatus, replyTo } = draft;
+ const { status, spoilerText, poll, mediaAttachments } = draftStatus;
+ const hasPoll = poll?.options?.length > 0;
+ const hasMedia = mediaAttachments?.length > 0;
+ const hasPollOrMedia = hasPoll || hasMedia;
+ const firstImageMedia = useMemo(() => {
+ if (!hasMedia) return;
+ const image = mediaAttachments.find((media) => /image/.test(media.type));
+ if (!image) return;
+ const { file } = image;
+ const objectURL = URL.createObjectURL(file);
+ return objectURL;
+ }, [hasMedia, mediaAttachments]);
+ return (
+ <>
+
+ {hasPollOrMedia && (
+
+ {hasPoll && }
+ {hasMedia && (
+
+ {' '}
+ {mediaAttachments?.length}
+
+ )}
+
+ )}
+
+ {!!spoilerText &&
{spoilerText}
}
+ {!!status &&
{status}
}
+
+
+ >
+ );
+}
+
+export default Drafts;
diff --git a/src/pages/home.jsx b/src/pages/home.jsx
index 76a7f5d0..99896610 100644
--- a/src/pages/home.jsx
+++ b/src/pages/home.jsx
@@ -7,7 +7,9 @@ import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Loader from '../components/loader';
import Status from '../components/status';
+import db from '../utils/db';
import states, { saveStatus } from '../utils/states';
+import { getCurrentAccountNS } from '../utils/store-utils';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import useScroll from '../utils/useScroll';
@@ -181,6 +183,19 @@ function Home({ hidden }) {
}
}, [reachTop]);
+ useEffect(() => {
+ (async () => {
+ const keys = await db.drafts.keys();
+ if (keys.length) {
+ const ns = getCurrentAccountNS();
+ const ownKeys = keys.filter((key) => key.startsWith(ns));
+ if (ownKeys.length) {
+ states.showDrafts = true;
+ }
+ }
+ })();
+ }, []);
+
return (
+
Hidden features
+
+
+
About
diff --git a/src/utils/db.js b/src/utils/db.js
new file mode 100644
index 00000000..5db67ffd
--- /dev/null
+++ b/src/utils/db.js
@@ -0,0 +1,28 @@
+import {
+ clear,
+ createStore,
+ del,
+ delMany,
+ get,
+ getMany,
+ keys,
+ set,
+} from 'idb-keyval';
+
+const draftsStore = createStore('drafts-db', 'drafts-store');
+
+// Add additonal `draftsStore` parameter to all methods
+
+const drafts = {
+ set: (key, val) => set(key, val, draftsStore),
+ get: (key) => get(key, draftsStore),
+ getMany: (keys) => getMany(keys, draftsStore),
+ del: (key) => del(key, draftsStore),
+ delMany: (keys) => delMany(keys, draftsStore),
+ clear: () => clear(draftsStore),
+ keys: () => keys(draftsStore),
+};
+
+export default {
+ drafts,
+};
diff --git a/src/utils/states.js b/src/utils/states.js
index acad28bd..0b6876a3 100644
--- a/src/utils/states.js
+++ b/src/utils/states.js
@@ -18,6 +18,7 @@ const states = proxy({
showCompose: false,
showSettings: false,
showAccount: false,
+ showDrafts: false,
composeCharacterCount: 0,
});
export default states;
diff --git a/src/utils/store-utils.js b/src/utils/store-utils.js
index 6e307f06..51763288 100644
--- a/src/utils/store-utils.js
+++ b/src/utils/store-utils.js
@@ -7,3 +7,12 @@ export function getCurrentAccount() {
accounts.find((a) => a.info.id === currentAccount) || accounts[0];
return account;
}
+
+export function getCurrentAccountNS() {
+ const account = getCurrentAccount();
+ const {
+ instanceURL,
+ info: { id },
+ } = account;
+ return `${id}@${instanceURL}`;
+}
diff --git a/src/utils/useInterval.js b/src/utils/useInterval.js
new file mode 100644
index 00000000..c26aa8e0
--- /dev/null
+++ b/src/utils/useInterval.js
@@ -0,0 +1,22 @@
+// useInterval with Preact
+import { useEffect, useRef } from 'preact/hooks';
+
+export default function useInterval(callback, delay) {
+ const savedCallback = useRef();
+
+ // Remember the latest callback.
+ useEffect(() => {
+ savedCallback.current = callback;
+ }, [callback]);
+
+ // Set up the interval.
+ useEffect(() => {
+ function tick() {
+ savedCallback.current();
+ }
+ if (delay !== null) {
+ let id = setInterval(tick, delay);
+ return () => clearInterval(id);
+ }
+ }, [delay]);
+}