feat(plugin): MoreStickers (#66)

This commit is contained in:
leko 2024-10-19 13:42:14 +08:00 committed by GitHub
parent 82cae8c771
commit 26a8961d9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3015 additions and 2 deletions

View file

@ -36,6 +36,8 @@
"testTsc": "tsc --noEmit"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@sapphi-red/web-noise-suppressor": "0.3.5",
"@types/less": "^3.0.6",
"@types/stylus": "^0.48.42",

View file

@ -16,6 +16,12 @@ importers:
.:
dependencies:
'@ffmpeg/ffmpeg':
specifier: ^0.12.10
version: 0.12.10
'@ffmpeg/util':
specifier: ^0.12.1
version: 0.12.1
'@sapphi-red/web-noise-suppressor':
specifier: 0.3.5
version: 0.3.5
@ -419,6 +425,21 @@ packages:
'@eslint/object-schema@2.1.4':
resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ffmpeg/ffmpeg@0.12.10':
resolution: {integrity: sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ==}
engines: {node: '>=18.x'}
'@ffmpeg/types@0.12.2':
resolution: {integrity: sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA==}
engines: {node: '>=16.x'}
'@ffmpeg/util@0.12.1':
resolution: {integrity: sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==}
engines: {node: '>=18.x'}
'@humanwhocodes/config-array@0.11.10':
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
engines: {node: '>=10.10.0'}
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@ -2564,6 +2585,21 @@ snapshots:
'@eslint/js@9.8.0': {}
'@eslint/object-schema@2.1.4': {}
'@ffmpeg/ffmpeg@0.12.10':
dependencies:
'@ffmpeg/types': 0.12.2
'@ffmpeg/types@0.12.2': {}
'@ffmpeg/util@0.12.1': {}
'@humanwhocodes/config-array@0.11.10':
dependencies:
'@humanwhocodes/object-schema': 1.2.1
debug: 4.3.4
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
'@humanwhocodes/module-importer@1.0.1': {}

View file

@ -34,13 +34,15 @@ interface TextInputProps {
* Otherwise, return a string containing the reason for this input being invalid
*/
validate(v: string): true | string;
placeholder?: string;
}
/**
* A very simple wrapper around Discord's TextInput that validates input and shows
* the user an error message and only calls your onChange when the input is valid
*/
export function CheckedTextInput({ value: initialValue, onChange, validate }: TextInputProps) {
export function CheckedTextInput({ value: initialValue, onChange, validate, placeholder }: TextInputProps) {
const [value, setValue] = React.useState(initialValue);
const [error, setError] = React.useState<string>();
@ -62,6 +64,7 @@ export function CheckedTextInput({ value: initialValue, onChange, validate }: Te
value={value}
onChange={handleChange}
error={error}
placeholder={placeholder}
/>
</>
);

View file

@ -0,0 +1,114 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { React, Text } from "@webpack/common";
import { CategoryImage } from "./categoryImage";
import { CategoryScroller } from "./categoryScroller";
import { CategoryWrapper } from "./categoryWrapper";
import { CogIcon, RecentlyUsedIcon } from "./icons";
import { RECENT_STICKERS_ID, RECENT_STICKERS_TITLE } from "./recent";
import { Settings } from "./settings";
import { StickerCategory } from "./stickerCategory";
import { cl, clPicker } from "../utils";
export interface StickerCategory {
id: string;
name: string;
iconUrl?: string;
}
export interface SidebarProps {
packMetas: StickerCategory[];
onPackSelect: (category: StickerCategory) => void;
}
export const RecentPack = {
id: RECENT_STICKERS_ID,
name: RECENT_STICKERS_TITLE,
} as StickerCategory;
export const PickerSidebar = ({ packMetas, onPackSelect }: SidebarProps) => {
const [activePack, setActivePack] = React.useState<StickerCategory>(RecentPack);
const [hovering, setHovering] = React.useState(false);
return (
<CategoryWrapper>
<CategoryScroller categoryLength={packMetas.length}>
<StickerCategory
style={{ padding: "4px", boxSizing: "border-box", width: "32px" }}
isActive={activePack === RecentPack}
onClick={() => {
if (activePack === RecentPack) return;
onPackSelect(RecentPack);
setActivePack(RecentPack);
}}
>
<RecentlyUsedIcon width={24} height={24} color={
activePack === RecentPack ? " var(--interactive-active)" : "var(--interactive-normal)"
} />
</StickerCategory>
{
...packMetas.map(pack => {
return (
<StickerCategory
key={pack.id}
onClick={() => {
if (activePack?.id === pack.id) return;
onPackSelect(pack);
setActivePack(pack);
}}
isActive={activePack?.id === pack.id}
>
<CategoryImage src={pack.iconUrl!} alt={pack.name} isActive={activePack?.id === pack.id} />
</StickerCategory>
);
})
}
</CategoryScroller>
<div className={clPicker("settings-cog-container")}>
<button
className={clPicker("settings-cog") + (
hovering ? ` ${clPicker('settings-cog-active')}` : ""
)}
onClick={() => {
openModal(modalProps => {
return (
<ModalRoot size={ModalSize.LARGE} {...modalProps}>
<ModalHeader>
<Text tag="h2">Stickers+</Text>
</ModalHeader>
<ModalContent>
<Settings />
</ModalContent>
</ModalRoot>
);
});
}}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<CogIcon width={20} height={20} />
</button>
</div>
</CategoryWrapper>
);
};

View file

@ -0,0 +1,402 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { React } from "@webpack/common";
import { Sticker, StickerPack } from "../types";
import { sendSticker } from "../upload";
import { RecentlyUsedIcon } from "./icons";
import { addRecentSticker, getRecentStickers, RECENT_STICKERS_ID, RECENT_STICKERS_TITLE } from "./recent";
import { clPicker, FFmpegStateContext } from "../utils";
export interface PickerContent {
stickerPacks: StickerPack[];
selectedStickerPackId?: string | null;
setSelectedStickerPackId: React.Dispatch<React.SetStateAction<string | null>>;
channelId: string;
closePopout: () => void;
query?: string;
}
export interface PickerContentHeader {
image: string | React.ReactNode;
title: string;
children?: React.ReactNode;
isSelected?: boolean;
afterScroll?: () => void;
beforeScroll?: () => void;
}
export interface PickerContentRow {
rowIndex: number;
grid1: PickerContentRowGrid;
grid2?: PickerContentRowGrid;
grid3?: PickerContentRowGrid;
channelId: string;
}
export interface PickerContentRowGrid {
rowIndex: number;
colIndex: number;
sticker: Sticker;
onHover: (sticker: Sticker | null) => void;
isHovered?: boolean;
channelId?: string;
onSend?: (sticker?: Sticker, shouldClose?: boolean) => void;
}
function PickerContentRowGrid({
rowIndex,
colIndex,
sticker,
onHover,
channelId,
onSend = () => { },
isHovered = false
}: PickerContentRowGrid) {
if (FFmpegStateContext === undefined) {
return <div>FFmpegStateContext is undefined</div>;
}
const ffmpegState = React.useContext(FFmpegStateContext);
return (
<div
role="gridcell"
aria-rowindex={rowIndex}
aria-colindex={colIndex}
id={clPicker(`content-row-grid-${rowIndex}-${colIndex}`)}
onMouseEnter={() => onHover(sticker)}
onClick={e => {
if (!channelId) return;
sendSticker({ channelId, sticker, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, ffmpegState });
addRecentSticker(sticker);
onSend(sticker, e.ctrlKey);
}}
>
<div
className={clPicker("content-row-grid-sticker")}
>
<span className={clPicker("content-row-grid-hidden-visually")}>{sticker.title}</span>
<div aria-hidden="true">
<div className={
[
clPicker("content-row-grid-inspected-indicator"),
`${isHovered ? "inspected" : ""}`
].join(" ")
}></div>
<div className={clPicker("content-row-grid-sticker-node")}>
<div className={clPicker("content-row-grid-asset-wrapper")} style={{
height: "96px",
width: "96px"
}}>
<img
alt={sticker.title}
src={sticker.image}
draggable="false"
datatype="sticker"
data-id={sticker.id}
className={clPicker("content-row-grid-img")}
loading="lazy"
/>
</div>
</div>
</div>
</div>
</div>
);
}
function PickerContentRow({ rowIndex, grid1, grid2, grid3, channelId }: PickerContentRow) {
return (
<div className={clPicker("content-row")}
role="row"
aria-rowindex={rowIndex}
>
<PickerContentRowGrid {...grid1} rowIndex={rowIndex} colIndex={1} channelId={channelId} />
{grid2 && <PickerContentRowGrid {...grid2} rowIndex={rowIndex} colIndex={2} channelId={channelId} />}
{grid3 && <PickerContentRowGrid {...grid3} rowIndex={rowIndex} colIndex={3} channelId={channelId} />}
</div>
);
}
function HeaderCollapseIcon({ isExpanded }: { isExpanded: boolean; }) {
return (
<svg
className={clPicker("content-header-collapse-icon")}
width={16} height={16} viewBox="0 0 24 24"
style={{
transform: `rotate(${isExpanded ? "0" : "-90deg"})`
}}
>
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M16.59 8.59004L12 13.17L7.41 8.59004L6 10L12 16L18 10L16.59 8.59004Z"></path>
</svg>
);
}
export function PickerContentHeader({
image,
title,
children,
isSelected = false,
afterScroll = () => { },
beforeScroll = () => { }
}: PickerContentHeader) {
const [isExpand, setIsExpand] = React.useState(true);
const headerElem = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (isSelected && headerElem.current) {
beforeScroll();
headerElem.current.scrollIntoView({
behavior: "smooth",
block: "start",
});
afterScroll();
}
}, [isSelected]);
return (
<span>
<div className={clPicker("content-header-wrapper")}>
<div className={clPicker("content-header-header")} ref={headerElem}
aria-expanded={isExpand}
aria-label={`Category, ${title}`}
role="button"
tabIndex={0}
onClick={() => {
setIsExpand(e => !e);
}}
>
<div className={clPicker("content-header-header-icon")}>
<div>
{typeof image === "string" ? <svg
className={clPicker("content-header-svg")}
width={16} height={16} viewBox="0 0 16 16"
>
<foreignObject
x={0} y={0} width={16} height={16}
overflow="visible" mask="url(#svg-mask-squircle)"
>
<img
alt={title}
src={image}
className={clPicker("content-header-guild-icon")}
loading="lazy"
></img>
</foreignObject>
</svg>
: image}
</div>
</div>
<span
className={clPicker("content-header-header-label")}
>
{title}
</span>
<HeaderCollapseIcon isExpanded={isExpand} />
</div>
</div>
{isExpand ? children : null}
</span>
);
}
export function PickerContent({ stickerPacks, selectedStickerPackId, setSelectedStickerPackId, channelId, closePopout, query }: PickerContent) {
const [currentSticker, setCurrentSticker] = (
React.useState<Sticker | null>((
stickerPacks.length && stickerPacks[0].stickers.length) ?
stickerPacks[0].stickers[0] :
null
)
);
const [currentStickerPack, setCurrentStickerPack] = React.useState<StickerPack | null>(stickerPacks.length ? stickerPacks[0] : null);
const [recentStickers, setRecentStickers] = React.useState<Sticker[]>([]);
const stickerPacksElemRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<HTMLDivElement>(null);
function queryFilter(stickers: Sticker[]): Sticker[] {
if (!query) return stickers;
return stickers.filter(sticker => sticker.title.toLowerCase().includes(query.toLowerCase()));
}
async function fetchRecentStickers() {
const recentStickers = await getRecentStickers();
setRecentStickers(recentStickers);
}
React.useEffect(() => {
fetchRecentStickers();
}, []);
React.useEffect(() => {
if (currentStickerPack?.id !== currentSticker?.stickerPackId) {
setCurrentStickerPack(stickerPacks.find(p => p.id === currentSticker?.stickerPackId) ?? currentStickerPack);
}
}, [currentSticker]);
const stickersToRows = (stickers: Sticker[]): JSX.Element[] => stickers
.reduce((acc, sticker, i) => {
if (i % 3 === 0) {
acc.push([]);
}
acc[acc.length - 1].push(sticker);
return acc;
}, [] as Sticker[][])
.map((stickers, i) => (
<PickerContentRow
rowIndex={i}
channelId={channelId}
grid1={{
rowIndex: i,
colIndex: 1,
sticker: stickers[0],
onHover: setCurrentSticker,
onSend: (_, s) => { !s && closePopout(); },
isHovered: currentSticker?.id === stickers[0].id
}}
grid2={
stickers.length > 1 ? {
rowIndex: i,
colIndex: 2,
sticker: stickers[1],
onHover: setCurrentSticker,
onSend: (_, s) => { !s && closePopout(); },
isHovered: currentSticker?.id === stickers[1].id
} : undefined
}
grid3={
stickers.length > 2 ? {
rowIndex: i,
colIndex: 3,
sticker: stickers[2],
onHover: setCurrentSticker,
onSend: (_, s) => { !s && closePopout(); },
isHovered: currentSticker?.id === stickers[2].id
} : undefined
}
/>
));
return (
<div className={clPicker("content-list-wrapper")}>
<div className={clPicker("content-wrapper")}>
<div className={clPicker("content-scroller")} ref={scrollerRef}>
<div className={clPicker("content-list-items")} role="none presentation">
<div ref={stickerPacksElemRef}>
<PickerContentHeader
image={
<RecentlyUsedIcon width={16} height={16} color="currentColor" />
}
title={RECENT_STICKERS_TITLE}
isSelected={RECENT_STICKERS_ID === selectedStickerPackId}
beforeScroll={() => {
scrollerRef.current?.scrollTo({
top: 0,
});
}}
afterScroll={() => { setSelectedStickerPackId(null); }}
>
{
...stickersToRows(
queryFilter(recentStickers)
)
}
</PickerContentHeader>
{
stickerPacks.map(sp => {
const rows = stickersToRows(queryFilter(sp.stickers));
return (
<PickerContentHeader
image={sp.logo.image}
title={sp.title}
isSelected={sp.id === selectedStickerPackId}
beforeScroll={() => {
scrollerRef.current?.scrollTo({
top: 0,
});
}}
afterScroll={() => { setSelectedStickerPackId(null); }}
>
{...rows}
</PickerContentHeader>
);
})
}
</div>
</div>
<div style={{
height: `${stickerPacksElemRef.current?.clientHeight ?? 0}px`
}}></div>
</div>
<div
className={clPicker("content-inspector")}
style={{
visibility: !currentSticker ? "hidden" : "visible",
...(!currentSticker ? {
height: "0"
} : {})
}}
>
<div className={clPicker("content-inspector-graphic-primary")} aria-hidden="true">
<div>
<div className={clPicker("content-row-grid-asset-wrapper")} style={{
height: "28px",
width: "28px"
}}>
<img
alt={currentSticker?.title ?? ""}
src={currentSticker?.image}
draggable="false"
datatype="sticker"
data-id={currentSticker?.id ?? ""}
className={clPicker("content-inspector-img")}
/>
</div>
</div>
</div>
<div className={clPicker("content-inspector-text-wrapper")}>
<div className={clPicker("content-inspector-title-primary")} data-text-variant="text-md/semibold">{currentSticker?.title ?? ""}</div>
<div className={clPicker("content-inspector-title-secondary")} data-text-variant="text-md/semibold">
{currentStickerPack?.title ? "from " : ""}
<strong>{currentStickerPack?.title ?? ""}</strong>
</div>
</div>
<div className={clPicker("content-inspector-graphic-secondary")} aria-hidden="true">
<div>
<svg width={32} height={32} viewBox="0 0 32 32">
<foreignObject x={0} y={0} width={32} height={32} overflow="visible" mask="url(#svg-mask-squircle)">
<img
alt={currentStickerPack?.title ?? ""}
src={currentStickerPack?.logo?.image}
></img>
</foreignObject>
</svg>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,70 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { debounce } from "@shared/debounce";
import { React, TextInput } from "@webpack/common";
import { Header } from "./header";
import { IconContainer } from "./iconContainer";
import { CancelIcon, SearchIcon } from "./icons";
import { clPicker } from "../utils";
export interface PickerHeaderProps {
onQueryChange: (query: string) => void;
}
const debounceQueryChange = debounce((cb: Function, ...args: any) => cb(...args), 150);
export const PickerHeader = ({ onQueryChange }: PickerHeaderProps) => {
const [query, setQuery] = React.useState<string | undefined>();
const setQueryDebounced = (value: string, immediate = false) => {
setQuery(value);
if (immediate) onQueryChange(value);
else debounceQueryChange(onQueryChange, value);
};
return (
<Header>
<div className={clPicker("container")}>
<div>
<div className={clPicker("search-box")}>
<TextInput
style={{ height: "30px" }}
placeholder="Search stickers"
autoFocus={true}
value={query}
onChange={(value: string) => setQueryDebounced(value)}
/>
</div>
<div className={clPicker("search-icon")}>
<IconContainer>
{
(query && query.length > 0) ?
<CancelIcon className={clPicker("clear-icon")} width={20} height={20} onClick={() => setQueryDebounced("", true)} /> :
<SearchIcon width={20} height={20} color="var(--text-muted)" />
}
</IconContainer>
</div>
</div>
</div>
</Header>
);
};

View file

@ -0,0 +1,60 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { cl } from "../utils";
export interface CategoryImageProps {
src: string;
alt?: string;
isActive?: boolean;
}
export function CategoryImage({ src, alt, isActive }: CategoryImageProps) {
return (
<div>
<svg width={32} height={32} style={{
display: "block",
contain: "paint",
overflow: "hidden",
overflowClipMargin: "content-box",
}}>
<foreignObject
className={
cl("foreign-object") + (
isActive ?
` ${cl('foreign-object-active')}`
: ""
)
}
x={0} y={0}
width={32}
height={32}
overflow="visible"
>
<img
src={src}
alt={alt}
width={32}
height={32}
/>
</foreignObject>
</svg>
</div>
);
}

View file

@ -0,0 +1,37 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { cl } from "../utils";
export function CategoryScroller(props: { children: React.ReactNode, categoryLength: number; }) {
const children = Array.isArray(props.children) ? props.children : [props.children];
return (
<div className={cl("category-scroller")}>
<div>{
children.map(child => (
<div role="listitem">
{child}
</div>
))
}</div>
<div style={{ height: `${Math.round(41.75 * (props.categoryLength + 1))}px` }}></div>
<div aria-hidden="true"></div>
</div>
);
}

View file

@ -0,0 +1,27 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { cl } from "../utils";
export function CategoryWrapper(props: { children: JSX.Element | JSX.Element[]; }) {
return (
<div className={cl("category-wrapper")}>
{props.children}
</div>
);
}

View file

@ -0,0 +1,27 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { cl } from "../utils";
export function Header(props: { children: JSX.Element | JSX.Element[]; }) {
return (
<div className={cl("header")}>
{props.children}
</div>
);
}

View file

@ -0,0 +1,31 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export function IconContainer(props: { children: JSX.Element | JSX.Element[]; }) {
return (
<div style={{
width: "20px",
height: "20px",
boxSizing: "border-box",
position: "relative",
cursor: "text"
}}>
{props.children}
</div>
);
}

View file

@ -0,0 +1,49 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export function SearchIcon({ width, height, color }: { width: number, height: number, color: string; }) {
return (
<svg role="img" width={width} height={height} viewBox="0 0 24 24">
<path fill={color} d="M21.707 20.293L16.314 14.9C17.403 13.504 18 11.799 18 10C18 7.863 17.167 5.854 15.656 4.344C14.146 2.832 12.137 2 10 2C7.863 2 5.854 2.832 4.344 4.344C2.833 5.854 2 7.863 2 10C2 12.137 2.833 14.146 4.344 15.656C5.854 17.168 7.863 18 10 18C11.799 18 13.504 17.404 14.9 16.314L20.293 21.706L21.707 20.293ZM10 16C8.397 16 6.891 15.376 5.758 14.243C4.624 13.11 4 11.603 4 10C4 8.398 4.624 6.891 5.758 5.758C6.891 4.624 8.397 4 10 4C11.603 4 13.109 4.624 14.242 5.758C15.376 6.891 16 8.398 16 10C16 11.603 15.376 13.11 14.242 14.243C13.109 15.376 11.603 16 10 16Z"></path>
</svg>
);
};
export function CancelIcon({ width, height, className, onClick }: { width: number, height: number, className: string, onClick: () => void; }) {
return (
<svg role="img" width={width} height={height} viewBox="0 0 24 24" className={className} onClick={onClick}>
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z"></path>
</svg>
);
};
export function RecentlyUsedIcon({ width, height, color }: { width: number, height: number, color: string; }) {
return (
<svg role="img" width={width} height={height} viewBox="0 0 24 24">
<path d="M12 2C6.4764 2 2 6.4764 2 12C2 17.5236 6.4764 22 12 22C17.5236 22 22 17.5236 22 12C22 6.4764 17.5236 2 12 2ZM12 5.6C12.4422 5.6 12.8 5.95781 12.8 6.4V11.5376L16.5625 13.7126C16.9453 13.9329 17.0703 14.4173 16.85 14.8001C16.6297 15.183 16.1453 15.3079 15.7625 15.0876L11.6873 12.7376C11.656 12.7251 11.6279 12.7048 11.5998 12.6876C11.3607 12.5486 11.1998 12.2954 11.1998 12.0001V6.4001C11.1998 5.9579 11.5578 5.6 12 5.6Z" fill={color}></path>
</svg>
);
};
export function CogIcon({ width, height }: { width: number, height: number; }) {
return (
<svg role="img" width={width} height={height} viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M19.738 10H22V14H19.739C19.498 14.931 19.1 15.798 18.565 16.564L20 18L18 20L16.565 18.564C15.797 19.099 14.932 19.498 14 19.738V22H10V19.738C9.069 19.498 8.203 19.099 7.436 18.564L6 20L4 18L5.436 16.564C4.901 15.799 4.502 14.932 4.262 14H2V10H4.262C4.502 9.068 4.9 8.202 5.436 7.436L4 6L6 4L7.436 5.436C8.202 4.9 9.068 4.502 10 4.262V2H14V4.261C14.932 4.502 15.797 4.9 16.565 5.435L18 3.999L20 5.999L18.564 7.436C19.099 8.202 19.498 9.069 19.738 10ZM12 16C14.2091 16 16 14.2091 16 12C16 9.79086 14.2091 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16Z"></path>
</svg>
);
};

View file

@ -0,0 +1,61 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as DataStore from "@api/DataStore";
import { Sticker } from "../types";
import { Mutex } from "../utils";
const mutex = new Mutex();
// The ID of recent sticker and recent sticker pack
export const RECENT_STICKERS_ID = "recent";
export const RECENT_STICKERS_TITLE = "Recently Used";
const KEY = "Vencord-MoreStickers-RecentStickers";
export async function getRecentStickers(): Promise<Sticker[]> {
return (await DataStore.get(KEY)) ?? [];
}
export async function setRecentStickers(stickers: Sticker[]): Promise<void> {
const unlock = await mutex.lock();
try {
await DataStore.set(KEY, stickers);
} finally {
unlock();
}
}
export async function addRecentSticker(sticker: Sticker): Promise<void> {
const stickers = await getRecentStickers();
const index = stickers.findIndex(s => s.id === sticker.id);
if (index !== -1) {
stickers.splice(index, 1);
}
stickers.unshift(sticker);
while (stickers.length > 16) {
stickers.pop();
}
await setRecentStickers(stickers);
}
export async function removeRecentStickerByPackId(packId: string): Promise<void> {
const stickers = await getRecentStickers();
await setRecentStickers(stickers.filter(s => s.stickerPackId !== packId));
}

View file

@ -0,0 +1,397 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { CheckedTextInput } from "@components/CheckedTextInput";
import { Flex } from "@components/Flex";
import { Button, Forms, React, TabBar, Text, TextArea, Toasts } from "@webpack/common";
import { convert as convertLineSP, getIdFromUrl as getLineStickerPackIdFromUrl, getStickerPackById as getLineStickerPackById, parseHtml as getLineSPFromHtml, isLineStickerPackHtml } from "../lineStickers";
import { convert as convertLineEP, getIdFromUrl as getLineEmojiPackIdFromUrl, getStickerPackById as getLineEmojiPackById, parseHtml as getLineEPFromHtml, isLineEmojiPackHtml } from "../lineEmojis";
import { deleteStickerPack, getStickerPackMetas, saveStickerPack } from "../stickers";
import { StickerPack, StickerPackMeta } from "../types";
import { cl, clPicker } from "../utils";
enum SettingsTabsKey {
ADD_STICKER_PACK_URL = "Add from URL",
ADD_STICKER_PACK_HTML = "Add from HTML",
ADD_STICKER_PACK_FILE = "Add from File",
}
const noDrag = {
onMouseDown: e => { e.preventDefault(); return false; },
onDragStart: e => { e.preventDefault(); return false; }
};
const StickerPackMetadata = ({ meta, hoveredStickerPackId, setHoveredStickerPackId, refreshStickerPackMetas }:
{ meta: StickerPackMeta, [key: string]: any; }
) => {
return (
<div className="sticker-pack"
onMouseEnter={() => setHoveredStickerPackId(meta.id)}
onMouseLeave={() => setHoveredStickerPackId(null)}
>
<div className={
[
clPicker("content-row-grid-inspected-indicator"),
hoveredStickerPackId === meta.id ? "inspected" : ""
].join(" ")
} style={{
top: "unset",
left: "unset",
height: "96px",
width: "96px",
}}></div>
<img src={meta.logo.image} width="96" {...noDrag} />
<button
className={hoveredStickerPackId === meta.id ? "show" : ""}
onClick={async () => {
try {
await deleteStickerPack(meta.id);
Toasts.show({
message: "Sticker Pack deleted",
type: Toasts.Type.SUCCESS,
id: Toasts.genId(),
options: {
duration: 1000
}
});
await refreshStickerPackMetas();
} catch (e: any) {
Toasts.show({
message: e.message,
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
duration: 1000
}
});
}
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" style={{ fill: "var(--status-danger)" }}>
<title>Delete</title>
<path d="M15 3.999V2H9V3.999H3V5.999H21V3.999H15Z" />
<path d="M5 6.99902V18.999C5 20.101 5.897 20.999 7 20.999H17C18.103 20.999 19 20.101 19 18.999V6.99902H5ZM11 17H9V11H11V17ZM15 17H13V11H15V17Z" />
</svg>
</button>
<Text className={cl("pack-title")} tag="span">{meta.title}</Text>
</div>
);
};
export const Settings = () => {
const [stickerPackMetas, setstickerPackMetas] = React.useState<StickerPackMeta[]>([]);
const [addStickerUrl, setAddStickerUrl] = React.useState<string>("");
const [addStickerHtml, setAddStickerHtml] = React.useState<string>("");
const [tab, setTab] = React.useState<SettingsTabsKey>(SettingsTabsKey.ADD_STICKER_PACK_URL);
const [hoveredStickerPackId, setHoveredStickerPackId] = React.useState<string | null>(null);
async function refreshStickerPackMetas() {
setstickerPackMetas(await getStickerPackMetas());
}
React.useEffect(() => {
refreshStickerPackMetas();
}, []);
return (
<div className={cl("settings")}>
<TabBar
type="top"
look="brand"
selectedItem={tab}
onItemSelect={setTab}
className="tab-bar"
>
{
Object.values(SettingsTabsKey).map(k => (
<TabBar.Item key={k} id={k} className="tab-bar-item">
{k}
</TabBar.Item>
))
}
</TabBar>
{tab === SettingsTabsKey.ADD_STICKER_PACK_URL &&
<div className="section">
<Forms.FormTitle tag="h5">Add Sticker Pack from URL</Forms.FormTitle>
<Forms.FormText>
<p>
Currently LINE stickers supported only. <br />
Telegram stickers support is planned, but due to the lack of a public API, it is most likely to be provided by sticker pack files instead of adding by URL.
</p>
</Forms.FormText>
<Flex flexDirection="row" style={{
alignItems: "center",
justifyContent: "center"
}} >
<span style={{
flexGrow: 1
}}>
<CheckedTextInput
value={addStickerUrl}
onChange={setAddStickerUrl}
validate={(v: string) => {
try {
getLineStickerPackIdFromUrl(v);
return true;
} catch (e: any) { }
try {
getLineEmojiPackIdFromUrl(v);
return true;
} catch (e: any) { }
return "Invalid URL";
}}
placeholder="Sticker Pack URL"
/>
</span>
<Button
size={Button.Sizes.SMALL}
onClick={async e => {
e.preventDefault();
let type: string = "";
try {
getLineStickerPackIdFromUrl(addStickerUrl);
type = "LineStickerPack";
} catch (e: any) { }
try {
getLineEmojiPackIdFromUrl(addStickerUrl);
type = "LineEmojiPack";
} catch (e: any) { }
let errorMessage = "";
switch (type) {
case "LineStickerPack": {
try {
const id = getLineStickerPackIdFromUrl(addStickerUrl);
const lineSP = await getLineStickerPackById(id);
const stickerPack = convertLineSP(lineSP);
await saveStickerPack(stickerPack);
} catch (e: any) {
console.error(e);
errorMessage = e.message;
}
break;
};
case "LineEmojiPack": {
try {
const id = getLineEmojiPackIdFromUrl(addStickerUrl);
const lineEP = await getLineEmojiPackById(id);
const stickerPack = convertLineEP(lineEP);
await saveStickerPack(stickerPack);
} catch (e: any) {
console.error(e);
errorMessage = e.message;
}
break;
}
}
setAddStickerUrl("");
refreshStickerPackMetas();
if (errorMessage) {
Toasts.show({
message: errorMessage,
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
duration: 1000
}
});
} else {
Toasts.show({
message: "Sticker Pack added",
type: Toasts.Type.SUCCESS,
id: Toasts.genId(),
options: {
duration: 1000
}
});
}
}}
>Insert</Button>
</Flex>
</div>
}
{tab === SettingsTabsKey.ADD_STICKER_PACK_HTML &&
<div className="section">
<Forms.FormTitle tag="h5">Add Sticker Pack from HTML</Forms.FormTitle>
<Forms.FormText>
<p>
When encountering errors while adding a sticker pack, you can try to add it using the HTML source code of the sticker pack page.<br />
This applies to stickers which are region locked / OS locked / etc.<br />
The region LINE recognized may vary from the region you are in due to the CORS proxy we're using.
</p>
</Forms.FormText>
<Flex flexDirection="row" style={{
alignItems: "center",
justifyContent: "center"
}} >
<span style={{
flexGrow: 1
}}>
<TextArea
value={addStickerHtml}
onChange={setAddStickerHtml}
placeholder="Paste HTML here"
rows={1}
/>
</span>
<Button
size={Button.Sizes.SMALL}
onClick={async e => {
e.preventDefault();
let errorMessage = "";
if (isLineEmojiPackHtml(addStickerHtml)) {
try {
const lineSP = getLineSPFromHtml(addStickerHtml);
const stickerPack = convertLineSP(lineSP);
await saveStickerPack(stickerPack);
} catch (e: any) {
console.error(e);
errorMessage = e.message;
}
} else if (isLineStickerPackHtml(addStickerHtml)) {
try {
const lineEP = getLineEPFromHtml(addStickerHtml);
const stickerPack = convertLineEP(lineEP);
await saveStickerPack(stickerPack);
} catch (e: any) {
console.error(e);
errorMessage = e.message;
}
}
setAddStickerHtml("");
refreshStickerPackMetas();
if (errorMessage) {
Toasts.show({
message: errorMessage,
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
duration: 1000
}
});
} else {
Toasts.show({
message: "Sticker Pack added",
type: Toasts.Type.SUCCESS,
id: Toasts.genId(),
options: {
duration: 1000
}
});
}
}}
>Insert from HTML</Button>
</Flex>
</div>
}
{
tab === SettingsTabsKey.ADD_STICKER_PACK_FILE &&
<div className="section">
<Forms.FormTitle tag="h5">Add Sticker Pack from File</Forms.FormTitle>
<Button
size={Button.Sizes.SMALL}
onClick={async e => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".stickerpack,.stickerpacks,.json";
input.onchange = async e => {
try {
const file = input.files?.[0];
if (!file) return;
const fileText = await file.text();
const fileJson = JSON.parse(fileText);
let stickerPacks: StickerPack[] = [];
if (Array.isArray(fileJson)) {
stickerPacks = fileJson;
} else {
stickerPacks = [fileJson];
}
for (const stickerPack of stickerPacks) {
await saveStickerPack(stickerPack);
}
Toasts.show({
message: "Sticker Packs added",
type: Toasts.Type.SUCCESS,
id: Toasts.genId(),
options: {
duration: 1000
}
});
} catch (e: any) {
console.error(e);
Toasts.show({
message: e.message,
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
duration: 1000
}
});
}
};
input.click();
}}
>
Open Sticker Pack File
</Button>
</div>
}
<Forms.FormDivider style={{
marginTop: "8px",
marginBottom: "8px"
}} />
<Forms.FormTitle tag="h5">Stickers Management</Forms.FormTitle>
<div className="section">
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))",
gap: "8px"
}}>
{
stickerPackMetas.map(meta => (
<StickerPackMetadata
key={meta.id}
meta={meta}
hoveredStickerPackId={hoveredStickerPackId}
setHoveredStickerPackId={setHoveredStickerPackId}
refreshStickerPackMetas={refreshStickerPackMetas}
/>
))
}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,43 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { cl } from "../utils";
export interface StickerCategoryProps {
children: React.ReactNode;
onClick?: () => void;
isActive: boolean;
style?: React.CSSProperties;
}
export function StickerCategory(props: StickerCategoryProps) {
return (
<div
style={props.style}
className={
cl("sticker-category") +
(props.isActive ? ` ${cl('sticker-category-active')}` : "")
}
tabIndex={0}
role="button"
onClick={props.onClick}
>
{props.children}
</div>
);
}

