From ac184030254650613350ed77e724146778d30d84 Mon Sep 17 00:00:00 2001 From: Ilyes Hernandez Date: Sat, 9 May 2026 09:57:50 +0200 Subject: [PATCH 1/2] feat(bulk-leave): basic bulk leave plugin --- plugins/bulk-leave/BulkLeave.scss | 139 ++++++++ plugins/bulk-leave/components/BulkLeave.tsx | 374 ++++++++++++++++++++ plugins/bulk-leave/index.tsx | 44 +++ plugins/bulk-leave/plugin.json | 5 + 4 files changed, 562 insertions(+) create mode 100644 plugins/bulk-leave/BulkLeave.scss create mode 100644 plugins/bulk-leave/components/BulkLeave.tsx create mode 100644 plugins/bulk-leave/index.tsx create mode 100644 plugins/bulk-leave/plugin.json diff --git a/plugins/bulk-leave/BulkLeave.scss b/plugins/bulk-leave/BulkLeave.scss new file mode 100644 index 0000000..3ae55a1 --- /dev/null +++ b/plugins/bulk-leave/BulkLeave.scss @@ -0,0 +1,139 @@ +.root { + display: flex; + flex-direction: column; + gap: 0; + padding: 4px 0; +} + +.headerRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.list { + display: flex; + flex-direction: column; + max-height: 340px; + overflow-y: auto; + border-radius: 8px; + background: var(--background-secondary); + padding: 4px; + gap: 2px; +} + +.guildItem { + display: flex; + align-items: center; + gap: 10px; + padding: 2px 0; +} + +.guildMeta { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.icon { + width: 28px; + height: 28px; + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; +} + +.iconPlaceholder { + width: 28px; + height: 28px; + border-radius: 50%; + flex-shrink: 0; + background: var(--brand-500, #5865f2); + color: var(--white, #fff); + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + user-select: none; +} + +.controls { + display: flex; + flex-direction: column; +} + +.selectionRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 24px; +} + +.selectionActions { + display: flex; + align-items: center; + gap: 4px; +} + +.progressSection { + display: flex; + flex-direction: column; + gap: 8px; +} + +.progressHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.progressTrack { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--background-modifier-accent); + overflow: hidden; +} + +.progressFill { + height: 100%; + border-radius: 3px; + background: var(--brand-500, #5865f2); + transition: width 0.3s ease; +} + +.centerState { + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; + text-align: center; +} + +.errorState { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding: 12px; + border-radius: 8px; + background: var(--background-modifier-accent); +} + +.mutedText { + color: var(--text-muted); +} + +.dangerText { + color: var(--text-danger); +} + +.progressNote { + color: var(--text-muted); + margin-top: 4px; +} \ No newline at end of file diff --git a/plugins/bulk-leave/components/BulkLeave.tsx b/plugins/bulk-leave/components/BulkLeave.tsx new file mode 100644 index 0000000..6e535e3 --- /dev/null +++ b/plugins/bulk-leave/components/BulkLeave.tsx @@ -0,0 +1,374 @@ +import { css, classes } from '../BulkLeave.scss' + +const { + ui: { + Button, + ButtonColors, + ButtonSizes, + CheckboxItem, + injectCss, + showToast, + ToastColors, + Header, + HeaderTags, + Divider, + Text, + TextTags, + TextWeights, + niceScrollbarsClass, + openConfirmationModal, + tooltip, + }, + solid: { + createSignal, + onMount, + onCleanup, + }, + http, + plugin: { + store, + }, + flux: { + stores: { + GuildStore, + UserStore, + }, + }, +} = shelter + +interface Guild { + id: string + name: string + icon?: string +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +interface PluginStore { + delay: number +} + +const pluginStore = store as PluginStore + +export function BulkLeave() { + const unloadCss = injectCss(css) + onCleanup(unloadCss) + + const [guilds, setGuilds] = createSignal([]) + const [selected, setSelected] = createSignal>(new Set()) + const [loading, setLoading] = createSignal(false) + const [leaving, setLeaving] = createSignal(false) + const [progress, setProgress] = createSignal(0) + const [total, setTotal] = createSignal(0) + const [error, setError] = createSignal(null) + const [cancelled, setCancelled] = createSignal(false) + + const delay = () => Math.max(0, Math.min(pluginStore.delay ?? 5000, 60_000)) + + const cancellableSleep = (ms: number) => + new Promise((resolve) => { + const end = Date.now() + ms + const iv = setInterval(() => { + if (cancelled() || Date.now() >= end) { + clearInterval(iv) + resolve() + } + }, 50) + }) + + onMount(() => { + loadGuilds() + }) + + const loadGuilds = async () => { + if (loading() || leaving()) return + setLoading(true) + setError(null) + try { + const currentUser = UserStore.getCurrentUser() + const currentUserId = currentUser?.id + const data = GuildStore.getGuildsArray() + setGuilds( + data + .filter((g) => g.ownerId !== currentUserId) + .map((g) => ({ id: g.id, name: g.name, icon: g.icon })) + .sort((a: Guild, b: Guild) => a.name.localeCompare(b.name)) + ) + } catch { + setError('Failed to load servers.') + } finally { + setLoading(false) + } + } + + const toggleSelected = (id: string) => { + const next = new Set(selected()) + next.has(id) ? next.delete(id) : next.add(id) + setSelected(next) + } + + const selectAll = () => setSelected(new Set(guilds().map((g) => g.id))) + const deselectAll = () => setSelected(new Set()) + + const leaveSelected = async () => { + const toLeave = selected() + if (toLeave.size === 0) { + showToast({ title: 'Bulk Leave', content: 'No servers selected.', color: ToastColors.WARNING }) + return + } + + try { + await openConfirmationModal({ + header: () => 'Leave servers?', + body: () => ( + + You are about to leave {toLeave.size} server{toLeave.size !== 1 ? 's' : ''}. This cannot be undone unless you are re-invited. + + ), + type: 'danger', + confirmText: `Leave ${toLeave.size} server${toLeave.size !== 1 ? 's' : ''}`, + cancelText: 'Cancel', + }) + } catch { + return + } + + setLeaving(true) + setCancelled(false) + setProgress(0) + setTotal(toLeave.size) + + const arr = Array.from(toLeave) + const failed: string[] = [] + + for (let i = 0; i < arr.length; i++) { + if (cancelled()) break + try { + await http.del(`/users/@me/guilds/${arr[i]}`, { body: { lurking: false } }) + } catch (e) { + console.error(`Failed to leave guild ${arr[i]}`, e) + failed.push(arr[i]) + } + setProgress(i + 1) + if (i < arr.length - 1) await cancellableSleep(delay()) + } + + const wasCancelled = cancelled() + const processed = progress() + const succeeded = processed - failed.length + + if (wasCancelled) { + showToast({ + title: 'Bulk Leave', + content: `Cancelled. Left ${succeeded} of ${toLeave.size} server${toLeave.size !== 1 ? 's' : ''}${failed.length > 0 ? `. ${failed.length} failed.` : '.'}`, + color: ToastColors.WARNING, + }) + } else if (failed.length === 0) { + showToast({ + title: 'Bulk Leave', + content: `Left ${toLeave.size} server${toLeave.size > 1 ? 's' : ''}!`, + color: ToastColors.SUCCESS, + }) + } else { + showToast({ + title: 'Bulk Leave', + content: `Left ${succeeded} of ${toLeave.size} server${toLeave.size !== 1 ? 's' : ''}. ${failed.length} failed.`, + color: ToastColors.WARNING, + }) + } + + setLeaving(false) + setSelected(new Set()) + await sleep(1000) + await loadGuilds() + } + + const progressPct = () => + total() > 0 ? Math.round((progress() / total()) * 100) : 0 + + const allSelected = () => + guilds().length > 0 && selected().size === guilds().length + + const renderGuildList = () => { + const list = guilds() + + if (list.length === 0) return null + + return ( +
+ {list.map((guild) => ( + toggleSelected(guild.id)} + disabled={leaving()} + > +
+ {guild.icon ? ( + + ) : ( +
+ {guild.name.charAt(0).toUpperCase()} +
+ )} +
+ + {guild.name} + +
+
+
+ ))} +
+ ) + } + + const renderControls = () => { + const count = selected().size + + return ( +
+
+ + {count > 0 + ? `${count} of ${guilds().length} selected` + : `${guilds().length} server${guilds().length !== 1 ? 's' : ''}`} + + +
+ +
+
+ + + + {leaving() ? ( +
+
+ + Leaving servers… + + + {progress()} / {total()} + +
+
+
+
+ + {progressPct()}% complete + + +
+ ) : ( +
+ +
+ )} +
+ ) + } + + const renderBody = () => { + if (loading()) { + return ( +
+ + Loading servers… + +
+ ) + } + + if (error()) { + return ( +
+ + {error()} + + +
+ ) + } + + if (guilds().length === 0) { + return ( +
+ + You're not a member of any servers, or you own all of them. + +
+ ) + } + + return ( + <> + {renderGuildList()} + + {renderControls()} + + ) + } + + return ( +
+
+
+ Bulk Leave +
+ + +
+ + + + {renderBody()} +
+ ) +} \ No newline at end of file diff --git a/plugins/bulk-leave/index.tsx b/plugins/bulk-leave/index.tsx new file mode 100644 index 0000000..3c3ef19 --- /dev/null +++ b/plugins/bulk-leave/index.tsx @@ -0,0 +1,44 @@ +import { BulkLeave } from './components/BulkLeave.jsx' + +const { + settings: { + registerSection, + }, + plugin: { + store, + }, + ui: { + Slider, + Text, + }, +} = shelter + +if (store.delay === undefined) { + store.delay = 5000 +} + +const unload = registerSection('section', 'bulk-leave', 'Bulk Leave', BulkLeave) + +export const settings = () => ( + <> + Configure the delay between each server leave request. +
+ { + store.delay = v + }} + /> + + Current: {store.delay}ms per server + + +) + +export const onUnload = () => { + unload() + cleanupCss() +} \ No newline at end of file diff --git a/plugins/bulk-leave/plugin.json b/plugins/bulk-leave/plugin.json new file mode 100644 index 0000000..1736d46 --- /dev/null +++ b/plugins/bulk-leave/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "Bulk Leave", + "author": "ilyeshdz", + "description": "Leave multiple servers at once." +} \ No newline at end of file From 93cfbcf1303ac1ace2d9363e7342c8abc4981e8c Mon Sep 17 00:00:00 2001 From: Ilyes Hernandez Date: Sat, 9 May 2026 09:59:05 +0200 Subject: [PATCH 2/2] feat(bulk-leave): add to readme --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 6547005..74a2843 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - [Plugins](#plugins) - [Always Trust](#always-trust) - [Blur NSFW](#blur-nsfw) + - [Bulk Leave](#bulk-leave) - [Declutter](#declutter) - [Disable F1](#disable-f1) - [Inline CSS](#inline-css) @@ -49,6 +50,12 @@ Blur images and videos in NSFW channels. Hover to unblur, the click-preview is a ![blurnsfw](https://github.com/SpikeHD/shelter-plugins/assets/25207995/921f5add-7d3e-4885-9d0b-a95b483caab6) +## Bulk Leave + +Leave multiple servers at once. + +`https://spikehd.dev/shelter-plugins/bulk-leave/` + ## Declutter Hide/disable unwanted or distracting components, such as Nitro effects, Store/Nitro tabs, and much more.