mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-03-13 09:28:50 +01:00
Extend at-mentions with dedicated UI
This commit is contained in:
parent
012b86d7ce
commit
2e0ef6494b
3 changed files with 379 additions and 16 deletions
src/components
|
@ -133,21 +133,18 @@ function AccountBlock({
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{showActivity && (
|
{showActivity && (
|
||||||
<>
|
<div class="account-block-stats">
|
||||||
<br />
|
Posts: {shortenNumber(statusesCount)}
|
||||||
<small class="last-status-at insignificant">
|
{!!lastStatusAt && (
|
||||||
Posts: {statusesCount}
|
<>
|
||||||
{!!lastStatusAt && (
|
{' '}
|
||||||
<>
|
· Last posted:{' '}
|
||||||
{' '}
|
{niceDateTime(lastStatusAt, {
|
||||||
· Last posted:{' '}
|
hideTime: true,
|
||||||
{niceDateTime(lastStatusAt, {
|
})}
|
||||||
hideTime: true,
|
</>
|
||||||
})}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</small>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{showStats && (
|
{showStats && (
|
||||||
<div class="account-block-stats">
|
<div class="account-block-stats">
|
||||||
|
|
|
@ -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 {
|
#custom-emojis-sheet {
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
max-height: 50dvh;
|
max-height: 50dvh;
|
||||||
|
|
|
@ -31,6 +31,7 @@ import localeMatch from '../utils/locale-match';
|
||||||
import localeCode2Text from '../utils/localeCode2Text';
|
import localeCode2Text from '../utils/localeCode2Text';
|
||||||
import openCompose from '../utils/open-compose';
|
import openCompose from '../utils/open-compose';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
|
import { fetchRelationships } from '../utils/relationships';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
|
@ -630,6 +631,7 @@ function Compose({
|
||||||
};
|
};
|
||||||
}, [mediaAttachments]);
|
}, [mediaAttachments]);
|
||||||
|
|
||||||
|
const [showMentionPicker, setShowMentionPicker] = useState(false);
|
||||||
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
|
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
|
||||||
const [showGIFPicker, setShowGIFPicker] = useState(false);
|
const [showGIFPicker, setShowGIFPicker] = useState(false);
|
||||||
|
|
||||||
|
@ -1166,6 +1168,10 @@ function Compose({
|
||||||
setShowEmoji2Picker({
|
setShowEmoji2Picker({
|
||||||
defaultSearchTerm: action?.defaultSearchTerm || null,
|
defaultSearchTerm: action?.defaultSearchTerm || null,
|
||||||
});
|
});
|
||||||
|
} else if (action?.name === 'mention') {
|
||||||
|
setShowMentionPicker({
|
||||||
|
defaultSearchTerm: action?.defaultSearchTerm || null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1304,6 +1310,16 @@ function Compose({
|
||||||
</button>{' '}
|
</button>{' '}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
{/* <button
|
||||||
|
type="button"
|
||||||
|
class="toolbar-button"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
setShowMentionPicker(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="at" />
|
||||||
|
</button> */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="toolbar-button"
|
class="toolbar-button"
|
||||||
|
@ -1377,6 +1393,55 @@ function Compose({
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 && (
|
{showEmoji2Picker && (
|
||||||
<Modal
|
<Modal
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
@ -1648,8 +1713,9 @@ const Textarea = forwardRef((props, ref) => {
|
||||||
</li>
|
</li>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
menu.innerHTML = html;
|
|
||||||
});
|
});
|
||||||
|
html += `<li role="option" data-value="" data-more="${text}">More…</li>`;
|
||||||
|
menu.innerHTML = html;
|
||||||
console.log('MENU', results, menu);
|
console.log('MENU', results, menu);
|
||||||
resolve({
|
resolve({
|
||||||
matched: results.length > 0,
|
matched: results.length > 0,
|
||||||
|
@ -1681,6 +1747,17 @@ const Textarea = forwardRef((props, ref) => {
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 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 {
|
} else {
|
||||||
e.detail.value = `${key}${value}`;
|
e.detail.value = `${key}${value}`;
|
||||||
}
|
}
|
||||||
|
@ -2345,6 +2422,226 @@ function removeNullUndefined(obj) {
|
||||||
return 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({
|
function CustomEmojisModal({
|
||||||
masto,
|
masto,
|
||||||
instance,
|
instance,
|
||||||
|
|
Loading…
Reference in a new issue