feat(gif-picker): add gif groups
Some checks failed
/ build-web (push) Successful in 8m40s
/ build-mobile (push) Failing after 11m17s
/ build-desktop (linux) (push) Successful in 14m8s
/ release (push) Has been skipped

feat(gif-picker): increase default size
This commit is contained in:
Alois 2026-08-11 13:01:09 +02:00
commit 6f4cc62530
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
11 changed files with 399 additions and 82 deletions

View file

@ -18,9 +18,9 @@ import { cn, useIsMobile } from "@methanium/ui";
import { encryptChatText } from "@tensamin/crypto/chatSecret";
import { useSession } from "@tensamin/storage/session";
import GifPicker from "./gifPicker";
import EmojiPicker from "./emojiPicker";
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
import EmojiPicker from "./emoji/emojiPicker";
import { useEmojiRanks, useRecordEmojiUse } from "./emoji/emojiRanks";
import GifPicker from "./media/gifPicker";
import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys";
import { editLastMessageHotkey } from "../hotkeys";
@ -345,7 +345,7 @@ export default function InputComponent({
/>
<PopoverContent
ref={gifPopoverRef}
className="relative min-h-80 max-h-180 min-w-80 max-w-180 overflow-hidden"
className="relative h-130 w-120 min-h-80 max-h-180 min-w-80 max-w-180 overflow-hidden"
onTouchMoveCapture={(event) => event.stopPropagation()}
onWheelCapture={(event) => event.stopPropagation()}
style={{

View file

@ -8,9 +8,11 @@ import {
TabsList,
TabsTrigger,
} from "@methanium/ui";
import type { ChatPickerMediaGroup } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
import { Loader2, Search } from "lucide-react";
import { ArrowLeft, Loader2, Search } from "lucide-react";
import MediaSaveButton from "./mediaSaveButton";
import { getUngroupedMedia } from "./mediaGroups";
import React, {
useEffect,
useLayoutEffect,
@ -443,21 +445,34 @@ function KlipyPanel({
}
function SavedPanel({
groups,
onSelect,
onSavedMediaChange,
onSavedMediaGroupsChange,
resizeWidth,
urls,
}: {
groups: ChatPickerMediaGroup[];
onSelect: (url: string) => void;
onSavedMediaChange: (savedMedia: string[]) => void;
onSavedMediaGroupsChange: (groups: ChatPickerMediaGroup[]) => void;
resizeWidth?: number;
urls: string[];
}) {
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [scrollRef, measuredWidth] = useMeasuredWidth();
const imageSizes = useImageSizes(urls);
const selectedGroup = groups.find((group) => group.id === selectedGroupId);
const sortedGroups = [...groups].sort((left, right) =>
left.name.localeCompare(right.name),
);
const visibleUrls = selectedGroup
? selectedGroup.media.filter((url) => urls.includes(url))
: getUngroupedMedia(urls, groups);
const imageSizes = useImageSizes(visibleUrls);
const columnWidth = resizeWidth ?? measuredWidth;
const columnCount = getColumnCount(columnWidth, urls.length);
const columns = distributeByHeight(urls, columnCount, (url) => {
const columnCount = getColumnCount(columnWidth, visibleUrls.length);
const groupColumnCount = getColumnCount(columnWidth, groups.length);
const columns = distributeByHeight(visibleUrls, columnCount, (url) => {
const size = imageSizes[url];
if (!size?.width || !size.height) return 1;
@ -475,6 +490,50 @@ function SavedPanel({
return (
<div ref={scrollRef} className="min-h-0 w-full flex-1 overflow-y-auto pr-1">
{selectedGroup ? (
<div className="mb-2 flex items-center gap-2">
<Button
variant="ghost"
aria-label="Back to saved media groups"
className="h-8 w-8 p-0"
onClick={() => setSelectedGroupId(null)}
>
<ArrowLeft className="size-4" />
</Button>
<span className="font-medium">{selectedGroup.name}</span>
</div>
) : groups.length > 0 ? (
<div
className="mb-2 grid gap-2"
style={{
gridTemplateColumns: `repeat(${groupColumnCount}, minmax(0, 1fr))`,
}}
>
{sortedGroups.map((group) => (
<button
key={group.id}
className="relative h-24 overflow-hidden rounded-lg border bg-muted bg-cover bg-center p-1 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{
backgroundImage: `url(${JSON.stringify(group.media[0])})`,
}}
type="button"
onClick={() => setSelectedGroupId(group.id)}
>
<span className="absolute inset-0 bg-black/40" />
<span className="relative flex h-full items-center justify-center font-medium text-white drop-shadow-sm">
{group.name}
</span>
</button>
))}
</div>
) : null}
{visibleUrls.length === 0 ? (
<div className="flex min-h-32 items-center justify-center text-center text-muted-foreground">
{selectedGroup ? "This group is empty." : "No ungrouped media."}
</div>
) : null}
<div className="flex w-full items-start gap-2">
{columns.map((column, columnIndex) => (
<div key={columnIndex} className="flex min-w-0 flex-1 flex-col gap-2">
@ -504,6 +563,7 @@ function SavedPanel({
ariaLabel="Unsave media"
defaultSaved
onSavedMediaChange={onSavedMediaChange}
onSavedMediaGroupsChange={onSavedMediaGroupsChange}
url={url}
/>
</div>
@ -530,6 +590,9 @@ export default function GifPicker({
const [searchString, setSearchString] = useState("");
const [debouncedSearchString, setDebouncedSearchString] = useState("");
const [savedMedia, setSavedMedia] = useState<string[]>([]);
const [savedMediaGroups, setSavedMediaGroups] = useState<
ChatPickerMediaGroup[]
>([]);
const didLoadLastTabRef = useRef(false);
const searchDebouncerRef = useRef<Debouncer<(value: string) => void> | null>(
null,
@ -555,7 +618,13 @@ export default function GifPicker({
useEffect(() => {
if (tab !== "saved") return;
void load("chat_picker_saved_media").then(setSavedMedia);
void Promise.all([
load("chat_picker_saved_media"),
load("chat_picker_saved_media_groups"),
]).then(([nextSavedMedia, nextGroups]) => {
setSavedMedia(nextSavedMedia);
setSavedMediaGroups(nextGroups);
});
}, [load, tab]);
useEffect(() => {
@ -627,8 +696,10 @@ export default function GifPicker({
className="flex min-h-0 w-full flex-col overflow-hidden"
>
<SavedPanel
groups={savedMediaGroups}
onSelect={onSelect}
onSavedMediaChange={setSavedMedia}
onSavedMediaGroupsChange={setSavedMediaGroups}
resizeWidth={resizeWidth}
urls={savedMedia}
/>

View file

@ -0,0 +1,65 @@
import type { ChatPickerMediaGroup } from "@tensamin/shared/data";
function withoutMedia(
groups: ChatPickerMediaGroup[],
url: string,
keepGroupId?: string,
) {
return groups
.map((group) => ({
...group,
media: group.media.filter((item) => item !== url),
}))
.filter((group) => group.id === keepGroupId || group.media.length > 0);
}
export function assignMediaToGroup(
groups: ChatPickerMediaGroup[],
groupId: string | null,
url: string,
) {
const nextGroups = withoutMedia(groups, url, groupId ?? undefined);
if (groupId === null) return nextGroups;
return nextGroups.map((group) =>
group.id === groupId ? { ...group, media: [url, ...group.media] } : group,
);
}
export function createMediaGroup(
groups: ChatPickerMediaGroup[],
id: string,
name: string,
url: string,
) {
const trimmedName = name.trim();
if (
!trimmedName ||
groups.some(
(group) =>
group.name.toLocaleLowerCase() === trimmedName.toLocaleLowerCase(),
)
) {
return groups;
}
return [
...withoutMedia(groups, url),
{ id, name: trimmedName, media: [url] },
];
}
export function getMediaGroupId(groups: ChatPickerMediaGroup[], url: string) {
return groups.find((group) => group.media.includes(url))?.id ?? null;
}
export function getUngroupedMedia(
savedMedia: string[],
groups: ChatPickerMediaGroup[],
) {
const groupedMedia = new Set(groups.flatMap((group) => group.media));
return savedMedia.filter((url) => !groupedMedia.has(url));
}

View file

@ -0,0 +1,238 @@
import {
Button,
cn,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
useIsMobile,
} from "@methanium/ui";
import type { ChatPickerMediaGroup } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
import { Ellipsis, Star } from "lucide-react";
import { useEffect, useState } from "react";
import {
assignMediaToGroup,
createMediaGroup,
getMediaGroupId,
} from "./mediaGroups";
export default function MediaSaveButton({
ariaLabel,
className,
defaultSaved = false,
onSavedMediaChange,
onSavedMediaGroupsChange,
url,
}: {
ariaLabel?: string;
className?: string;
defaultSaved?: boolean;
onSavedMediaChange?: (savedMedia: string[]) => void;
onSavedMediaGroupsChange?: (groups: ChatPickerMediaGroup[]) => void;
url: string;
}) {
const { load, save } = useStorage();
const isMobile = useIsMobile();
const [savedMedia, setSavedMedia] = useState<string[] | null>(null);
const [groups, setGroups] = useState<ChatPickerMediaGroup[] | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [newGroupName, setNewGroupName] = useState("");
const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved;
const sortedGroups = [...(groups ?? [])].sort((left, right) =>
left.name.localeCompare(right.name),
);
const normalizedNewGroupName = newGroupName.trim().toLocaleLowerCase();
const canCreateGroup =
normalizedNewGroupName.length > 0 &&
!(groups ?? []).some(
(group) => group.name.toLocaleLowerCase() === normalizedNewGroupName,
);
useEffect(() => {
void Promise.all([
load("chat_picker_saved_media"),
load("chat_picker_saved_media_groups"),
]).then(([nextSavedMedia, nextGroups]) => {
setSavedMedia(nextSavedMedia);
setGroups(nextGroups);
});
}, [load]);
async function handleSaveClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
const [currentSavedMedia, currentGroups] = await Promise.all([
load("chat_picker_saved_media"),
load("chat_picker_saved_media_groups"),
]);
const willUnsave = currentSavedMedia.includes(url);
const nextSavedMedia = willUnsave
? currentSavedMedia.filter((item) => item !== url)
: [url, ...currentSavedMedia];
setSavedMedia(nextSavedMedia);
onSavedMediaChange?.(nextSavedMedia);
if (willUnsave) {
const nextGroups = assignMediaToGroup(currentGroups, null, url);
setGroups(nextGroups);
onSavedMediaGroupsChange?.(nextGroups);
await Promise.all([
save("chat_picker_saved_media", nextSavedMedia),
save("chat_picker_saved_media_groups", nextGroups),
]);
return;
}
await save("chat_picker_saved_media", nextSavedMedia);
}
async function handleGroupClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
const currentGroups = await load("chat_picker_saved_media_groups");
setGroups(currentGroups);
setSelectedGroupId(getMediaGroupId(currentGroups, url));
setNewGroupName("");
setDialogOpen(true);
}
async function handleGroupSave() {
const currentGroups = await load("chat_picker_saved_media_groups");
const nextGroups = assignMediaToGroup(currentGroups, selectedGroupId, url);
setGroups(nextGroups);
onSavedMediaGroupsChange?.(nextGroups);
await save("chat_picker_saved_media_groups", nextGroups);
setDialogOpen(false);
}
async function handleCreateGroup() {
if (!canCreateGroup) return;
const currentGroups = await load("chat_picker_saved_media_groups");
const groupId = crypto.randomUUID();
const nextGroups = createMediaGroup(
currentGroups,
groupId,
newGroupName,
url,
);
setGroups(nextGroups);
setSelectedGroupId(groupId);
setNewGroupName("");
onSavedMediaGroupsChange?.(nextGroups);
await save("chat_picker_saved_media_groups", nextGroups);
setDialogOpen(false);
}
return (
<>
<div
className={cn(
"absolute right-1 top-1 z-10 flex gap-1 transition-all",
!isMobile &&
"-translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
className,
)}
>
<Button
variant="secondary"
aria-label={ariaLabel ?? (isSaved ? "Unsave media" : "Save media")}
className="h-9 w-9 rounded-sm border-0! p-0"
onClick={handleSaveClick}
>
<Star
color="var(--primary-foreground-alt)"
className={cn(
"size-4.5 stroke-1.5!",
isSaved ? "fill-(--primary)" : "",
)}
/>
</Button>
{isSaved ? (
<Button
variant="secondary"
aria-label="Choose saved media group"
className="h-9 w-9 rounded-sm border-0! p-0"
onClick={handleGroupClick}
>
<Ellipsis className="size-4.5 stroke-1.5!" />
</Button>
) : null}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent onClick={(event) => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>Organize saved media</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="saved-media-group">Select group</Label>
<Select
value={selectedGroupId ?? "ungrouped"}
onValueChange={(value) =>
setSelectedGroupId(value === "ungrouped" ? null : value)
}
>
<SelectTrigger id="saved-media-group" className="w-full">
<SelectValue>
{selectedGroupId === null
? "Ungrouped"
: groups?.find((group) => group.id === selectedGroupId)
?.name}
</SelectValue>
</SelectTrigger>
<SelectContent className="p-1">
<SelectItem value="ungrouped">Ungrouped</SelectItem>
{sortedGroups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="new-saved-media-group">New group</Label>
<div className="flex gap-2">
<Input
id="new-saved-media-group"
placeholder="Group name"
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleCreateGroup();
}}
/>
<Button disabled={!canCreateGroup} onClick={handleCreateGroup}>
Create Group
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleGroupSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -1,63 +0,0 @@
import { Button, cn, useIsMobile } from "@methanium/ui";
import { useStorage } from "@tensamin/storage/context";
import { Star } from "lucide-react";
import { useEffect, useState } from "react";
export default function MediaSaveButton({
ariaLabel,
className,
defaultSaved = false,
onSavedMediaChange,
url,
}: {
ariaLabel?: string;
className?: string;
defaultSaved?: boolean;
onSavedMediaChange?: (savedMedia: string[]) => void;
url: string;
}) {
const { load, save } = useStorage();
const isMobile = useIsMobile();
const [savedMedia, setSavedMedia] = useState<string[] | null>(null);
const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved;
useEffect(() => {
void load("chat_picker_saved_media").then(setSavedMedia);
}, [load]);
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
const currentSavedMedia =
savedMedia ?? (await load("chat_picker_saved_media"));
const nextSavedMedia = currentSavedMedia.includes(url)
? currentSavedMedia.filter((item) => item !== url)
: [url, ...currentSavedMedia];
setSavedMedia(nextSavedMedia);
onSavedMediaChange?.(nextSavedMedia);
void save("chat_picker_saved_media", nextSavedMedia);
}
return (
<Button
variant="secondary"
aria-label={ariaLabel ?? (isSaved ? "Unsave media" : "Save media")}
className={cn(
"rounded-sm border-0! p-0 absolute right-1 top-1 z-10 h-9 w-9 transition-all",
!isMobile &&
"-translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
className,
)}
onClick={handleClick}
>
<Star
color="var(--primary-foreground-alt)"
className={cn(
"size-4.5 stroke-1.5!",
isSaved ? "fill-(--primary)" : "",
)}
/>
</Button>
);
}

View file

@ -13,14 +13,14 @@ import {
Skeleton,
} from "@methanium/ui";
import MessageContextMenu from "./messageContextMenu";
import Media from "./media";
import Media from "./media/media";
import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp";
import { getMessage, useChat } from "../context";
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
import { log, toast } from "@tensamin/shared/log";
import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown";
import { useRecordEmojiUse } from "./emojiRanks";
import { useRecordEmojiUse } from "./emoji/emojiRanks";
import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys";
import { cancelMessageEditHotkey } from "../hotkeys";
@ -381,8 +381,8 @@ function MessageComponent({
</div>
)}
{editing ? (
<div className="flex w-full flex-col gap-1">
<Card className="p-2!">
<div className="flex w-full flex-col gap-1 py-1.5">
<Card className="px-2! py-0! rounded-md">
<Input
autoFocus
className="w-full"
@ -400,8 +400,7 @@ function MessageComponent({
<div className="flex gap-1">
<Button
size="xs"
variant="link"
className="text-primary-foreground-alt"
variant="subtleDefault"
onClick={() => {
if (!editDraft.trim()) return;
if (editDraft !== message.Content) {
@ -415,8 +414,7 @@ function MessageComponent({
</Button>
<Button
size="xs"
variant="link"
className="text-muted-foreground"
variant="outline"
onClick={cancelEditing}
>
Cancel

View file

@ -45,8 +45,8 @@ import type {
} from "react";
import { useChat } from "../context";
import { Emoji } from "@methanium/ui/markdown";
import EmojiPicker from "./emojiPicker";
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
import EmojiPicker from "./emoji/emojiPicker";
import { getRecentEmojis, useEmojiRanks } from "./emoji/emojiRanks";
async function copyText(text: string) {
await navigator.clipboard.writeText(text);

View file

@ -497,6 +497,12 @@ export const mtp = {
export type MTP = typeof mtp;
// Storage
export type ChatPickerMediaGroup = {
id: string;
name: string;
media: string[];
};
export interface Storage extends SettingsStorageDefaults {
session_id: number;
user_id: number;
@ -549,6 +555,7 @@ export interface Storage extends SettingsStorageDefaults {
theme_design: ThemeDesign;
chat_trusted_domains: string[];
chat_picker_saved_media: string[];
chat_picker_saved_media_groups: ChatPickerMediaGroup[];
chat_picker_last_tab: "gif" | "meme" | "saved";
chat_picker_size: {
width: number;
@ -640,6 +647,7 @@ export const storageDefaults: Storage = {
"media4.giphy.com",
],
chat_picker_saved_media: [],
chat_picker_saved_media_groups: [],
chat_picker_last_tab: "gif",
chat_picker_size: null,
reactions: {