this repo has no description
at main 229 lines 7.0 kB view raw
1import { Trans, useLingui } from '@lingui/react/macro'; 2import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu'; 3import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; 4import { useHotkeys } from 'react-hotkeys-hook'; 5import { useLongPress } from 'use-long-press'; 6import { useSnapshot } from 'valtio'; 7 8import { api } from '../utils/api'; 9import niceDateTime from '../utils/nice-date-time'; 10import openCompose from '../utils/open-compose'; 11import openOSK from '../utils/open-osk'; 12import pmem from '../utils/pmem'; 13import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; 14import showCompose from '../utils/show-compose'; 15import states from '../utils/states'; 16import statusPeek from '../utils/status-peek'; 17import { getCurrentAccountID } from '../utils/store-utils'; 18 19import Icon from './icon'; 20import Loader from './loader'; 21import MenuLink from './menu-link'; 22import RelativeTime from './relative-time'; 23import SubMenu2 from './submenu2'; 24 25// Function to fetch the latest posts from the current user 26// Use pmem to memoize fetch results for 1 minute 27const fetchLatestPostsMemoized = pmem( 28 async (masto, currentAccountID) => { 29 const statusesIterator = masto.v1.accounts 30 .$select(currentAccountID) 31 .statuses.list({ 32 limit: 3, 33 exclude_replies: true, 34 exclude_reblogs: true, 35 }) 36 .values(); 37 const { value } = await statusesIterator.next(); 38 return value || []; 39 }, 40 { maxAge: 60000 }, 41); // 1 minute cache 42 43export default function ComposeButton() { 44 const { t } = useLingui(); 45 const snapStates = useSnapshot(states); 46 const { masto } = api(); 47 48 // Context menu state 49 const [menuOpen, setMenuOpen] = useState(false); 50 const [latestPosts, setLatestPosts] = useState([]); 51 const [loadingPosts, setLoadingPosts] = useState(false); 52 const buttonRef = useRef(null); 53 const menuRef = useRef(null); 54 55 const columnMode = snapStates.settings.shortcutsViewMode === 'multi-column'; 56 57 function handleButton(e) { 58 // useKey will even listen to Shift 59 // e.g. press Shift (without c) will trigger this 😱 60 if (e.key && e.key.toLowerCase() !== 'c') return; 61 62 if (snapStates.composerState.minimized) { 63 states.composerState.minimized = false; 64 openOSK(); 65 return; 66 } 67 68 const composeDataElements = document.querySelectorAll('data.compose-data'); 69 // If there's a lot of them, ignore 70 const opts = 71 !columnMode && composeDataElements.length === 1 72 ? JSON.parse(composeDataElements[0].value) 73 : undefined; 74 75 if (e.shiftKey) { 76 const newWin = openCompose(opts); 77 78 if (!newWin) { 79 states.showCompose = opts || true; 80 } 81 } else { 82 openOSK(); 83 states.showCompose = opts || true; 84 } 85 } 86 87 useHotkeys('c, shift+c', handleButton, { 88 useKey: true, 89 ignoreEventWhen: (e) => { 90 const hasModal = !!document.querySelector('#modal-container > *'); 91 return hasModal || e.metaKey || e.ctrlKey || e.altKey; 92 }, 93 }); 94 95 // Setup longpress handler to open context menu 96 const bindLongPress = useLongPress( 97 () => { 98 setMenuOpen(true); 99 }, 100 { 101 threshold: 600, 102 }, 103 ); 104 105 const fetchLatestPosts = useCallback(async () => { 106 try { 107 setLoadingPosts(true); 108 const currentAccountID = getCurrentAccountID(); 109 if (!currentAccountID) { 110 return; 111 } 112 const posts = await fetchLatestPostsMemoized(masto, currentAccountID); 113 setLatestPosts(posts); 114 } catch (error) { 115 } finally { 116 setLoadingPosts(false); 117 } 118 }, [masto]); 119 120 // Function to handle opening the compose window to reply to a post 121 const handleReplyToPost = useCallback((post) => { 122 showCompose({ 123 replyToStatus: post, 124 }); 125 setMenuOpen(false); 126 }, []); 127 128 useEffect(() => { 129 if (menuOpen) { 130 fetchLatestPosts(); 131 } 132 }, [fetchLatestPosts, menuOpen]); 133 134 return ( 135 <> 136 <button 137 ref={buttonRef} 138 type="button" 139 id="compose-button" 140 onClick={handleButton} 141 onContextMenu={(e) => { 142 e.preventDefault(); 143 setMenuOpen(true); 144 }} 145 {...bindLongPress()} 146 class={`${snapStates.composerState.minimized ? 'min' : ''} ${ 147 snapStates.composerState.publishing ? 'loading' : '' 148 } ${snapStates.composerState.publishingError ? 'error' : ''}`} 149 > 150 <Icon icon="quill" size="xl" alt={t`Compose`} /> 151 </button> 152 <ControlledMenu 153 ref={menuRef} 154 state={menuOpen ? 'open' : undefined} 155 anchorRef={buttonRef} 156 onClose={() => setMenuOpen(false)} 157 direction="top" 158 gap={8} // Add gap between menu and button 159 unmountOnClose 160 portal={{ 161 target: document.body, 162 }} 163 boundingBoxPadding={safeBoundingBoxPadding()} 164 containerProps={{ 165 style: { 166 zIndex: 19, 167 }, 168 onClick: () => { 169 menuRef.current?.closeMenu?.(); 170 }, 171 }} 172 submenuOpenDelay={600} 173 > 174 <MenuLink to="/sp"> 175 <Icon icon="schedule" />{' '} 176 <span> 177 <Trans>Scheduled Posts</Trans> 178 </span> 179 </MenuLink> 180 <MenuDivider /> 181 <SubMenu2 182 align="end" 183 direction="top" 184 shift={-8} 185 disabled={loadingPosts || latestPosts.length === 0} 186 label={ 187 <> 188 <Icon icon="comment" />{' '} 189 <span className="menu-grow"> 190 <Trans>Add to thread</Trans> 191 </span> 192 {loadingPosts ? '…' : <Icon icon="chevron-right" />} 193 </> 194 } 195 > 196 {latestPosts.length > 0 && 197 latestPosts.map((post) => { 198 const createdDate = new Date(post.createdAt); 199 const isWithinDay = Date.now() - createdDate.getTime() < 86400000; 200 201 return ( 202 <MenuItem key={post.id} onClick={() => handleReplyToPost(post)}> 203 <small> 204 <div class="menu-post-text">{statusPeek(post)}</div> 205 <span className="more-insignificant"> 206 {/* Show relative time if within a day */} 207 {isWithinDay && ( 208 <> 209 <RelativeTime datetime={createdDate} format="micro" />{' '} 210 {' '} 211 </> 212 )} 213 <time 214 className="created" 215 dateTime={createdDate.toISOString()} 216 title={createdDate.toLocaleString()} 217 > 218 {niceDateTime(post.createdAt)} 219 </time> 220 </span> 221 </small> 222 </MenuItem> 223 ); 224 })} 225 </SubMenu2> 226 </ControlledMenu> 227 </> 228 ); 229}