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 ( +
+
+

+ Unsent drafts

+ {hasDrafts && ( +
+ Looks like you have unsent drafts. Let's continue where you left + off. +
+ )} +
+
+ {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]); +}