This commit is contained in:
@@ -209,6 +209,19 @@
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
/* GIFs tab — the GifPicker owns its own `.searchRow` + `.body`
|
||||
layout and needs to paint edge-to-edge so its
|
||||
`--background-primary` body fills the picker surface. Strips the
|
||||
`.grid` wrapper's padding so the hairline + body bleed to the
|
||||
picker's border. */
|
||||
.gridGifs {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pickerMobile .gridGifs {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Collapsible section (desktop only) ───────────────────────── */
|
||||
.section {
|
||||
margin-top: 4px;
|
||||
|
||||
@@ -134,6 +134,12 @@ export function EmojiPicker({
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [hovered, setHovered] = useState<EmojiPickerValue | null>(null);
|
||||
const [recents, setRecents] = useState<EmojiPickerValue[]>(() => loadRecents());
|
||||
// Saved-media filter chips. `all` shows every row; the others
|
||||
// filter by the `kind` string that `api.savedMedia.save` writes
|
||||
// (first half of the MIME type: image / video / audio).
|
||||
const [mediaFilter, setMediaFilter] = useState<
|
||||
'all' | 'image' | 'video' | 'audio'
|
||||
>('all');
|
||||
|
||||
// Saved-media library — only fetched when the Media tab is open
|
||||
// to keep the picker cheap during normal emoji use.
|
||||
@@ -393,7 +399,7 @@ export function EmojiPicker({
|
||||
return (
|
||||
<div className={`${styles.picker} ${styles.pickerMobile}`}>
|
||||
<div className={styles.searchBar}>
|
||||
<MagnifyingGlass size={16} className={styles.searchIcon} />
|
||||
<MagnifyingGlass size={16} weight="regular" className={styles.searchIcon} />
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.searchInput}
|
||||
@@ -523,16 +529,24 @@ export function EmojiPicker({
|
||||
</div>
|
||||
|
||||
{activeTab !== 'gifs' && (
|
||||
<div className={styles.searchRow}>
|
||||
<div
|
||||
className={`${styles.searchRow} ${activeTab === 'media' ? styles.searchRowFlush : ''}`}
|
||||
>
|
||||
<div className={styles.searchBar}>
|
||||
<MagnifyingGlass size={16} className={styles.searchIcon} />
|
||||
<MagnifyingGlass size={16} weight="regular" className={styles.searchIcon} />
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.searchInput}
|
||||
placeholder={activeTab === 'emojis' ? 'Search emoji' : 'Coming soon'}
|
||||
placeholder={
|
||||
activeTab === 'emojis'
|
||||
? 'Search emoji'
|
||||
: activeTab === 'media'
|
||||
? 'Search media'
|
||||
: 'Coming soon'
|
||||
}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={activeTab !== 'emojis'}
|
||||
disabled={activeTab !== 'emojis' && activeTab !== 'media'}
|
||||
/>
|
||||
{search && (
|
||||
<button type="button" className={styles.searchClear} onClick={() => setSearch('')}>
|
||||
@@ -543,7 +557,34 @@ export function EmojiPicker({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.main}>
|
||||
{activeTab === 'media' && (
|
||||
<div className={styles.filterChips}>
|
||||
{(
|
||||
[
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'image', label: 'Images' },
|
||||
{ id: 'video', label: 'Videos' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
] as const
|
||||
).map((chip) => {
|
||||
const isActive = mediaFilter === chip.id;
|
||||
return (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${isActive ? styles.filterChipActive : ''}`}
|
||||
onClick={() => setMediaFilter(chip.id)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`${styles.main} ${activeTab === 'media' ? styles.mainMedia : ''}`}
|
||||
>
|
||||
{activeTab === 'emojis' && (
|
||||
<div className={styles.sideBar}>
|
||||
{customEmojis.length > 0 &&
|
||||
@@ -554,7 +595,11 @@ export function EmojiPicker({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.grid} ref={gridRef} onScroll={handleScroll}>
|
||||
<div
|
||||
className={`${styles.grid} ${activeTab === 'gifs' ? styles.gridGifs : ''}`}
|
||||
ref={gridRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{activeTab === 'gifs' ? (
|
||||
<GifPicker
|
||||
onSelectGif={(url) => {
|
||||
@@ -563,12 +608,38 @@ export function EmojiPicker({
|
||||
}}
|
||||
/>
|
||||
) : activeTab === 'media' ? (
|
||||
savedMedia.length === 0 ? (
|
||||
<div className={styles.comingSoon}>
|
||||
Nothing saved yet. Star an attachment to bookmark it
|
||||
here for quick re-sharing.
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
// Filter the saved library by the active chip + the
|
||||
// search box (case-insensitive filename substring).
|
||||
const q = search.trim().toLowerCase();
|
||||
const filteredSaved = (savedMedia as any[]).filter((item) => {
|
||||
if (
|
||||
mediaFilter !== 'all' &&
|
||||
(item.kind ?? '') !== mediaFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (q && !(item.filename ?? '').toLowerCase().includes(q)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (savedMedia.length === 0) {
|
||||
return (
|
||||
<div className={styles.comingSoon}>
|
||||
Nothing saved yet. Star an attachment to bookmark it
|
||||
here for quick re-sharing.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (filteredSaved.length === 0) {
|
||||
return (
|
||||
<div className={styles.comingSoon}>
|
||||
No saved media match your filter.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -577,7 +648,7 @@ export function EmojiPicker({
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{savedMedia.map((item: any) => {
|
||||
{filteredSaved.map((item: any) => {
|
||||
const isImage = item.kind === 'image';
|
||||
const isVideo = item.kind === 'video';
|
||||
return (
|
||||
@@ -632,7 +703,8 @@ export function EmojiPicker({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()
|
||||
) : activeTab !== 'emojis' ? (
|
||||
<div className={styles.comingSoon}>
|
||||
{EXPRESSION_TABS.find((t) => t.key === activeTab)?.label} are coming soon.
|
||||
|
||||
@@ -7,8 +7,31 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Matches the emoji picker's search row — full-width slot with a
|
||||
bottom hairline, holding a rounded `.searchBar` pill inside. */
|
||||
.searchRow {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--background-modifier-hover);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Body wrapper — everything below the search row. Uses
|
||||
`--background-primary` so the categories / featured / grid
|
||||
content reads against a lighter surface instead of the picker's
|
||||
tertiary base. */
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--background-primary);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -23,6 +46,14 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Kill any :focus-within highlight that global styles might paint
|
||||
on the search bar wrapper when the input gains focus. */
|
||||
.searchBar:focus-within {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
border-color: var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
@@ -39,6 +70,15 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Some UA stylesheets still paint a focus ring via `:focus-visible`
|
||||
even with `outline: none` on the base rule. Nuke both explicitly. */
|
||||
.searchInput:focus,
|
||||
.searchInput:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.featuredRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -87,6 +127,83 @@
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── Category grid (home view) ─────────────────────────────────
|
||||
Discord-style 2-column tiles, each backed by a still frame of
|
||||
a real GIF from that category. A dark gradient overlay keeps
|
||||
the label readable against anything — bright cartoons, night
|
||||
scenes, black-and-white clips. */
|
||||
.categoriesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: max-content;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.categoriesGrid::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.categoryTile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 12px;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background-color: var(--background-tertiary);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.categoryTile:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.categoryTileImage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.categoryTile::after {
|
||||
/* Uniformly dark scrim so the centred label reads cleanly
|
||||
against any preview — bright cartoons, night scenes,
|
||||
black-and-white clips. */
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.categoryTileLabel {
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: capitalize;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.subHeaderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -33,6 +33,12 @@ interface GifResult {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface GifCategory {
|
||||
name: string;
|
||||
image: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface GifPickerProps {
|
||||
onSelectGif: (url: string) => void;
|
||||
}
|
||||
@@ -65,6 +71,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
|
||||
const [tab, setTab] = useState<Tab>('home');
|
||||
const [trending, setTrending] = useState<GifResult[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<GifResult[]>([]);
|
||||
const [categories, setCategories] = useState<GifCategory[]>([]);
|
||||
const [favorites, setFavorites] = useState<GifResult[]>(() =>
|
||||
loadFavorites(),
|
||||
);
|
||||
@@ -73,19 +80,26 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
|
||||
|
||||
const searchAction = useAction(api.gifs.search);
|
||||
const trendingAction = useAction(api.gifs.trending);
|
||||
const categoriesAction = useAction(api.gifs.categories);
|
||||
|
||||
// Load trending feed once when the picker mounts. The result is
|
||||
// cached for the rest of the session — no need to refetch every
|
||||
// time the user toggles back to the home tab.
|
||||
// Load trending feed + categories once when the picker mounts.
|
||||
// Both are cached server-side (convex/gifs.ts in-memory TTL) so
|
||||
// toggling tabs or re-opening the picker doesn't re-hit Klipy.
|
||||
// The two requests run in parallel so first paint shows the
|
||||
// category chips even if trending is still loading.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res: any = await trendingAction({ limit: 24 });
|
||||
const [trendingRes, categoriesRes] = await Promise.all([
|
||||
trendingAction({ limit: 24 }),
|
||||
categoriesAction({}),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setTrending(res?.results ?? []);
|
||||
setTrending((trendingRes as any)?.results ?? []);
|
||||
setCategories((categoriesRes as any)?.categories ?? []);
|
||||
} catch (err: any) {
|
||||
if (cancelled) return;
|
||||
setError(err?.message ?? 'Failed to load GIFs.');
|
||||
@@ -96,7 +110,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trendingAction]);
|
||||
}, [trendingAction, categoriesAction]);
|
||||
|
||||
// Debounced search — fires 350ms after the last keystroke so we
|
||||
// don't hammer the upstream API on every character.
|
||||
@@ -142,36 +156,49 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
|
||||
|
||||
// Decide which list to render. Searching always wins — once the
|
||||
// user types anything, we show the search results regardless of
|
||||
// which featured tab was active.
|
||||
// which featured tab was active. `home` is now a distinct surface
|
||||
// (featured row + categories grid) and doesn't use `displayList`
|
||||
// at all.
|
||||
const isSearching = search.trim().length > 0;
|
||||
const displayList: GifResult[] = useMemo(() => {
|
||||
if (isSearching) return searchResults;
|
||||
if (tab === 'favorites') return favorites;
|
||||
if (tab === 'trending') return trending;
|
||||
// Home → trending
|
||||
return trending;
|
||||
return [];
|
||||
}, [isSearching, searchResults, tab, favorites, trending]);
|
||||
|
||||
const showFeaturedRow = !isSearching && tab === 'home';
|
||||
const showCategories = !isSearching && tab === 'home';
|
||||
|
||||
const handlePickCategory = (category: GifCategory) => {
|
||||
// Piping the category name into the search box lets the
|
||||
// existing debounced search effect do the work — same code
|
||||
// path as typing "happy birthday" by hand, so results are
|
||||
// cached and consistent.
|
||||
setSearch(category.query || category.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.searchBar}>
|
||||
<MagnifyingGlass
|
||||
size={16}
|
||||
weight="regular"
|
||||
className={styles.searchIcon}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
placeholder="Search Tenor"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.searchRow}>
|
||||
<div className={styles.searchBar}>
|
||||
<MagnifyingGlass
|
||||
size={16}
|
||||
weight="regular"
|
||||
className={styles.searchIcon}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
placeholder="Search Klipy"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{showFeaturedRow && (
|
||||
<div className={styles.featuredRow}>
|
||||
<button
|
||||
@@ -208,7 +235,38 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && displayList.length === 0 ? (
|
||||
{showCategories ? (
|
||||
loading && categories.length === 0 ? (
|
||||
<div className={styles.statusMessage}>Loading categories…</div>
|
||||
) : error && categories.length === 0 ? (
|
||||
<div className={styles.statusMessageError}>{error}</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className={styles.statusMessage}>No categories to show.</div>
|
||||
) : (
|
||||
<div className={styles.categoriesGrid}>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={`${cat.name}-${cat.query}`}
|
||||
type="button"
|
||||
className={styles.categoryTile}
|
||||
onClick={() => handlePickCategory(cat)}
|
||||
title={cat.name}
|
||||
>
|
||||
{cat.image && (
|
||||
<img
|
||||
src={cat.image}
|
||||
alt=""
|
||||
className={styles.categoryTileImage}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.categoryTileLabel}>{cat.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : loading && displayList.length === 0 ? (
|
||||
<div className={styles.statusMessage}>Loading GIFs…</div>
|
||||
) : error ? (
|
||||
<div className={styles.statusMessageError}>{error}</div>
|
||||
@@ -255,6 +313,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Direct media embeds (GIFs, raw videos, raw images) don't need
|
||||
the brand-coloured left bar or the card chrome — the media IS
|
||||
the content. Strip the card so it reads as inline media. */
|
||||
.embedBare {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.directGif {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
overflow: hidden;
|
||||
padding: 12px 12px 14px 12px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowSquareOut, Play } from '@phosphor-icons/react';
|
||||
import { useAction } from 'convex/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { PausedGif } from './PausedGif';
|
||||
import styles from './LinkEmbed.module.css';
|
||||
|
||||
interface UrlPreview {
|
||||
@@ -125,7 +126,15 @@ function useUrlPreview(url: string): UrlPreview | null {
|
||||
return preview;
|
||||
}
|
||||
|
||||
function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image' }) {
|
||||
function DirectMediaEmbed({
|
||||
url,
|
||||
type,
|
||||
onOpenGif,
|
||||
}: {
|
||||
url: string;
|
||||
type: 'video' | 'image';
|
||||
onOpenGif?: (url: string) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
@@ -138,7 +147,7 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.embed}>
|
||||
<div className={`${styles.embed} ${styles.embedBare}`}>
|
||||
<div className={styles.directVideoWrapper}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -172,8 +181,20 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
|
||||
);
|
||||
}
|
||||
|
||||
// GIFs (and other direct images) render through PausedGif so
|
||||
// the idle state freezes on the first frame and only animates
|
||||
// on hover. Click opens a fullscreen viewer via `onOpenGif` —
|
||||
// the parent MessageGroup owns the lightbox state and reuses
|
||||
// its existing ImageLightbox.
|
||||
const isGif = /\.gif(\?|#|$)/i.test(url);
|
||||
if (isGif) {
|
||||
return (
|
||||
<PausedGif url={url} onOpen={onOpenGif} className={styles.directGif} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.embed}>
|
||||
<div className={`${styles.embed} ${styles.embedBare}`}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
className={styles.directImage}
|
||||
@@ -188,12 +209,16 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
|
||||
|
||||
interface LinkEmbedProps {
|
||||
url: string;
|
||||
/** Called when the user clicks a paused GIF preview. The parent
|
||||
* owns the fullscreen viewer state (MessageGroup reuses its
|
||||
* existing ImageLightbox). Ignored for non-GIF embeds. */
|
||||
onOpenGif?: (url: string) => void;
|
||||
}
|
||||
|
||||
export function LinkEmbed({ url }: LinkEmbedProps) {
|
||||
export function LinkEmbed({ url, onOpenGif }: LinkEmbedProps) {
|
||||
const directType = isDirectMedia(url);
|
||||
if (directType) {
|
||||
return <DirectMediaEmbed url={url} type={directType} />;
|
||||
return <DirectMediaEmbed url={url} type={directType} onOpenGif={onOpenGif} />;
|
||||
}
|
||||
|
||||
const preview = useUrlPreview(url);
|
||||
|
||||
@@ -309,9 +309,9 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--background-secondary);
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
@@ -329,9 +329,21 @@
|
||||
}
|
||||
|
||||
.reactionCount {
|
||||
font-size: 0.75rem;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Emoji glyph inside a reaction chip — shared between the custom
|
||||
<img> path and the unicode TwemojiImg path so both chip styles
|
||||
have identical box sizing. `rem` units scale with the user's
|
||||
font size instead of hard-locking to 16px. */
|
||||
.reactionEmoji {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: 0.125rem 0;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Custom MSC2545 emoji reaction — rendered as an inline image in the
|
||||
|
||||
@@ -41,9 +41,43 @@ function extractUrls(text: string): string[] {
|
||||
return Array.from(new Set(cleaned));
|
||||
}
|
||||
|
||||
/** True when a message body is entirely made up of one or more GIF
|
||||
* URLs plus whitespace — i.e. the user posted a GIF from the
|
||||
* picker and there's nothing worth showing as text. The render
|
||||
* path hides the <MessageContent> block in that case so only the
|
||||
* embedded preview appears. */
|
||||
function isGifOnlyContent(text: string): boolean {
|
||||
const urls = extractUrls(text);
|
||||
if (urls.length === 0) return false;
|
||||
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
|
||||
let remainder = text;
|
||||
for (const u of urls) {
|
||||
remainder = remainder.split(u).join('');
|
||||
}
|
||||
return remainder.trim().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord-style relative timestamp:
|
||||
* - Same calendar day → `Today at 7:08 PM`
|
||||
* - Previous calendar day → `Yesterday at 7:08 PM`
|
||||
* - Anything else → `4/11/2026, 7:08 PM`
|
||||
*/
|
||||
function formatTime(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
const now = new Date();
|
||||
const time = date.toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
if (isToday) return `Today at ${time}`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return `Yesterday at ${time}`;
|
||||
}
|
||||
return `${date.toLocaleDateString()}, ${time}`;
|
||||
}
|
||||
|
||||
function formatFullTime(ts: number): string {
|
||||
@@ -96,12 +130,17 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
} | null>(null);
|
||||
|
||||
// Image lightbox state — tracks the decrypted blob URL + the
|
||||
// full attachment metadata of the image that was clicked so the
|
||||
// lightbox info card can render filename / size / dimensions.
|
||||
// Null means the lightbox is closed.
|
||||
// optional attachment metadata of the image that was clicked so
|
||||
// the lightbox info card can render filename / size / dimensions.
|
||||
// Null means the lightbox is closed. For inline GIFs posted via
|
||||
// a URL (no encrypted attachment), the metadata is absent — the
|
||||
// lightbox gracefully hides the star / detail chrome in that
|
||||
// case.
|
||||
const [lightboxItem, setLightboxItem] = useState<{
|
||||
src: string;
|
||||
attachment: AttachmentMetadata;
|
||||
attachment?: AttachmentMetadata;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Right-click context menu state. When set, the MessageActionBar for
|
||||
@@ -437,7 +476,7 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{msg.content && (
|
||||
{msg.content && !isGifOnlyContent(msg.content) && (
|
||||
<div className={styles.text}>
|
||||
<MessageContent
|
||||
content={msg.content}
|
||||
@@ -453,7 +492,17 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
extractUrls(msg.content)
|
||||
.slice(0, 3)
|
||||
.map((url, idx) => (
|
||||
<LinkEmbed key={`embed-${idx}-${url}`} url={url} />
|
||||
<LinkEmbed
|
||||
key={`embed-${idx}-${url}`}
|
||||
url={url}
|
||||
onOpenGif={(gifUrl) =>
|
||||
setLightboxItem({
|
||||
src: gifUrl,
|
||||
filename: gifUrl.split('/').pop() || 'gif',
|
||||
mimeType: 'image/gif',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{msg.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
@@ -578,17 +627,13 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
alt={`:${r.emoji}:`}
|
||||
title={`:${r.emoji}:`}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: 'contain',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
className={styles.reactionEmoji}
|
||||
/>
|
||||
) : (
|
||||
<TwemojiImg
|
||||
emoji={resolveReactionKeyToUnicode(r.emoji)}
|
||||
size={16}
|
||||
className={styles.reactionEmoji}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.reactionCount}>{r.count}</span>
|
||||
@@ -623,11 +668,15 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
<ImageLightbox
|
||||
isOpen={!!lightboxItem}
|
||||
src={lightboxItem?.src ?? ''}
|
||||
filename={lightboxItem?.attachment.filename}
|
||||
mimeType={lightboxItem?.attachment.mimeType}
|
||||
size={lightboxItem?.attachment.size}
|
||||
width={lightboxItem?.attachment.width}
|
||||
height={lightboxItem?.attachment.height}
|
||||
filename={
|
||||
lightboxItem?.attachment?.filename ?? lightboxItem?.filename
|
||||
}
|
||||
mimeType={
|
||||
lightboxItem?.attachment?.mimeType ?? lightboxItem?.mimeType
|
||||
}
|
||||
size={lightboxItem?.attachment?.size}
|
||||
width={lightboxItem?.attachment?.width}
|
||||
height={lightboxItem?.attachment?.height}
|
||||
attachment={lightboxItem?.attachment}
|
||||
onClose={() => setLightboxItem(null)}
|
||||
/>
|
||||
|
||||
@@ -107,6 +107,42 @@
|
||||
background-color: var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
/* ── New-messages divider ─────────────────────────────────────────
|
||||
Red hairline with a "NEW" pill on the right edge. Rendered between
|
||||
the last message the user has already read and the first unread
|
||||
one. The divider anchor is snapshotted on channel open so it stays
|
||||
put during the session — live new messages arriving while the user
|
||||
is reading don't push it further down the list. */
|
||||
.newDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 1rem 0.5rem;
|
||||
color: var(--status-danger, #ed4245);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.newDivider::before {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--status-danger, #ed4245);
|
||||
}
|
||||
|
||||
.newDividerBadge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--status-danger, #ed4245);
|
||||
color: #ffffff;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Jump highlight ───────────────────────────────────────────────
|
||||
Pulsed background applied to a message row by the
|
||||
`brycord:scroll-to-message` listener. Class is added globally
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { usePaginatedQuery, useQuery } from 'convex/react';
|
||||
import { useMutation, usePaginatedQuery, useQuery } from 'convex/react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
@@ -97,6 +97,21 @@ function DayDivider({ timestamp }: { timestamp: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Red "NEW" line inserted between the last message the viewer has
|
||||
* already read and the first unseen one. Anchored to a snapshot of
|
||||
* `lastReadTimestamp` taken when the channel opened, so live new
|
||||
* messages arriving during the session don't push the divider
|
||||
* further down.
|
||||
*/
|
||||
function NewMessagesDivider() {
|
||||
return (
|
||||
<div className={styles.newDivider} role="separator">
|
||||
<span className={styles.newDividerBadge}>New</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
const { crypto } = usePlatform();
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -124,6 +139,99 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
userId ? { userId: userId as any } : 'skip',
|
||||
);
|
||||
|
||||
// ── Read-state + "NEW" divider plumbing ─────────────────────────
|
||||
//
|
||||
// `readState` is the server's live record of the latest message
|
||||
// timestamp this user has acknowledged for the current channel.
|
||||
// `readSnapshot` captures the value at the time the channel was
|
||||
// opened so the divider stays anchored in place even after we
|
||||
// flush a later `markRead` mutation during the session. Stored
|
||||
// as state (not a ref) so the first render after the query
|
||||
// resolves picks it up deterministically.
|
||||
const readState = useQuery(
|
||||
api.readState.getReadState,
|
||||
userId && channelId
|
||||
? { userId: userId as any, channelId: channelId as any }
|
||||
: 'skip',
|
||||
);
|
||||
const markRead = useMutation(api.readState.markRead);
|
||||
const [readSnapshot, setReadSnapshot] = useState<{
|
||||
channelId: string;
|
||||
lastRead: number;
|
||||
} | null>(null);
|
||||
// Tracks the freshest timestamp we've observed in this channel so
|
||||
// the mark-read flush has something to send. Pure ref — updates
|
||||
// should never trigger a re-render.
|
||||
const latestSeenTimestampRef = useRef<number>(0);
|
||||
// Debounce timer for batched mark-read flushes.
|
||||
const markReadTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Reset the snapshot whenever the user changes channels.
|
||||
useLayoutEffect(() => {
|
||||
setReadSnapshot(null);
|
||||
latestSeenTimestampRef.current = 0;
|
||||
if (markReadTimerRef.current !== null) {
|
||||
window.clearTimeout(markReadTimerRef.current);
|
||||
markReadTimerRef.current = null;
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
// First time a non-null `readState` arrives for the current
|
||||
// channel, lock it in as the divider anchor. `null` (no stored
|
||||
// read state yet — brand-new channel) is treated as "0" so the
|
||||
// divider appears the moment anyone posts.
|
||||
useEffect(() => {
|
||||
if (!channelId) return;
|
||||
if (readSnapshot?.channelId === channelId) return;
|
||||
if (readState === undefined) return;
|
||||
setReadSnapshot({
|
||||
channelId,
|
||||
lastRead: readState?.lastReadTimestamp ?? 0,
|
||||
});
|
||||
}, [readState, channelId, readSnapshot]);
|
||||
|
||||
/** Fire-and-forget mark-read flush. Gated by:
|
||||
* - an authenticated user
|
||||
* - a channel loaded
|
||||
* - the window being visible (otherwise we keep the snapshot
|
||||
* and the NEW line so the user sees it when they return)
|
||||
* - the user being pinned to the bottom of the scroller
|
||||
* - the server's stored timestamp being strictly older than
|
||||
* the freshest message we've observed */
|
||||
const flushMarkRead = useCallback(() => {
|
||||
if (!userId || !channelId) return;
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
document.visibilityState === 'hidden'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!pinnedRef.current) return;
|
||||
const ts = latestSeenTimestampRef.current;
|
||||
if (!ts) return;
|
||||
const serverTs = readState?.lastReadTimestamp ?? 0;
|
||||
if (ts <= serverTs) return;
|
||||
void markRead({
|
||||
userId: userId as any,
|
||||
channelId: channelId as any,
|
||||
lastReadTimestamp: ts,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, channelId, readState, markRead]);
|
||||
|
||||
const scheduleMarkRead = useCallback(() => {
|
||||
if (markReadTimerRef.current !== null) {
|
||||
window.clearTimeout(markReadTimerRef.current);
|
||||
}
|
||||
// 600ms debounce — batches bursts of new messages into a
|
||||
// single mutation without feeling laggy to users who watch
|
||||
// the sidebar unread dot.
|
||||
markReadTimerRef.current = window.setTimeout(() => {
|
||||
markReadTimerRef.current = null;
|
||||
flushMarkRead();
|
||||
}, 600);
|
||||
}, [flushMarkRead]);
|
||||
|
||||
// Walk every bundle we have, decrypt the ones tagged for this
|
||||
// channel, and build a {version → keyHex} map. A single bundle's
|
||||
// plaintext is a JSON object mapping channelId → keyHex (legacy
|
||||
@@ -683,6 +791,58 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
return list;
|
||||
}, [groups, pollsInChannel]);
|
||||
|
||||
// Whenever the timeline grows past the freshest timestamp we've
|
||||
// seen, update the ref and schedule a debounced mark-read. The
|
||||
// ref-only update doesn't cause re-renders — it just feeds the
|
||||
// mark-read flush with an up-to-date target.
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
const newest = items[items.length - 1].ts;
|
||||
if (newest > latestSeenTimestampRef.current) {
|
||||
latestSeenTimestampRef.current = newest;
|
||||
scheduleMarkRead();
|
||||
}
|
||||
}, [items, scheduleMarkRead]);
|
||||
|
||||
// Window visibility → when the tab comes back into focus, flush
|
||||
// any pending mark-read so the sidebar dot disappears without
|
||||
// needing a new message to land.
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
scheduleMarkRead();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('focus', onVisibility);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('focus', onVisibility);
|
||||
};
|
||||
}, [scheduleMarkRead]);
|
||||
|
||||
// Channel unmount (navigated elsewhere / logged out) → fire a
|
||||
// final mark-read with whatever the latest observed timestamp
|
||||
// is, bypassing the debounce. Matches Fluxer's "leaving a
|
||||
// channel marks it read" UX so the sidebar dot doesn't linger.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (markReadTimerRef.current !== null) {
|
||||
window.clearTimeout(markReadTimerRef.current);
|
||||
markReadTimerRef.current = null;
|
||||
}
|
||||
if (!userId || !channelId) return;
|
||||
const ts = latestSeenTimestampRef.current;
|
||||
if (!ts) return;
|
||||
void markRead({
|
||||
userId: userId as any,
|
||||
channelId: channelId as any,
|
||||
lastReadTimestamp: ts,
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channelId]);
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
|
||||
<div className={styles.scroller}>
|
||||
@@ -711,17 +871,48 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
)}
|
||||
{(() => {
|
||||
// Walk the timeline once, inserting a `DayDivider`
|
||||
// whenever the calendar day changes. `lastTs` tracks
|
||||
// the most-recent rendered item so two consecutive
|
||||
// items on the same day skip the divider.
|
||||
// whenever the calendar day changes and a single
|
||||
// `NewMessagesDivider` before the first item whose
|
||||
// timestamp is strictly greater than the snapshot
|
||||
// of `lastReadTimestamp` taken when the channel
|
||||
// opened. `lastTs` tracks the most-recent rendered
|
||||
// item so two consecutive items on the same day
|
||||
// skip the date divider.
|
||||
//
|
||||
// The NEW line never shows for the viewer's own
|
||||
// messages — if you're the one that sent it, you
|
||||
// obviously already "saw" it. A message group
|
||||
// authored entirely by the current user is skipped
|
||||
// when looking for the divider boundary, so the
|
||||
// line stays anchored at the first message from
|
||||
// somebody else.
|
||||
let lastTs: number | null = null;
|
||||
const out: React.ReactNode[] = [];
|
||||
const snapshot =
|
||||
readSnapshot?.channelId === channelId
|
||||
? readSnapshot.lastRead
|
||||
: null;
|
||||
let newLinePlaced = snapshot === null;
|
||||
const isOwnGroup = (item: TimelineItem): boolean => {
|
||||
if (item.kind !== 'group') return false;
|
||||
if (!userId) return false;
|
||||
return item.group.every((m) => m.senderId === userId);
|
||||
};
|
||||
for (const item of items) {
|
||||
if (lastTs === null || !isSameDay(lastTs, item.ts)) {
|
||||
out.push(
|
||||
<DayDivider key={`day-${item.ts}`} timestamp={item.ts} />,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!newLinePlaced &&
|
||||
snapshot !== null &&
|
||||
item.ts > snapshot &&
|
||||
!isOwnGroup(item)
|
||||
) {
|
||||
out.push(<NewMessagesDivider key={`new-${item.key}`} />);
|
||||
newLinePlaced = true;
|
||||
}
|
||||
lastTs = item.ts;
|
||||
if (item.kind === 'group') {
|
||||
out.push(
|
||||
|
||||
41
packages/shared/src/components/channel/PausedGif.module.css
Normal file
41
packages/shared/src/components/channel/PausedGif.module.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 400px;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--background-tertiary);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.canvas,
|
||||
.img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
123
packages/shared/src/components/channel/PausedGif.tsx
Normal file
123
packages/shared/src/components/channel/PausedGif.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* PausedGif — inline GIF preview that stays paused by default and
|
||||
* only animates while the mouse is over it. Clicking opens a
|
||||
* fullscreen viewer (handled by the parent via `onOpen`) where the
|
||||
* browser plays the GIF normally.
|
||||
*
|
||||
* Trick: the browser decodes GIFs natively whenever an <img> is
|
||||
* visible, and there's no way to "pause" a decoded GIF. So we load
|
||||
* the GIF into a HTMLImageElement, snapshot frame 0 onto a
|
||||
* <canvas>, and show the canvas in the idle state. On hover we
|
||||
* swap to the live <img> and the GIF plays. On mouse-leave we flip
|
||||
* back to the canvas so it resets to the first frame.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import styles from './PausedGif.module.css';
|
||||
|
||||
interface PausedGifProps {
|
||||
url: string;
|
||||
className?: string;
|
||||
onOpen?: (url: string) => void;
|
||||
}
|
||||
|
||||
export function PausedGif({ url, className, onOpen }: PausedGifProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
// `imgKey` is bumped every time we need to re-mount the live
|
||||
// <img> so the GIF starts over from frame 0 on each hover.
|
||||
// Without this, rapid hover-in / hover-out cycles would pick up
|
||||
// wherever the decoder left off.
|
||||
const [imgKey, setImgKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoaded(false);
|
||||
setDims(null);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
const w = img.naturalWidth || 400;
|
||||
const h = img.naturalHeight || 300;
|
||||
setDims({ w, h });
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
try {
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
} catch {
|
||||
// Cross-origin canvas taint — fall through to
|
||||
// the img-only path below. The user still sees
|
||||
// the GIF, it just won't be paused-by-default.
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setLoaded(true); // Let the <img> path show a broken image
|
||||
};
|
||||
img.src = url;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpen(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHovered(true);
|
||||
setImgKey((k) => k + 1);
|
||||
};
|
||||
const handleMouseLeave = () => setHovered(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.wrapper} ${className || ''}`}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
role={onOpen ? 'button' : undefined}
|
||||
tabIndex={onOpen ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (onOpen && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onOpen(url);
|
||||
}
|
||||
}}
|
||||
style={
|
||||
dims
|
||||
? { aspectRatio: `${dims.w} / ${dims.h}`, maxWidth: Math.min(dims.w, 400) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
style={{ opacity: !hovered && loaded ? 1 : 0 }}
|
||||
/>
|
||||
{hovered && (
|
||||
<img
|
||||
key={imgKey}
|
||||
src={url}
|
||||
alt=""
|
||||
className={styles.img}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.badge}>GIF</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user