From 87e4a158e381f92b9af4b139c77dc03d73e48f3f Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 14 Jun 2026 15:25:24 +0200 Subject: [PATCH] (feat): add gif picker & media saving --- apps/web/src/routes/settings/chat.tsx | 4 + bun.lock | 9 +- packages/chat/package.json | 7 +- packages/chat/src/components/gifPicker.tsx | 638 ++++++++++++++++++ packages/chat/src/components/input.tsx | 128 +++- packages/chat/src/components/media.tsx | 15 +- .../chat/src/components/mediaSaveButton.tsx | 63 ++ packages/shared/src/data.ts | 34 +- 8 files changed, 873 insertions(+), 25 deletions(-) create mode 100644 packages/chat/src/components/gifPicker.tsx create mode 100644 packages/chat/src/components/mediaSaveButton.tsx diff --git a/apps/web/src/routes/settings/chat.tsx b/apps/web/src/routes/settings/chat.tsx index 9f070df..aef03c6 100644 --- a/apps/web/src/routes/settings/chat.tsx +++ b/apps/web/src/routes/settings/chat.tsx @@ -25,6 +25,10 @@ export default function Page() { label="Sidebar message preview" id="settings.show_start_of_last_message_in_sidebar" /> +

+ Trusted embed domains can get your IP-Address! Only add domains if you + really trust them! +

); diff --git a/bun.lock b/bun.lock index 5e2b865..a7ffa1c 100644 --- a/bun.lock +++ b/bun.lock @@ -126,6 +126,7 @@ "name": "@tensamin/chat", "version": "0.0.0", "dependencies": { + "@tanstack/pacer": "^0.21.1", "@tanstack/react-query": "^5.0.0", "@tanstack/react-router": "^1.0.0", "@tanstack/react-virtual": "^3.0.0", @@ -674,8 +675,12 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], + "@tanstack/pacer": ["@tanstack/pacer@0.21.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.3", "@tanstack/store": "^0.11.0" } }, "sha512-hB01dd4rlsYcTCNP7wK186jgAe6K5qimgM1Y5Jtvz+9PUaILvpmeLLjmQNUNSO1l23lIt+CeQR6mO1mjlPvRtQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.8", "", {}, "sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg=="], "@tanstack/react-query": ["@tanstack/react-query@5.100.8", "", { "dependencies": { "@tanstack/query-core": "5.100.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA=="], @@ -688,7 +693,7 @@ "@tanstack/router-core": ["@tanstack/router-core@1.169.1", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-x+2gIGKTTE1qAn7tLieGfrB5ciOviDmmi2ox9fAWUubRV+yTU5ruGFXocoCIWF+lB+SOtnHjo2E9BLSWyYoEmA=="], - "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], @@ -2018,6 +2023,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tensamin/electron/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@tensamin/tauri/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], diff --git a/packages/chat/package.json b/packages/chat/package.json index 3846ddd..a4b9b86 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -15,16 +15,17 @@ "build": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@tanstack/pacer": "^0.21.1", "@tanstack/react-query": "^5.0.0", "@tanstack/react-router": "^1.0.0", "@tanstack/react-virtual": "^3.0.0", "@tensamin/crypto": "workspace:*", - "@tensamin/ttp": "workspace:*", - "@tensamin/storage": "workspace:*", - "@tensamin/user": "workspace:*", "@tensamin/markdown": "workspace:*", "@tensamin/shared": "workspace:*", + "@tensamin/storage": "workspace:*", + "@tensamin/ttp": "workspace:*", "@tensamin/ui": "*", + "@tensamin/user": "workspace:*", "lucide-react": "^1.14.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/packages/chat/src/components/gifPicker.tsx b/packages/chat/src/components/gifPicker.tsx new file mode 100644 index 0000000..32229a5 --- /dev/null +++ b/packages/chat/src/components/gifPicker.tsx @@ -0,0 +1,638 @@ +import { Debouncer } from "@tanstack/pacer"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { + Button, + Input, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@tensamin/ui"; +import { useStorage } from "@tensamin/storage/context"; +import { Loader2, Search } from "lucide-react"; +import MediaSaveButton from "./mediaSaveButton"; +import React, { + useEffect, + useLayoutEffect, + useRef, + useState, + startTransition, +} from "react"; + +const klipyApiKey = + "KzyghnvdlaHjJuDycVx6rLIEu5eaacPyFERieoI7L8UWLt2rnJloL45UL5MVc9IG"; +const klipyBaseUrl = "https://api.klipy.com/api/v1"; +const pageSize = 24; +const minColumnWidth = 200; + +function getColumnCount(width: number, itemCount: number) { + return Math.max( + 1, + Math.min(itemCount || 1, Math.floor(width / minColumnWidth)), + ); +} + +type KlipyKind = "gif" | "meme"; + +type KlipyMediaFile = { + url?: string; + width?: number; + height?: number; +}; + +type KlipyMediaFormats = Record; + +type KlipyItem = { + id: number | string; + title?: string; + file?: Record; + blur_preview?: string; +}; + +type KlipyPage = { + items: KlipyItem[]; + currentPage: number; + hasNext: boolean; +}; + +type KlipyResponse = { + data?: { + data?: KlipyItem[]; + current_page?: number; + has_next?: boolean; + }; +}; + +type PickerMedia = { + key: React.Key; + url: string; + alt: string; + width?: number; + height?: number; +}; + +function distributeByHeight( + items: T[], + columnCount: number, + getAspectRatio: (item: T) => number, +) { + const columns = Array.from({ length: columnCount }, () => [] as T[]); + const columnHeights = Array.from({ length: columnCount }, () => 0); + + for (const item of items) { + const shortestColumnIndex = columnHeights.indexOf( + Math.min(...columnHeights), + ); + + columns[shortestColumnIndex].push(item); + columnHeights[shortestColumnIndex] += getAspectRatio(item); + } + + return columns; +} + +function getKlipyPath(kind: KlipyKind) { + const resource = kind === "gif" ? "gifs" : "static-memes"; + + return `${resource}/search`; +} + +async function fetchKlipyPage({ + kind, + page, + search, +}: { + kind: KlipyKind; + page: number; + search: string; +}): Promise { + const params = new URLSearchParams({ + page: String(page), + per_page: String(pageSize), + content_filter: "medium", + }); + + if (search !== "") { + params.set("q", search); + } + + if (kind === "gif") { + params.set("format_filter", "gif,webp"); + } + + const response = await fetch( + `${klipyBaseUrl}/${klipyApiKey}/${getKlipyPath(kind)}?${params}`, + ); + + if (!response.ok) { + throw new Error(`Klipy request failed with status ${response.status}`); + } + + const body = (await response.json()) as KlipyResponse; + const data = body.data; + + return { + items: data?.data ?? [], + currentPage: data?.current_page ?? page, + hasNext: data?.has_next ?? false, + }; +} + +function getFormat(item: KlipyItem, size: string, format: string) { + return item.file?.[size]?.[format]; +} + +function pickDisplayMedia(kind: KlipyKind, item: KlipyItem) { + if (kind === "gif") { + return ( + getFormat(item, "sm", "webp") ?? + getFormat(item, "md", "webp") ?? + getFormat(item, "sm", "gif") ?? + getFormat(item, "md", "gif") ?? + getFormat(item, "hd", "webp") ?? + getFormat(item, "hd", "gif") + ); + } + + return ( + getFormat(item, "sm", "webp") ?? + getFormat(item, "md", "webp") ?? + getFormat(item, "sm", "png") ?? + getFormat(item, "md", "png") ?? + getFormat(item, "hd", "webp") ?? + getFormat(item, "hd", "png") + ); +} + +function pickSelectionUrl(kind: KlipyKind, item: KlipyItem) { + if (kind === "gif") { + return ( + getFormat(item, "md", "gif")?.url ?? + getFormat(item, "sm", "gif")?.url ?? + getFormat(item, "hd", "gif")?.url + ); + } + + return ( + getFormat(item, "md", "png")?.url ?? + getFormat(item, "md", "webp")?.url ?? + getFormat(item, "sm", "png")?.url ?? + getFormat(item, "sm", "webp")?.url ?? + getFormat(item, "hd", "png")?.url ?? + getFormat(item, "hd", "webp")?.url + ); +} + +function useMeasuredWidth() { + const scrollRef = useRef(null); + const [scrollWidth, setScrollWidth] = useState(0); + + useLayoutEffect(() => { + const element = scrollRef.current; + + if (!element || typeof ResizeObserver === "undefined") return; + + const updateWidth = () => setScrollWidth(element.clientWidth); + const observer = new ResizeObserver(updateWidth); + + updateWidth(); + observer.observe(element); + + return () => observer.disconnect(); + }, []); + + return [scrollRef, scrollWidth] as const; +} + +function useImageSizes(urls: string[]) { + const [sizes, setSizes] = useState< + Record + >({}); + + useEffect(() => { + let active = true; + + for (const url of urls) { + if (sizes[url]) continue; + + const image = new Image(); + + image.onload = () => { + if (!active) return; + + setSizes((current) => ({ + ...current, + [url]: { + width: image.naturalWidth, + height: image.naturalHeight, + }, + })); + }; + image.src = url; + } + + return () => { + active = false; + }; + }, [sizes, urls]); + + return sizes; +} + +function MediaGrid({ + hasNextPage = false, + isFetchingNextPage = false, + items, + onLoadMore, + onSelect, + resizeWidth, +}: { + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + items: PickerMedia[]; + onLoadMore?: () => void; + onSelect: (url: string) => void; + resizeWidth?: number; +}) { + const [scrollRef, measuredWidth] = useMeasuredWidth(); + const columnWidth = resizeWidth ?? measuredWidth; + const columnCount = getColumnCount(columnWidth, items.length); + const columns = distributeByHeight(items, columnCount, (item) => { + if (!item.width || !item.height) return 1; + + return item.height / item.width; + }); + + function handleScroll(event: React.UIEvent) { + const element = event.currentTarget; + const distanceFromBottom = + element.scrollHeight - element.scrollTop - element.clientHeight; + + if ( + distanceFromBottom > 240 || + !hasNextPage || + isFetchingNextPage || + !onLoadMore + ) { + return; + } + + onLoadMore(); + } + + useEffect(() => { + const element = scrollRef.current; + + if (!element || !hasNextPage || isFetchingNextPage || !onLoadMore) return; + + const distanceFromBottom = + element.scrollHeight - element.scrollTop - element.clientHeight; + + if (distanceFromBottom <= 240) { + onLoadMore(); + } + }, [ + columnCount, + hasNextPage, + isFetchingNextPage, + items.length, + onLoadMore, + scrollRef, + ]); + + return ( +
+
+ {columns.map((column, columnIndex) => ( +
+ {column.map((item) => ( + + ))} +
+ ))} +
+
+ {hasNextPage ? ( +
+ + Loading more... +
+ ) : null} +
+
+ ); +} + +function KlipyPanel({ + kind, + onSelect, + resizeWidth, + searchString, +}: { + kind: KlipyKind; + onSelect: (url: string) => void; + resizeWidth?: number; + searchString: string; +}) { + const query = useInfiniteQuery({ + queryKey: ["klipy", kind, searchString], + initialPageParam: 1, + enabled: searchString !== "", + queryFn: ({ pageParam }) => + fetchKlipyPage({ + kind, + page: Number(pageParam), + search: searchString, + }), + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.currentPage + 1 : undefined, + }); + const items: PickerMedia[] = + query.data?.pages.flatMap((page) => + page.items.flatMap((item) => { + const displayMedia = pickDisplayMedia(kind, item); + const selectionUrl = pickSelectionUrl(kind, item); + + if (!displayMedia?.url || !selectionUrl) return []; + + return [ + { + key: item.id, + url: selectionUrl, + alt: item.title ?? "Klipy result", + width: displayMedia.width, + height: displayMedia.height, + }, + ]; + }), + ) ?? []; + + if (searchString === "") { + return ( +
+ +

+ {kind === "gif" && 'Try searching for "Funny cat"'} + {kind === "meme" && + 'Try searching for "Spiderman pointing at Spiderman"'} +

+
+ ); + } + + if (query.isLoading) { + return ( +
+ + Loading... +
+ ); + } + + if (query.isError) { + return ( +
+

Failed to load {kind === "gif" ? "GIFs" : "memes"}.

+ +
+ ); + } + + if (items.length === 0) { + return ( +
+ No {kind === "gif" ? "GIFs" : "memes"} found. +
+ ); + } + + return ( + void query.fetchNextPage()} + onSelect={onSelect} + resizeWidth={resizeWidth} + /> + ); +} + +function SavedPanel({ + onSelect, + onSavedMediaChange, + resizeWidth, + urls, +}: { + onSelect: (url: string) => void; + onSavedMediaChange: (savedMedia: string[]) => void; + resizeWidth?: number; + urls: string[]; +}) { + const [scrollRef, measuredWidth] = useMeasuredWidth(); + const imageSizes = useImageSizes(urls); + const columnWidth = resizeWidth ?? measuredWidth; + const columnCount = getColumnCount(columnWidth, urls.length); + const columns = distributeByHeight(urls, columnCount, (url) => { + const size = imageSizes[url]; + + if (!size?.width || !size.height) return 1; + + return size.height / size.width; + }); + + if (urls.length === 0) { + return ( +
+ Saved media will appear here. +
+ ); + } + + return ( +
+
+ {columns.map((column, columnIndex) => ( +
+ {column.map((url) => { + const size = imageSizes[url]; + + return ( +
+ + +
+ ); + })} +
+ ))} +
+
+ ); +} + +export default function GifPicker({ + onSelect, + resizeHeight, + resizeWidth, +}: { + onSelect: (url: string) => void; + resizeHeight?: number; + resizeWidth?: number; +}) { + const { load, save } = useStorage(); + const [tab, setTab] = useState("gif"); + const [searchString, setSearchString] = useState(""); + const [debouncedSearchString, setDebouncedSearchString] = useState(""); + const [savedMedia, setSavedMedia] = useState([]); + const didLoadLastTabRef = useRef(false); + const searchDebouncerRef = useRef void> | null>( + null, + ); + + if (searchDebouncerRef.current === null) { + searchDebouncerRef.current = new Debouncer( + (value) => { + startTransition(() => { + setDebouncedSearchString(value.trim()); + }); + }, + { wait: 300 }, + ); + } + + useEffect(() => { + return () => { + searchDebouncerRef.current?.cancel(); + }; + }, []); + + useEffect(() => { + if (tab !== "saved") return; + + void load("chat_picker_saved_media").then(setSavedMedia); + }, [load, tab]); + + useEffect(() => { + void load("chat_picker_last_tab").then((savedTab) => { + setTab(savedTab); + didLoadLastTabRef.current = true; + }); + }, [load]); + + useEffect(() => { + if (!didLoadLastTabRef.current) return; + + void save("chat_picker_last_tab", tab); + }, [save, tab]); + + function handleTabChange(nextTab: string) { + if (nextTab !== "gif" && nextTab !== "meme" && nextTab !== "saved") return; + + setTab(nextTab); + } + + function handleSearchChange(event: React.ChangeEvent) { + const nextValue = event.target.value; + + setSearchString(nextValue); + searchDebouncerRef.current?.maybeExecute(nextValue); + } + + return ( + + + GIFs + Memes + Saved + + + + + + + + + + + + + + ); +} diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index b0032d5..ae27596 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -1,16 +1,23 @@ import Input from "@tensamin/markdown/input"; -import { Card, CardHeader } from "@tensamin/ui"; +import { + Card, + CardHeader, + Popover, + PopoverContent, + PopoverTrigger, +} from "@tensamin/ui"; import { useStorage } from "@tensamin/storage/context"; import * as React from "react"; import { Button } from "@tensamin/ui"; -import { Plus, Laugh, Clapperboard } from "lucide-react"; +import { Plus, Laugh, FileVideo } from "lucide-react"; import { useChat } from "../context"; import { useTTP } from "@tensamin/ttp"; import { log, toast } from "@tensamin/shared/log"; import { cn, useIsMobile } from "@tensamin/ui"; import { encryptText } from "@tensamin/crypto/worker"; import { useSession } from "@tensamin/storage/session"; +import GifPicker from "./gifPicker"; export default function InputComponent({ value, @@ -23,8 +30,14 @@ export default function InputComponent({ const { send } = useTTP(); const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat(); - const { load } = useStorage(); + const { load, save } = useStorage(); const { moveUserIdToTop } = useSession(); + const gifPopoverRef = React.useRef(null); + const [gifPopoverOpen, setGifPopoverOpen] = React.useState(false); + const [gifPopoverSize, setGifPopoverSize] = React.useState<{ + width: number; + height: number; + }>(); React.useEffect(() => { void load("settings.reverse_enter_behavior").then((shouldInvert) => { @@ -32,16 +45,19 @@ export default function InputComponent({ }); }, [load]); - /** - * Executes handleSubmit. - * @param none This function has no parameters. - * @returns unknown. - */ - async function handleSubmit() { - if (value.trim() === "") return; + React.useEffect(() => { + void load("chat_picker_size").then((size) => { + if (size) { + setGifPopoverSize(size); + } + }); + }, [load]); + + async function handleSubmit(content = value, preserveContent = false) { + if (content.trim() === "") return; const time = Date.now(); - const currentValue = value; + const currentValue = content; if (!Number.isSafeInteger(userId) || userId <= 0) { toast("error", "No conversation selected"); @@ -82,11 +98,64 @@ export default function InputComponent({ moveUserIdToTop(userId); - setValue(""); + if (!preserveContent) { + setValue(""); + } } const isMobile = useIsMobile(); + function handleGifPopoverResizeStart( + event: React.PointerEvent, + ) { + const popover = gifPopoverRef.current; + + if (!popover) return; + + event.preventDefault(); + event.stopPropagation(); + + const rect = popover.getBoundingClientRect(); + const startX = event.clientX; + const startY = event.clientY; + const startWidth = rect.width; + const startHeight = rect.height; + const minSize = 40; + const maxSize = 720; + + function clampSize(size: number) { + return Math.min(Math.max(size, minSize), maxSize); + } + + function handlePointerMove(moveEvent: PointerEvent) { + const nextSize = { + width: clampSize(startWidth + startX - moveEvent.clientX), + height: clampSize(startHeight + startY - moveEvent.clientY), + }; + + setGifPopoverSize(nextSize); + } + + function handlePointerUp() { + const popover = gifPopoverRef.current; + + if (popover) { + const rect = popover.getBoundingClientRect(); + + void save("chat_picker_size", { + width: clampSize(rect.width), + height: clampSize(rect.height), + }); + } + + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + } + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + } + return ( - + + + + + } + /> + event.stopPropagation()} + onWheelCapture={(event) => event.stopPropagation()} + style={{ + ...gifPopoverSize, + maxHeight: gifPopoverSize?.height, + }} + > +
+ { + void handleSubmit(url, true); + setGifPopoverOpen(false); + }} + /> + +
diff --git a/packages/chat/src/components/media.tsx b/packages/chat/src/components/media.tsx index eb38b68..b647c77 100644 --- a/packages/chat/src/components/media.tsx +++ b/packages/chat/src/components/media.tsx @@ -3,6 +3,7 @@ import { useStorage } from "@tensamin/storage/context"; import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui"; import { TriangleAlert } from "lucide-react"; import { useState, useMemo, useEffect } from "react"; +import MediaSaveButton from "./mediaSaveButton"; export default function Media({ link }: { link: string }) { const { load } = useStorage(); @@ -16,15 +17,19 @@ export default function Media({ link }: { link: string }) { }, [load]); return trustedDomains.includes(hostname) ? ( - <> - {hidden && } +
+ {hidden ? ( + + ) : ( + + )} setHidden(false)} className="max-h-70 max-w-70 py-1 rounded-lg" /> - +
) : (
@@ -32,8 +37,8 @@ export default function Media({ link }: { link: string }) { render={} /> - Link embeds can expose your ip address. You can configure trusted - domains in the settings. + Link embeds can get your IP-Address! You can configure trusted domains + in the settings. diff --git a/packages/chat/src/components/mediaSaveButton.tsx b/packages/chat/src/components/mediaSaveButton.tsx new file mode 100644 index 0000000..5179aac --- /dev/null +++ b/packages/chat/src/components/mediaSaveButton.tsx @@ -0,0 +1,63 @@ +import { Button, cn } from "@tensamin/ui"; +import { useStorage } from "@tensamin/storage/context"; +import { Save } 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 [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/shared/src/data.ts b/packages/shared/src/data.ts index ade2562..aa14339 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -261,6 +261,12 @@ export interface Storage extends SettingsStorageDefaults { call_mute_range_start: number; call_mute_range_end: number; chat_trusted_domains: string[]; + chat_picker_saved_media: string[]; + chat_picker_last_tab: "gif" | "meme" | "saved"; + chat_picker_size: { + width: number; + height: number; + } | null; } export const storageDefaults: Storage = { @@ -297,7 +303,33 @@ export const storageDefaults: Storage = { ttp_server_cert: "", call_mute_range_start: -55, call_mute_range_end: -45, - chat_trusted_domains: ["tenor.com", "c.tenor.com"], + chat_trusted_domains: [ + // Other platforms + "cdn.discordapp.com", + + // Tenor + "tenor.com", + "c.tenor.com", + + // Klipy + "static.klipy.com", + "static1.klipy.com", + "static2.klipy.com", + + // Giphy + "giphy.com", + "www.giphy.com", + "media.giphy.com", + "i.giphy.com", + "media0.giphy.com", + "media1.giphy.com", + "media2.giphy.com", + "media3.giphy.com", + "media4.giphy.com", + ], + chat_picker_saved_media: [], + chat_picker_last_tab: "gif", + chat_picker_size: null, }; // User Status