From 4422f843be9076fffdfaa91cd4e8ce15515e7c1d Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Mon, 3 Jun 2024 16:57:31 -0400 Subject: [PATCH] feat(BetterActivites): Added --- .../components/ActivityTooltip.tsx | 74 +++++ .../betterActivities/components/Caret.tsx | 13 + .../components/SpotifyIcon.tsx | 11 + .../components/TwitchIcon.tsx | 11 + src/plugins/betterActivities/index.tsx | 272 ++++++++++++++++++ src/plugins/betterActivities/settings.tsx | 77 +++++ src/plugins/betterActivities/styles.css | 131 +++++++++ src/plugins/betterActivities/types.ts | 91 ++++++ src/plugins/betterActivities/utils.ts | 158 ++++++++++ 9 files changed, 838 insertions(+) create mode 100644 src/plugins/betterActivities/components/ActivityTooltip.tsx create mode 100644 src/plugins/betterActivities/components/Caret.tsx create mode 100644 src/plugins/betterActivities/components/SpotifyIcon.tsx create mode 100644 src/plugins/betterActivities/components/TwitchIcon.tsx create mode 100644 src/plugins/betterActivities/index.tsx create mode 100644 src/plugins/betterActivities/settings.tsx create mode 100644 src/plugins/betterActivities/styles.css create mode 100644 src/plugins/betterActivities/types.ts create mode 100644 src/plugins/betterActivities/utils.ts diff --git a/src/plugins/betterActivities/components/ActivityTooltip.tsx b/src/plugins/betterActivities/components/ActivityTooltip.tsx new file mode 100644 index 00000000..7776aa30 --- /dev/null +++ b/src/plugins/betterActivities/components/ActivityTooltip.tsx @@ -0,0 +1,74 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@api/Styles"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { findComponentByCodeLazy } from "@webpack"; +import { moment, React, useMemo } from "@webpack/common"; +import { User } from "discord-types/general"; + +import { Activity, Application } from "../types"; +import { + formatElapsedTime, + getActivityImage, + getApplicationIcons, + getValidStartTimeStamp, + getValidTimestamps +} from "../utils"; + +const TimeBar = findComponentByCodeLazy<{ + start: number; + end: number; + themed: boolean; + className: string; +}>("isSingleLine"); + +interface ActivityTooltipProps { + activity: Activity; + application?: Application; + user: User; + cl: ReturnType; +} + +export default function ActivityTooltip({ activity, application, user, cl }: Readonly) { + const image = useMemo(() => { + const activityImage = getActivityImage(activity, application); + if (activityImage) { + return activityImage; + } + const icon = getApplicationIcons([activity], true)[0]; + return icon?.image.src; + }, [activity]); + const timestamps = useMemo(() => getValidTimestamps(activity), [activity]); + const startTime = useMemo(() => getValidStartTimeStamp(activity), [activity]); + + const hasDetails = activity.details ?? activity.state; + return ( + +
+ {image && Activity logo} +
{activity.name}
+ {hasDetails &&
} +
+
{activity.details}
+
{activity.state}
+ {!timestamps && startTime && +
+ {formatElapsedTime(moment(startTime), moment())} +
+ } +
+ {timestamps && ( + + )} +
+ + ); +} diff --git a/src/plugins/betterActivities/components/Caret.tsx b/src/plugins/betterActivities/components/Caret.tsx new file mode 100644 index 00000000..b948d435 --- /dev/null +++ b/src/plugins/betterActivities/components/Caret.tsx @@ -0,0 +1,13 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export function Caret({ disabled, direction }: { disabled: boolean; direction: "left" | "right"; }) { + return ( + + + + ); +} diff --git a/src/plugins/betterActivities/components/SpotifyIcon.tsx b/src/plugins/betterActivities/components/SpotifyIcon.tsx new file mode 100644 index 00000000..9210169e --- /dev/null +++ b/src/plugins/betterActivities/components/SpotifyIcon.tsx @@ -0,0 +1,11 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { SVGProps } from "react"; + +export function SpotifyIcon(props: SVGProps) { + return (); +} diff --git a/src/plugins/betterActivities/components/TwitchIcon.tsx b/src/plugins/betterActivities/components/TwitchIcon.tsx new file mode 100644 index 00000000..f0246c16 --- /dev/null +++ b/src/plugins/betterActivities/components/TwitchIcon.tsx @@ -0,0 +1,11 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { SVGProps } from "react"; + +export function TwitchIcon(props: SVGProps) { + return (); +} diff --git a/src/plugins/betterActivities/index.tsx b/src/plugins/betterActivities/index.tsx new file mode 100644 index 00000000..1150331b --- /dev/null +++ b/src/plugins/betterActivities/index.tsx @@ -0,0 +1,272 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import "./styles.css"; + +import { classNameFactory } from "@api/Styles"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { findComponentByCodeLazy } from "@webpack"; +import { PresenceStore, React, Tooltip, useStateFromStores } from "@webpack/common"; +import { User } from "discord-types/general"; + +import ActivityTooltip from "./components/ActivityTooltip"; +import { Caret } from "./components/Caret"; +import { SpotifyIcon } from "./components/SpotifyIcon"; +import { TwitchIcon } from "./components/TwitchIcon"; +import settings from "./settings"; +import { Activity, ActivityListIcon, ActivityViewProps, ApplicationIcon, IconCSSProperties } from "./types"; +import { + getApplicationIcons +} from "./utils"; + +const cl = classNameFactory("vc-bactivities-"); + +const ActivityView = findComponentByCodeLazy("onOpenGameProfile:"); + +// if discord one day decides to change their icon this needs to be updated +const DefaultActivityIcon = findComponentByCodeLazy("M6,7 L2,7 L2,6 L6,6 L6,7 Z M8,5 L2,5 L2,4 L8,4 L8,5 Z M8,3 L2,3 L2,2 L8,2 L8,3 Z M8.88888889,0 L1.11111111,0 C0.494444444,0 0,0.494444444 0,1.11111111 L0,8.88888889 C0,9.50253861 0.497461389,10 1.11111111,10 L8.88888889,10 C9.50253861,10 10,9.50253861 10,8.88888889 L10,1.11111111 C10,0.494444444 9.5,0 8.88888889,0 Z"); + +export default definePlugin({ + name: "BetterActivities", + description: "Shows activity icons in the member list and allows showing all activities", + authors: [Devs.D3SOX, Devs.Arjix, Devs.AutumnVN], + tags: ["activity"], + + settings, + + patchActivityList: ({ activities, user }: { activities: Activity[], user: User; }): JSX.Element | null => { + const icons: ActivityListIcon[] = []; + + const applicationIcons = getApplicationIcons(activities); + if (applicationIcons.length) { + const compareImageSource = (a: ApplicationIcon, b: ApplicationIcon) => { + return a.image.src === b.image.src; + }; + const uniqueIcons = applicationIcons.filter((element, index, array) => { + return array.findIndex(el => compareImageSource(el, element)) === index; + }); + for (const appIcon of uniqueIcons) { + icons.push({ + iconElement: , + tooltip: + }); + } + } + + const addActivityIcon = (activityName: string, IconComponent: React.ComponentType) => { + const activityIndex = activities.findIndex(({ name }) => name === activityName); + if (activityIndex !== -1) { + const activity = activities[activityIndex]; + const iconObject: ActivityListIcon = { + iconElement: , + tooltip: + }; + + if (settings.store.specialFirst) { + icons.unshift(iconObject); + } else { + icons.splice(activityIndex, 0, iconObject); + } + } + }; + addActivityIcon("Twitch", TwitchIcon); + addActivityIcon("Spotify", SpotifyIcon); + + if (icons.length) { + const iconStyle: IconCSSProperties = { + "--icon-size": `${settings.store.iconSize}px`, + }; + + return +
+ {icons.map(({ iconElement, tooltip }, i) => ( +
+ {tooltip ? + {({ onMouseEnter, onMouseLeave }) => ( +
+ {iconElement} +
+ )} +
: iconElement} +
+ ))} +
+
; + } else { + // Show default icon when there are no custom icons + // We need to filter out custom statuses + const shouldShow = activities.filter(a => a.type !== 4).length !== icons.length; + if (shouldShow) { + return ; + } + } + + return null; + }, + + showAllActivitiesComponent({ activity, user, guild, channelId, onClose }: ActivityViewProps) { + const [currentActivity, setCurrentActivity] = React.useState( + activity?.type !== 4 ? activity! : null + ); + + const activities = useStateFromStores( + [PresenceStore], () => PresenceStore.getActivities(user.id).filter((activity: Activity) => activity.type !== 4) + ) ?? []; + + React.useEffect(() => { + if (!activities.length) { + setCurrentActivity(null); + return; + } + + if (!currentActivity || !activities.includes(currentActivity)) + setCurrentActivity(activities[0]); + + }, [activities]); + + if (!activities.length) return null; + + if (settings.store.allActivitiesStyle === "carousel") { + return ( +
+ +
+ {({ + onMouseEnter, + onMouseLeave + }) => { + return { + const index = activities.indexOf(currentActivity!); + if (index - 1 >= 0) + setCurrentActivity(activities[index - 1]); + }} + > + + ; + }} + +
+ {activities.map((activity, index) => ( +
setCurrentActivity(activity)} + className={`dot ${currentActivity === activity ? "selected" : ""}`} /> + ))} +
+ + {({ + onMouseEnter, + onMouseLeave + }) => { + return { + const index = activities.indexOf(currentActivity!); + if (index + 1 < activities.length) + setCurrentActivity(activities[index + 1]); + }} + > + = activities.length - 1} + direction="right" /> + ; + }} +
+
+ ); + } else { + return ( +
+ {activities.map((activity, index) => ( + + ))} +
+ ); + } + }, + + patches: [ + { + // Patch activity icons + find: "default.getHangStatusActivity():null!", + replacement: { + match: /null!=(\i)&&\i.some\(\i=>\(0,\i.default\)\(\i,\i\)\)\?/, + replace: "$self.patchActivityList(e),false?" + }, + predicate: () => settings.store.memberList, + }, + { + // Show all activities in the profile panel + find: "Profile Panel: user cannot be undefined", + replacement: { + match: /(?<=\(0,\i\.jsx\)\()\i\.\i(?=,{activity:.+?,user:\i,channelId:\i.id,)/, + replace: "$self.showAllActivitiesComponent" + }, + predicate: () => settings.store.profileSidebar, + }, + { + // Show all activities in the user popout + find: "customStatusSection,", + replacement: { + match: /(?<=\(0,\i\.jsx\)\()\i\.\i(?=,{activity:\i,user:\i,guild:\i,channelId:\i,onClose:\i,)/, + replace: "$self.showAllActivitiesComponent" + }, + predicate: () => settings.store.userPopout + } + ], +}); diff --git a/src/plugins/betterActivities/settings.tsx b/src/plugins/betterActivities/settings.tsx new file mode 100644 index 00000000..98f1b06e --- /dev/null +++ b/src/plugins/betterActivities/settings.tsx @@ -0,0 +1,77 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; +import { React } from "@webpack/common"; + +const settings = definePluginSettings({ + memberList: { + type: OptionType.BOOLEAN, + description: "Show activity icons in the member list", + default: true, + restartNeeded: true, + }, + iconSize: { + type: OptionType.SLIDER, + description: "Size of the activity icons", + markers: [10, 15, 20], + default: 15, + stickToMarkers: false, + }, + specialFirst: { + type: OptionType.BOOLEAN, + description: "Show special activities first (Currently Spotify and Twitch)", + default: true, + }, + renderGifs: { + type: OptionType.BOOLEAN, + description: "Allow rendering GIFs", + default: true, + }, + divider: { + type: OptionType.COMPONENT, + description: "", + component: () => ( +
+ ), + }, + profileSidebar: { + type: OptionType.BOOLEAN, + description: "Show all activities in the profile sidebar", + default: true, + restartNeeded: true, + }, + userPopout: { + type: OptionType.BOOLEAN, + description: "Show all activities in the user popout", + default: true, + restartNeeded: true, + }, + allActivitiesStyle: { + type: OptionType.SELECT, + description: "Style for showing all activities", + options: [ + { + default: true, + label: "Carousel", + value: "carousel", + }, + { + label: "List", + value: "list", + }, + ] + } +}); + +export default settings; diff --git a/src/plugins/betterActivities/styles.css b/src/plugins/betterActivities/styles.css new file mode 100644 index 00000000..1990c173 --- /dev/null +++ b/src/plugins/betterActivities/styles.css @@ -0,0 +1,131 @@ +.vc-bactivities-row { + display: flex; + flex-wrap: nowrap; + align-items: center; + margin-left: 5px; + text-align: center; + gap: 3px; +} + +.vc-bactivities-icon { + height: var(--icon-size); + width: var(--icon-size); +} + +.vc-bactivities-icon img { + width: var(--icon-size); + height: var(--icon-size); + object-fit: cover; + border-radius: 50%; +} + +.vc-bactivities-activity { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 5px; +} + +.vc-bactivities-activity-title { + font-weight: bold; + text-align: center; +} + +.vc-bactivities-activity-image { + height: 20px; + width: 20px; + border-radius: 50%; + object-fit: cover; +} + +.vc-bactivities-activity-divider { + width: 100%; + border-top: 1px dotted rgb(255 255 255 / 20%); + margin-top: 3px; + margin-bottom: 3px; +} + +.vc-bactivities-activity-details { + display: flex; + flex-direction: column; + color: var(--text-muted); + word-break: break-word; +} + +.vc-bactivities-activity-time-bar { + width: 100%; + margin-top: 3px; + margin-bottom: 3px; +} + +.vc-bactivities-caret-left, +.vc-bactivities-caret-right { + color: #ddd; +} + +.vc-bactivities-caret-left { + transform: rotate(90deg); +} + +.vc-bactivities-caret-right { + transform: rotate(-90deg); +} + +.vc-bactivities-controls { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px; + background: var(--background-secondary-alt); + border-radius: 3px; + flex: 1 0; + margin-top: 10px; +} + +.vc-bactivities-controls [class^="vc-activities-caret-"] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 3px; + background-color: #ffffff4d; +} + +.vc-bactivities-controls [class^="vc-activities-caret-"].disabled { + cursor: not-allowed; + opacity: 0.3; +} + +.vc-bactivities-controls [class^="vc-activities-caret-"]:hover:not(.disabled) { + background: var(--background-modifier-accent); +} + +.vc-bactivities-controls .carousell { + display: flex; + align-items: center; +} + +.vc-bactivities-controls .carousell .dot { + margin: 0 4px; + width: 10px; + cursor: pointer; + height: 10px; + border-radius: 100px; + background: var(--interactive-muted); + transition: background 0.3s; + opacity: 0.6; +} + +.vc-bactivities-controls .carousell .dot:hover:not(.selected) { + opacity: 1; +} + +.vc-bactivities-controls .carousell .dot.selected { + opacity: 1; + background: var(--dot-color, var(--brand-500)); +} + +.vc-bactivities-controls-tooltip { + --background-floating: var(--background-secondary); +} diff --git a/src/plugins/betterActivities/types.ts b/src/plugins/betterActivities/types.ts new file mode 100644 index 00000000..6ca4b533 --- /dev/null +++ b/src/plugins/betterActivities/types.ts @@ -0,0 +1,91 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Guild, User } from "discord-types/general"; +import { CSSProperties, ImgHTMLAttributes } from "react"; + +export interface Timestamp { + start?: number; + end?: number; +} + +export interface Activity { + created_at: number; + id: string; + name: string; + type: number; + emoji?: { + animated: boolean; + id: string; + name: string; + }; + state?: string; + flags?: number; + sync_id?: string; + details?: string; + application_id?: string; + assets?: { + large_text?: string; + large_image?: string; + small_text?: string; + small_image?: string; + }; + timestamps?: Timestamp; + platform?: string; +} + +export interface Application { + id: string; + name: string; + icon: string; + description: string; + summary: string; + type: number; + hook: boolean; + guild_id: string; + executables: Executable[]; + verify_key: string; + publishers: Developer[]; + developers: Developer[]; + flags: number; +} + +export interface Developer { + id: string; + name: string; +} + +export interface Executable { + os: string; + name: string; + is_launcher: boolean; +} + +export interface ApplicationIcon { + image: ImgHTMLAttributes & { + src: string; + alt: string; + }; + activity: Activity; + application?: Application; +} + +export interface ActivityListIcon { + iconElement: JSX.Element; + tooltip?: JSX.Element | string; +} + +export interface IconCSSProperties extends CSSProperties { + "--icon-size": string; +} + +export interface ActivityViewProps { + activity: Activity | null; + user: User; + guild: Guild; + channelId: string; + onClose: () => void; +} diff --git a/src/plugins/betterActivities/utils.ts b/src/plugins/betterActivities/utils.ts new file mode 100644 index 00000000..d1511acc --- /dev/null +++ b/src/plugins/betterActivities/utils.ts @@ -0,0 +1,158 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findByPropsLazy, findStoreLazy } from "@webpack"; +import { moment } from "@webpack/common"; + +import settings from "./settings"; +import { Activity, Application, ApplicationIcon, Timestamp } from "./types"; + +const ApplicationStore: { + getApplication: (id: string) => Application | null; +} = findStoreLazy("ApplicationStore"); + +const { fetchApplication }: { + fetchApplication: (id: string) => Promise; +} = findByPropsLazy("fetchApplication"); + +export function getActivityImage(activity: Activity, application?: Application): string | undefined { + if (activity.type === 2 && activity.name === "Spotify") { + // get either from large or small image + const image = activity.assets?.large_image ?? activity.assets?.small_image; + // image needs to replace 'spotify:' + if (image?.startsWith("spotify:")) { + // spotify cover art is always https://i.scdn.co/image/ID + return image.replace("spotify:", "https://i.scdn.co/image/"); + } + } + if (activity.type === 1 && activity.name === "Twitch") { + const image = activity.assets?.large_image; + // image needs to replace 'twitch:' + if (image?.startsWith("twitch:")) { + // twitch images are always https://static-cdn.jtvnw.net/previews-ttv/live_user_USERNAME-RESOLTUON.jpg + return `${image.replace("twitch:", "https://static-cdn.jtvnw.net/previews-ttv/live_user_")}-108x60.jpg`; + } + } + // TODO: we could support other assets here +} + +const fetchedApplications = new Map(); + +// TODO: replace with "renderXboxImage"? +const xboxUrl = "https://discord.com/assets/9a15d086141be29d9fcd.png"; + +export function getApplicationIcons(activities: Activity[], preferSmall = false) { + const applicationIcons: ApplicationIcon[] = []; + const applications = activities.filter(activity => activity.application_id || activity.platform); + + for (const activity of applications) { + const { assets, application_id, platform } = activity; + if (!application_id && !platform) { + continue; + } + if (assets) { + + const addImage = (image: string, alt: string) => { + if (image.startsWith("mp:")) { + const discordMediaLink = `https://media.discordapp.net/${image.replace(/mp:/, "")}`; + if (settings.store.renderGifs || !discordMediaLink.endsWith(".gif")) { + applicationIcons.push({ + image: { src: discordMediaLink, alt }, + activity + }); + } + } else { + const src = `https://cdn.discordapp.com/app-assets/${application_id}/${image}.png`; + applicationIcons.push({ + image: { src, alt }, + activity + }); + } + }; + + const smallImage = assets.small_image; + const smallText = assets.small_text ?? "Small Text"; + const largeImage = assets.large_image; + const largeText = assets.large_text ?? "Large Text"; + if (preferSmall) { + if (smallImage) { + addImage(smallImage, smallText); + } else if (largeImage) { + addImage(largeImage, largeText); + } + } else { + if (largeImage) { + addImage(largeImage, largeText); + } else if (smallImage) { + addImage(smallImage, smallText); + } + } + } else if (application_id) { + let application = ApplicationStore.getApplication(application_id); + if (!application) { + if (fetchedApplications.has(application_id)) { + application = fetchedApplications.get(application_id) as Application | null; + } else { + fetchedApplications.set(application_id, null); + fetchApplication(application_id).then(app => { + fetchedApplications.set(application_id, app); + }); + } + } + + if (application) { + if (application.icon) { + const src = `https://cdn.discordapp.com/app-icons/${application.id}/${application.icon}.png`; + applicationIcons.push({ + image: { src, alt: application.name }, + activity, + application + }); + } else if (platform === "xbox") { + applicationIcons.push({ + image: { src: xboxUrl, alt: "Xbox" }, + activity, + application + }); + } + } + } else { + if (platform === "xbox") { + applicationIcons.push({ + image: { src: xboxUrl, alt: "Xbox" }, + activity + }); + } + } + } + + return applicationIcons; +} + +export function getValidTimestamps(activity: Activity): Required | null { + if (activity.timestamps?.start !== undefined && activity.timestamps?.end !== undefined) { + return activity.timestamps as Required; + } + return null; +} + +export function getValidStartTimeStamp(activity: Activity): number | null { + if (activity.timestamps?.start !== undefined) { + return activity.timestamps.start; + } + return null; +} + +const customFormat = (momentObj: moment.Moment): string => { + const hours = momentObj.hours(); + const formattedTime = momentObj.format("mm:ss"); + return hours > 0 ? `${momentObj.format("HH:")}${formattedTime}` : formattedTime; +}; + +export function formatElapsedTime(startTime: moment.Moment, endTime: moment.Moment): string { + const duration = moment.duration(endTime.diff(startTime)); + return `${customFormat(moment.utc(duration.asMilliseconds()))} elapsed`; +}