this repo has no description

Merge pull request #10 from redstonekasi/mb-filtering

Moonbase extension filtering

authored by notnite.com and committed by GitHub bab950e3 8c80bd7e

Changed files
+460 -35
packages
core-extensions
types
src
coreExtensions
discord
+2 -1
packages/core-extensions/src/moonbase/index.tsx
··· 21 21 TrashIconSVG, 22 22 CircleXIconSVG, 23 23 "Masks.PANEL_BUTTON", 24 - "removeButtonContainer:" 24 + "removeButtonContainer:", 25 + '"Missing channel in Channel.openChannelContextMenu"' 25 26 ], 26 27 entrypoint: true, 27 28 run: (module, exports, require) => {
+6
packages/core-extensions/src/moonbase/types.ts
··· 8 8 "M7.02799 0.333252C3.346 0.333252 0.361328 3.31792 0.361328 6.99992C0.361328 10.6819 3.346 13.6666 7.02799 13.6666C10.71 13.6666 13.6947 10.6819 13.6947 6.99992C13.6947 3.31792 10.7093 0.333252 7.02799 0.333252ZM10.166 9.19525L9.22333 10.1379L7.02799 7.94325L4.83266 10.1379L3.89 9.19525L6.08466 6.99992L3.88933 4.80459L4.832 3.86259L7.02733 6.05792L9.22266 3.86259L10.1653 4.80459L7.97066 6.99992L10.166 9.19525Z"; 9 9 export const DangerIconSVG = 10 10 "M12 23a11 11 0 1 0 0-22 11 11 0 0 0 0 22Zm1.44-15.94L13.06 14a1.06 1.06 0 0 1-2.12 0l-.38-6.94a1 1 0 0 1 1-1.06h.88a1 1 0 0 1 1 1.06Zm-.19 10.69a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z"; 11 + export const ChevronSmallDownIconSVG = 12 + "M16.59 8.59003L12 13.17L7.41 8.59003L6 10L12 16L18 10L16.59 8.59003Z"; 13 + export const ChevronSmallUpIconSVG = 14 + "M7.41 16.0001L12 11.4201L16.59 16.0001L18 14.5901L12 8.59006L6 14.5901L7.41 16.0001Z"; 15 + export const ArrowsUpDownIconSVG = 16 + "M3.81962 11.3333L3.81962 1.33325L5.52983 1.33325L5.52985 11.3333L7.46703 9.36658L8.66663 10.5916L4.67068 14.6666L0.666626 10.5916L1.86622 9.34158L3.81962 11.3333Z"; 11 17 12 18 export type MoonbaseNatives = { 13 19 fetchRepositories(
+378
packages/core-extensions/src/moonbase/ui/filterBar.tsx
··· 1 + import { WebpackRequireType } from "@moonlight-mod/types"; 2 + import { tagNames } from "./info"; 3 + import { 4 + ArrowsUpDownIconSVG, 5 + ChevronSmallDownIconSVG, 6 + ChevronSmallUpIconSVG 7 + } from "../types"; 8 + 9 + export const defaultFilter = { 10 + core: true, 11 + normal: true, 12 + developer: true, 13 + enabled: true, 14 + disabled: true, 15 + installed: true, 16 + repository: true 17 + }; 18 + export type Filter = typeof defaultFilter; 19 + 20 + export default async (require: WebpackRequireType) => { 21 + const spacepack = require("spacepack_spacepack").spacepack; 22 + const React = require("common_react"); 23 + const Flux = require("common_flux"); 24 + const { WindowStore } = require("common_stores"); 25 + 26 + const { 27 + Button, 28 + Text, 29 + Heading, 30 + Popout, 31 + Dialog 32 + } = require("common_components"); 33 + 34 + const channelModule = 35 + require.m[ 36 + spacepack.findByCode( 37 + '"Missing channel in Channel.openChannelContextMenu"' 38 + )[0].id 39 + ].toString(); 40 + const moduleId = channelModule.match(/webpackId:"(.+?)"/)![1]; 41 + await require.el(moduleId); 42 + 43 + const Margins = spacepack.findByCode("marginCenterHorz:")[0].exports; 44 + const SortMenuClasses = spacepack.findByCode("container:", "clearText:")[0] 45 + .exports; 46 + const FilterDialogClasses = spacepack.findByCode( 47 + "countContainer:", 48 + "tagContainer:" 49 + )[0].exports; 50 + const FilterBarClasses = spacepack.findByCode("tagsButtonWithCount:")[0] 51 + .exports; 52 + 53 + const TagItem = spacepack.findByCode("IncreasedActivityForumTagPill:")[0] 54 + .exports.default; 55 + 56 + const ChevronSmallDownIcon = spacepack.findByCode(ChevronSmallDownIconSVG)[0] 57 + .exports.default; 58 + const ChevronSmallUpIcon = spacepack.findByCode(ChevronSmallUpIconSVG)[0] 59 + .exports.default; 60 + const ArrowsUpDownIcon = 61 + spacepack.findByCode(ArrowsUpDownIconSVG)[0].exports.default; 62 + 63 + function toggleTag( 64 + selectedTags: Set<string>, 65 + setSelectedTags: (tags: Set<string>) => void, 66 + tag: string 67 + ) { 68 + const newState = new Set(selectedTags); 69 + if (newState.has(tag)) newState.delete(tag); 70 + else newState.add(tag); 71 + setSelectedTags(newState); 72 + } 73 + 74 + function FilterButtonPopout({ 75 + filter, 76 + setFilter, 77 + closePopout 78 + }: { 79 + filter: Filter; 80 + setFilter: (filter: Filter) => void; 81 + closePopout: () => void; 82 + }) { 83 + const { 84 + Menu, 85 + MenuItem, 86 + MenuGroup, 87 + MenuCheckboxItem 88 + } = require("common_components"); 89 + 90 + return ( 91 + <div className={SortMenuClasses.container}> 92 + <Menu navId="sort-filter" hideScrollbar={true} onClose={closePopout}> 93 + <MenuGroup label="Type"> 94 + <MenuCheckboxItem 95 + id="t-core" 96 + label="Core" 97 + checked={filter.core} 98 + action={() => setFilter({ ...filter, core: !filter.core })} 99 + /> 100 + <MenuCheckboxItem 101 + id="t-normal" 102 + label="Normal" 103 + checked={filter.normal} 104 + action={() => setFilter({ ...filter, normal: !filter.normal })} 105 + /> 106 + <MenuCheckboxItem 107 + id="t-developer" 108 + label="Developer" 109 + checked={filter.developer} 110 + action={() => 111 + setFilter({ ...filter, developer: !filter.developer }) 112 + } 113 + /> 114 + </MenuGroup> 115 + <MenuGroup label="State"> 116 + <MenuCheckboxItem 117 + id="s-enabled" 118 + label="Enabled" 119 + checked={filter.enabled} 120 + action={() => setFilter({ ...filter, enabled: !filter.enabled })} 121 + /> 122 + <MenuCheckboxItem 123 + id="s-disabled" 124 + label="Disabled" 125 + checked={filter.disabled} 126 + action={() => 127 + setFilter({ ...filter, disabled: !filter.disabled }) 128 + } 129 + /> 130 + </MenuGroup> 131 + <MenuGroup label="Location"> 132 + <MenuCheckboxItem 133 + id="l-installed" 134 + label="Installed" 135 + checked={filter.installed} 136 + action={() => 137 + setFilter({ ...filter, installed: !filter.installed }) 138 + } 139 + /> 140 + <MenuCheckboxItem 141 + id="l-repository" 142 + label="Repository" 143 + checked={filter.repository} 144 + action={() => 145 + setFilter({ ...filter, repository: !filter.repository }) 146 + } 147 + /> 148 + </MenuGroup> 149 + <MenuGroup> 150 + <MenuItem 151 + id="reset-all" 152 + className={SortMenuClasses.clearText} 153 + label={ 154 + <Text variant="text-sm/medium" color="none"> 155 + Reset to default 156 + </Text> 157 + } 158 + action={() => { 159 + setFilter({ ...defaultFilter }); 160 + closePopout(); 161 + }} 162 + /> 163 + </MenuGroup> 164 + </Menu> 165 + </div> 166 + ); 167 + } 168 + 169 + function TagButtonPopout({ 170 + selectedTags, 171 + setSelectedTags, 172 + setPopoutRef, 173 + closePopout 174 + }: any) { 175 + return ( 176 + <Dialog ref={setPopoutRef} className={FilterDialogClasses.container}> 177 + <div className={FilterDialogClasses.header}> 178 + <div className={FilterDialogClasses.headerLeft}> 179 + <Heading 180 + color="interactive-normal" 181 + variant="text-xs/bold" 182 + className={FilterDialogClasses.headerText} 183 + > 184 + Select tags 185 + </Heading> 186 + <div className={FilterDialogClasses.countContainer}> 187 + <Text 188 + className={FilterDialogClasses.countText} 189 + color="none" 190 + variant="text-xs/medium" 191 + > 192 + {selectedTags.size} 193 + </Text> 194 + </div> 195 + </div> 196 + </div> 197 + <div className={FilterDialogClasses.tagContainer}> 198 + {Object.keys(tagNames).map((tag) => ( 199 + <TagItem 200 + key={tag} 201 + className={FilterDialogClasses.tag} 202 + tag={{ name: tagNames[tag as keyof typeof tagNames] }} 203 + onClick={() => toggleTag(selectedTags, setSelectedTags, tag)} 204 + selected={selectedTags.has(tag)} 205 + /> 206 + ))} 207 + </div> 208 + <div className={FilterDialogClasses.separator} /> 209 + <Button 210 + look={Button.Looks.LINK} 211 + size={Button.Sizes.MIN} 212 + color={Button.Colors.CUSTOM} 213 + className={FilterDialogClasses.clear} 214 + onClick={() => { 215 + setSelectedTags(new Set()); 216 + closePopout(); 217 + }} 218 + > 219 + <Text variant="text-sm/medium" color="text-link"> 220 + Clear all 221 + </Text> 222 + </Button> 223 + </Dialog> 224 + ); 225 + } 226 + 227 + return function FilterBar({ 228 + filter, 229 + setFilter, 230 + selectedTags, 231 + setSelectedTags 232 + }: { 233 + filter: Filter; 234 + setFilter: (filter: Filter) => void; 235 + selectedTags: Set<string>; 236 + setSelectedTags: (tags: Set<string>) => void; 237 + }) { 238 + const windowSize = Flux.useStateFromStores([WindowStore], () => 239 + WindowStore.windowSize() 240 + ); 241 + 242 + const tagsContainer = React.useRef<HTMLDivElement>(null); 243 + const tagListInner = React.useRef<HTMLDivElement>(null); 244 + const [tagsButtonOffset, setTagsButtonOffset] = React.useState(0); 245 + React.useLayoutEffect(() => { 246 + if (tagsContainer.current === null || tagListInner.current === null) 247 + return; 248 + const { left: containerX, top: containerY } = 249 + tagsContainer.current.getBoundingClientRect(); 250 + let offset = 0; 251 + for (const child of tagListInner.current.children) { 252 + const { 253 + right: childX, 254 + top: childY, 255 + height 256 + } = child.getBoundingClientRect(); 257 + if (childY - containerY > height) break; 258 + const newOffset = childX - containerX; 259 + if (newOffset > offset) { 260 + offset = newOffset; 261 + } 262 + } 263 + setTagsButtonOffset(offset); 264 + }, [windowSize]); 265 + 266 + return ( 267 + <div 268 + ref={tagsContainer} 269 + style={{ 270 + paddingTop: "12px" 271 + }} 272 + className={`${FilterBarClasses.tagsContainer} ${Margins.marginBottom8}`} 273 + > 274 + <Popout 275 + renderPopout={({ closePopout }: any) => ( 276 + <FilterButtonPopout 277 + filter={filter} 278 + setFilter={setFilter} 279 + closePopout={closePopout} 280 + /> 281 + )} 282 + position="bottom" 283 + align="left" 284 + > 285 + {(props: any, { isShown }: { isShown: boolean }) => ( 286 + <Button 287 + {...props} 288 + size={Button.Sizes.MIN} 289 + color={Button.Colors.CUSTOM} 290 + className={FilterBarClasses.sortDropdown} 291 + innerClassName={FilterBarClasses.sortDropdownInner} 292 + > 293 + <ArrowsUpDownIcon /> 294 + <Text 295 + className={FilterBarClasses.sortDropdownText} 296 + variant="text-sm/medium" 297 + color="interactive-normal" 298 + > 299 + Sort & filter 300 + </Text> 301 + {isShown ? ( 302 + <ChevronSmallUpIcon size={20} /> 303 + ) : ( 304 + <ChevronSmallDownIcon size={20} /> 305 + )} 306 + </Button> 307 + )} 308 + </Popout> 309 + <div className={FilterBarClasses.divider} /> 310 + <div className={FilterBarClasses.tagList}> 311 + <div ref={tagListInner} className={FilterBarClasses.tagListInner}> 312 + {Object.keys(tagNames).map((tag) => ( 313 + <TagItem 314 + key={tag} 315 + className={FilterBarClasses.tag} 316 + tag={{ name: tagNames[tag as keyof typeof tagNames] }} 317 + onClick={() => toggleTag(selectedTags, setSelectedTags, tag)} 318 + selected={selectedTags.has(tag)} 319 + /> 320 + ))} 321 + </div> 322 + </div> 323 + <Popout 324 + renderPopout={({ setPopoutRef, closePopout }: any) => ( 325 + <TagButtonPopout 326 + selectedTags={selectedTags} 327 + setSelectedTags={setSelectedTags} 328 + setPopoutRef={setPopoutRef} 329 + closePopout={closePopout} 330 + /> 331 + )} 332 + position="bottom" 333 + align="right" 334 + > 335 + {(props: any, { isShown }: { isShown: boolean }) => ( 336 + <Button 337 + {...props} 338 + size={Button.Sizes.MIN} 339 + color={Button.Colors.CUSTOM} 340 + style={{ 341 + left: tagsButtonOffset 342 + }} 343 + // TODO: Use Discord's class name utility 344 + className={`${FilterBarClasses.tagsButton} ${ 345 + selectedTags.size > 0 346 + ? FilterBarClasses.tagsButtonWithCount 347 + : "" 348 + }`} 349 + innerClassName={FilterBarClasses.tagsButtonInner} 350 + > 351 + {selectedTags.size > 0 ? ( 352 + <div 353 + style={{ boxSizing: "content-box" }} 354 + className={FilterBarClasses.countContainer} 355 + > 356 + <Text 357 + className={FilterBarClasses.countText} 358 + color="none" 359 + variant="text-xs/medium" 360 + > 361 + {selectedTags.size} 362 + </Text> 363 + </div> 364 + ) : ( 365 + <>All</> 366 + )} 367 + {isShown ? ( 368 + <ChevronSmallUpIcon size={20} /> 369 + ) : ( 370 + <ChevronSmallDownIcon size={20} /> 371 + )} 372 + </Button> 373 + )} 374 + </Popout> 375 + </div> 376 + ); 377 + }; 378 + };
+45 -13
packages/core-extensions/src/moonbase/ui/index.tsx
··· 1 - import WebpackRequire from "@moonlight-mod/types/discord/require"; 1 + import { 2 + ExtensionLoadSource, 3 + ExtensionTag, 4 + WebpackRequireType 5 + } from "@moonlight-mod/types"; 2 6 import card from "./card"; 7 + import filterBar, { defaultFilter } from "./filterBar"; 8 + import { ExtensionState } from "../types"; 3 9 4 10 export enum ExtensionPage { 5 11 Info, ··· 7 13 Settings 8 14 } 9 15 10 - export default (require: typeof WebpackRequire) => { 16 + export default (require: WebpackRequireType) => { 11 17 const React = require("common_react"); 12 18 const spacepack = require("spacepack_spacepack").spacepack; 13 19 const Flux = require("common_flux"); ··· 16 22 require("moonbase_stores") as typeof import("../webpackModules/stores"); 17 23 18 24 const ExtensionCard = card(require); 25 + const FilterBar = React.lazy(() => 26 + filterBar(require).then((c) => ({ default: c })) 27 + ); 19 28 20 29 const Margins = spacepack.findByCode("marginCenterHorz:")[0].exports; 21 30 const SearchBar = spacepack.findByCode("Messages.SEARCH", "hideSearchIcon")[0] ··· 32 41 ); 33 42 34 43 const [query, setQuery] = React.useState(""); 44 + const [filter, setFilter] = React.useState({ ...defaultFilter }); 45 + const [selectedTags, setSelectedTags] = React.useState(new Set<string>()); 35 46 36 47 const sorted = Object.values(extensions).sort((a, b) => { 37 48 const aName = a.manifest.meta?.name ?? a.id; ··· 39 50 return aName.localeCompare(bName); 40 51 }); 41 52 42 - const filtered = query.trim().length 43 - ? sorted.filter( 44 - (ext) => 45 - ext.manifest.meta?.name?.toLowerCase().includes(query) || 46 - ext.manifest.meta?.tagline?.toLowerCase().includes(query) || 47 - ext.manifest.meta?.description?.toLowerCase().includes(query) 53 + const filtered = sorted.filter( 54 + (ext) => 55 + (ext.manifest.meta?.name?.toLowerCase().includes(query) || 56 + ext.manifest.meta?.tagline?.toLowerCase().includes(query) || 57 + ext.manifest.meta?.description?.toLowerCase().includes(query)) && 58 + [...selectedTags.values()].every( 59 + (tag) => ext.manifest.meta?.tags?.includes(tag as ExtensionTag) 60 + ) && 61 + // This seems very bad, sorry 62 + !( 63 + (!filter.core && ext.source.type === ExtensionLoadSource.Core) || 64 + (!filter.normal && ext.source.type === ExtensionLoadSource.Normal) || 65 + (!filter.developer && 66 + ext.source.type === ExtensionLoadSource.Developer) || 67 + (!filter.enabled && 68 + MoonbaseSettingsStore.getExtensionEnabled(ext.id)) || 69 + (!filter.disabled && 70 + !MoonbaseSettingsStore.getExtensionEnabled(ext.id)) || 71 + (!filter.installed && ext.state !== ExtensionState.NotDownloaded) || 72 + (!filter.repository && ext.state === ExtensionState.NotDownloaded) 48 73 ) 49 - : sorted; 74 + ); 50 75 51 76 return ( 52 77 <> 53 78 <Text 54 - style={{ 55 - "margin-bottom": "16px" 56 - }} 79 + className={Margins.marginBottom20} 57 80 variant="heading-lg/semibold" 58 81 tag="h2" 59 82 > ··· 61 84 </Text> 62 85 <SearchBar 63 86 size={SearchBar.Sizes.MEDIUM} 64 - className={Margins.marginBottom20} 65 87 query={query} 66 88 onChange={(v: string) => setQuery(v.toLowerCase())} 67 89 onClear={() => setQuery("")} ··· 73 95 spellCheck: "false" 74 96 }} 75 97 /> 98 + <React.Suspense 99 + fallback={<div className={Margins.marginBottom20}></div>} 100 + > 101 + <FilterBar 102 + filter={filter} 103 + setFilter={setFilter} 104 + selectedTags={selectedTags} 105 + setSelectedTags={setSelectedTags} 106 + /> 107 + </React.Suspense> 76 108 {filtered.map((ext) => ( 77 109 <ExtensionCard id={ext.id} key={ext.id} /> 78 110 ))}
+19 -18
packages/core-extensions/src/moonbase/ui/info.tsx
··· 13 13 Incompatible = "incompatible" 14 14 } 15 15 16 + export const tagNames: Record<ExtensionTag, string> = { 17 + [ExtensionTag.Accessibility]: "Accessibility", 18 + [ExtensionTag.Appearance]: "Appearance", 19 + [ExtensionTag.Chat]: "Chat", 20 + [ExtensionTag.Commands]: "Commands", 21 + [ExtensionTag.ContextMenu]: "Context Menu", 22 + [ExtensionTag.DangerZone]: "Danger Zone", 23 + [ExtensionTag.Development]: "Development", 24 + [ExtensionTag.Fixes]: "Fixes", 25 + [ExtensionTag.Fun]: "Fun", 26 + [ExtensionTag.Markdown]: "Markdown", 27 + [ExtensionTag.Voice]: "Voice", 28 + [ExtensionTag.Privacy]: "Privacy", 29 + [ExtensionTag.Profiles]: "Profiles", 30 + [ExtensionTag.QualityOfLife]: "Quality of Life", 31 + [ExtensionTag.Library]: "Library" 32 + }; 33 + 16 34 export default (require: typeof WebpackRequire) => { 17 35 const React = require("common_react"); 18 36 const spacepack = require("spacepack_spacepack").spacepack; ··· 137 155 {tags != null && ( 138 156 <InfoSection title="Tags"> 139 157 {tags.map((tag, i) => { 140 - const names: Record<ExtensionTag, string> = { 141 - [ExtensionTag.Accessibility]: "Accessibility", 142 - [ExtensionTag.Appearance]: "Appearance", 143 - [ExtensionTag.Chat]: "Chat", 144 - [ExtensionTag.Commands]: "Commands", 145 - [ExtensionTag.ContextMenu]: "Context Menu", 146 - [ExtensionTag.DangerZone]: "Danger Zone", 147 - [ExtensionTag.Development]: "Development", 148 - [ExtensionTag.Fixes]: "Fixes", 149 - [ExtensionTag.Fun]: "Fun", 150 - [ExtensionTag.Markdown]: "Markdown", 151 - [ExtensionTag.Voice]: "Voice", 152 - [ExtensionTag.Privacy]: "Privacy", 153 - [ExtensionTag.Profiles]: "Profiles", 154 - [ExtensionTag.QualityOfLife]: "Quality of Life", 155 - [ExtensionTag.Library]: "Library" 156 - }; 157 - const name = names[tag]; 158 + const name = tagNames[tag]; 158 159 159 160 return ( 160 161 <Badge
+2 -3
packages/types/src/coreExtensions.ts
··· 2 2 import { CommonComponents as CommonComponents_ } from "./coreExtensions/components"; 3 3 import { Dispatcher } from "flux"; 4 4 import React from "react"; 5 - import { WebpackModuleFunc } from "./discord"; 6 - import WebpackRequire from "./discord/require"; 5 + import { WebpackModuleFunc, WebpackRequireType } from "./discord"; 7 6 8 7 export type Spacepack = { 9 8 inspect: (module: number | string) => WebpackModuleFunc | null; 10 9 findByCode: (...args: (string | RegExp)[]) => any[]; 11 10 findByExports: (...args: string[]) => any[]; 12 - require: typeof WebpackRequire; 11 + require: WebpackRequireType; 13 12 modules: Record<string, WebpackModuleFunc>; 14 13 cache: Record<string, any>; 15 14 findObjectFromKey: (exports: Record<string, any>, key: string) => any | null;
+7
packages/types/src/coreExtensions/components.ts
··· 298 298 Avatar: Component; 299 299 Scroller: Component; 300 300 Text: ComponentClass<PropsWithChildren<any>>; 301 + Heading: ComponentClass<PropsWithChildren<any>>; 301 302 LegacyText: Component; 302 303 Flex: Flex; 303 304 Card: ComponentClass<PropsWithChildren<any>>; 305 + Popout: ComponentClass<PropsWithChildren<any>>; 306 + Dialog: ComponentClass<PropsWithChildren<any>>; 307 + Menu: ComponentClass<PropsWithChildren<any>>; 308 + MenuItem: ComponentClass<PropsWithChildren<any>>; 309 + MenuGroup: ComponentClass<PropsWithChildren<any>>; 310 + MenuCheckboxItem: ComponentClass<PropsWithChildren<any>>; 304 311 CardClasses: { 305 312 card: string; 306 313 cardHeader: string;
+1
packages/types/src/discord/webpack.ts
··· 3 3 export type WebpackRequireType = typeof WebpackRequire & { 4 4 c: Record<string, WebpackModule>; 5 5 m: Record<string, WebpackModuleFunc>; 6 + el: (module: number | string) => Promise<void>; 6 7 }; 7 8 8 9 export type WebpackModule = {