An emoji picker that offers everything the account can send
The window half of #205. The picker asked what the room on screen owns, which is the wrong question on every service moho speaks: Discord Nitro reaches every guild's emoji from anywhere, and a Kick subscription reaches that channel's emotes across the whole site. It now asks the daemon what this account can send and where, and draws a section per source with a strip of icons above to move between them. The strip scrolls sideways rather than wrapping - an account in thirty guilds would otherwise push the emoji themselves off the bottom of a 360px picker with a block of tabs. Measured rather than assumed: seventeen tabs stay on one 36px row and the picker does not grow. Sources that reach this conversation come first. The rest are still shown, because that is how somebody learns what Nitro or a subscription would buy, but they should not sit between somebody and the emoji they can use. Two different refusals are told apart in the title, since they need different answers: `locked` is "you have not paid for this", not usable here is "you have, and not in this room". Nothing in here knows a service by name. A cell draws `url` where the daemon gave one and falls back to Discord's CDN rule otherwise, and what gets inserted is the entry's id, which each backend already put in the form its own service reads. Adding a service is adding a source. The old per-buffer list is kept for a daemon too old to answer, which is what the picker showed before it asked. Closes #205. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 197cfd3668...2a0e2a95a4
@@ -5435,6 +5435,67 @@ video.lightbox-media {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/* The strip of section icons. Horizontal and scrollable rather than wrapping:
|
||||
an account in thirty guilds would otherwise push the emoji themselves off
|
||||
the bottom of the picker with a block of tabs. */
|
||||
.emoji-jump {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
padding: 4px 2px;
|
||||
border-bottom: 1px solid var(--outline-variant);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.emoji-jump::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.emoji-jump-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emoji-jump-tab:hover {
|
||||
background: var(--surface-container-highest);
|
||||
}
|
||||
|
||||
.emoji-jump-tab img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* A source whose emoji cannot be sent here is still reachable - it is how
|
||||
somebody learns what a subscription would buy - but says so. */
|
||||
.emoji-jump-tab.locked {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.emoji-jump-initial {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Same reasoning as the tab: shown, plainly unusable, and the title says
|
||||
which of the two reasons it is. */
|
||||
.emoji-cell.locked {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* --- Kick channel points ---------------------------------------------- */
|
||||
|
||||
/* The plaque sits at the foot of a full-height column, so its pane has to
|
||||
|
||||
@@ -866,8 +866,12 @@ export function Composer(): JSX.Element | null {
|
||||
{pickerOpen && (
|
||||
<EmojiPicker
|
||||
anchor={emojiButtonRef.current}
|
||||
// Only the active buffer's own guild emoji are offered - nobilis
|
||||
// already filters that list to what's actually usable there.
|
||||
// Which conversation this is for. The picker asks the daemon what
|
||||
// this account can send anywhere, and the answer says which of it
|
||||
// reaches here - see #205.
|
||||
bufferId={buffer.id}
|
||||
// Only reached with a daemon too old to answer that, where this is
|
||||
// what the picker showed before it asked.
|
||||
customEmoji={bufferEmojiByBuffer[buffer.id] || []}
|
||||
// Recent picks belong to whoever is signed in here, not to the app.
|
||||
accountId={account?.id}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { resolveMediaUrl } from '../lib/util'
|
||||
import { Icon } from './Icon'
|
||||
import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format'
|
||||
import type { CustomEmoji } from '../../../shared/wire'
|
||||
|
||||
@@ -108,8 +109,30 @@ export interface StickerEntry {
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
/** One place emoji come from, as `listAllEmoji` answers. */
|
||||
export interface EmojiSource {
|
||||
id: string
|
||||
name: string
|
||||
service: string
|
||||
/** "text" inserts into the message; "event" sends on its own. */
|
||||
kind: string
|
||||
iconUrl?: string
|
||||
/** Whether these can be sent in the conversation the picker was opened in. */
|
||||
usableHere: boolean
|
||||
emoji: {
|
||||
id: string
|
||||
name: string
|
||||
url?: string
|
||||
animated?: boolean
|
||||
/** Owned somewhere this account has not paid into. */
|
||||
locked?: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
anchor: HTMLElement | null
|
||||
/** Which conversation this is for, so the daemon can say what reaches it. */
|
||||
bufferId?: string
|
||||
customEmoji?: CustomEmoji[]
|
||||
smilies?: SmilieEntry[]
|
||||
/**
|
||||
@@ -135,6 +158,7 @@ interface Props {
|
||||
|
||||
export function EmojiPicker({
|
||||
anchor,
|
||||
bufferId,
|
||||
customEmoji = [],
|
||||
smilies = [],
|
||||
stickers = [],
|
||||
@@ -148,6 +172,22 @@ export function EmojiPicker({
|
||||
const [query, setQuery] = useState('')
|
||||
const [pos, setPos] = useState({ left: 0, top: 0 })
|
||||
const [recent, setRecent] = useState<string[]>(() => readRecent(accountId))
|
||||
// Everything this account can send, wherever it came from. Asked of the
|
||||
// daemon rather than assembled here, because the answer depends on things
|
||||
// only it knows - a Nitro subscription, a Kick channel's standing.
|
||||
const [sources, setSources] = useState<EmojiSource[]>([])
|
||||
const scroll = useRef<HTMLDivElement>(null)
|
||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (stickersOnly || !bufferId) return
|
||||
void window.moho
|
||||
.rpc<EmojiSource[]>('listAllEmoji', { bufferId })
|
||||
// An older daemon has no such method; the buffer's own emoji below are
|
||||
// what this showed before it asked, so that is what it falls back to.
|
||||
.catch(() => [])
|
||||
.then(setSources)
|
||||
}, [bufferId, stickersOnly])
|
||||
|
||||
// The picker is normally opened fresh, but it stays up across a buffer
|
||||
// switch - and that switch can cross from one account to another, at which
|
||||
@@ -203,10 +243,28 @@ export function EmojiPicker({
|
||||
[q, customEmoji]
|
||||
)
|
||||
|
||||
// Grouped in the order the daemon listed them, so a channel's own emotes
|
||||
// stay above the global sets rather than being alphabetised away from where
|
||||
// whoever is watching expects them.
|
||||
// The sources the daemon listed, filtered by the search but never
|
||||
// reordered: a section's place is how somebody finds it again, and the jump
|
||||
// strip above is drawn from this same order.
|
||||
//
|
||||
// The ones that reach this conversation come first. An emote that cannot be
|
||||
// sent here is still worth showing - it is how somebody learns what Nitro or
|
||||
// a subscription would buy - but it should not sit between them and the
|
||||
// ones they can actually use.
|
||||
const sourceSections = useMemo(() => {
|
||||
const matching = sources
|
||||
.map((src) => ({
|
||||
...src,
|
||||
emoji: q ? src.emoji.filter((e) => e.name.toLowerCase().includes(q)) : src.emoji
|
||||
}))
|
||||
.filter((src) => src.emoji.length > 0)
|
||||
return [...matching.filter((s) => s.usableHere), ...matching.filter((s) => !s.usableHere)]
|
||||
}, [sources, q])
|
||||
|
||||
// Kept for a daemon too old to answer `listAllEmoji`, which is the only
|
||||
// case this still runs in - `customEmoji` is the buffer's own list.
|
||||
const customSections = useMemo<[string, CustomEmoji[]][]>(() => {
|
||||
if (sources.length > 0) return []
|
||||
const groups: [string, CustomEmoji[]][] = []
|
||||
for (const e of custom) {
|
||||
const title = e.set ?? 'Server emoji'
|
||||
@@ -215,7 +273,11 @@ export function EmojiPicker({
|
||||
else groups.push([title, [e]])
|
||||
}
|
||||
return groups
|
||||
}, [custom])
|
||||
}, [custom, sources])
|
||||
|
||||
const jumpTo = (id: string): void => {
|
||||
sectionRefs.current[id]?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}
|
||||
const smilieList = useMemo(
|
||||
() =>
|
||||
q
|
||||
@@ -266,9 +328,40 @@ export function EmojiPicker({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="emoji-scroll">
|
||||
{/* One icon per section, to move down a list that is now long enough
|
||||
to need it. Recent first because it is the one people reach for
|
||||
most, and it is where the list starts. */}
|
||||
{sourceSections.length > 0 && (
|
||||
<div className="emoji-jump">
|
||||
{!q && recent.length > 0 && (
|
||||
<button type="button" className="emoji-jump-tab" title="Recent" onClick={() => jumpTo('recent')}>
|
||||
<Icon name="history" size={16} />
|
||||
</button>
|
||||
)}
|
||||
{sourceSections.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
className={src.usableHere ? 'emoji-jump-tab' : 'emoji-jump-tab locked'}
|
||||
title={src.usableHere ? src.name : `${src.name} — cannot be sent here`}
|
||||
onClick={() => jumpTo(src.id)}
|
||||
>
|
||||
{src.iconUrl ? (
|
||||
<img src={resolveMediaUrl(src.iconUrl)} alt="" />
|
||||
) : (
|
||||
<span className="emoji-jump-initial">{src.name.slice(0, 1).toUpperCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="emoji-jump-tab" title="Emoji" onClick={() => jumpTo('unicode')}>
|
||||
<Icon name="mood" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="emoji-scroll" ref={scroll}>
|
||||
{!stickersOnly && !q && recent.length > 0 && (
|
||||
<Section title="Recent">
|
||||
<Section title="Recent" anchorRef={(el) => (sectionRefs.current.recent = el)}>
|
||||
{recent.map((r) => {
|
||||
// A recent is stored as the text that gets sent, which for a
|
||||
// custom emoji or a shortcode is a stand-in rather than the
|
||||
@@ -298,6 +391,43 @@ export function EmojiPicker({
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{sourceSections.map((src) => (
|
||||
<Section
|
||||
key={src.id}
|
||||
title={src.usableHere ? src.name : `${src.name} — not here`}
|
||||
anchorRef={(el) => (sectionRefs.current[src.id] = el)}
|
||||
>
|
||||
{src.emoji.map((e) => {
|
||||
// Two different reasons a cell cannot be used, and they are
|
||||
// worth telling apart: `locked` is "you have not paid for
|
||||
// this", `!usableHere` is "you have, but not in this room".
|
||||
const why = e.locked
|
||||
? `${e.name} — subscriber only`
|
||||
: !src.usableHere
|
||||
? `${e.name} — cannot be sent in this conversation`
|
||||
: ''
|
||||
return (
|
||||
<button
|
||||
key={`${src.id}:${e.id}`}
|
||||
type="button"
|
||||
className={why ? 'emoji-cell locked' : 'emoji-cell'}
|
||||
disabled={!!why}
|
||||
title={why || e.name}
|
||||
onClick={() => pick(e.id)}
|
||||
>
|
||||
{e.url ? (
|
||||
<img src={resolveMediaUrl(e.url)} alt={e.name} loading="lazy" />
|
||||
) : src.service === 'discord' ? (
|
||||
<img src={discordEmojiUrl(e.id, 48)} alt={e.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="emoji-token">{e.name}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</Section>
|
||||
))}
|
||||
|
||||
{packs.map(([pack, entries]) => (
|
||||
<Section key={pack} title={pack}>
|
||||
{entries.map((sticker) => (
|
||||
@@ -326,7 +456,7 @@ export function EmojiPicker({
|
||||
)}
|
||||
|
||||
{!stickersOnly && unicode.length > 0 && (
|
||||
<Section title="Emoji">
|
||||
<Section title="Emoji" anchorRef={(el) => (sectionRefs.current.unicode = el)}>
|
||||
{unicode.map((e) => (
|
||||
<button
|
||||
key={e.emoji}
|
||||
@@ -396,10 +526,21 @@ export function EmojiPicker({
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element {
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
anchorRef
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
/** Where the jump strip scrolls to, for the sections that have a tab. */
|
||||
anchorRef?: (el: HTMLDivElement | null) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="emoji-section small muted">{title}</div>
|
||||
<div className="emoji-section small muted" ref={anchorRef}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="emoji-grid">{children}</div>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user