mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-03-23 14:13:21 +01:00
New feature: custom emoji picker
This commit is contained in:
parent
f623ccd856
commit
2a85ad2f45
3 changed files with 291 additions and 58 deletions
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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');
|
||||||
|
|
Loading…
Add table
Reference in a new issue