Plugin

FavoriteGifSearch

Adds a search bar to favorite gifs.

Media Customisation
index.tsx
Download

Source

src/plugins/favGifSearch/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { definePluginSettings } from "@api/Settings";
8import ErrorBoundary from "@components/ErrorBoundary";
9import { Devs } from "@utils/constants";
10import definePlugin, { OptionType } from "@utils/types";
11import { useCallback, useEffect, useRef, useState } from "@webpack/common";
12
13interface SearchBarComponentProps {
14 ref?: React.RefObject<any>;
15 autoFocus: boolean;
16 size: string;
17 onChange: (query: string) => void;
18 onClear: () => void;
19 query: string;
20 placeholder: string;
21 className?: string;
22}
23
24type TSearchBarComponent =
25 React.FC<SearchBarComponentProps>;
26
27interface Gif {
28 format: number;
29 src: string;
30 width: number;
31 height: number;
32 order: number;
33 url: string;
34}
35
36interface Instance {
37 dead?: boolean;
38 state: {
39 resultType?: string;
40 };
41 props: {
42 favCopy: Gif[],
43
44 favorites: Gif[],
45 },
46 forceUpdate: () => void;
47}
48
49export const settings = definePluginSettings({
50 searchOption: {
51 type: OptionType.SELECT,
52 description: "The part of the url you want to search",
53 options: [
54 {
55 label: "Entire Url",
56 value: "url"
57 },
58 {
59 label: "Path Only (/somegif.gif)",
60 value: "path"
61 },
62 {
63 label: "Host & Path (tenor.com somgif.gif)",
64 value: "hostandpath",
65 default: true
66 }
67 ] as const
68 }
69});
70
71export default definePlugin({
72 name: "FavoriteGifSearch",
73 authors: [Devs.Aria],
74 description: "Adds a search bar to favorite gifs.",
75 tags: ["Media", "Customisation"],
76
77 patches: [
78 {
79 find: "renderHeaderContent()",
80 replacement: [
81 {
82 // https://regex101.com/r/07gpzP/1
83 // ($1 renderHeaderContent=function { ... switch (x) ... case FAVORITES:return) ($2) ($3 case default: ... return r.jsx(($<searchComp>), {...props}))
84 match: /(renderHeaderContent\(\).{1,150}FAVORITES:return)(.{1,150});(case.{1,200}default:.{0,50}?return\(0,\i\.jsx\)\((?<searchComp>\i\.\i),)/,
85 replace: "$1 this?.state?.resultType === &#039;Favorites&#039; ? $self.renderSearchBar(this, $<searchComp>) : $2;$3"
86 },
87 {
88 // to persist filtered favorites when component re-renders.
89 // when resizing the window the component rerenders and we loose the filtered favorites and have to type in the search bar to get them again
90 match: /(,suggestions:\i,favorites:)(\i),/,
91 replace: "$1$self.getFav($2),favCopy:$2,"
92 }
93
94 ]
95 }
96 ],
97
98 settings,
99
100 getTargetString,
101
102 instance: null as Instance | null,
103 renderSearchBar(instance: Instance, SearchBarComponent: TSearchBarComponent) {
104 this.instance = instance;
105 return (
106 <ErrorBoundary noop>
107 <SearchBar instance={instance} SearchBarComponent={SearchBarComponent} />
108 </ErrorBoundary>
109 );
110 },
111
112 getFav(favorites: Gif[]) {
113 if (!this.instance || this.instance.dead) return favorites;
114 const { favorites: filteredFavorites } = this.instance.props;
115
116 return filteredFavorites != null && filteredFavorites?.length !== favorites.length ? filteredFavorites : favorites;
117
118 }
119});
120
121
122function SearchBar({ instance, SearchBarComponent }: { instance: Instance; SearchBarComponent: TSearchBarComponent; }) {
123 const [query, setQuery] = useState("");
124 const ref = useRef<HTMLElement>(null);
125
126 const onChange = useCallback((searchQuery: string) => {
127 setQuery(searchQuery);
128 const { props } = instance;
129
130 // return early
131 if (searchQuery === "") {
132 props.favorites = props.favCopy;
133 instance.forceUpdate();
134 return;
135 }
136
137
138 // scroll back to top
139 ref.current
140 ?.closest("#gif-picker-tab-panel")
141 ?.querySelector(&#039;[class*="scrollerBase"]&#039;)
142 ?.scrollTo(0, 0);
143
144
145 const result =
146 props.favCopy
147 .map(gif => ({
148 score: fuzzySearch(searchQuery.toLowerCase(), getTargetString(gif.url ?? gif.src).replace(/(%20|[_-])/g, " ").toLowerCase()),
149 gif,
150 }))
151 .filter(m => m.score != null) as { score: number; gif: Gif; }[];
152
153 result.sort((a, b) => b.score - a.score);
154 props.favorites = result.map(e => e.gif);
155
156 instance.forceUpdate();
157 }, [instance.state]);
158
159 useEffect(() => {
160 return () => {
161 instance.dead = true;
162 };
163 }, []);
164
165 return (
166 <SearchBarComponent
167 ref={ref}
168 autoFocus={true}
169 size="md"
170 className=""
171 onChange={onChange}
172 onClear={() => {
173 setQuery("");
174 if (instance.props.favCopy != null) {
175 instance.props.favorites = instance.props.favCopy;
176 instance.forceUpdate();
177 }
178 }}
179 query={query}
180 placeholder="Search Favorite Gifs"
181 />
182 );
183}
184
185
186
187export function getTargetString(urlStr: string) {
188 let url: URL;
189 try {
190 url = new URL(urlStr);
191 } catch (err) {
192 // Can't resolve URL, return as-is
193 return urlStr;
194 }
195
196 switch (settings.store.searchOption) {
197 case "url":
198 return url.href;
199 case "path":
200 if (url.host === "media.discordapp.net" || url.host === "tenor.com")
201 // /attachments/899763415290097664/1095711736461537381/attachment-1.gif -> attachment-1.gif
202 // /view/some-gif-hi-24248063 -> some-gif-hi-24248063
203 return url.pathname.split("/").at(-1) ?? url.pathname;
204 return url.pathname;
205 case "hostandpath":
206 if (url.host === "media.discordapp.net" || url.host === "tenor.com")
207 return `${url.host} ${url.pathname.split("/").at(-1) ?? url.pathname}`;
208 return `${url.host} ${url.pathname}`;
209
210 default:
211 return "";
212 }
213}
214
215function fuzzySearch(searchQuery: string, searchString: string) {
216 let searchIndex = 0;
217 let score = 0;
218
219 for (let i = 0; i < searchString.length; i++) {
220 if (searchString[i] === searchQuery[searchIndex]) {
221 score++;
222 searchIndex++;
223 } else {
224 score--;
225 }
226
227 if (searchIndex === searchQuery.length) {
228 return score;
229 }
230 }
231
232 return null;
233}
234