Plugin
FavoriteGifSearch
Adds a search bar to favorite gifs.
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { definePluginSettings } from "@api/Settings";8
import ErrorBoundary from "@components/ErrorBoundary";9
import { Devs } from "@utils/constants";10
import definePlugin, { OptionType } from "@utils/types";11
import { useCallback, useEffect, useRef, useState } from "@webpack/common";12
13
interface 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
24
type TSearchBarComponent =25
React.FC<SearchBarComponentProps>;26
27
interface Gif {28
format: number;29
src: string;30
width: number;31
height: number;32
order: number;33
url: string;34
}35
36
interface 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
49
export 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: true66
}67
] as const68
}69
});70
71
export 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/183
// ($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;Favorites039; ? $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 again90
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
122
function 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 early131
if (searchQuery === "") {132
props.favorites = props.favCopy;133
instance.forceUpdate();134
return;135
}136
137
138
// scroll back to top139
ref.current140
?.closest("#gif-picker-tab-panel")141
?.querySelector(039;[class*="scrollerBase"]039;)142
?.scrollTo(0, 0);143
144
145
const result =146
props.favCopy147
.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
<SearchBarComponent167
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
187
export 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-is193
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.gif202
// /view/some-gif-hi-24248063 -> some-gif-hi-24248063203
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
215
function 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