View file

@ -0,0 +1,30 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export function Wrapper(props: { children: JSX.Element | JSX.Element[]; }) {
return (
<div style={{
position: "relative",
display: "grid",
gridTemplateColumns: "48px auto",
gridTemplateRows: "auto 1fr auto",
}}>
{props.children}
</div>
);
}

View file

@ -0,0 +1,225 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "./style.css";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { React } from "@webpack/common";
import { Channel } from "discord-types/general";
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { PickerSidebar } from "./components/PickerCategoriesSidebar";
import { PickerContent } from "./components/PickerContent";
import { PickerHeader } from "./components/PickerHeader";
import { Settings } from "./components/settings";
import { Wrapper } from "./components/wrapper";
import { getStickerPack, getStickerPackMetas } from "./stickers";
import { StickerPack, StickerPackMeta, FFmpegState } from "./types";
import { cl, FFmpegStateContext, loadFFmpeg } from "./utils";
export default definePlugin({
name: "MoreStickers",
description: "Adds sticker packs from other social media platforms. (e.g. LINE)",
authors: [EquicordDevs.Leko, Devs.Arjix],
options: {
settings: {
type: OptionType.COMPONENT,
description: "Why is this here? Who is going to read this on a custom component? It isn't even rendered? What is its purpose?",
component: Settings
}
},
patches: [
{
find: "STICKER_BUTTON_LABEL,",
replacement: [{
match: /(children:\(0,\w\.jsx\)\()([\w.]+?)(,{innerClassName.{10,30}\.stickerButton)/,
replace: (_, head, button, tail) => {
const isMoreStickers = "arguments[0]?.stickersType";
return `${head}${isMoreStickers}?$self.stickerButton:${button}${tail}`;
}
}, {
match: /(\w=)(\w\.useCallback\(\(\)=>\{\(0,\w+\.\w+\)\([\w\.]*?\.STICKER,.*?);/,
replace: (_, decl, cb) => {
const newCb = cb.replace(/(?<=\(\)=>\{\(.*?\)\().+?\.STICKER/, "\"stickers+\"");
return `${decl}arguments[0]?.stickersType?${newCb}:${cb};`;
}
}, {
match: /(\w)=((\w)===\w+?\.\w+?\.STICKER)/,
replace: (_, isActive, isStickerTab, currentTab) => {
const c = "arguments[0].stickersType";
return `${isActive}=${c}?(${currentTab}===${c}):(${isStickerTab})`;
}
}]
},
{
find: '.gifts)',
replacement: {
match: /,\(null===\(\w=\w\.stickers\)\|\|void 0.*?(\w)\.push\((\(0,\w\.jsx\))\((.+?),{disabled:\w,type:(\w)},"sticker"\)\)/,
replace: (m, _, jsx, compo, type) => {
const c = "arguments[0].type";
return `${m},${c}?.submit?.button&&${_}.push(${jsx}(${compo},{disabled:!${c}?.submit?.button,type:${type},stickersType:"stickers+"},"stickers+"))`;
}
}
},
{
find: ".Messages.EXPRESSION_PICKER_GIF",
replacement: {
match: /role:"tablist",.+?\.Messages\.EXPRESSION_PICKER_CATEGORIES_A11Y_LABEL,children:(\[.*?\)\]}\)}\):null,)(.*?closePopout:\w.*?:null)/s,
replace: m => {
const stickerTabRegex = /(\w+?)\?(\([^()]+?\))\((.{1,2}),{.{0,128},isActive:(.{1,2})===.{1,150},children:(.{1,10}Messages.EXPRESSION_PICKER_STICKER).*?:null/s;
const res = m.replace(stickerTabRegex, (_m, canUseStickers, jsx, tabHeaderComp, currentTab, stickerText) => {
const isActive = `${currentTab}==="stickers+"`;
return (
`${_m},${canUseStickers}?` +
`${jsx}(${tabHeaderComp},{id:"stickers+-picker-tab","aria-controls":"more-stickers-picker-tab-panel","aria-selected":${isActive},isActive:${isActive},autoFocus:true,viewType:"stickers+",children:${jsx}("div",{children:${stickerText}+"+"})})` +
":null"
);
});
return res.replace(/:null,((.{1,200})===.{1,30}\.STICKER&&\w+\?(\([^()]{1,10}\)).{1,15}?(\{.*?,onSelectSticker:.*?\})\):null)/s, (_, _m, currentTab, jsx, props) => {
return `:null,${currentTab}==="stickers+"?${jsx}($self.moreStickersComponent,${props}):null,${_m}`;
});
}
}
},
{
find: '==="remove_text"',
replacement: {
match: /,\w\.insertText=\w=>{[\w ;]*?1===\w\.length&&.+?==="remove_text"/,
replace: ",$self.textEditor=arguments[0]$&"
}
}
],
stickerButton({
innerClassName,
isActive,
onClick
}) {
return (
<button
className={innerClassName}
onClick={onClick}
style={{ backgroundColor: "transparent" }}
>
{/* Icon taken from: https://github.com/Pitu/Magane/blob/0ebb09acf9901933ebebe19fbd473ec08cf917b3/src/Button.svelte#L29 */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
className={cl("icon", { "icon-active": isActive })}
>
<path d="M18.5 11c-4.136 0-7.5 3.364-7.5 7.5c0 .871.157 1.704.432 2.482l9.551-9.551A7.462 7.462 0 0 0 18.5 11z" />
<path d="M12 2C6.486 2 2 6.486 2 12c0 4.583 3.158 8.585 7.563 9.69A9.431 9.431 0 0 1 9 18.5C9 13.262 13.262 9 18.5 9c1.12 0 2.191.205 3.19.563C20.585 5.158 16.583 2 12 2z" />
</svg>
</button>
);
},
moreStickersComponent({
channel,
closePopout
}: {
channel: Channel,
closePopout: () => void;
}) {
if (FFmpegStateContext === undefined) {
return <div>FFmpegStateContext is undefined</div>;
}
const [query, setQuery] = React.useState<string | undefined>();
const [stickerPackMetas, setStickerPackMetas] = React.useState<StickerPackMeta[]>([]);
const [stickerPacks, setStickerPacks] = React.useState<StickerPack[]>([]);
const [counter, setCounter] = React.useState(0);
const [selectedStickerPackId, setSelectedStickerPackId] = React.useState<string | null>(null);
const ffmpegLoaded = React.useState(false);
const ffmpeg = React.useState<FFmpeg>(new FFmpeg());
const getMetasSignature = (m: StickerPackMeta[]) => m.map(x => x.id).sort().join(",");
React.useEffect(() => {
(async () => {
console.log("Updating sticker packs...", counter);
setCounter(counter + 1);
const sps = (await Promise.all(
stickerPackMetas.map(meta => getStickerPack(meta.id))
))
.filter((x): x is Exclude<typeof x, null> => x !== null);
setStickerPacks(sps);
})();
}, [stickerPackMetas]);
React.useEffect(() => {
(async () => {
const metas = await getStickerPackMetas();
if (getMetasSignature(metas) !== getMetasSignature(stickerPackMetas)) {
setStickerPackMetas(metas);
}
})();
}, []);
React.useEffect(() => {
if (ffmpegLoaded[0]) return;
loadFFmpeg(ffmpeg[0], () => {
ffmpegLoaded[1](true);
});
}, []);
return (
<Wrapper>
<svg width="1" height="1" viewBox="0 0 1 1" fill="none" xmlns="http://www.w3.org/2000/svg" id={cl("inspectedIndicatorMask")}>
<path d="M0 0.26087C0 0.137894 0 0.0764069 0.0382035 0.0382035C0.0764069 0 0.137894 0 0.26087 0H0.73913C0.862106 0 0.923593 0 0.961797 0.0382035C1 0.0764069 1 0.137894 1 0.26087V0.73913C1 0.862106 1 0.923593 0.961797 0.961797C0.923593 1 0.862106 1 0.73913 1H0.26087C0.137894 1 0.0764069 1 0.0382035 0.961797C0 0.923593 0 0.862106 0 0.73913V0.26087Z" fill="white" />
</svg>
<PickerHeader onQueryChange={setQuery} />
<FFmpegStateContext.Provider value={{
ffmpeg: ffmpeg[0],
isLoaded: ffmpegLoaded[0]
}}>
<PickerContent
stickerPacks={stickerPacks}
selectedStickerPackId={selectedStickerPackId}
setSelectedStickerPackId={setSelectedStickerPackId}
channelId={channel.id}
closePopout={closePopout}
query={query}
/>
</FFmpegStateContext.Provider>
<PickerSidebar
packMetas={
stickerPackMetas.map(meta => ({
id: meta.id,
name: meta.title,
iconUrl: meta.logo.image
}))
}
onPackSelect={pack => {
setSelectedStickerPackId(pack.id);
}}
></PickerSidebar>
</Wrapper>
);
}
});

View file

@ -0,0 +1,150 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { LineEmoji, LineEmojiPack, Sticker, StickerPack } from "./types";
import { corsFetch } from "./utils";
export interface StickerCategory {
title: string;
id: number;
packs: {
title: string;
id: string;
img: string;
}[];
}
/**
* Get ID of sticker pack from a URL
*
* @param url The URL to get the ID from.
* @returns {string} The ID.
* @throws {Error} If the URL is invalid.
*/
export function getIdFromUrl(url: string): string {
const re = /^https:\/\/store\.line\.me\/emojishop\/product\/([a-z0-9]+)\/.*$/;
const match = re.exec(url);
if (match === null) {
throw new Error("Invalid URL");
}
return match[1];
}
/**
* Convert LineEmojiPack id to StickerPack id
*
* @param id The id to convert.
* @returns {string} The converted id.
*/
function toStickerPackId(id: string): string {
return "Vencord-MoreStickers-Line-Emoji-Pack-" + id;
}
/**
* Convert LineEmoji id to Sticker id
*
* @param stickerId The id to convert.
* @param lineEmojiPackId The id of the LineEmojiPack.
* @returns {string} The converted id.
*/
function toStickerId(stickerId: string, lineEmojiPackId: string): string {
return "Vencord-MoreStickers-Line-Emoji" + lineEmojiPackId + "-" + stickerId;
}
/**
* Convert LineEmoji to Sticker
*
* @param {LineEmoji} s The LineEmoji to convert.
* @return {Sticker} The sticker.
*/
export function convertSticker(s: LineEmoji): Sticker {
return {
id: toStickerId(s.id, s.stickerPackId),
image: s.animationUrl || s.staticUrl,
title: s.id,
stickerPackId: toStickerPackId(s.stickerPackId),
isAnimated: !!s.animationUrl
};
}
/**
* Convert LineEmojiPack to StickerPack
*
* @param {LineEmojiPack} sp The LineEmojiPack to convert.
* @return {StickerPack} The sticker pack.
*/
export function convert(sp: LineEmojiPack): StickerPack {
return {
id: toStickerPackId(sp.id),
title: sp.title,
author: sp.author,
logo: convertSticker(sp.mainImage),
stickers: sp.stickers.map(convertSticker)
};
}
/**
* Get stickers from given HTML
*
* @param {string} html The HTML.
* @return {Promise<LineEmojiPack>} The sticker pack.
*/
export function parseHtml(html: string): LineEmojiPack {
const doc = new DOMParser().parseFromString(html, "text/html");
const mainImage = JSON.parse((doc.querySelector("[ref=mainImage]") as HTMLElement)?.dataset?.preview ?? "null") as LineEmoji;
const { id } = mainImage;
const stickers =
[...doc.querySelectorAll('.FnStickerPreviewItem')]
.map(x => JSON.parse((x as HTMLElement).dataset.preview ?? "null"))
.filter(x => x !== null)
.map(x => ({ ...x, stickerPackId: id })) as LineEmoji[];
const stickerPack = {
title: doc.querySelector("[data-test=emoji-name-title]")?.textContent ?? "null",
author: {
name: doc.querySelector("[data-test=emoji-author]")?.textContent ?? "null",
url: "https://store.line.me/" + (doc.querySelector("[data-test=emoji-author]")?.getAttribute("href") ?? "null")
},
id,
mainImage,
stickers
} as LineEmojiPack;
return stickerPack;
}
export function isLineEmojiPackHtml(html: string): boolean {
return html.includes("data-test=\"emoji-name-title\"");
}
/**
* Get stickers from LINE
*
* @param {string} id The id of the sticker pack.
* @return {Promise<LineEmojiPack>} The sticker pack.
*/
export async function getStickerPackById(id: string, region = "en"): Promise<LineEmojiPack> {
const res = await corsFetch(`https://store.line.me/emojishop/product/${id}/${region}`);
const html = await res.text();
return parseHtml(html);
}

View file

@ -0,0 +1,149 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { LineSticker, LineStickerPack, Sticker, StickerPack } from "./types";
import { corsFetch } from "./utils";
export interface StickerCategory {
title: string;
id: number;
packs: {
title: string;
id: string;
img: string;
}[];
}
/**
* Get ID of sticker pack from a URL
*
* @param url The URL to get the ID from.
* @returns {string} The ID.
* @throws {Error} If the URL is invalid.
*/
export function getIdFromUrl(url: string): string {
const re = /^https:\/\/store\.line\.me\/stickershop\/product\/([a-z0-9]+)\/.*$/;
const match = re.exec(url);
if (match === null) {
throw new Error("Invalid URL");
}
return match[1];
}
/**
* Convert LineStickerPack id to StickerPack id
*
* @param id The id to convert.
* @returns {string} The converted id.
*/
function toStickerPackId(id: string): string {
return "Vencord-MoreStickers-Line-Pack-" + id;
}
/**
* Convert LineSticker id to Sticker id
*
* @param stickerId The id to convert.
* @param lineStickerPackId The id of the LineStickerPack.
* @returns {string} The converted id.
*/
function toStickerId(stickerId: string, lineStickerPackId: string): string {
return "Vencord-MoreStickers-Line-Sticker" + lineStickerPackId + "-" + stickerId;
}
/**
* Convert LineSticker to Sticker
*
* @param {LineSticker} s The LineSticker to convert.
* @return {Sticker} The sticker.
*/
export function convertSticker(s: LineSticker): Sticker {
return {
id: toStickerId(s.id, s.stickerPackId),
image: s.staticUrl,
title: s.id,
stickerPackId: toStickerPackId(s.stickerPackId)
};
}
/**
* Convert LineStickerPack to StickerPack
*
* @param {LineStickerPack} sp The LineStickerPack to convert.
* @return {StickerPack} The sticker pack.
*/
export function convert(sp: LineStickerPack): StickerPack {
return {
id: toStickerPackId(sp.id),
title: sp.title,
author: sp.author,
logo: convertSticker(sp.mainImage),
stickers: sp.stickers.map(convertSticker)
};
}
/**
* Get stickers from given HTML
*
* @param {string} html The HTML.
* @return {Promise<LineStickerPack>} The sticker pack.
*/
export function parseHtml(html: string): LineStickerPack {
const doc = new DOMParser().parseFromString(html, "text/html");
const mainImage = JSON.parse((doc.querySelector("[ref=mainImage]") as HTMLElement)?.dataset?.preview ?? "null") as LineSticker;
const { id } = mainImage;
const stickers =
[...doc.querySelectorAll('[data-test="sticker-item"]')]
.map(x => JSON.parse((x as HTMLElement).dataset.preview ?? "null"))
.filter(x => x !== null)
.map(x => ({ ...x, stickerPackId: id })) as LineSticker[];
const stickerPack = {
title: doc.querySelector("[data-test=sticker-name-title]")?.textContent ?? "null",
author: {
name: doc.querySelector("[data-test=sticker-author]")?.textContent ?? "null",
url: "https://store.line.me/" + (doc.querySelector("[data-test=sticker-author]")?.getAttribute("href") ?? "null")
},
id,
mainImage,
stickers
} as LineStickerPack;
return stickerPack;
}
export function isLineStickerPackHtml(html: string): boolean {
return html.includes("data-test=\"sticker-name-title\"");
}
/**
* Get stickers from LINE
*
* @param {string} id The id of the sticker pack.
* @return {Promise<LineStickerPack>} The sticker pack.
*/
export async function getStickerPackById(id: string, region = "en"): Promise<LineStickerPack> {
const res = await corsFetch(`https://store.line.me/stickershop/product/${id}/${region}`);
const html = await res.text();
return parseHtml(html);
}

View file

@ -0,0 +1,120 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as DataStore from "@api/DataStore";
import { removeRecentStickerByPackId } from "./components/recent";
import { StickerPack, StickerPackMeta } from "./types";
import { Mutex } from "./utils";
const mutex = new Mutex();
const PACKS_KEY = "Vencord-MoreStickers-Packs";
/**
* Convert StickerPack to StickerPackMeta
*
* @param {StickerPack} sp The StickerPack to convert.
* @return {StickerPackMeta} The sticker pack metadata.
*/
function stickerPackToMeta(sp: StickerPack): StickerPackMeta {
return {
id: sp.id,
title: sp.title,
author: sp.author,
logo: sp.logo
};
}
/**
* Save a sticker pack to the DataStore
*
* @param {StickerPack} sp The StickerPack to save.
* @return {Promise<void>}
*/
export async function saveStickerPack(sp: StickerPack): Promise<void> {
const meta = stickerPackToMeta(sp);
await Promise.all([
DataStore.set(`${sp.id}`, sp),
(async () => {
const unlock = await mutex.lock();
try {
const packs = (await DataStore.get(PACKS_KEY) ?? null) as (StickerPackMeta[] | null);
await DataStore.set(PACKS_KEY, packs === null ? [meta] : [...packs, meta]);
} finally {
unlock();
}
})()
]);
}
/**
* Get sticker packs' metadata from the DataStore
*
* @return {Promise<StickerPackMeta[]>}
*/
export async function getStickerPackMetas(): Promise<StickerPackMeta[]> {
const packs = (await DataStore.get(PACKS_KEY)) ?? null as (StickerPackMeta[] | null);
return packs ?? [];
}
/**
* Get a sticker pack from the DataStore
*
* @param {string} id The id of the sticker pack.
* @return {Promise<StickerPack | null>}
* */
export async function getStickerPack(id: string): Promise<StickerPack | null> {
return (await DataStore.get(id)) ?? null as StickerPack | null;
}
/**
* Get a sticker pack meta from the DataStore
*
* @param {string} id The id of the sticker pack.
* @return {Promise<StickerPackMeta | null>}
* */
export async function getStickerPackMeta(id: string): Promise<StickerPackMeta | null> {
const sp = await getStickerPack(id);
return sp ? stickerPackToMeta(sp) : null;
}
/**
* Delete a sticker pack from the DataStore
*
* @param {string} id The id of the sticker pack.
* @return {Promise<void>}
* */
export async function deleteStickerPack(id: string): Promise<void> {
await Promise.all([
DataStore.del(id),
removeRecentStickerByPackId(id),
(async () => {
const unlock = await mutex.lock();
try {
const packs = (await DataStore.get(PACKS_KEY) ?? null) as (StickerPackMeta[] | null);
if (packs === null) return;
await DataStore.set(PACKS_KEY, packs.filter(p => p.id !== id));
} finally {
unlock();
}
})()
]);
}

View file

@ -0,0 +1,508 @@
/* stylelint-disable function-url-quotes */
.vc-more-stickers-icon {
fill: #b9bbbe;
margin-top: 20%;
}
.vc-more-stickers-icon:hover,
.vc-more-stickers-icon:focus-visible,
.vc-more-stickers-icon-active {
fill: #fff;
}
.vc-more-stickers-picker-container {
background-color: var(--background-tertiary);
overflow: hidden;
border-radius: 4px;
width: 100%;
}
.vc-more-stickers-picker-container>div:first-child {
display: flex;
flex: 1 1 auto;
flex-flow: wrap;
padding: 1px 0 1px 1px;
overflow: hidden;
}
.vc-more-stickers-picker-search-box {
height: 30px;
line-height: 32px;
font-size: 16px;
flex: 1;
min-width: 48px;
margin: 1px;
}
.vc-more-stickers-picker-search-icon {
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
cursor: text;
box-sizing: border-box;
margin-right: 8px;
}
.vc-more-stickers-foreign-object>img {
text-indent: -9999px;
align-items: center;
background-color: var(--background-primary);
color: var(--text-normal);
display: flex;
height: 100%;
justify-content: center;
width: 100%;
}
.vc-more-stickers-foreign-object {
mask: url(#svg-mask-avatar-default) !important;
}
.vc-more-stickers-foreign-object:hover,
.vc-more-stickers-foreign-object-active {
mask: url(#svg-mask-squircle) !important;
}
.vc-more-stickers-category-wrapper {
background-color: var(--background-tertiary);
position: absolute;
overflow: hidden;
width: 48px;
top: 50px;
left: 0;
height: calc(100% - 50px);
}
.vc-more-stickers-picker-content {
position: absolute;
margin-left: 48px;
}
.vc-more-stickers-picker-content:has(.temporary-text-will-be-removed) {
display: flex;
text-align: center;
justify-content: center;
align-items: center;
width: 90%;
height: 80%;
}
.vc-more-stickers-category-scroller {
overflow: hidden scroll;
padding-right: 0;
height: 100%;
position: relative;
box-sizing: border-box;
min-height: 0;
flex: 1 1 auto;
}
.vc-more-stickers-category-scroller> :first-child {
inset: 8px;
contain: layout;
position: absolute;
}
.vc-more-stickers-category-scroller> :last-child {
position: "absolute";
pointer-events: none;
min-height: 0;
min-width: 1px;
flex: 0 0 auto;
height: 0;
}
.vc-more-stickers-category-scroller,
.vc-more-stickers-picker-content-scroller {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thin-thumb) var(--scrollbar-thin-track);
}
.vc-more-stickers-category-scroller::-webkit-scrollbar,
.vc-more-stickers-picker-content-scroller::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.vc-more-stickers-category-scroller::-webkit-scrollbar-corner,
.vc-more-stickers-picker-content-scroller::-webkit-scrollbar-corner {
background-color: transparent;
}
.vc-more-stickers-category-scroller::-webkit-scrollbar-thumb,
.vc-more-stickers-picker-content-scroller::-webkit-scrollbar-thumb {
background-clip: padding-box;
border: 2px solid transparent;
border-radius: 4px;
background-color: var(--scrollbar-thin-thumb);
min-height: 40px;
}
.vc-more-stickers-category-scroller::-webkit-scrollbar-track,
.vc-more-stickers-picker-content-scroller::-webkit-scrollbar-track {
border-color: var(--scrollbar-thin-track);
background-color: var(--scrollbar-thin-track);
border: 2px solid var(--scrollbar-thin-track);
}
.vc-more-stickers-header {
box-shadow: var(--elevation-low);
grid-column: 1/3;
grid-row: 1/2;
min-height: 1px;
z-index: 1;
padding: 0 16px 16px;
display: flex;
align-items: center;
}
.vc-more-stickers-sticker-category {
border-radius: 4px;
color: var(--interactive-normal);
cursor: pointer;
height: 32px;
margin-bottom: 8px;
}
.vc-more-stickers-sticker-category:hover,
.vc-more-stickers-sticker-category-active {
background-color: var(--background-modifier-hover);
}
.vc-more-stickers-clear-icon {
color: var(--interactive-normal);
cursor: pointer;
}
.vc-more-stickers-clear-icon:hover {
color: var(--interactive-hover);
}
.vc-more-stickers-picker-content-list-wrapper {
grid-row: 2/2;
grid-column: 2/2;
overflow: hidden;
position: relative;
}
.vc-more-stickers-picker-content-wrapper {
display: grid;
grid-template-rows: 1fr auto;
height: 100%;
position: absolute;
inset: 0;
}
.vc-more-stickers-picker-content-scroller {
overflow: hidden scroll;
padding-right: 0;
height: 100%;
position: relative;
box-sizing: border-box;
min-height: 0;
-webkit-box-flex: 1;
flex: 1 1 auto;
}
.vc-more-stickers-picker-content-list-items {
contain: layout;
position: absolute;
inset: 0 0 0 8px;
}
.vc-more-stickers-picker-content-header-wrapper {
position: sticky;
top: 0;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
background-color: var(--background-secondary);
box-sizing: border-box;
display: flex;
height: 32px;
padding: 0 4px;
z-index: 1;
}
.vc-more-stickers-picker-content-header-header {
color: var(--header-secondary);
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-ms-flex-pack: start;
justify-content: flex-start;
font-size: 12px;
font-weight: 600;
transition: color 0.125s;
}
.vc-more-stickers-picker-content-header-header-icon {
display: contents;
height: 100%;
margin-right: 8px;
}
.vc-more-stickers-picker-content-header-svg {
display: block;
contain: paint;
}
.vc-more-stickers-picker-content-header-guild-icon,
.vc-more-stickers-picker-content-inspector-graphic-secondary img {
text-indent: -9999px;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
background-color: var(--background-primary);
color: var(--text-normal);
display: flex;
height: 100%;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
width: 100%;
}
.vc-more-stickers-picker-content-header-header-label {
margin-left: 8px;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
margin-right: 8px;
}
.vc-more-stickers-picker-content-header-header:hover {
color: var(--interactive-active);
cursor: pointer;
}
.vc-more-stickers-picker-content-row {
column-gap: 67px;
grid-template-columns: repeat(auto-fill, 96px);
height: 96px;
position: relative;
display: grid;
margin-bottom: 12px;
overflow: hidden;
}
.vc-more-stickers-picker-content-row-grid-sticker {
width: 96px;
height: 96px;
padding: 2px;
cursor: pointer;
border-radius: 4px;
position: relative;
}
.vc-more-stickers-picker-content-row-grid-hidden-visually {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.vc-more-stickers-picker-content-row-grid-inspected-indicator {
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
/* prettier-ignore */
mask: url("https://discord.com/assets/7d65d61e6cd25bf1847ec2d3e4a0144d.svg") 0 0/100% 100%;
transition: background-color 0.08s ease-out, color 0.08s ease-out;
z-index: -1;
}
.vc-more-stickers-picker-content-row-grid-inspected-indicator.inspected {
background-color: var(--background-accent);
}
.vc-more-stickers-picker-content-row-grid-sticker-node {
transition: opacity 0.25s;
}
.vc-more-stickers-picker-content-row-grid-asset-wrapper {
position: relative;
user-select: none;
}
.vc-more-stickers-picker-content-row-grid-asset-wrapper:hover {
/* prettier-ignore */
mask: url("https://discord.com/assets/7d65d61e6cd25bf1847ec2d3e4a0144d.svg") 0 0/100% 100%;
}
.vc-more-stickers-picker-content-row-grid-img,
.vc-more-stickers-picker-content-inspector-img {
text-indent: -9999px;
display: block;
object-fit: contain;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
.vc-more-stickers-picker-content-inspector {
box-sizing: border-box;
height: 48px;
width: 100%;
padding: 0 16px;
background-color: var(--background-secondary-alt);
display: flex;
flex: 0 0 auto;
flex-direction: row;
align-items: center;
overflow: hidden;
}
.vc-more-stickers-picker-content-inspector-graphic-primary {
height: 28px;
width: 28px;
}
.vc-more-stickers-picker-content-inspector-text-wrapper {
flex: 1;
margin-left: 8px;
overflow: hidden;
}
.vc-more-stickers-picker-content-inspector-title-primary {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-family: var(--font-primary);
font-size: 16px;
line-height: 20px;
font-weight: 600;
color: var(--text-normal);
}
.vc-more-stickers-picker-content-inspector-title-secondary {
color: var(--interactive-normal);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-family: var(--font-primary);
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.vc-more-stickers-picker-content-inspector-graphic-secondary {
height: 32px;
margin-left: 8px;
width: 32px;
}
.vc-more-stickers-picker-content-inspector-graphic-secondary svg {
display: block;
contain: paint;
}
.vc-more-stickers-picker-settings-cog-container {
position: fixed;
transform: translateY(-100%);
background-color: var(--background-tertiary);
padding: 8px;
padding-right: 4px;
border-bottom-left-radius: 5px;
}
.vc-more-stickers-picker-settings-cog {
cursor: pointer;
border-radius: 2em;
background-color: var(--background-primary);
padding: 8px;
transform: translate(-5%, 10%);
transition: all 0.15s ease-out;
}
.vc-more-stickers-picker-settings-cog-active {
border-radius: 1em;
background-color: var(--green-300);
}
.vc-more-stickers-picker-settings-cog>svg {
color: var(--green-360) !important;
width: 100%;
height: 100%;
transform: translateY(10%);
}
/* prettier-ignore */
.vc-more-stickers-picker-settings-cog-active>svg {
color: var(--white-360) !important;
}
.vc-more-stickers-picker-content-header-collapse-icon {
transition: transform 0.1s ease-out;
}
.vc-more-stickers-settings {
margin-top: 16px;
}
.vc-more-stickers-settings .bg {
border-radius: 8px;
background-color: var(--background-secondary);
padding: 16px;
margin: 8px 16px 16px;
}
.vc-more-stickers-settings .sticker-pack {
position: relative;
text-align: center;
}
.vc-more-stickers-settings .sticker-pack button {
display: flex;
position: absolute;
top: 0;
right: 0;
border-radius: 8px;
background: var(--background-secondary);
width: 26px;
height: 0;
align-items: center;
justify-content: center;
padding: 2px;
opacity: 0;
}
.vc-more-stickers-settings button.show {
height: 26px;
opacity: 1;
transition: opacity 0.25s linear;
}
.vc-more-stickers-settings .section {
margin-top: 8px;
}
.vc-more-stickers-settings .tab-bar {
margin-bottom: 16px;
}
.vc-more-stickers-settings .tab-bar-item {
margin-right: 16px;
padding-bottom: 4px;
margin-bottom: -4px;
}
.vc-more-stickers-pack-title {
font-family: var(--font-primary);
font-size: 8pt;
font-weight: 600;
color: var(--text-normal);
}

View file

@ -0,0 +1,89 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { setRecentStickers } from "./components/recent";
import {
convert,
getStickerPackById
} from "./lineStickers";
import {
deleteStickerPack,
getStickerPackMetas,
saveStickerPack
} from "./stickers";
import { StickerPack } from "./types";
export async function initTest() {
console.log("initTest.");
console.log("Clearing recent stickers.");
setRecentStickers([]);
// Clear all sticker packs
console.log("Clearing all sticker packs.");
const stickerPackMetas = await getStickerPackMetas();
for (const meta of stickerPackMetas) {
await deleteStickerPack(meta.id);
}
// Add test sticker packs
console.log("Adding test sticker packs.");
const lineStickerPackIds = [
"22814489", // LV.47 野生喵喵怪
"22567773", // LV.46 野生喵喵怪
"22256215", // LV.45 野生喵喵怪
"21936635", // LV.44 野生喵喵怪
"21836565", // LV.43 野生喵喵怪
];
const ps: Promise<StickerPack | null>[] = [];
for (const id of lineStickerPackIds) {
ps.push((async () => {
try {
const lsp = await getStickerPackById(id);
const sp = convert(lsp);
return sp;
} catch (e) {
console.error("Failed to fetch sticker pack: " + id);
console.error(e);
return null;
}
})());
}
const stickerPacks = (await Promise.all(ps)).filter(sp => sp !== null) as StickerPack[];
console.log("Saving test sticker packs.");
for (const sp of stickerPacks) {
await saveStickerPack(sp);
}
console.log(await getStickerPackMetas());
}
export async function clearTest() {
console.log("clearTest.");
console.log("Clearing recent stickers.");
setRecentStickers([]);
// Clear all sticker packs
console.log("Clearing all sticker packs.");
const stickerPackMetas = await getStickerPackMetas();
for (const meta of stickerPackMetas) {
await deleteStickerPack(meta.id);
}
}

View file

@ -0,0 +1,91 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { FFmpeg } from '@ffmpeg/ffmpeg';
export interface LineSticker {
animationUrl: string,
fallbackStaticUrl?: string,
id: string;
popupUrl: string;
soundUrl: string;
staticUrl: string;
type: string;
stickerPackId: LineStickerPack["id"];
}
export interface LineEmoji {
animationUrl: string;
type: string;
id: string;
staticUrl: string;
animationMainImages?: string[];
staticMainImages?: string[];
stickerPackId: LineStickerPack["id"];
fallbackStaticUrl?: string;
}
export interface LineStickerPack {
title: string;
author: {
name: string;
url: string;
},
id: string;
mainImage: LineSticker;
stickers: LineSticker[];
}
export interface LineEmojiPack {
title: string;
author: {
name: string;
url: string;
},
id: string;
mainImage: LineSticker;
stickers: LineEmoji[];
}
export interface Sticker {
id: string;
image: string;
title: string;
stickerPackId: StickerPackMeta["id"];
filename?: string;
isAnimated?: boolean;
}
export interface StickerPackMeta {
id: string;
title: string;
author?: {
name: string;
url?: string;
};
logo: Sticker;
}
export interface StickerPack extends StickerPackMeta {
stickers: Sticker[];
}
export interface FFmpegState {
ffmpeg?: FFmpeg;
isLoaded: boolean;
}

View file

@ -0,0 +1,210 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { findByPropsLazy, findLazy } from "@webpack";
import { ChannelStore } from "@webpack/common";
import { FFmpegState, Sticker } from "./types";
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
const MessageUpload = findByPropsLazy("instantBatchUpload");
const CloudUpload = findLazy(m => m.prototype?.trackUploadFinished);
const PendingReplyStore = findByPropsLazy("getPendingReply");
const MessageUtils = findByPropsLazy("sendMessage");
const DraftStore = findByPropsLazy("getDraft", "getState");
const promptToUploadParent = findByPropsLazy("promptToUpload");
export const ffmpeg = new FFmpeg();
async function resizeImage(url: string) {
const originalImage = new Image();
originalImage.crossOrigin = "anonymous"; // If the image is hosted on a different domain, enable CORS
const loadImage = new Promise((resolve, reject) => {
originalImage.onload = resolve;
originalImage.onerror = reject;
originalImage.src = url;
});
await loadImage;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get canvas context");
// Determine the target size of the processed image (160x160)
const targetSize = 160;
// Calculate the scale factor to resize the image
const scaleFactor = Math.min(targetSize / originalImage.width, targetSize / originalImage.height);
// Calculate the dimensions for resizing the image while maintaining aspect ratio
const resizedWidth = originalImage.width * scaleFactor;
const resizedHeight = originalImage.height * scaleFactor;
// Set the canvas size to the target dimensions
canvas.width = targetSize;
canvas.height = targetSize;
// Draw the resized image onto the canvas
ctx.drawImage(originalImage, 0, 0, resizedWidth, resizedHeight);
// Get the canvas image data
const imageData = ctx.getImageData(0, 0, targetSize, targetSize);
const { data } = imageData;
// Apply any additional image processing or filters here if desired
// Convert the image data to a Blob
const blob: Blob | null = await new Promise(resolve => {
canvas.toBlob(resolve, "image/png");
});
if (!blob) throw new Error("Could not convert canvas to blob");
// return the object URL representing the Blob
return blob;
}
async function toGIF(url: string, ffmpeg: FFmpeg): Promise<File> {
const filename = (new URL(url)).pathname.split("/").pop() ?? "image.png";
await ffmpeg.writeFile(filename, await fetchFile(url));
const outputFilename = "output.gif";
await ffmpeg.exec(["-i", filename,
"-filter_complex", `split[s0][s1];
[s0]palettegen=
stats_mode=single:
transparency_color=000000[p];
[s1][p]paletteuse=
new=1:
alpha_threshold=10`,
outputFilename]);
const data = await ffmpeg.readFile(outputFilename);
await ffmpeg.deleteFile(filename);
await ffmpeg.deleteFile(outputFilename);
if (typeof data === "string") {
throw new Error("Could not read file");
}
return new File([data.buffer], outputFilename, { type: 'image/gif' });
}
export async function sendSticker({
channelId,
sticker,
sendAsLink,
ctrlKey,
shiftKey,
ffmpegState
}: { channelId: string; sticker: Sticker; sendAsLink?: boolean; ctrlKey: boolean; shiftKey: boolean; ffmpegState?: FFmpegState; }) {
let messageContent = "";
const { textEditor } = Vencord.Plugins.plugins.MoreStickers as any;
if (DraftStore) {
messageContent = DraftStore.getDraft(channelId, 0);
}
let messageOptions = {};
if (PendingReplyStore) {
const pendingReply = PendingReplyStore.getPendingReply(channelId);
if (pendingReply) {
messageOptions = MessageUtils.getSendMessageOptionsForReply(pendingReply);
}
}
if ((ctrlKey || !sendAsLink) && !shiftKey) {
let file: File | null = null;
if (sticker?.isAnimated) {
if (!ffmpegState) {
throw new Error("FFmpeg state is not provided");
}
if (!ffmpegState?.ffmpeg) {
throw new Error("FFmpeg is not provided");
}
if (!ffmpegState?.isLoaded) {
throw new Error("FFmpeg is not loaded");
}
file = await toGIF(sticker.image, ffmpegState.ffmpeg);
}
else {
const response = await fetch(sticker.image, { cache: "force-cache" });
// const blob = await response.blob();
const orgImageUrl = URL.createObjectURL(await response.blob());
const processedImage = await resizeImage(orgImageUrl);
const filename = sticker.filename ?? (new URL(sticker.image)).pathname.split("/").pop();
let mimeType = "image/png";
switch (filename?.split(".").pop()?.toLowerCase()) {
case "jpg":
case "jpeg":
mimeType = "image/jpeg";
break;
case "gif":
mimeType = "image/gif";
break;
case "webp":
mimeType = "image/webp";
break;
case "svg":
mimeType = "image/svg+xml";
break;
}
file = new File([processedImage], filename!, { type: mimeType });
}
if (ctrlKey) {
promptToUploadParent.promptToUpload([file], ChannelStore.getChannel(channelId), 0);
return;
}
MessageUpload.uploadFiles({
channelId,
draftType: 0,
hasSpoiler: false,
options: messageOptions || {},
parsedMessage: {
content: messageContent
},
uploads: [
new CloudUpload({
file,
platform: 1
}, channelId, false, 0)
]
});
} else if (shiftKey) {
if (!messageContent.endsWith(" ") || !messageContent.endsWith("\n")) messageContent += " ";
messageContent += sticker.image;
if (ctrlKey && textEditor && textEditor.insertText && typeof textEditor.insertText === "function") {
textEditor.insertText(messageContent);
} else {
MessageUtils._sendMessage(channelId, {
content: sticker.image
}, messageOptions || {});
}
} else {
MessageUtils._sendMessage(channelId, {
content: `${messageContent} ${sticker.image}`.trim()
}, messageOptions || {});
}
}

File diff suppressed because one or more lines are too long

View file

@ -920,7 +920,11 @@ export const EquicordDevs = Object.freeze({
creations: {
name: "Creation's",
id: 209830981060788225n
}
},
Leko: {
name: "Leko",
id: 108153734541942784n
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly