diff --git a/src/plugins/betterActivities/README.md b/src/plugins/betterActivities/README.md new file mode 100644 index 000000000..cebfddbeb --- /dev/null +++ b/src/plugins/betterActivities/README.md @@ -0,0 +1,7 @@ +# BetterActivities + +Shows activity icons in the member list and allows showing all activities + +![Shows activity icons next to the status line in the member list](https://github.com/Vendicated/Vencord/assets/24937357/4c034963-4448-483a-ba1d-c04f74e4aa51) +![Shows all activities in the profile via a carousel](https://github.com/Vendicated/Vencord/assets/24937357/11a2f15e-59b0-4072-847a-33444d964674) +![Optionally show all activities as list](https://github.com/Vendicated/Vencord/assets/24937357/277f425f-65e7-4e25-ad98-ff61b4370f08) diff --git a/src/plugins/betterActivities/components/ActivityTooltip.tsx b/src/plugins/betterActivities/components/ActivityTooltip.tsx new file mode 100644 index 000000000..c74f04693 --- /dev/null +++ b/src/plugins/betterActivities/components/ActivityTooltip.tsx @@ -0,0 +1,34 @@ +/* + * 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 { User } from "discord-types/general"; + +import { ActivityView } from "../index"; +import { Activity, Application } from "../types"; + +interface ActivityTooltipProps { + activity: Activity; + application?: Application; + user: User; + cl: ReturnType; +} + +export default function ActivityTooltip({ activity, application, user, cl }: Readonly) { + return ( + +
+ +
+
+ ); +} diff --git a/src/plugins/betterActivities/components/Caret.tsx b/src/plugins/betterActivities/components/Caret.tsx new file mode 100644 index 000000000..b948d4358 --- /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 000000000..9210169e2 --- /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 000000000..f0246c161 --- /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 000000000..6eb301d06 --- /dev/null +++ b/src/plugins/betterActivities/index.tsx @@ -0,0 +1,291 @@ +/* + * 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, useEffect, useMemo, useState, 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 { + getActivityApplication, + getApplicationIcons +} from "./utils"; + +const cl = classNameFactory("vc-bactivities-"); + +export const ActivityView = findComponentByCodeLazy(",onOpenGameProfileModal:"); + +// 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, ...props }: ActivityViewProps) { + const [currentActivity, setCurrentActivity] = useState( + activity?.type !== 4 ? activity! : null + ); + + const activities = useStateFromStores( + [PresenceStore], () => PresenceStore.getActivities(user.id).filter((activity: Activity) => activity.type !== 4) + ) ?? []; + + useEffect(() => { + if (!activities.length) { + setCurrentActivity(null); + return; + } + + if (!currentActivity || !activities.includes(currentActivity)) + setCurrentActivity(activities[0]); + }, [activities]); + + // we use these for other activities, it would be better to somehow get the corresponding activity props + const generalProps = useMemo(() => Object.keys(props).reduce((acc, key) => { + // exclude activity specific props to prevent copying them to all activities (e.g. buttons) + if (key !== "renderActions" && key !== "application") acc[key] = props[key]; + return acc; + }, {} as Omit), [props]); + + if (!activities.length) return null; + + if (settings.store.allActivitiesStyle === "carousel") { + return ( +
+ {currentActivity?.id === activity?.id ? ( + + ) : ( + + )} +
+ {({ + 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) => + index === 0 ? ( + ) : ( + + ))} +
+ ); + } + }, + + patches: [ + { + // Patch activity icons + find: ".getHangStatusActivity():null!", + replacement: { + match: /null!=(\i)&&\i.some\(\i=>\(0,\i.\i\)\(\i,\i\)\)\?/, + replace: "$self.patchActivityList(e),false?" + }, + predicate: () => settings.store.memberList, + }, + { + // Show all activities in the user popout/sidebar + find: '"UserActivityContainer"', + replacement: { + match: /(?<=\(0,\i\.jsx\)\()(\i\.\i)(?=,{...(\i),activity:\i,user:\i,application:\i)/, + replace: "$2.type==='BiteSizePopout'?$self.showAllActivitiesComponent:$1" + }, + predicate: () => settings.store.profiles + }, + ], +}); diff --git a/src/plugins/betterActivities/settings.tsx b/src/plugins/betterActivities/settings.tsx new file mode 100644 index 000000000..9064b0150 --- /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, + }, + showAppDescriptions: { + type: OptionType.BOOLEAN, + description: "Show application descriptions in the activity tooltip", + default: true, + restartNeeded: false, + }, + divider: { + type: OptionType.COMPONENT, + description: "", + component: () => ( +
+ ), + }, + profiles: { + type: OptionType.BOOLEAN, + description: "Show all activities in the profile popout/sidebar", + 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 000000000..7421b0ce5 --- /dev/null +++ b/src/plugins/betterActivities/styles.css @@ -0,0 +1,95 @@ +.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-tooltip { + padding: 1px; +} + +.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 000000000..8925b5898 --- /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 { 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; + application?: Application; + renderActions?: () => JSX.Element; + type: string; +} diff --git a/src/plugins/betterActivities/utils.ts b/src/plugins/betterActivities/utils.ts new file mode 100644 index 000000000..dc274d8fe --- /dev/null +++ b/src/plugins/betterActivities/utils.ts @@ -0,0 +1,122 @@ +/* + * 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 settings from "./settings"; +import { Activity, Application, ApplicationIcon } from "./types"; + +const ApplicationStore: { + getApplication: (id: string) => Application | null; +} = findStoreLazy("ApplicationStore"); + +const { fetchApplication }: { + fetchApplication: (id: string) => Promise; +} = findByPropsLazy("fetchApplication"); + +const fetchedApplications = new Map(); + +export function getActivityApplication({ application_id }: Activity) { + if (!application_id) return undefined; + let application = ApplicationStore.getApplication(application_id); + if (!application && fetchedApplications.has(application_id)) { + application = fetchedApplications.get(application_id) ?? null; + } + return application ?? undefined; +} + +// 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); + }).catch(console.error); + } + } + + 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 + }); + } + } else if (platform === "xbox") { + applicationIcons.push({ + image: { src: xboxUrl, alt: "Xbox" }, + activity + }); + } + } + + return applicationIcons; +}