this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+538 -10
+30 -4
package-lock.json
··· 13 13 "dayjs": "~1.11.7", 14 14 "dayjs-twitter": "~0.5.0", 15 15 "fast-blurhash": "~1.1.2", 16 + "fast-deep-equal": "~3.1.3", 16 17 "history": "~5.3.0", 18 + "idb-keyval": "~6.2.0", 17 19 "just-debounce-it": "~3.2.0", 18 20 "masto": "~5.2.0", 19 21 "mem": "~9.0.2", ··· 3259 3261 "node_modules/fast-deep-equal": { 3260 3262 "version": "3.1.3", 3261 3263 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 3262 - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 3263 - "dev": true 3264 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 3264 3265 }, 3265 3266 "node_modules/fast-glob": { 3266 3267 "version": "3.2.12", ··· 3624 3625 "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", 3625 3626 "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", 3626 3627 "dev": true 3628 + }, 3629 + "node_modules/idb-keyval": { 3630 + "version": "6.2.0", 3631 + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz", 3632 + "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==", 3633 + "dependencies": { 3634 + "safari-14-idb-fix": "^3.0.0" 3635 + } 3627 3636 }, 3628 3637 "node_modules/inflight": { 3629 3638 "version": "1.0.6", ··· 4822 4831 "queue-microtask": "^1.2.2" 4823 4832 } 4824 4833 }, 4834 + "node_modules/safari-14-idb-fix": { 4835 + "version": "3.0.0", 4836 + "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz", 4837 + "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==" 4838 + }, 4825 4839 "node_modules/safe-buffer": { 4826 4840 "version": "5.2.1", 4827 4841 "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", ··· 8132 8146 "fast-deep-equal": { 8133 8147 "version": "3.1.3", 8134 8148 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 8135 - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 8136 - "dev": true 8149 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 8137 8150 }, 8138 8151 "fast-glob": { 8139 8152 "version": "3.2.12", ··· 8410 8423 "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", 8411 8424 "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", 8412 8425 "dev": true 8426 + }, 8427 + "idb-keyval": { 8428 + "version": "6.2.0", 8429 + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz", 8430 + "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==", 8431 + "requires": { 8432 + "safari-14-idb-fix": "^3.0.0" 8433 + } 8413 8434 }, 8414 8435 "inflight": { 8415 8436 "version": "1.0.6", ··· 9266 9287 "requires": { 9267 9288 "queue-microtask": "^1.2.2" 9268 9289 } 9290 + }, 9291 + "safari-14-idb-fix": { 9292 + "version": "3.0.0", 9293 + "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz", 9294 + "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==" 9269 9295 }, 9270 9296 "safe-buffer": { 9271 9297 "version": "5.2.1",
+2
package.json
··· 15 15 "dayjs": "~1.11.7", 16 16 "dayjs-twitter": "~0.5.0", 17 17 "fast-blurhash": "~1.1.2", 18 + "fast-deep-equal": "~3.1.3", 18 19 "history": "~5.3.0", 20 + "idb-keyval": "~6.2.0", 19 21 "just-debounce-it": "~3.2.0", 20 22 "masto": "~5.2.0", 21 23 "mem": "~9.0.2",
+12
src/app.jsx
··· 11 11 12 12 import Account from './components/account'; 13 13 import Compose from './components/compose'; 14 + import Drafts from './components/drafts'; 14 15 import Loader from './components/loader'; 15 16 import Modal from './components/modal'; 16 17 import Home from './pages/home'; ··· 278 279 }} 279 280 > 280 281 <Account account={snapStates.showAccount} /> 282 + </Modal> 283 + )} 284 + {!!snapStates.showDrafts && ( 285 + <Modal 286 + onClick={(e) => { 287 + if (e.target === e.currentTarget) { 288 + states.showDrafts = false; 289 + } 290 + }} 291 + > 292 + <Drafts /> 281 293 </Modal> 282 294 )} 283 295 </>
+72 -6
src/components/compose.jsx
··· 1 1 import './compose.css'; 2 2 3 3 import '@github/text-expander-element'; 4 + import equal from 'fast-deep-equal'; 4 5 import { forwardRef } from 'preact/compat'; 5 6 import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; 6 7 import { useHotkeys } from 'react-hotkeys-hook'; ··· 10 11 11 12 import supportedLanguages from '../data/status-supported-languages'; 12 13 import urlRegex from '../data/url-regex'; 14 + import db from '../utils/db'; 13 15 import emojifyText from '../utils/emojify-text'; 14 16 import openCompose from '../utils/open-compose'; 15 17 import states from '../utils/states'; 16 18 import store from '../utils/store'; 17 - import { getCurrentAccount } from '../utils/store-utils'; 19 + import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils'; 18 20 import useDebouncedCallback from '../utils/useDebouncedCallback'; 21 + import useInterval from '../utils/useInterval'; 19 22 import visibilityIconsMap from '../utils/visibility-icons-map'; 20 23 21 24 import Avatar from './avatar'; ··· 81 84 }) { 82 85 console.warn('RENDER COMPOSER'); 83 86 const [uiState, setUIState] = useState('default'); 84 - const UID = useRef(uid()); 87 + const UID = useRef(draftStatus?.uid || uid()); 85 88 console.log('Compose UID', UID.current); 86 89 87 90 const currentAccount = getCurrentAccount(); ··· 178 181 } 179 182 if (draftStatus) { 180 183 const { 181 - uid, 182 184 status, 183 185 spoilerText, 184 186 visibility, ··· 187 189 poll, 188 190 mediaAttachments, 189 191 } = draftStatus; 190 - UID.current = uid; 191 192 const composablePoll = !!poll?.options && { 192 193 ...poll, 193 194 options: poll.options.map((o) => o?.title || o), ··· 348 349 }, 349 350 ); 350 351 352 + const prevBackgroundDraft = useRef({}); 353 + const draftKey = () => { 354 + const ns = getCurrentAccountNS(); 355 + return `${ns}#${UID.current}`; 356 + }; 357 + const saveUnsavedDraft = () => { 358 + // Not enabling this for editing status 359 + // I don't think this warrant a draft mode for a status that's already posted 360 + // Maybe it could be a big edit change but it should be rare 361 + if (editStatus) return; 362 + const key = draftKey(); 363 + const backgroundDraft = { 364 + key, 365 + replyTo: replyToStatus 366 + ? { 367 + /* Smaller payload of replyToStatus. Reasons: 368 + - No point storing whole thing 369 + - Could have media attachments 370 + - Could be deleted/edited later 371 + */ 372 + id: replyToStatus.id, 373 + account: { 374 + id: replyToStatus.account.id, 375 + username: replyToStatus.account.username, 376 + acct: replyToStatus.account.acct, 377 + }, 378 + } 379 + : null, 380 + draftStatus: { 381 + uid: UID.current, 382 + status: textareaRef.current.value, 383 + spoilerText: spoilerTextRef.current.value, 384 + visibility, 385 + language, 386 + sensitive, 387 + poll, 388 + mediaAttachments, 389 + }, 390 + }; 391 + if (!equal(backgroundDraft, prevBackgroundDraft.current) && !canClose()) { 392 + console.debug('not equal', backgroundDraft, prevBackgroundDraft.current); 393 + db.drafts 394 + .set(key, { 395 + ...backgroundDraft, 396 + state: 'unsaved', 397 + updatedAt: Date.now(), 398 + }) 399 + .then(() => { 400 + console.debug('DRAFT saved', key, backgroundDraft); 401 + }) 402 + .catch((e) => { 403 + console.error('DRAFT failed', key, e); 404 + }); 405 + prevBackgroundDraft.current = structuredClone(backgroundDraft); 406 + } 407 + }; 408 + useInterval(saveUnsavedDraft, 5000); // background save every 5s 409 + useEffect(() => { 410 + saveUnsavedDraft(); 411 + // If unmounted, means user discarded the draft 412 + // Also means pop-out 🙈, but it's okay because the pop-out will persist the ID and re-create the draft 413 + return () => { 414 + db.drafts.del(draftKey()); 415 + }; 416 + }, []); 417 + 351 418 return ( 352 419 <div id="compose-container" class={standalone ? 'standalone' : ''}> 353 420 <div class="compose-top"> ··· 383 450 // ); 384 451 385 452 const newWin = openCompose({ 386 - uid: UID.current, 387 453 editStatus, 388 454 replyToStatus, 389 455 draftStatus: { ··· 473 539 mediaAttachments, 474 540 }, 475 541 }; 476 - window.opener.__COMPOSE__ = passData; 542 + window.opener.__COMPOSE__ = passData; // Pass it here instead of `showCompose` due to some weird proxy issue again 477 543 window.opener.__STATES__.showCompose = true; 478 544 }, 479 545 });
+94
src/components/drafts.css
··· 1 + .drafts-list { 2 + margin: 1em 0; 3 + padding: 0; 4 + list-style: none; 5 + } 6 + .drafts-list > li { 7 + margin: 8px 0 16px; 8 + padding: 0; 9 + } 10 + 11 + .mini-draft-meta { 12 + font-size: 80%; 13 + justify-content: space-between; 14 + align-items: center; 15 + display: flex; 16 + padding: 8px 0; 17 + } 18 + .mini-draft-meta * { 19 + vertical-align: middle; 20 + } 21 + 22 + button.draft-item { 23 + display: block; 24 + width: 100%; 25 + border: 0; 26 + border-radius: 8px; 27 + background-color: var(--bg-color); 28 + color: var(--text-color); 29 + border: 1px solid var(--link-faded-color); 30 + text-align: left; 31 + padding: 0; 32 + } 33 + button.draft-item:is(:hover, :focus) { 34 + border-color: var(--link-color); 35 + box-shadow: 0 0 0 3px var(--link-faded-color); 36 + filter: none !important; 37 + } 38 + 39 + .mini-draft { 40 + display: flex; 41 + gap: 0 8px; 42 + font-size: 90%; 43 + padding: 8px; 44 + } 45 + 46 + .mini-draft-aside { 47 + width: 64px; 48 + aspect-ratio: 1 / 1; 49 + display: flex; 50 + align-items: center; 51 + justify-content: center; 52 + background-color: var(--bg-faded-color); 53 + border-radius: 4px; 54 + flex-shrink: 0; 55 + border: 1px solid var(--outline-color); 56 + } 57 + .mini-draft-aside.has-image { 58 + background-image: var(--bg-image); 59 + background-size: cover; 60 + background-position: center; 61 + background-repeat: no-repeat; 62 + } 63 + .mini-draft-aside.has-image > span { 64 + background-color: var(--bg-faded-blur-color); 65 + backdrop-filter: blur(8px); 66 + padding: 4px 8px; 67 + border-radius: 32px; 68 + } 69 + .mini-draft-aside.has-image > span * { 70 + vertical-align: middle; 71 + } 72 + 73 + .mini-draft-main { 74 + flex-grow: 1; 75 + } 76 + 77 + .mini-draft-spoiler, 78 + .mini-draft-status { 79 + text-overflow: ellipsis; 80 + overflow: hidden; 81 + display: -webkit-box; 82 + display: box; 83 + -webkit-box-orient: vertical; 84 + box-orient: vertical; 85 + -webkit-line-clamp: 2; 86 + line-clamp: 2; 87 + line-height: 1.1; 88 + } 89 + .mini-draft-spoiler + .mini-draft-status { 90 + border-top: 1px dashed var(--text-insignificant-color); 91 + padding-top: 4px; 92 + margin-top: 4px; 93 + color: var(--text-insignificant-color); 94 + }
+240
src/components/drafts.jsx
··· 1 + import './drafts.css'; 2 + 3 + import { useEffect, useMemo, useReducer, useState } from 'react'; 4 + 5 + import db from '../utils/db'; 6 + import states from '../utils/states'; 7 + import { getCurrentAccountNS } from '../utils/store-utils'; 8 + 9 + import Icon from './icon'; 10 + import Loader from './loader'; 11 + 12 + function Drafts() { 13 + const [uiState, setUIState] = useState('default'); 14 + const [drafts, setDrafts] = useState([]); 15 + const [reloadCount, reload] = useReducer((c) => c + 1, 0); 16 + 17 + useEffect(() => { 18 + setUIState('loading'); 19 + (async () => { 20 + try { 21 + const keys = await db.drafts.keys(); 22 + if (keys.length) { 23 + const ns = getCurrentAccountNS(); 24 + const ownKeys = keys.filter((key) => key.startsWith(ns)); 25 + if (ownKeys.length) { 26 + const drafts = await db.drafts.getMany(ownKeys); 27 + drafts.sort( 28 + (a, b) => 29 + new Date(b.updatedAt).getTime() - 30 + new Date(a.updatedAt).getTime(), 31 + ); 32 + setDrafts(drafts); 33 + } else { 34 + setDrafts([]); 35 + } 36 + } else { 37 + setDrafts([]); 38 + } 39 + setUIState('default'); 40 + } catch (e) { 41 + console.error(e); 42 + setUIState('error'); 43 + } 44 + })(); 45 + }, [reloadCount]); 46 + 47 + const hasDrafts = drafts?.length > 0; 48 + 49 + return ( 50 + <div class="sheet"> 51 + <header> 52 + <h2> 53 + Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} /> 54 + </h2> 55 + {hasDrafts && ( 56 + <div class="insignificant"> 57 + Looks like you have unsent drafts. Let's continue where you left 58 + off. 59 + </div> 60 + )} 61 + </header> 62 + <main> 63 + {hasDrafts ? ( 64 + <> 65 + <ul class="drafts-list"> 66 + {drafts.map((draft) => { 67 + const { updatedAt, key, draftStatus, replyTo } = draft; 68 + const currentYear = new Date().getFullYear(); 69 + const updatedAtDate = new Date(updatedAt); 70 + return ( 71 + <li key={updatedAt}> 72 + <div class="mini-draft-meta"> 73 + <b> 74 + <Icon icon={replyTo ? 'reply' : 'quill'} size="s" />{' '} 75 + <time> 76 + {!!replyTo && ( 77 + <> 78 + @{replyTo.account.acct} 79 + <br /> 80 + </> 81 + )} 82 + {Intl.DateTimeFormat('en', { 83 + // Show year if not current year 84 + year: 85 + updatedAtDate.getFullYear() === currentYear 86 + ? undefined 87 + : 'numeric', 88 + month: 'short', 89 + day: 'numeric', 90 + weekday: 'short', 91 + hour: 'numeric', 92 + minute: '2-digit', 93 + second: '2-digit', 94 + }).format(updatedAtDate)} 95 + </time> 96 + </b> 97 + <button 98 + type="button" 99 + class="small light" 100 + disabled={uiState === 'loading'} 101 + onClick={() => { 102 + (async () => { 103 + try { 104 + const yes = confirm( 105 + 'Are you sure you want to delete this draft?', 106 + ); 107 + if (yes) { 108 + await db.drafts.del(key); 109 + reload(); 110 + } 111 + } catch (e) { 112 + alert('Error deleting draft! Please try again.'); 113 + } 114 + })(); 115 + }} 116 + > 117 + Delete&hellip; 118 + </button> 119 + </div> 120 + <button 121 + type="button" 122 + disabled={uiState === 'loading'} 123 + class="draft-item" 124 + onClick={async () => { 125 + // console.log({ draftStatus }); 126 + let replyToStatus; 127 + if (replyTo) { 128 + setUIState('loading'); 129 + try { 130 + replyToStatus = await masto.v1.statuses.fetch( 131 + replyTo.id, 132 + ); 133 + } catch (e) { 134 + console.error(e); 135 + alert('Error fetching reply-to status!'); 136 + setUIState('default'); 137 + return; 138 + } 139 + setUIState('default'); 140 + } 141 + window.__COMPOSE__ = { 142 + draftStatus, 143 + replyToStatus, 144 + }; 145 + states.showCompose = true; 146 + states.showDrafts = false; 147 + }} 148 + > 149 + <MiniDraft draft={draft} /> 150 + </button> 151 + </li> 152 + ); 153 + })} 154 + </ul> 155 + <p> 156 + <button 157 + type="button" 158 + class="light danger" 159 + disabled={uiState === 'loading'} 160 + onClick={() => { 161 + (async () => { 162 + const yes = confirm( 163 + 'Are you sure you want to delete all drafts?', 164 + ); 165 + if (yes) { 166 + setUIState('loading'); 167 + try { 168 + await db.drafts.delMany( 169 + drafts.map((draft) => draft.key), 170 + ); 171 + setUIState('default'); 172 + reload(); 173 + } catch (e) { 174 + console.error(e); 175 + alert('Error deleting drafts! Please try again.'); 176 + setUIState('error'); 177 + } 178 + } 179 + })(); 180 + }} 181 + > 182 + Delete all drafts&hellip; 183 + </button> 184 + </p> 185 + </> 186 + ) : ( 187 + <p>No drafts found.</p> 188 + )} 189 + </main> 190 + </div> 191 + ); 192 + } 193 + 194 + function MiniDraft({ draft }) { 195 + const { draftStatus, replyTo } = draft; 196 + const { status, spoilerText, poll, mediaAttachments } = draftStatus; 197 + const hasPoll = poll?.options?.length > 0; 198 + const hasMedia = mediaAttachments?.length > 0; 199 + const hasPollOrMedia = hasPoll || hasMedia; 200 + const firstImageMedia = useMemo(() => { 201 + if (!hasMedia) return; 202 + const image = mediaAttachments.find((media) => /image/.test(media.type)); 203 + if (!image) return; 204 + const { file } = image; 205 + const objectURL = URL.createObjectURL(file); 206 + return objectURL; 207 + }, [hasMedia, mediaAttachments]); 208 + return ( 209 + <> 210 + <div class="mini-draft"> 211 + {hasPollOrMedia && ( 212 + <div 213 + class={`mini-draft-aside ${firstImageMedia ? 'has-image' : ''}`} 214 + style={ 215 + firstImageMedia 216 + ? { 217 + '--bg-image': `url(${firstImageMedia})`, 218 + } 219 + : {} 220 + } 221 + > 222 + {hasPoll && <Icon icon="poll" />} 223 + {hasMedia && ( 224 + <span> 225 + <Icon icon="attachment" />{' '} 226 + <small>{mediaAttachments?.length}</small> 227 + </span> 228 + )} 229 + </div> 230 + )} 231 + <div class="mini-draft-main"> 232 + {!!spoilerText && <div class="mini-draft-spoiler">{spoilerText}</div>} 233 + {!!status && <div class="mini-draft-status">{status}</div>} 234 + </div> 235 + </div> 236 + </> 237 + ); 238 + } 239 + 240 + export default Drafts;
+15
src/pages/home.jsx
··· 7 7 import Icon from '../components/icon'; 8 8 import Loader from '../components/loader'; 9 9 import Status from '../components/status'; 10 + import db from '../utils/db'; 10 11 import states, { saveStatus } from '../utils/states'; 12 + import { getCurrentAccountNS } from '../utils/store-utils'; 11 13 import useDebouncedCallback from '../utils/useDebouncedCallback'; 12 14 import useScroll from '../utils/useScroll'; 13 15 ··· 180 182 loadStatuses(true); 181 183 } 182 184 }, [reachTop]); 185 + 186 + useEffect(() => { 187 + (async () => { 188 + const keys = await db.drafts.keys(); 189 + if (keys.length) { 190 + const ns = getCurrentAccountNS(); 191 + const ownKeys = keys.filter((key) => key.startsWith(ns)); 192 + if (ownKeys.length) { 193 + states.showDrafts = true; 194 + } 195 + } 196 + })(); 197 + }, []); 183 198 184 199 return ( 185 200 <div
+13
src/pages/settings.jsx
··· 184 184 </label> 185 185 </div> 186 186 </form> 187 + <h2>Hidden features</h2> 188 + <p> 189 + <button 190 + type="button" 191 + class="light" 192 + onClick={() => { 193 + states.showDrafts = true; 194 + states.showSettings = false; 195 + }} 196 + > 197 + Unsent drafts 198 + </button> 199 + </p> 187 200 <h2>About</h2> 188 201 <p> 189 202 <a href="https://github.com/cheeaun/phanpy" target="_blank">
+28
src/utils/db.js
··· 1 + import { 2 + clear, 3 + createStore, 4 + del, 5 + delMany, 6 + get, 7 + getMany, 8 + keys, 9 + set, 10 + } from 'idb-keyval'; 11 + 12 + const draftsStore = createStore('drafts-db', 'drafts-store'); 13 + 14 + // Add additonal `draftsStore` parameter to all methods 15 + 16 + const drafts = { 17 + set: (key, val) => set(key, val, draftsStore), 18 + get: (key) => get(key, draftsStore), 19 + getMany: (keys) => getMany(keys, draftsStore), 20 + del: (key) => del(key, draftsStore), 21 + delMany: (keys) => delMany(keys, draftsStore), 22 + clear: () => clear(draftsStore), 23 + keys: () => keys(draftsStore), 24 + }; 25 + 26 + export default { 27 + drafts, 28 + };
+1
src/utils/states.js
··· 18 18 showCompose: false, 19 19 showSettings: false, 20 20 showAccount: false, 21 + showDrafts: false, 21 22 composeCharacterCount: 0, 22 23 }); 23 24 export default states;
+9
src/utils/store-utils.js
··· 7 7 accounts.find((a) => a.info.id === currentAccount) || accounts[0]; 8 8 return account; 9 9 } 10 + 11 + export function getCurrentAccountNS() { 12 + const account = getCurrentAccount(); 13 + const { 14 + instanceURL, 15 + info: { id }, 16 + } = account; 17 + return `${id}@${instanceURL}`; 18 + }
+22
src/utils/useInterval.js
··· 1 + // useInterval with Preact 2 + import { useEffect, useRef } from 'preact/hooks'; 3 + 4 + export default function useInterval(callback, delay) { 5 + const savedCallback = useRef(); 6 + 7 + // Remember the latest callback. 8 + useEffect(() => { 9 + savedCallback.current = callback; 10 + }, [callback]); 11 + 12 + // Set up the interval. 13 + useEffect(() => { 14 + function tick() { 15 + savedCallback.current(); 16 + } 17 + if (delay !== null) { 18 + let id = setInterval(tick, delay); 19 + return () => clearInterval(id); 20 + } 21 + }, [delay]); 22 + }