New feature: custom emoji picker

This commit is contained in:
Lim Chee Aun 2023-03-24 22:30:05 +08:00
parent f623ccd856
commit 2a85ad2f45
3 changed files with 291 additions and 58 deletions

View file

@ -523,3 +523,43 @@
height: auto; height: auto;
} }
} }
#custom-emojis-sheet {
max-height: 50vh;
max-height: 50dvh;
}
#custom-emojis-sheet main {
mask-image: none;
}
#custom-emojis-sheet .custom-emojis-list .section-header {
font-size: 80%;
text-transform: uppercase;
color: var(--text-insignificant-color);
padding: 8px 0 4px;
position: sticky;
top: 0;
background-color: var(--bg-blur-color);
backdrop-filter: blur(8px);
}
#custom-emojis-sheet .custom-emojis-list section {
display: flex;
flex-wrap: wrap;
}
#custom-emojis-sheet .custom-emojis-list button {
border-radius: 8px;
background-image: radial-gradient(
closest-side,
var(--img-bg-color),
transparent
);
}
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
filter: none;
background-color: var(--bg-faded-color);
}
#custom-emojis-sheet .custom-emojis-list button img {
transition: transform 0.1s ease-out;
}
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
transform: scale(1.5);
}

View file

@ -4,7 +4,7 @@ import { match } from '@formatjs/intl-localematcher';
import '@github/text-expander-element'; import '@github/text-expander-element';
import equal from 'fast-deep-equal'; import equal from 'fast-deep-equal';
import { forwardRef } from 'preact/compat'; import { forwardRef } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import stringLength from 'string-length'; import stringLength from 'string-length';
import { uid } from 'uid/single'; import { uid } from 'uid/single';
@ -497,6 +497,8 @@ function Compose({
}; };
}, [mediaAttachments]); }, [mediaAttachments]);
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
return ( return (
<div id="compose-container-outer"> <div id="compose-container-outer">
<div id="compose-container" class={standalone ? 'standalone' : ''}> <div id="compose-container" class={standalone ? 'standalone' : ''}>
@ -982,65 +984,77 @@ function Compose({
justifyContent: 'flex-end', justifyContent: 'flex-end',
}} }}
> >
<label class="toolbar-button"> <span>
<input <label class="toolbar-button">
type="file" <input
accept={supportedMimeTypes.join(',')} type="file"
multiple={mediaAttachments.length < maxMediaAttachments - 1} accept={supportedMimeTypes.join(',')}
disabled={ multiple={mediaAttachments.length < maxMediaAttachments - 1}
uiState === 'loading' || disabled={
mediaAttachments.length >= maxMediaAttachments || uiState === 'loading' ||
!!poll mediaAttachments.length >= maxMediaAttachments ||
} !!poll
onChange={(e) => {
const files = e.target.files;
if (!files) return;
const mediaFiles = Array.from(files).map((file) => ({
file,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
id: null, // indicate uploaded state
description: null,
}));
console.log('MEDIA ATTACHMENTS', files, mediaFiles);
// Validate max media attachments
if (
mediaAttachments.length + mediaFiles.length >
maxMediaAttachments
) {
alert(
`You can only attach up to ${maxMediaAttachments} files.`,
);
} else {
setMediaAttachments((attachments) => {
return attachments.concat(mediaFiles);
});
} }
// Reset onChange={(e) => {
e.target.value = ''; const files = e.target.files;
if (!files) return;
const mediaFiles = Array.from(files).map((file) => ({
file,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
id: null, // indicate uploaded state
description: null,
}));
console.log('MEDIA ATTACHMENTS', files, mediaFiles);
// Validate max media attachments
if (
mediaAttachments.length + mediaFiles.length >
maxMediaAttachments
) {
alert(
`You can only attach up to ${maxMediaAttachments} files.`,
);
} else {
setMediaAttachments((attachments) => {
return attachments.concat(mediaFiles);
});
}
// Reset
e.target.value = '';
}}
/>
<Icon icon="attachment" />
</label>{' '}
<button
type="button"
class="toolbar-button"
disabled={
uiState === 'loading' || !!poll || !!mediaAttachments.length
}
onClick={() => {
setPoll({
options: ['', ''],
expiresIn: 24 * 60 * 60, // 1 day
multiple: false,
});
}} }}
/> >
<Icon icon="attachment" /> <Icon icon="poll" alt="Add poll" />
</label>{' '} </button>{' '}
<button <button
type="button" type="button"
class="toolbar-button" class="toolbar-button"
disabled={ disabled={uiState === 'loading'}
uiState === 'loading' || !!poll || !!mediaAttachments.length onClick={() => {
} setShowEmoji2Picker(true);
onClick={() => { }}
setPoll({ >
options: ['', ''], <Icon icon="emoji2" />
expiresIn: 24 * 60 * 60, // 1 day </button>
multiple: false, </span>
});
}}
>
<Icon icon="poll" alt="Add poll" />
</button>{' '}
<div class="spacer" /> <div class="spacer" />
{uiState === 'loading' ? ( {uiState === 'loading' ? (
<Loader abrupt /> <Loader abrupt />
@ -1089,6 +1103,40 @@ function Compose({
</div> </div>
</form> </form>
</div> </div>
{showEmoji2Picker && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEmoji2Picker(false);
}
}}
>
<CustomEmojisModal
masto={masto}
instance={instance}
onClose={() => {
setShowEmoji2Picker(false);
}}
onSelect={(emoji) => {
const emojiWithSpace = ` ${emoji} `;
const textarea = textareaRef.current;
if (!textarea) return;
const { selectionStart, selectionEnd } = textarea;
const text = textarea.value;
const newText =
text.slice(0, selectionStart) +
emojiWithSpace +
text.slice(selectionEnd);
textarea.value = newText;
textarea.selectionStart = textarea.selectionEnd =
selectionEnd + emojiWithSpace.length;
textarea.focus();
textarea.dispatchEvent(new Event('input'));
}}
/>
</Modal>
)}
</div> </div>
); );
} }
@ -1287,6 +1335,7 @@ const Textarea = forwardRef((props, ref) => {
value={text} value={text}
onInput={(e) => { onInput={(e) => {
const { scrollHeight, offsetHeight, clientHeight, value } = e.target; const { scrollHeight, offsetHeight, clientHeight, value } = e.target;
console.log('textarea input', value);
setText(value); setText(value);
const offset = offsetHeight - clientHeight; const offset = offsetHeight - clientHeight;
e.target.style.height = value ? scrollHeight + offset + 'px' : null; e.target.style.height = value ? scrollHeight + offset + 'px' : null;
@ -1626,4 +1675,147 @@ function removeNullUndefined(obj) {
return obj; return obj;
} }
function CustomEmojisModal({
masto,
instance,
onClose = () => {},
onSelect = () => {},
}) {
const [uiState, setUIState] = useState('default');
const customEmojisList = useRef([]);
const [customEmojis, setCustomEmojis] = useState({});
const recentlyUsedCustomEmojis = useMemo(
() => store.account.get('recentlyUsedCustomEmojis') || [],
);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const emojis = await masto.v1.customEmojis.list();
// Group emojis by category
const emojisCat = {
'--recent--': recentlyUsedCustomEmojis.filter((emoji) =>
emojis.find((e) => e.shortcode === emoji.shortcode),
),
};
const othersCat = [];
emojis.forEach((emoji) => {
if (!emoji.visibleInPicker) return;
customEmojisList.current?.push?.(emoji);
if (!emoji.category) {
othersCat.push(emoji);
return;
}
if (!emojisCat[emoji.category]) {
emojisCat[emoji.category] = [];
}
emojisCat[emoji.category].push(emoji);
});
if (othersCat.length) {
emojisCat['--others--'] = othersCat;
}
setCustomEmojis(emojisCat);
setUIState('default');
} catch (e) {
setUIState('error');
console.error(e);
}
})();
}, []);
return (
<div id="custom-emojis-sheet" class="sheet">
<header>
<b>Custom emojis</b>{' '}
{uiState === 'loading' ? (
<Loader />
) : (
<small class="insignificant"> {instance}</small>
)}
</header>
<main>
<div class="custom-emojis-list">
{uiState === 'error' && (
<div class="ui-state">
<p>Error loading custom emojis</p>
</div>
)}
{uiState === 'default' &&
Object.entries(customEmojis).map(
([category, emojis]) =>
!!emojis?.length && (
<>
<div class="section-header">
{{
'--recent--': 'Recently used',
'--others--': 'Others',
}[category] || category}
</div>
<section>
{emojis.map((emoji) => (
<button
key={emoji}
type="button"
class="plain4"
onClick={() => {
onClose();
requestAnimationFrame(() => {
onSelect(`:${emoji.shortcode}:`);
});
let recentlyUsedCustomEmojis =
store.account.get('recentlyUsedCustomEmojis') ||
[];
const recentlyUsedEmojiIndex =
recentlyUsedCustomEmojis.findIndex(
(e) => e.shortcode === emoji.shortcode,
);
if (recentlyUsedEmojiIndex !== -1) {
// Move emoji to index 0
recentlyUsedCustomEmojis.splice(
recentlyUsedEmojiIndex,
1,
);
recentlyUsedCustomEmojis.unshift(emoji);
} else {
recentlyUsedCustomEmojis.unshift(emoji);
// Remove unavailable ones
recentlyUsedCustomEmojis =
recentlyUsedCustomEmojis.filter((e) =>
customEmojisList.current?.find?.(
(emoji) => emoji.shortcode === e.shortcode,
),
);
// Limit to 10
recentlyUsedCustomEmojis =
recentlyUsedCustomEmojis.slice(0, 10);
}
// Store back
store.account.set(
'recentlyUsedCustomEmojis',
recentlyUsedCustomEmojis,
);
}}
title={`:${emoji.shortcode}:`}
>
<img
src={emoji.url || emoji.staticUrl}
alt={emoji.shortcode}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</button>
))}
</section>
</>
),
)}
</div>
</main>
</div>
);
}
export default Compose; export default Compose;

View file

@ -73,6 +73,7 @@ const ICONS = {
flag: 'mingcute:flag-4-line', flag: 'mingcute:flag-4-line',
time: 'mingcute:time-line', time: 'mingcute:time-line',
refresh: 'mingcute:refresh-2-line', refresh: 'mingcute:refresh-2-line',
emoji2: 'mingcute:emoji-2-line',
}; };
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js'); const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');