New feature: Unsent Drafts

For now, this only works for unsent unsaved drafts e.g. the browser kill the page without giving the user the chance to discard
This commit is contained in:
Lim Chee Aun 2023-01-13 15:30:09 +08:00
parent 486695cbeb
commit 71b50382e9
12 changed files with 538 additions and 10 deletions

34
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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() {
<Account account={snapStates.showAccount} />
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showDrafts = false;
}
}}
>
<Drafts />
</Modal>
)}
</>
);
}

View file

@ -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 (
<div id="compose-container" class={standalone ? 'standalone' : ''}>
<div class="compose-top">
@ -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;
},
});

94
src/components/drafts.css Normal file
View file

@ -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);
}

240
src/components/drafts.jsx Normal file
View file

@ -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 (
<div class="sheet">
<header>
<h2>
Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} />
</h2>
{hasDrafts && (
<div class="insignificant">
Looks like you have unsent drafts. Let's continue where you left
off.
</div>
)}
</header>
<main>
{hasDrafts ? (
<>
<ul class="drafts-list">
{drafts.map((draft) => {
const { updatedAt, key, draftStatus, replyTo } = draft;
const currentYear = new Date().getFullYear();
const updatedAtDate = new Date(updatedAt);
return (
<li key={updatedAt}>
<div class="mini-draft-meta">
<b>
<Icon icon={replyTo ? 'reply' : 'quill'} size="s" />{' '}
<time>
{!!replyTo && (
<>
@{replyTo.account.acct}
<br />
</>
)}
{Intl.DateTimeFormat('en', {
// Show year if not current year
year:
updatedAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
}).format(updatedAtDate)}
</time>
</b>
<button
type="button"
class="small light"
disabled={uiState === 'loading'}
onClick={() => {
(async () => {
try {
const yes = confirm(
'Are you sure you want to delete this draft?',
);
if (yes) {
await db.drafts.del(key);
reload();
}
} catch (e) {
alert('Error deleting draft! Please try again.');
}
})();
}}
>
Delete&hellip;
</button>
</div>
<button
type="button"
disabled={uiState === 'loading'}
class="draft-item"
onClick={async () => {
// console.log({ draftStatus });
let replyToStatus;
if (replyTo) {
setUIState('loading');
try {
replyToStatus = await masto.v1.statuses.fetch(
replyTo.id,
);
} catch (e) {
console.error(e);
alert('Error fetching reply-to status!');
setUIState('default');
return;
}
setUIState('default');
}
window.__COMPOSE__ = {
draftStatus,
replyToStatus,
};
states.showCompose = true;
states.showDrafts = false;
}}
>
<MiniDraft draft={draft} />
</button>
</li>
);
})}
</ul>
<p>
<button
type="button"
class="light danger"
disabled={uiState === 'loading'}
onClick={() => {
(async () => {
const yes = confirm(
'Are you sure you want to delete all drafts?',
);
if (yes) {
setUIState('loading');
try {
await db.drafts.delMany(
drafts.map((draft) => draft.key),
);
setUIState('default');
reload();
} catch (e) {
console.error(e);
alert('Error deleting drafts! Please try again.');
setUIState('error');
}
}
})();
}}
>
Delete all drafts&hellip;
</button>
</p>
</>
) : (
<p>No drafts found.</p>
)}
</main>
</div>
);
}
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 (
<>
<div class="mini-draft">
{hasPollOrMedia && (
<div
class={`mini-draft-aside ${firstImageMedia ? 'has-image' : ''}`}
style={
firstImageMedia
? {
'--bg-image': `url(${firstImageMedia})`,
}
: {}
}
>
{hasPoll && <Icon icon="poll" />}
{hasMedia && (
<span>
<Icon icon="attachment" />{' '}
<small>{mediaAttachments?.length}</small>
</span>
)}
</div>
)}
<div class="mini-draft-main">
{!!spoilerText && <div class="mini-draft-spoiler">{spoilerText}</div>}
{!!status && <div class="mini-draft-status">{status}</div>}
</div>
</div>
</>
);
}
export default Drafts;

View file

@ -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 (
<div
id="home-page"

View file

@ -184,6 +184,19 @@ function Settings({ onClose }) {
</label>
</div>
</form>
<h2>Hidden features</h2>
<p>
<button
type="button"
class="light"
onClick={() => {
states.showDrafts = true;
states.showSettings = false;
}}
>
Unsent drafts
</button>
</p>
<h2>About</h2>
<p>
<a href="https://github.com/cheeaun/phanpy" target="_blank">

28
src/utils/db.js Normal file
View file

@ -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,
};

View file

@ -18,6 +18,7 @@ const states = proxy({
showCompose: false,
showSettings: false,
showAccount: false,
showDrafts: false,
composeCharacterCount: 0,
});
export default states;

View file

@ -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}`;
}

22
src/utils/useInterval.js Normal file
View file

@ -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]);
}