feat(gif-picker): add gif groups
feat(gif-picker): increase default size
This commit is contained in:
parent
eaeff1b8d5
commit
6f4cc62530
11 changed files with 399 additions and 82 deletions
|
|
@ -18,9 +18,9 @@ import { cn, useIsMobile } from "@methanium/ui";
|
||||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
|
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import GifPicker from "./gifPicker";
|
import EmojiPicker from "./emoji/emojiPicker";
|
||||||
import EmojiPicker from "./emojiPicker";
|
import { useEmojiRanks, useRecordEmojiUse } from "./emoji/emojiRanks";
|
||||||
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
import GifPicker from "./media/gifPicker";
|
||||||
import ReplyBox from "./replyBox";
|
import ReplyBox from "./replyBox";
|
||||||
import { useHotkey } from "@tensamin/hotkeys";
|
import { useHotkey } from "@tensamin/hotkeys";
|
||||||
import { editLastMessageHotkey } from "../hotkeys";
|
import { editLastMessageHotkey } from "../hotkeys";
|
||||||
|
|
@ -345,7 +345,7 @@ export default function InputComponent({
|
||||||
/>
|
/>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
ref={gifPopoverRef}
|
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()}
|
onTouchMoveCapture={(event) => event.stopPropagation()}
|
||||||
onWheelCapture={(event) => event.stopPropagation()}
|
onWheelCapture={(event) => event.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import {
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
|
import type { ChatPickerMediaGroup } from "@tensamin/shared/data";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { Loader2, Search } from "lucide-react";
|
import { ArrowLeft, Loader2, Search } from "lucide-react";
|
||||||
import MediaSaveButton from "./mediaSaveButton";
|
import MediaSaveButton from "./mediaSaveButton";
|
||||||
|
import { getUngroupedMedia } from "./mediaGroups";
|
||||||
import React, {
|
import React, {
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
|
@ -443,21 +445,34 @@ function KlipyPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
function SavedPanel({
|
function SavedPanel({
|
||||||
|
groups,
|
||||||
onSelect,
|
onSelect,
|
||||||
onSavedMediaChange,
|
onSavedMediaChange,
|
||||||
|
onSavedMediaGroupsChange,
|
||||||
resizeWidth,
|
resizeWidth,
|
||||||
urls,
|
urls,
|
||||||
}: {
|
}: {
|
||||||
|
groups: ChatPickerMediaGroup[];
|
||||||
onSelect: (url: string) => void;
|
onSelect: (url: string) => void;
|
||||||
onSavedMediaChange: (savedMedia: string[]) => void;
|
onSavedMediaChange: (savedMedia: string[]) => void;
|
||||||
|
onSavedMediaGroupsChange: (groups: ChatPickerMediaGroup[]) => void;
|
||||||
resizeWidth?: number;
|
resizeWidth?: number;
|
||||||
urls: string[];
|
urls: string[];
|
||||||
}) {
|
}) {
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const [scrollRef, measuredWidth] = useMeasuredWidth();
|
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 columnWidth = resizeWidth ?? measuredWidth;
|
||||||
const columnCount = getColumnCount(columnWidth, urls.length);
|
const columnCount = getColumnCount(columnWidth, visibleUrls.length);
|
||||||
const columns = distributeByHeight(urls, columnCount, (url) => {
|
const groupColumnCount = getColumnCount(columnWidth, groups.length);
|
||||||
|
const columns = distributeByHeight(visibleUrls, columnCount, (url) => {
|
||||||
const size = imageSizes[url];
|
const size = imageSizes[url];
|
||||||
|
|
||||||
if (!size?.width || !size.height) return 1;
|
if (!size?.width || !size.height) return 1;
|
||||||
|
|
@ -475,6 +490,50 @@ function SavedPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollRef} className="min-h-0 w-full flex-1 overflow-y-auto pr-1">
|
<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">
|
<div className="flex w-full items-start gap-2">
|
||||||
{columns.map((column, columnIndex) => (
|
{columns.map((column, columnIndex) => (
|
||||||
<div key={columnIndex} className="flex min-w-0 flex-1 flex-col gap-2">
|
<div key={columnIndex} className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
|
|
@ -504,6 +563,7 @@ function SavedPanel({
|
||||||
ariaLabel="Unsave media"
|
ariaLabel="Unsave media"
|
||||||
defaultSaved
|
defaultSaved
|
||||||
onSavedMediaChange={onSavedMediaChange}
|
onSavedMediaChange={onSavedMediaChange}
|
||||||
|
onSavedMediaGroupsChange={onSavedMediaGroupsChange}
|
||||||
url={url}
|
url={url}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -530,6 +590,9 @@ export default function GifPicker({
|
||||||
const [searchString, setSearchString] = useState("");
|
const [searchString, setSearchString] = useState("");
|
||||||
const [debouncedSearchString, setDebouncedSearchString] = useState("");
|
const [debouncedSearchString, setDebouncedSearchString] = useState("");
|
||||||
const [savedMedia, setSavedMedia] = useState<string[]>([]);
|
const [savedMedia, setSavedMedia] = useState<string[]>([]);
|
||||||
|
const [savedMediaGroups, setSavedMediaGroups] = useState<
|
||||||
|
ChatPickerMediaGroup[]
|
||||||
|
>([]);
|
||||||
const didLoadLastTabRef = useRef(false);
|
const didLoadLastTabRef = useRef(false);
|
||||||
const searchDebouncerRef = useRef<Debouncer<(value: string) => void> | null>(
|
const searchDebouncerRef = useRef<Debouncer<(value: string) => void> | null>(
|
||||||
null,
|
null,
|
||||||
|
|
@ -555,7 +618,13 @@ export default function GifPicker({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== "saved") return;
|
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]);
|
}, [load, tab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -627,8 +696,10 @@ export default function GifPicker({
|
||||||
className="flex min-h-0 w-full flex-col overflow-hidden"
|
className="flex min-h-0 w-full flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<SavedPanel
|
<SavedPanel
|
||||||
|
groups={savedMediaGroups}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onSavedMediaChange={setSavedMedia}
|
onSavedMediaChange={setSavedMedia}
|
||||||
|
onSavedMediaGroupsChange={setSavedMediaGroups}
|
||||||
resizeWidth={resizeWidth}
|
resizeWidth={resizeWidth}
|
||||||
urls={savedMedia}
|
urls={savedMedia}
|
||||||
/>
|
/>
|
||||||
65
packages/chat/src/components/media/mediaGroups.ts
Normal file
65
packages/chat/src/components/media/mediaGroups.ts
Normal 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));
|
||||||
|
}
|
||||||
238
packages/chat/src/components/media/mediaSaveButton.tsx
Normal file
238
packages/chat/src/components/media/mediaSaveButton.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -13,14 +13,14 @@ import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import MessageContextMenu from "./messageContextMenu";
|
import MessageContextMenu from "./messageContextMenu";
|
||||||
import Media from "./media";
|
import Media from "./media/media";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { getMessage, useChat } from "../context";
|
import { getMessage, useChat } from "../context";
|
||||||
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
|
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown";
|
import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown";
|
||||||
import { useRecordEmojiUse } from "./emojiRanks";
|
import { useRecordEmojiUse } from "./emoji/emojiRanks";
|
||||||
import ReplyBox from "./replyBox";
|
import ReplyBox from "./replyBox";
|
||||||
import { useHotkey } from "@tensamin/hotkeys";
|
import { useHotkey } from "@tensamin/hotkeys";
|
||||||
import { cancelMessageEditHotkey } from "../hotkeys";
|
import { cancelMessageEditHotkey } from "../hotkeys";
|
||||||
|
|
@ -381,8 +381,8 @@ function MessageComponent({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1 py-1.5">
|
||||||
<Card className="p-2!">
|
<Card className="px-2! py-0! rounded-md">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|
@ -400,8 +400,7 @@ function MessageComponent({
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="link"
|
variant="subtleDefault"
|
||||||
className="text-primary-foreground-alt"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!editDraft.trim()) return;
|
if (!editDraft.trim()) return;
|
||||||
if (editDraft !== message.Content) {
|
if (editDraft !== message.Content) {
|
||||||
|
|
@ -415,8 +414,7 @@ function MessageComponent({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="link"
|
variant="outline"
|
||||||
className="text-muted-foreground"
|
|
||||||
onClick={cancelEditing}
|
onClick={cancelEditing}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ import type {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useChat } from "../context";
|
import { useChat } from "../context";
|
||||||
import { Emoji } from "@methanium/ui/markdown";
|
import { Emoji } from "@methanium/ui/markdown";
|
||||||
import EmojiPicker from "./emojiPicker";
|
import EmojiPicker from "./emoji/emojiPicker";
|
||||||
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
import { getRecentEmojis, useEmojiRanks } from "./emoji/emojiRanks";
|
||||||
|
|
||||||
async function copyText(text: string) {
|
async function copyText(text: string) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
|
||||||
|
|
@ -497,6 +497,12 @@ export const mtp = {
|
||||||
export type MTP = typeof mtp;
|
export type MTP = typeof mtp;
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
|
export type ChatPickerMediaGroup = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
media: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface Storage extends SettingsStorageDefaults {
|
export interface Storage extends SettingsStorageDefaults {
|
||||||
session_id: number;
|
session_id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
|
|
@ -549,6 +555,7 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
theme_design: ThemeDesign;
|
theme_design: ThemeDesign;
|
||||||
chat_trusted_domains: string[];
|
chat_trusted_domains: string[];
|
||||||
chat_picker_saved_media: string[];
|
chat_picker_saved_media: string[];
|
||||||
|
chat_picker_saved_media_groups: ChatPickerMediaGroup[];
|
||||||
chat_picker_last_tab: "gif" | "meme" | "saved";
|
chat_picker_last_tab: "gif" | "meme" | "saved";
|
||||||
chat_picker_size: {
|
chat_picker_size: {
|
||||||
width: number;
|
width: number;
|
||||||
|
|
@ -640,6 +647,7 @@ export const storageDefaults: Storage = {
|
||||||
"media4.giphy.com",
|
"media4.giphy.com",
|
||||||
],
|
],
|
||||||
chat_picker_saved_media: [],
|
chat_picker_saved_media: [],
|
||||||
|
chat_picker_saved_media_groups: [],
|
||||||
chat_picker_last_tab: "gif",
|
chat_picker_last_tab: "gif",
|
||||||
chat_picker_size: null,
|
chat_picker_size: null,
|
||||||
reactions: {
|
reactions: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue