diff --git a/packages/chat/src/components/emojiPicker.tsx b/packages/chat/src/components/emoji/emojiPicker.tsx similarity index 100% rename from packages/chat/src/components/emojiPicker.tsx rename to packages/chat/src/components/emoji/emojiPicker.tsx diff --git a/packages/chat/src/components/emojiRanks.ts b/packages/chat/src/components/emoji/emojiRanks.ts similarity index 100% rename from packages/chat/src/components/emojiRanks.ts rename to packages/chat/src/components/emoji/emojiRanks.ts diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 28752f0..15c5c55 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -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({ /> event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()} style={{ diff --git a/packages/chat/src/components/gifPicker.tsx b/packages/chat/src/components/media/gifPicker.tsx similarity index 83% rename from packages/chat/src/components/gifPicker.tsx rename to packages/chat/src/components/media/gifPicker.tsx index e44742b..0907d12 100644 --- a/packages/chat/src/components/gifPicker.tsx +++ b/packages/chat/src/components/media/gifPicker.tsx @@ -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(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 (
+ {selectedGroup ? ( +
+ + {selectedGroup.name} +
+ ) : groups.length > 0 ? ( +
+ {sortedGroups.map((group) => ( + + ))} +
+ ) : null} + + {visibleUrls.length === 0 ? ( +
+ {selectedGroup ? "This group is empty." : "No ungrouped media."} +
+ ) : null} +
{columns.map((column, columnIndex) => (
@@ -504,6 +563,7 @@ function SavedPanel({ ariaLabel="Unsave media" defaultSaved onSavedMediaChange={onSavedMediaChange} + onSavedMediaGroupsChange={onSavedMediaGroupsChange} url={url} />
@@ -530,6 +590,9 @@ export default function GifPicker({ const [searchString, setSearchString] = useState(""); const [debouncedSearchString, setDebouncedSearchString] = useState(""); const [savedMedia, setSavedMedia] = useState([]); + const [savedMediaGroups, setSavedMediaGroups] = useState< + ChatPickerMediaGroup[] + >([]); const didLoadLastTabRef = useRef(false); const searchDebouncerRef = useRef 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" > diff --git a/packages/chat/src/components/media.tsx b/packages/chat/src/components/media/media.tsx similarity index 100% rename from packages/chat/src/components/media.tsx rename to packages/chat/src/components/media/media.tsx diff --git a/packages/chat/src/components/media/mediaGroups.ts b/packages/chat/src/components/media/mediaGroups.ts new file mode 100644 index 0000000..1103781 --- /dev/null +++ b/packages/chat/src/components/media/mediaGroups.ts @@ -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)); +} diff --git a/packages/chat/src/components/media/mediaSaveButton.tsx b/packages/chat/src/components/media/mediaSaveButton.tsx new file mode 100644 index 0000000..e585eae --- /dev/null +++ b/packages/chat/src/components/media/mediaSaveButton.tsx @@ -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(null); + const [groups, setGroups] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedGroupId, setSelectedGroupId] = useState(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) { + 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) { + 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 ( + <> +
+ + {isSaved ? ( + + ) : null} +
+ + + event.stopPropagation()}> + + Organize saved media + + +
+
+ + +
+ +
+ +
+ setNewGroupName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void handleCreateGroup(); + }} + /> + +
+
+
+ + + + + +
+
+ + ); +} diff --git a/packages/chat/src/components/mediaSaveButton.tsx b/packages/chat/src/components/mediaSaveButton.tsx deleted file mode 100644 index 99fbcec..0000000 --- a/packages/chat/src/components/mediaSaveButton.tsx +++ /dev/null @@ -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(null); - const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved; - - useEffect(() => { - void load("chat_picker_saved_media").then(setSavedMedia); - }, [load]); - - async function handleClick(event: React.MouseEvent) { - 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 ( - - ); -} diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 4dd6624..cbcd37e 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -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({
)} {editing ? ( -
- +
+