Extend at-mentions with dedicated UI

This commit is contained in:
Lim Chee Aun 2024-05-25 11:06:58 +08:00
parent 012b86d7ce
commit 2e0ef6494b
3 changed files with 379 additions and 16 deletions

View file

@ -133,10 +133,8 @@ function AccountBlock({
)}
</span>
{showActivity && (
<>
<br />
<small class="last-status-at insignificant">
Posts: {statusesCount}
<div class="account-block-stats">
Posts: {shortenNumber(statusesCount)}
{!!lastStatusAt && (
<>
{' '}
@ -146,8 +144,7 @@ function AccountBlock({
})}
</>
)}
</small>
</>
</div>
)}
{showStats && (
<div class="account-block-stats">

View file

@ -600,6 +600,75 @@
} */
}
#mention-sheet {
height: 50vh;
.accounts-list {
--list-gap: 1px;
list-style: none;
margin: 0;
padding: 8px 0;
display: flex;
flex-direction: column;
row-gap: var(--list-gap);
&.loading {
opacity: 0.5;
}
li {
display: flex;
flex-grow: 1;
/* align-items: center; */
margin: 0 -8px;
padding: 8px;
gap: 8px;
position: relative;
justify-content: space-between;
border-radius: 8px;
/* align-items: center; */
&:hover {
background-image: linear-gradient(
to right,
transparent 75%,
var(--link-bg-color)
);
}
&.selected {
background-image: linear-gradient(
to right,
var(--bg-faded-color) 75%,
var(--link-bg-color)
);
}
&:before {
content: '';
display: block;
border-top: var(--hairline-width) solid var(--divider-color);
position: absolute;
bottom: 0;
left: 58px;
right: 0;
}
&:has(+ li:is(.selected, :hover)):before,
&:is(.selected, :hover):before {
opacity: 0;
}
> button {
border-radius: 4px;
&:hover {
outline: 2px solid var(--button-bg-blur-color);
}
}
}
}
}
#custom-emojis-sheet {
max-height: 50vh;
max-height: 50dvh;

View file

