1.0.60
All checks were successful
Build and Release / build-and-release (push) Successful in 12m29s

This commit is contained in:
Bryan1029384756
2026-04-14 20:03:54 -05:00
parent b7a4cf4ce8
commit 965048f7d2
47 changed files with 2558 additions and 135 deletions

View File

@@ -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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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)}
/>

View File

@@ -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

View File

@@ -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(

View 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;
}

View 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>
);
}