From 6b5ff87d662f8de9dbbaec865b90da27ebc8119e Mon Sep 17 00:00:00 2001 From: kmmiio99o Date: Sun, 24 May 2026 16:21:55 +0200 Subject: [PATCH] feat(multi-scrobbler): implement 60s rate limiting for Libre.fm Enforces a minimum update interval of 60 seconds when Libre.fm is the active service, as requested by the Libre.fm team to reduce server load. - Added LIBREFM_MIN_UPDATE_INTERVAL to Constants. - Updated PluginManager to enforce the new minimum for Libre.fm. - Disabled user-configurable update interval in UI when Libre.fm is active. Closes #4 --- chatboxavatar/index.tsx | 178 ------------------ chatboxavatar/settings.tsx | 58 ------ chatboxavatar/storage.ts | 22 --- plugins/ChatboxAvatar/manifest.json | 18 -- plugins/ChatboxAvatar/src/index.tsx | 165 ---------------- plugins/ChatboxAvatar/src/settings.tsx | 101 ---------- plugins/multi-scrobbler/src/constants.ts | 2 + plugins/multi-scrobbler/src/manager.ts | 12 +- .../ui/pages/pages/DisplaySettingsPage.tsx | 48 +++-- 9 files changed, 42 insertions(+), 562 deletions(-) delete mode 100644 chatboxavatar/index.tsx delete mode 100644 chatboxavatar/settings.tsx delete mode 100644 chatboxavatar/storage.ts delete mode 100644 plugins/ChatboxAvatar/manifest.json delete mode 100644 plugins/ChatboxAvatar/src/index.tsx delete mode 100644 plugins/ChatboxAvatar/src/settings.tsx diff --git a/chatboxavatar/index.tsx b/chatboxavatar/index.tsx deleted file mode 100644 index afdba15..0000000 --- a/chatboxavatar/index.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { after } from "@api/patcher"; -import { findByNameLazy, findByProps, findByPropsLazy, findByStoreName, findByTypeDisplayName } from "@metro"; -import { ReactNative } from "@metro/common"; -import { definePlugin } from "@plugins"; -import { Contributors } from "@rain/Developers"; -import React, { useEffect, useRef } from "react"; - -import ChatboxAvatarSettings from "./settings"; -import { useChatboxAvatarSettings } from "./storage"; - -const Flux = findByProps("useStateFromStores"); -const ChatInputActions = findByTypeDisplayName("ChatInputActions"); -const ChatInputSendButton = findByTypeDisplayName("ChatInputSendButton"); -let hasText = false; -let sendBtnRef: { setHasText?: (hasText: boolean) => void } | undefined; -const { Pressable, View, Animated } = ReactNative; - -const avatarCollapse = new Animated.Value(0); - -const Avatar = findByPropsLazy("default", "AvatarSizes", "getStatusSize")?.default; - -const UserStore = findByStoreName("UserStore"); -const SelectedChannelStore = findByStoreName("SelectedChannelStore"); -const ChannelStore = findByStoreName("ChannelStore"); -const SelfPresenceStore = findByStoreName("SelfPresenceStore"); -const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet"); -const showYouAccountActionSheetByProp = findByPropsLazy("showYouAccountActionSheet"); - -function AvatarAction() { - const [textState, setTextState] = React.useState(false); - const self = Flux?.useStateFromStores?.([UserStore], () => UserStore?.getCurrentUser?.()); - const status = Flux?.useStateFromStores?.([SelfPresenceStore], () => SelfPresenceStore?.getStatus?.()); - const settings = useChatboxAvatarSettings(); - const channelId = Flux?.useStateFromStores?.([SelectedChannelStore], () => SelectedChannelStore?.getCurrentlySelectedChannelId?.()); - const channel = Flux?.useStateFromStores?.([ChannelStore], () => ChannelStore?.getChannel?.(channelId), [channelId]); - - const animated = useRef(avatarCollapse).current; - - React.useEffect(() => { - const interval = setInterval(() => { - setTextState(hasText); - }, 100); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - const shouldCollapse = settings.collapseWhileTyping && textState; - Animated.parallel([ - Animated.timing(animated, { - toValue: shouldCollapse ? 1 : 0, - duration: 200, - useNativeDriver: false, - }), - ]).start(); - }, [textState, settings.collapseWhileTyping, animated]); - - if (!self) return null; - - const openAccountSheet = () => { - const fn = showYouAccountActionSheetByProp?.showYouAccountActionSheet; - if (typeof fn === "function") { - try { - fn(true, true); - return; - } catch (err) { - } - } - showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId }); - }; - - const handlePress = () => { - switch (settings.pressAction) { - case "profile": - showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId }); - break; - case "server": - openAccountSheet(); - break; - default: - break; - } - }; - - const handleLongPress = () => { - switch (settings.longPressAction) { - case "profile": - showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId }); - break; - case "server": - openAccountSheet(); - break; - default: - break; - } - }; - - return ( - - - {Avatar && ( - - )} - - - ); -} - -const unpatches: (() => void)[] = []; - -export default definePlugin({ - name: "ChatboxAvatar", - description: "Adds your avatar to the chatbox.", - author: [Contributors.LampDelivery], - id: "chatboxavatar", - version: "v1.0.0", - requiresRestart: true, - settings: ChatboxAvatarSettings, - async start() { - if (!ChatInputActions?.type || !ChatInputSendButton?.type) return; - unpatches.push( - after("render", ChatInputSendButton.type, (args, ret) => { - setImmediate(() => { - setImmediate(() => { - if (args?.[1]?.current) { - sendBtnRef = args[1].current; - const origSetHasText = sendBtnRef?.setHasText; - unpatches.push(() => { if (sendBtnRef && origSetHasText) sendBtnRef.setHasText = origSetHasText; }); - if (sendBtnRef) { - sendBtnRef.setHasText = (hasText_: boolean) => { - hasText = hasText_; - if (origSetHasText) return origSetHasText.call(sendBtnRef, hasText_); - }; - } - } - }); - }); - }) - ); - - }, - async eagerStart() { - if (!ChatInputActions?.type || !ChatInputSendButton?.type) return; - unpatches.push( - after("render", ChatInputActions.type, (args, ret) => { - return React.createElement( - View, - { style: { flexDirection: "row", alignItems: "center" } }, - ret, - React.createElement(AvatarAction) - ); - }) - ); - }, - stop() { - for (const unpatch of unpatches) unpatch?.(); - unpatches.length = 0; - }, -}); diff --git a/chatboxavatar/settings.tsx b/chatboxavatar/settings.tsx deleted file mode 100644 index 0c54d5b..0000000 --- a/chatboxavatar/settings.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Stack, TableRadioGroup, TableRadioRow } from "@metro/common/components"; -import React from "react"; - -import { ChatboxAvatarSettings, useChatboxAvatarSettings } from "./storage"; -export default function ChatboxAvatarSettings() { - const settings = useChatboxAvatarSettings(); - const [, forceUpdate] = React.useReducer(x => ~x, 0); - const setPressAction = (value: string) => { - useChatboxAvatarSettings.getState().updateSettings?.({ pressAction: value as ChatboxAvatarSettings["pressAction"] }); - forceUpdate(); - }; - const setLongPressAction = (value: string) => { - useChatboxAvatarSettings.getState().updateSettings?.({ longPressAction: value as ChatboxAvatarSettings["longPressAction"] }); - forceUpdate(); - }; - return ( - - - - - - - - - - { - useChatboxAvatarSettings.getState().updateSettings?.({ showStatusCutout: v === "true" }); - forceUpdate(); - }} - title="Status Icon" - > - - - - { - useChatboxAvatarSettings.getState().updateSettings?.({ collapseWhileTyping: v === "true" }); - forceUpdate(); - }} - title="Collapse While Typing" - > - - - - - ); -} diff --git a/chatboxavatar/storage.ts b/chatboxavatar/storage.ts deleted file mode 100644 index 046f2c6..0000000 --- a/chatboxavatar/storage.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createPluginStore } from "@api/storage"; - -export interface ChatboxAvatarSettings { - replaceButton: "gallery" | "apps" | "gift"; - pressAction: "profile" | "server"; - longPressAction: "profile" | "server"; - serverId?: string; - showStatusCutout?: boolean; - collapseWhileTyping?: boolean; -} - -export const { - useStore: useChatboxAvatarSettings, - settings: chatboxAvatarSettings, -} = createPluginStore("chatboxavatar", { - replaceButton: "gallery", - pressAction: "profile", - longPressAction: "server", - serverId: "", - showStatusCutout: false, - collapseWhileTyping: false, -}); diff --git a/plugins/ChatboxAvatar/manifest.json b/plugins/ChatboxAvatar/manifest.json deleted file mode 100644 index 8ebd537..0000000 --- a/plugins/ChatboxAvatar/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "Chatbox Avatar", - "description": "Quickly view your profile from the chatbox.", - "authors": [ - { - "name": "Cynosphere", - "id": "150745989836308480" - }, - { - "name": "kmmiio99o", - "id": "879393496627306587" - } - ], - "main": "src/index.tsx", - "vendetta": { - "icon": "UserSquareIcon" - } -} diff --git a/plugins/ChatboxAvatar/src/index.tsx b/plugins/ChatboxAvatar/src/index.tsx deleted file mode 100644 index d08b9e8..0000000 --- a/plugins/ChatboxAvatar/src/index.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { after } from "@vendetta/patcher"; -import { findByName, findByProps, findByStoreName, findByDisplayName } from "@vendetta/metro"; -import { ReactNative } from "@vendetta/metro/common"; -import { plugin } from "@vendetta"; -import React, { useEffect, useRef } from "react"; - -import ChatboxAvatarSettings from "./settings"; - -const Flux = findByProps("useStateFromStores"); -const ChatInputActions = findByDisplayName("ChatInputActions"); -const ChatInputSendButton = findByDisplayName("ChatInputSendButton"); -let hasText = false; -let sendBtnRef: { setHasText?: (hasText: boolean) => void } | undefined; -const { Pressable, View, Animated } = ReactNative; - -const avatarCollapse = new Animated.Value(0); - -const Avatar = findByProps("default", "AvatarSizes", "getStatusSize")?.default; - -const UserStore = findByStoreName("UserStore"); -const SelectedChannelStore = findByStoreName("SelectedChannelStore"); -const ChannelStore = findByStoreName("ChannelStore"); -const SelfPresenceStore = findByStoreName("SelfPresenceStore"); -const showUserProfileActionSheet = findByName("showUserProfileActionSheet"); -const showYouAccountActionSheetByProp = findByProps("showYouAccountActionSheet"); - -function AvatarAction() { - const [textState, setTextState] = React.useState(false); - const self = Flux?.useStateFromStores?.([UserStore], () => UserStore?.getCurrentUser?.()); - const status = Flux?.useStateFromStores?.([SelfPresenceStore], () => SelfPresenceStore?.getStatus?.()); - const settings = plugin.storage; - const channelId = Flux?.useStateFromStores?.([SelectedChannelStore], () => SelectedChannelStore?.getCurrentlySelectedChannelId?.()); - const channel = Flux?.useStateFromStores?.([ChannelStore], () => ChannelStore?.getChannel?.(channelId), [channelId]); - - const animated = useRef(avatarCollapse).current; - - React.useEffect(() => { - const interval = setInterval(() => { - setTextState(hasText); - }, 100); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - const shouldCollapse = settings.collapseWhileTyping && textState; - Animated.parallel([ - Animated.timing(animated, { - toValue: shouldCollapse ? 1 : 0, - duration: 200, - useNativeDriver: false, - }), - ]).start(); - }, [textState, settings.collapseWhileTyping, animated]); - - if (!self) return null; - - const openAccountSheet = () => { - const fn = showYouAccountActionSheetByProp?.showYouAccountActionSheet; - if (typeof fn === "function") { - try { - fn(true, true); - return; - } catch {} - } - showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId }); - }; - - const handlePress = () => { - switch (settings.pressAction) { - case "profile": - showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId }); - break; - case "server": - openAccountSheet(); - break; - default: - break; - } - }; - - const handleLongPress = () => { - switch (settings.longPressAction) { - case "profile": - showUserProfileActionSheet?.({ userId: self.id, channelId: channel?.id ?? channelId }); - break; - case "server": - openAccountSheet(); - break; - default: - break; - } - }; - - return ( - - - {Avatar && ( - - )} - - - ); -} - -const unpatches: (() => void)[] = []; - -export default { - onLoad() { - if (!ChatInputActions?.prototype?.render || !ChatInputSendButton?.prototype?.render) return; - unpatches.push( - after("render", ChatInputActions.prototype, (args, ret) => { - return React.createElement( - View, - { style: { flexDirection: "row", alignItems: "center" } }, - ret, - React.createElement(AvatarAction) - ); - }) - ); - unpatches.push( - after("render", ChatInputSendButton.prototype, (args, ret) => { - setImmediate(() => { - setImmediate(() => { - if (args?.[1]?.current) { - sendBtnRef = args[1].current; - const origSetHasText = sendBtnRef?.setHasText; - unpatches.push(() => { if (sendBtnRef && origSetHasText) sendBtnRef.setHasText = origSetHasText; }); - if (sendBtnRef) { - sendBtnRef.setHasText = (hasText_: boolean) => { - hasText = hasText_; - if (origSetHasText) return origSetHasText.call(sendBtnRef, hasText_); - }; - } - } - }); - }); - }) - ); - }, - onUnload() { - for (const unpatch of unpatches) unpatch?.(); - unpatches.length = 0; - }, - settings: ChatboxAvatarSettings, -}; diff --git a/plugins/ChatboxAvatar/src/settings.tsx b/plugins/ChatboxAvatar/src/settings.tsx deleted file mode 100644 index e4005eb..0000000 --- a/plugins/ChatboxAvatar/src/settings.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { plugin } from "@vendetta"; -import { useProxy } from "@vendetta/storage"; -import { React, ReactNative } from "@vendetta/metro/common"; -import { findByProps } from "@vendetta/metro"; - -const { ScrollView } = ReactNative; -const { TableRowGroup, TableCheckboxRow, Stack } = findByProps( - "TableCheckboxRow", "TableRowGroup", "Stack" -); - -plugin.storage.pressAction ??= "profile"; -plugin.storage.longPressAction ??= "server"; -plugin.storage.showStatusCutout ??= false; -plugin.storage.collapseWhileTyping ??= false; - -export default function ChatboxAvatarSettings() { - useProxy(plugin.storage); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); - - return ( - - - - { - plugin.storage.pressAction = "profile"; - forceUpdate(); - }} - /> - { - plugin.storage.pressAction = "server"; - forceUpdate(); - }} - /> - - - - { - plugin.storage.longPressAction = "profile"; - forceUpdate(); - }} - /> - { - plugin.storage.longPressAction = "server"; - forceUpdate(); - }} - /> - - - - { - plugin.storage.showStatusCutout = true; - forceUpdate(); - }} - /> - { - plugin.storage.showStatusCutout = false; - forceUpdate(); - }} - /> - - - - { - plugin.storage.collapseWhileTyping = true; - forceUpdate(); - }} - /> - { - plugin.storage.collapseWhileTyping = false; - forceUpdate(); - }} - /> - - - - ); -} diff --git a/plugins/multi-scrobbler/src/constants.ts b/plugins/multi-scrobbler/src/constants.ts index 8d627c1..3173733 100644 --- a/plugins/multi-scrobbler/src/constants.ts +++ b/plugins/multi-scrobbler/src/constants.ts @@ -7,6 +7,8 @@ const Constants = { APPLICATION_ID: "1368513179272871956", // Don't check more than once every 3 seconds to avoid getting rate limited MIN_UPDATE_INTERVAL: 3, + // Libre.fm specifically requested a 1-minute minimum to reduce load + LIBREFM_MIN_UPDATE_INTERVAL: 60, // How many times to retry failed API calls MAX_RETRY_ATTEMPTS: 3, // Wait 5 seconds between retries diff --git a/plugins/multi-scrobbler/src/manager.ts b/plugins/multi-scrobbler/src/manager.ts index e5a67d6..2cd0a98 100644 --- a/plugins/multi-scrobbler/src/manager.ts +++ b/plugins/multi-scrobbler/src/manager.ts @@ -381,10 +381,18 @@ class PluginManager { // Don't reset track state - let updateActivity() handle whether to update await this.updateActivity(); + const currentService = currentSettings.service; + let minInterval: number = Constants.MIN_UPDATE_INTERVAL; + + if (currentService === "librefm") { + minInterval = Constants.LIBREFM_MIN_UPDATE_INTERVAL; + log(`Libre.fm active: enforcing ${minInterval}s minimum update interval`); + } + const interval = Math.max( (Number(currentSettings.timeInterval) || - Constants.DEFAULT_SETTINGS.timeInterval) * 1000, - Constants.MIN_UPDATE_INTERVAL * 1000, + Constants.DEFAULT_SETTINGS.timeInterval) * 1000, + minInterval * 1000, ); this.updateTimer = setInterval(() => this.updateActivity(), interval); diff --git a/plugins/multi-scrobbler/src/ui/pages/pages/DisplaySettingsPage.tsx b/plugins/multi-scrobbler/src/ui/pages/pages/DisplaySettingsPage.tsx index 25bd4a9..46cf837 100644 --- a/plugins/multi-scrobbler/src/ui/pages/pages/DisplaySettingsPage.tsx +++ b/plugins/multi-scrobbler/src/ui/pages/pages/DisplaySettingsPage.tsx @@ -15,6 +15,9 @@ export default function DisplaySettingsPage() { useProxy(plugin.storage); const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + const currentService = getStorage("service"); + const isLibreFm = currentService === "librefm"; + return ( @@ -29,24 +32,33 @@ export default function DisplaySettingsPage() { }} isClearable /> - { - const interval = Number(v); - if (interval >= Constants.MIN_UPDATE_INTERVAL) { - setStorage("timeInterval", interval); - forceUpdate(); - } - }} - keyboardType="numeric" - isClearable - /> + {isLibreFm ? ( + + ) : ( + { + const interval = Number(v); + if (interval >= Constants.MIN_UPDATE_INTERVAL) { + setStorage("timeInterval", interval); + forceUpdate(); + } + }} + keyboardType="numeric" + isClearable + /> + )}