@ -31,6 +31,7 @@ import localeMatch from '../utils/locale-match';
import localeCode2Text from '../utils/localeCode2Text';
import openCompose from '../utils/open-compose';
import pmem from '../utils/pmem';
import { fetchRelationships } from '../utils/relationships';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states';
@ -630,6 +631,7 @@ function Compose({
};
}, [mediaAttachments]);
const [showMentionPicker, setShowMentionPicker] = useState(false);
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
const [showGIFPicker, setShowGIFPicker] = useState(false);
@ -1166,6 +1168,10 @@ function Compose({
setShowEmoji2Picker({
defaultSearchTerm: action?.defaultSearchTerm || null,
});
} else if (action?.name === 'mention') {
setShowMentionPicker({
defaultSearchTerm: action?.defaultSearchTerm || null,
});
}
}}
/>
@ -1304,6 +1310,16 @@ function Compose({
</button>{' '}
</>
))}
{/* <button
type="button"
class="toolbar-button"
disabled={uiState === 'loading'}
onClick={() => {
setShowMentionPicker(true);
}}
>
<Icon icon="at" />
</button> */}
<button
type="button"
class="toolbar-button"
@ -1377,6 +1393,55 @@ function Compose({
</div>
</form>
</div>
{showMentionPicker && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowMentionPicker(false);
}
}}
>
<MentionModal
masto={masto}
instance={instance}
onClose={() => {
setShowMentionPicker(false);
}}
defaultSearchTerm={showMentionPicker?.defaultSearchTerm}
onSelect={(socialAddress) => {
const textarea = textareaRef.current;
if (!textarea) return;
const { selectionStart, selectionEnd } = textarea;
const text = textarea.value;
const textBeforeMention = text.slice(0, selectionStart);
const spaceBeforeMention = textBeforeMention
? /[\s\t\n\r]$/.test(textBeforeMention)
? ''
: ' '
: '';
const textAfterMention = text.slice(selectionEnd);
const spaceAfterMention = /^[\s\t\n\r]/.test(textAfterMention)
? ''
: ' ';
const newText =
textBeforeMention +
spaceBeforeMention +
'@' +
socialAddress +
spaceAfterMention +
textAfterMention;
textarea.value = newText;
textarea.selectionStart = textarea.selectionEnd =
selectionEnd +
1 +
socialAddress.length +
spaceAfterMention.length;
textarea.focus();
textarea.dispatchEvent(new Event('input'));
}}
/>
</Modal>
)}
{showEmoji2Picker && (
<Modal
onClick={(e) => {
@ -1648,8 +1713,9 @@ const Textarea = forwardRef((props, ref) => {
</li>
`;
}
menu.innerHTML = html;
});
html += `<li role="option" data-value="" data-more="${text}">More…</li>`;
menu.innerHTML = html;
console.log('MENU', results, menu);
resolve({
matched: results.length > 0,
@ -1681,6 +1747,17 @@ const Textarea = forwardRef((props, ref) => {
});
}, 300);
}
} else if (key === '@') {
e.detail.value = value ? `@${value} ` : ''; // zero-width space
if (more) {
e.detail.continue = true;
setTimeout(() => {
onTrigger?.({
name: 'mention',
defaultSearchTerm: more,
});
}, 300);
}
} else {
e.detail.value = `${key}${value}`;
}
@ -2345,6 +2422,226 @@ function removeNullUndefined(obj) {
return obj;
}
function MentionModal({
onClose = () => {},
onSelect = () => {},
defaultSearchTerm,
}) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [accounts, setAccounts] = useState([]);
const [relationshipsMap, setRelationshipsMap] = useState({});
const [selectedIndex, setSelectedIndex] = useState(0);
const loadRelationships = async (accounts) => {
if (!accounts?.length) return;
const relationships = await fetchRelationships(accounts, relationshipsMap);
if (relationships) {
setRelationshipsMap({
...relationshipsMap,
...relationships,
});
}
};
const loadAccounts = (term) => {
if (!term) return;
setUIState('loading');
(async () => {
try {
const accounts = await masto.v1.accounts.search.list({
q: term,
limit: 40,
resolve: false,
});
setAccounts(accounts);
loadRelationships(accounts);
setUIState('default');
} catch (e) {
setUIState('error');
console.error(e);
}
})();
};
const debouncedLoadAccounts = useDebouncedCallback(loadAccounts, 1000);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
const inputRef = useRef();
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
// Put cursor at the end
if (inputRef.current.value) {
inputRef.current.selectionStart = inputRef.current.value.length;
inputRef.current.selectionEnd = inputRef.current.value.length;
}
}
}, []);
useEffect(() => {
if (defaultSearchTerm) {
loadAccounts(defaultSearchTerm);
}
}, [defaultSearchTerm]);
const selectAccount = (account) => {
const socialAddress = account.acct;
onSelect(socialAddress);
onClose();
};
useHotkeys(
'enter',
() => {
const selectedAccount = accounts[selectedIndex];
if (selectedAccount) {
selectAccount(selectedAccount);
}
},
{
preventDefault: true,
enableOnFormTags: ['input'],
},
);
const listRef = useRef();
useHotkeys(
'down',
() => {
if (selectedIndex < accounts.length - 1) {
setSelectedIndex(selectedIndex + 1);
} else {
setSelectedIndex(0);
}
setTimeout(() => {
const selectedItem = listRef.current.querySelector('.selected');
if (selectedItem) {
selectedItem.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
}
}, 1);
},
{
preventDefault: true,
enableOnFormTags: ['input'],
},
);
useHotkeys(
'up',
() => {
if (selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
} else {
setSelectedIndex(accounts.length - 1);
}
setTimeout(() => {
const selectedItem = listRef.current.querySelector('.selected');
if (selectedItem) {
selectedItem.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
}
}, 1);
},
{
preventDefault: true,
enableOnFormTags: ['input'],
},
);
return (
<div id="mention-sheet" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<form
onSubmit={(e) => {
e.preventDefault();
debouncedLoadAccounts.flush?.();
// const searchTerm = inputRef.current.value;
// debouncedLoadAccounts(searchTerm);
}}
>
<input
ref={inputRef}
required
type="search"
class="block"
placeholder="Search accounts"
onInput={(e) => {
const { value } = e.target;
debouncedLoadAccounts(value);
}}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellCheck="false"
dir="auto"
defaultValue={defaultSearchTerm || ''}
/>
</form>
</header>
<main>
{accounts?.length > 0 ? (
<ul
ref={listRef}
class={`accounts-list ${uiState === 'loading' ? 'loading' : ''}`}
>
{accounts.map((account, i) => {
const relationship = relationshipsMap[account.id];
return (
<li
key={account.id}
class={i === selectedIndex ? 'selected' : ''}
>
<AccountBlock
avatarSize="xxl"
account={account}
relationship={relationship}
showStats
showActivity
/>
<button
type="button"
class="plain2"
onClick={() => {
selectAccount(account);
}}
>
<Icon icon="plus" size="xl" />
</button>
</li>
);
})}
</ul>
) : uiState === 'loading' ? (
<div class="ui-state">
<Loader abrupt />
</div>
) : uiState === 'error' ? (
<div class="ui-state">
<p>Error loading accounts</p>
</div>
) : null}
</main>
</div>
);
}
function CustomEmojisModal({
masto,
instance,