this repo has no description
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}