this repo has no description
0
fork

Configure Feed

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

Extend at-mentions with dedicated UI

+379 -16
+12 -15
src/components/account-block.jsx
··· 133 133 )} 134 134 </span> 135 135 {showActivity && ( 136 - <> 137 - <br /> 138 - <small class="last-status-at insignificant"> 139 - Posts: {statusesCount} 140 - {!!lastStatusAt && ( 141 - <> 142 - {' '} 143 - &middot; Last posted:{' '} 144 - {niceDateTime(lastStatusAt, { 145 - hideTime: true, 146 - })} 147 - </> 148 - )} 149 - </small> 150 - </> 136 + <div class="account-block-stats"> 137 + Posts: {shortenNumber(statusesCount)} 138 + {!!lastStatusAt && ( 139 + <> 140 + {' '} 141 + &middot; Last posted:{' '} 142 + {niceDateTime(lastStatusAt, { 143 + hideTime: true, 144 + })} 145 + </> 146 + )} 147 + </div> 151 148 )} 152 149 {showStats && ( 153 150 <div class="account-block-stats">
+69
src/components/compose.css
··· 600 600 } */ 601 601 } 602 602 603 + #mention-sheet { 604 + height: 50vh; 605 + 606 + .accounts-list { 607 + --list-gap: 1px; 608 + list-style: none; 609 + margin: 0; 610 + padding: 8px 0; 611 + display: flex; 612 + flex-direction: column; 613 + row-gap: var(--list-gap); 614 + 615 + &.loading { 616 + opacity: 0.5; 617 + } 618 + 619 + li { 620 + display: flex; 621 + flex-grow: 1; 622 + /* align-items: center; */ 623 + margin: 0 -8px; 624 + padding: 8px; 625 + gap: 8px; 626 + position: relative; 627 + justify-content: space-between; 628 + border-radius: 8px; 629 + /* align-items: center; */ 630 + 631 + &:hover { 632 + background-image: linear-gradient( 633 + to right, 634 + transparent 75%, 635 + var(--link-bg-color) 636 + ); 637 + } 638 + 639 + &.selected { 640 + background-image: linear-gradient( 641 + to right, 642 + var(--bg-faded-color) 75%, 643 + var(--link-bg-color) 644 + ); 645 + } 646 + 647 + &:before { 648 + content: ''; 649 + display: block; 650 + border-top: var(--hairline-width) solid var(--divider-color); 651 + position: absolute; 652 + bottom: 0; 653 + left: 58px; 654 + right: 0; 655 + } 656 + 657 + &:has(+ li:is(.selected, :hover)):before, 658 + &:is(.selected, :hover):before { 659 + opacity: 0; 660 + } 661 + 662 + > button { 663 + border-radius: 4px; 664 + &:hover { 665 + outline: 2px solid var(--button-bg-blur-color); 666 + } 667 + } 668 + } 669 + } 670 + } 671 + 603 672 #custom-emojis-sheet { 604 673 max-height: 50vh; 605 674 max-height: 50dvh;
+298 -1
src/components/compose.jsx
··· 31 31 import localeCode2Text from '../utils/localeCode2Text'; 32 32 import openCompose from '../utils/open-compose'; 33 33 import pmem from '../utils/pmem'; 34 + import { fetchRelationships } from '../utils/relationships'; 34 35 import shortenNumber from '../utils/shorten-number'; 35 36 import showToast from '../utils/show-toast'; 36 37 import states, { saveStatus } from '../utils/states'; ··· 630 631 }; 631 632 }, [mediaAttachments]); 632 633 634 + const [showMentionPicker, setShowMentionPicker] = useState(false); 633 635 const [showEmoji2Picker, setShowEmoji2Picker] = useState(false); 634 636 const [showGIFPicker, setShowGIFPicker] = useState(false); 635 637 ··· 1166 1168 setShowEmoji2Picker({ 1167 1169 defaultSearchTerm: action?.defaultSearchTerm || null, 1168 1170 }); 1171 + } else if (action?.name === 'mention') { 1172 + setShowMentionPicker({ 1173 + defaultSearchTerm: action?.defaultSearchTerm || null, 1174 + }); 1169 1175 } 1170 1176 }} 1171 1177 /> ··· 1304 1310 </button>{' '} 1305 1311 </> 1306 1312 ))} 1313 + {/* <button 1314 + type="button" 1315 + class="toolbar-button" 1316 + disabled={uiState === 'loading'} 1317 + onClick={() => { 1318 + setShowMentionPicker(true); 1319 + }} 1320 + > 1321 + <Icon icon="at" /> 1322 + </button> */} 1307 1323 <button 1308 1324 type="button" 1309 1325 class="toolbar-button" ··· 1377 1393 </div> 1378 1394 </form> 1379 1395 </div> 1396 + {showMentionPicker && ( 1397 + <Modal 1398 + onClick={(e) => { 1399 + if (e.target === e.currentTarget) { 1400 + setShowMentionPicker(false); 1401 + } 1402 + }} 1403 + > 1404 + <MentionModal 1405 + masto={masto} 1406 + instance={instance} 1407 + onClose={() => { 1408 + setShowMentionPicker(false); 1409 + }} 1410 + defaultSearchTerm={showMentionPicker?.defaultSearchTerm} 1411 + onSelect={(socialAddress) => { 1412 + const textarea = textareaRef.current; 1413 + if (!textarea) return; 1414 + const { selectionStart, selectionEnd } = textarea; 1415 + const text = textarea.value; 1416 + const textBeforeMention = text.slice(0, selectionStart); 1417 + const spaceBeforeMention = textBeforeMention 1418 + ? /[\s\t\n\r]$/.test(textBeforeMention) 1419 + ? '' 1420 + : ' ' 1421 + : ''; 1422 + const textAfterMention = text.slice(selectionEnd); 1423 + const spaceAfterMention = /^[\s\t\n\r]/.test(textAfterMention) 1424 + ? '' 1425 + : ' '; 1426 + const newText = 1427 + textBeforeMention + 1428 + spaceBeforeMention + 1429 + '@' + 1430 + socialAddress + 1431 + spaceAfterMention + 1432 + textAfterMention; 1433 + textarea.value = newText; 1434 + textarea.selectionStart = textarea.selectionEnd = 1435 + selectionEnd + 1436 + 1 + 1437 + socialAddress.length + 1438 + spaceAfterMention.length; 1439 + textarea.focus(); 1440 + textarea.dispatchEvent(new Event('input')); 1441 + }} 1442 + /> 1443 + </Modal> 1444 + )} 1380 1445 {showEmoji2Picker && ( 1381 1446 <Modal 1382 1447 onClick={(e) => { ··· 1648 1713 </li> 1649 1714 `; 1650 1715 } 1651 - menu.innerHTML = html; 1652 1716 }); 1717 + html += `<li role="option" data-value="" data-more="${text}">More…</li>`; 1718 + menu.innerHTML = html; 1653 1719 console.log('MENU', results, menu); 1654 1720 resolve({ 1655 1721 matched: results.length > 0, ··· 1681 1747 }); 1682 1748 }, 300); 1683 1749 } 1750 + } else if (key === '@') { 1751 + e.detail.value = value ? `@${value} ` : '​'; // zero-width space 1752 + if (more) { 1753 + e.detail.continue = true; 1754 + setTimeout(() => { 1755 + onTrigger?.({ 1756 + name: 'mention', 1757 + defaultSearchTerm: more, 1758 + }); 1759 + }, 300); 1760 + } 1684 1761 } else { 1685 1762 e.detail.value = `${key}${value}`; 1686 1763 } ··· 2343 2420 } 2344 2421 } 2345 2422 return obj; 2423 + } 2424 + 2425 + function MentionModal({ 2426 + onClose = () => {}, 2427 + onSelect = () => {}, 2428 + defaultSearchTerm, 2429 + }) { 2430 + const { masto } = api(); 2431 + const [uiState, setUIState] = useState('default'); 2432 + const [accounts, setAccounts] = useState([]); 2433 + const [relationshipsMap, setRelationshipsMap] = useState({}); 2434 + 2435 + const [selectedIndex, setSelectedIndex] = useState(0); 2436 + 2437 + const loadRelationships = async (accounts) => { 2438 + if (!accounts?.length) return; 2439 + const relationships = await fetchRelationships(accounts, relationshipsMap); 2440 + if (relationships) { 2441 + setRelationshipsMap({ 2442 + ...relationshipsMap, 2443 + ...relationships, 2444 + }); 2445 + } 2446 + }; 2447 + 2448 + const loadAccounts = (term) => { 2449 + if (!term) return; 2450 + setUIState('loading'); 2451 + (async () => { 2452 + try { 2453 + const accounts = await masto.v1.accounts.search.list({ 2454 + q: term, 2455 + limit: 40, 2456 + resolve: false, 2457 + }); 2458 + setAccounts(accounts); 2459 + loadRelationships(accounts); 2460 + setUIState('default'); 2461 + } catch (e) { 2462 + setUIState('error'); 2463 + console.error(e); 2464 + } 2465 + })(); 2466 + }; 2467 + 2468 + const debouncedLoadAccounts = useDebouncedCallback(loadAccounts, 1000); 2469 + 2470 + useEffect(() => { 2471 + loadAccounts(); 2472 + }, [loadAccounts]); 2473 + 2474 + const inputRef = useRef(); 2475 + useEffect(() => { 2476 + if (inputRef.current) { 2477 + inputRef.current.focus(); 2478 + // Put cursor at the end 2479 + if (inputRef.current.value) { 2480 + inputRef.current.selectionStart = inputRef.current.value.length; 2481 + inputRef.current.selectionEnd = inputRef.current.value.length; 2482 + } 2483 + } 2484 + }, []); 2485 + 2486 + useEffect(() => { 2487 + if (defaultSearchTerm) { 2488 + loadAccounts(defaultSearchTerm); 2489 + } 2490 + }, [defaultSearchTerm]); 2491 + 2492 + const selectAccount = (account) => { 2493 + const socialAddress = account.acct; 2494 + onSelect(socialAddress); 2495 + onClose(); 2496 + }; 2497 + 2498 + useHotkeys( 2499 + 'enter', 2500 + () => { 2501 + const selectedAccount = accounts[selectedIndex]; 2502 + if (selectedAccount) { 2503 + selectAccount(selectedAccount); 2504 + } 2505 + }, 2506 + { 2507 + preventDefault: true, 2508 + enableOnFormTags: ['input'], 2509 + }, 2510 + ); 2511 + 2512 + const listRef = useRef(); 2513 + useHotkeys( 2514 + 'down', 2515 + () => { 2516 + if (selectedIndex < accounts.length - 1) { 2517 + setSelectedIndex(selectedIndex + 1); 2518 + } else { 2519 + setSelectedIndex(0); 2520 + } 2521 + setTimeout(() => { 2522 + const selectedItem = listRef.current.querySelector('.selected'); 2523 + if (selectedItem) { 2524 + selectedItem.scrollIntoView({ 2525 + behavior: 'smooth', 2526 + block: 'center', 2527 + inline: 'center', 2528 + }); 2529 + } 2530 + }, 1); 2531 + }, 2532 + { 2533 + preventDefault: true, 2534 + enableOnFormTags: ['input'], 2535 + }, 2536 + ); 2537 + 2538 + useHotkeys( 2539 + 'up', 2540 + () => { 2541 + if (selectedIndex > 0) { 2542 + setSelectedIndex(selectedIndex - 1); 2543 + } else { 2544 + setSelectedIndex(accounts.length - 1); 2545 + } 2546 + setTimeout(() => { 2547 + const selectedItem = listRef.current.querySelector('.selected'); 2548 + if (selectedItem) { 2549 + selectedItem.scrollIntoView({ 2550 + behavior: 'smooth', 2551 + block: 'center', 2552 + inline: 'center', 2553 + }); 2554 + } 2555 + }, 1); 2556 + }, 2557 + { 2558 + preventDefault: true, 2559 + enableOnFormTags: ['input'], 2560 + }, 2561 + ); 2562 + 2563 + return ( 2564 + <div id="mention-sheet" class="sheet"> 2565 + {!!onClose && ( 2566 + <button type="button" class="sheet-close" onClick={onClose}> 2567 + <Icon icon="x" /> 2568 + </button> 2569 + )} 2570 + <header> 2571 + <form 2572 + onSubmit={(e) => { 2573 + e.preventDefault(); 2574 + debouncedLoadAccounts.flush?.(); 2575 + // const searchTerm = inputRef.current.value; 2576 + // debouncedLoadAccounts(searchTerm); 2577 + }} 2578 + > 2579 + <input 2580 + ref={inputRef} 2581 + required 2582 + type="search" 2583 + class="block" 2584 + placeholder="Search accounts" 2585 + onInput={(e) => { 2586 + const { value } = e.target; 2587 + debouncedLoadAccounts(value); 2588 + }} 2589 + autocomplete="off" 2590 + autocorrect="off" 2591 + autocapitalize="off" 2592 + spellCheck="false" 2593 + dir="auto" 2594 + defaultValue={defaultSearchTerm || ''} 2595 + /> 2596 + </form> 2597 + </header> 2598 + <main> 2599 + {accounts?.length > 0 ? ( 2600 + <ul 2601 + ref={listRef} 2602 + class={`accounts-list ${uiState === 'loading' ? 'loading' : ''}`} 2603 + > 2604 + {accounts.map((account, i) => { 2605 + const relationship = relationshipsMap[account.id]; 2606 + return ( 2607 + <li 2608 + key={account.id} 2609 + class={i === selectedIndex ? 'selected' : ''} 2610 + > 2611 + <AccountBlock 2612 + avatarSize="xxl" 2613 + account={account} 2614 + relationship={relationship} 2615 + showStats 2616 + showActivity 2617 + /> 2618 + <button 2619 + type="button" 2620 + class="plain2" 2621 + onClick={() => { 2622 + selectAccount(account); 2623 + }} 2624 + > 2625 + <Icon icon="plus" size="xl" /> 2626 + </button> 2627 + </li> 2628 + ); 2629 + })} 2630 + </ul> 2631 + ) : uiState === 'loading' ? ( 2632 + <div class="ui-state"> 2633 + <Loader abrupt /> 2634 + </div> 2635 + ) : uiState === 'error' ? ( 2636 + <div class="ui-state"> 2637 + <p>Error loading accounts</p> 2638 + </div> 2639 + ) : null} 2640 + </main> 2641 + </div> 2642 + ); 2346 2643 } 2347 2644 2348 2645 function CustomEmojisModal({