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:
parent
486695cbeb
commit
71b50382e9
34
package-lock.json
generated
34
package-lock.json
generated
|
@ -13,7 +13,9 @@
|
||||||
"dayjs": "~1.11.7",
|
"dayjs": "~1.11.7",
|
||||||
"dayjs-twitter": "~0.5.0",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
|
"fast-deep-equal": "~3.1.3",
|
||||||
"history": "~5.3.0",
|
"history": "~5.3.0",
|
||||||
|
"idb-keyval": "~6.2.0",
|
||||||
"just-debounce-it": "~3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"masto": "~5.2.0",
|
"masto": "~5.2.0",
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
|
@ -3259,8 +3261,7 @@
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
|
@ -3625,6 +3626,14 @@
|
||||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
@ -4822,6 +4831,11 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
@ -8132,8 +8146,7 @@
|
||||||
"fast-deep-equal": {
|
"fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"fast-glob": {
|
"fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
|
@ -8411,6 +8424,14 @@
|
||||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||||
"dev": true
|
"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": {
|
"inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
@ -9267,6 +9288,11 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
|
|
@ -15,7 +15,9 @@
|
||||||
"dayjs": "~1.11.7",
|
"dayjs": "~1.11.7",
|
||||||
"dayjs-twitter": "~0.5.0",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
|
"fast-deep-equal": "~3.1.3",
|
||||||
"history": "~5.3.0",
|
"history": "~5.3.0",
|
||||||
|
"idb-keyval": "~6.2.0",
|
||||||
"just-debounce-it": "~3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"masto": "~5.2.0",
|
"masto": "~5.2.0",
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
|
|
12
src/app.jsx
12
src/app.jsx
|
@ -11,6 +11,7 @@ import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Account from './components/account';
|
import Account from './components/account';
|
||||||
import Compose from './components/compose';
|
import Compose from './components/compose';
|
||||||
|
import Drafts from './components/drafts';
|
||||||
import Loader from './components/loader';
|
import Loader from './components/loader';
|
||||||
import Modal from './components/modal';
|
import Modal from './components/modal';
|
||||||
import Home from './pages/home';
|
import Home from './pages/home';
|
||||||
|
@ -280,6 +281,17 @@ function App() {
|
||||||
<Account account={snapStates.showAccount} />
|
<Account account={snapStates.showAccount} />
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{!!snapStates.showDrafts && (
|
||||||
|
<Modal
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
states.showDrafts = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Drafts />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import './compose.css';
|
import './compose.css';
|
||||||
|
|
||||||
import '@github/text-expander-element';
|
import '@github/text-expander-element';
|
||||||
|
import equal from 'fast-deep-equal';
|
||||||
import { forwardRef } from 'preact/compat';
|
import { forwardRef } from 'preact/compat';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
@ -10,12 +11,14 @@ import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import supportedLanguages from '../data/status-supported-languages';
|
import supportedLanguages from '../data/status-supported-languages';
|
||||||
import urlRegex from '../data/url-regex';
|
import urlRegex from '../data/url-regex';
|
||||||
|
import db from '../utils/db';
|
||||||
import emojifyText from '../utils/emojify-text';
|
import emojifyText from '../utils/emojify-text';
|
||||||
import openCompose from '../utils/open-compose';
|
import openCompose from '../utils/open-compose';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
import { getCurrentAccount } from '../utils/store-utils';
|
import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils';
|
||||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
||||||
|
import useInterval from '../utils/useInterval';
|
||||||
import visibilityIconsMap from '../utils/visibility-icons-map';
|
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||||
|
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
|
@ -81,7 +84,7 @@ function Compose({
|
||||||
}) {
|
}) {
|
||||||
console.warn('RENDER COMPOSER');
|
console.warn('RENDER COMPOSER');
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
const UID = useRef(uid());
|
const UID = useRef(draftStatus?.uid || uid());
|
||||||
console.log('Compose UID', UID.current);
|
console.log('Compose UID', UID.current);
|
||||||
|
|
||||||
const currentAccount = getCurrentAccount();
|
const currentAccount = getCurrentAccount();
|
||||||
|
@ -178,7 +181,6 @@ function Compose({
|
||||||
}
|
}
|
||||||
if (draftStatus) {
|
if (draftStatus) {
|
||||||
const {
|
const {
|
||||||
uid,
|
|
||||||
status,
|
status,
|
||||||
spoilerText,
|
spoilerText,
|
||||||
visibility,
|
visibility,
|
||||||
|
@ -187,7 +189,6 @@ function Compose({
|
||||||
poll,
|
poll,
|
||||||
mediaAttachments,
|
mediaAttachments,
|
||||||
} = draftStatus;
|
} = draftStatus;
|
||||||
UID.current = uid;
|
|
||||||
const composablePoll = !!poll?.options && {
|
const composablePoll = !!poll?.options && {
|
||||||
...poll,
|
...poll,
|
||||||
options: poll.options.map((o) => o?.title || o),
|
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 (
|
return (
|
||||||
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
||||||
<div class="compose-top">
|
<div class="compose-top">
|
||||||
|
@ -383,7 +450,6 @@ function Compose({
|
||||||
// );
|
// );
|
||||||
|
|
||||||
const newWin = openCompose({
|
const newWin = openCompose({
|
||||||
uid: UID.current,
|
|
||||||
editStatus,
|
editStatus,
|
||||||
replyToStatus,
|
replyToStatus,
|
||||||
draftStatus: {
|
draftStatus: {
|
||||||
|
@ -473,7 +539,7 @@ function Compose({
|
||||||
mediaAttachments,
|
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;
|
window.opener.__STATES__.showCompose = true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
94
src/components/drafts.css
Normal file
94
src/components/drafts.css
Normal 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
240
src/components/drafts.jsx
Normal 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…
|
||||||
|
</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…
|
||||||
|
</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;
|
|
@ -7,7 +7,9 @@ import { useSnapshot } from 'valtio';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import Status from '../components/status';
|
import Status from '../components/status';
|
||||||
|
import db from '../utils/db';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
|
import { getCurrentAccountNS } from '../utils/store-utils';
|
||||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
||||||
import useScroll from '../utils/useScroll';
|
import useScroll from '../utils/useScroll';
|
||||||
|
|
||||||
|
@ -181,6 +183,19 @@ function Home({ hidden }) {
|
||||||
}
|
}
|
||||||
}, [reachTop]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
id="home-page"
|
id="home-page"
|
||||||
|
|
|
@ -184,6 +184,19 @@ function Settings({ onClose }) {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
<h2>About</h2>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
||||||
|
|
28
src/utils/db.js
Normal file
28
src/utils/db.js
Normal 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,
|
||||||
|
};
|
|
@ -18,6 +18,7 @@ const states = proxy({
|
||||||
showCompose: false,
|
showCompose: false,
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
showAccount: false,
|
showAccount: false,
|
||||||
|
showDrafts: false,
|
||||||
composeCharacterCount: 0,
|
composeCharacterCount: 0,
|
||||||
});
|
});
|
||||||
export default states;
|
export default states;
|
||||||
|
|
|
@ -7,3 +7,12 @@ export function getCurrentAccount() {
|
||||||
accounts.find((a) => a.info.id === currentAccount) || accounts[0];
|
accounts.find((a) => a.info.id === currentAccount) || accounts[0];
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCurrentAccountNS() {
|
||||||
|
const account = getCurrentAccount();
|
||||||
|
const {
|
||||||
|
instanceURL,
|
||||||
|
info: { id },
|
||||||
|
} = account;
|
||||||
|
return `${id}@${instanceURL}`;
|
||||||
|
}
|
||||||
|
|
22
src/utils/useInterval.js
Normal file
22
src/utils/useInterval.js
Normal 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]);
|
||||||
|
}
|
Loading…
Reference in a new issue