Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9fb7f842b | |||
|
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 { 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={{
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
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,
|
||||
} 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue