diff --git a/src/plugins/activities/icons.tsx b/src/plugins/activities/icons.tsx new file mode 100644 index 0000000000..75e8694032 --- /dev/null +++ b/src/plugins/activities/icons.tsx @@ -0,0 +1,86 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { LazyComponent } from "@utils/react"; +import { findByCode } from "@webpack"; +import { React } from "@webpack/common"; + +interface IconProps { + width: number; + height: number; + color?: string; +} +export const RichActivityIcon: React.FC = LazyComponent(() => findByCode("M6,7 L2,7 L2,6 L6,6 L6,7 Z M8,5 L2,5 L2,4 L8,4")); + +export const HeadsetIcon: React.FC = ({ width, height, color }: IconProps) => { + return ( + + + + ); +}; + +export const ActivityIcon: React.FC = ({ width, height, color }: IconProps) => { + return ( + + + + ); +}; + +export const MobileIcon: React.FC = ({ width, height, color }) => { + return + + ; +}; + +export const ControllerIcon: React.FC = ({ width, height, color }) => { + return + + ; +}; + +export const XboxIcon: React.FC = ({ width, height, color }: IconProps) => { + return ( + + + + ); +}; + +export const PlaystationIcon: React.FC = ({ width, height, color }: IconProps) => { + return ( + + + + ); +}; + +export const Caret = ({ disabled, direction }: { disabled: boolean; direction: "left" | "right"; }) => { + return ( + + + + ); +}; diff --git a/src/plugins/activities/index.tsx b/src/plugins/activities/index.tsx new file mode 100644 index 0000000000..0fb8d0fc8d --- /dev/null +++ b/src/plugins/activities/index.tsx @@ -0,0 +1,241 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import { LazyComponent } from "@utils/react"; +import definePlugin, { OptionType } from "@utils/types"; +import { findByCode, findStoreLazy } from "@webpack"; +import { React, Tooltip, useStateFromStores } from "@webpack/common"; +import { Guild, User } from "discord-types/general"; + +import { ActivityIcon, Caret, ControllerIcon, HeadsetIcon, MobileIcon, PlaystationIcon, RichActivityIcon, XboxIcon } from "./icons"; +import { Activity, ActivityProps, ActivityType } from "./types"; + +const PresenceStore = findStoreLazy("PresenceStore"); +const ActivityView = LazyComponent(() => findByCode("onOpenGameProfile:")); + +import "./style.css"; + +import { definePluginSettings } from "@api/Settings"; + +const settings = definePluginSettings({ + moreActivityIcons: { + type: OptionType.BOOLEAN, + restartNeeded: true, + default: true, + description: "e.g. the controller icon for games, headphone icon for spotify.", + }, + showAllActivities: { + type: OptionType.BOOLEAN, + restartNeeded: true, + default: true, + description: "The activities carousel in the user profile." + }, + ignoreBotsIcon: { + type: OptionType.BOOLEAN, + restartNeeded: true, + default: true, + description: "Ignore bots' activities icon." + } +}); + +export default definePlugin({ + name: "Activities", + description: "A combination of the ActivityIcons and ShowAllActivities BD plugins.", + authors: [Devs.Arjix, Devs.AutumnVN], + tags: ["ActivityIcons", "ShowAllActivities"], + settings, + + patches: [ + { + find: "textRuler,", + replacement: { + match: /(\i)=>\{.*?let{activities:\i,.*?children:\[.*?null!=\i&&(\i\.some\(\i=>\(0,\i\.\i\)\(\i,\i\)\)\?.*?:null)/, + replace: (m, activities, icon) => m.replace(icon, `$self.ActivitiesComponent(${activities})`) + }, + predicate: () => settings.store.moreActivityIcons + }, + { + find: "customStatusSection,", + replacement: { + match: /\(0,\i\.jsx\)\((\i\.\i),{activity:\i,user:\i,guild:\i,channelId:\i,onClose:\i,/, + replace: (m, component) => m.replace(component, "$self.ShowAllActivitiesComponent") + }, + predicate: () => settings.store.showAllActivities + } + ], + + ShowAllActivitiesComponent({ activity, user, guild, channelId, onClose }: + { activity: Activity; user: User, guild: Guild, channelId: string, onClose: () => void; }) { + const [currentActivity, setCurrentActivity] = React.useState( + activity?.type !== ActivityType.CustomStatus ? activity! : null + ); + + const activities = useStateFromStores( + [PresenceStore], () => PresenceStore.getActivities(user.id).filter((activity: Activity) => activity.type !== ActivityType.CustomStatus) + ) ?? []; + + React.useEffect(() => { + if (!activities.length) { + setCurrentActivity(null); + return; + } + + if (!currentActivity || !activities.includes(currentActivity)) + setCurrentActivity(activities[0]); + + }, [activities]); + + if (!activities.length) return null; + + 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" + /> + ; + }} +
+
+ ); + }, + + ActivitiesComponent(props: ActivityProps) { + const botActivityKeys = ["type", "name", "id", "created_at"]; + const isBot = props.activities.length === 1 && Object.keys(props.activities[0]).every((value, i) => value === botActivityKeys[i]); + if (!props.activities.length || (isBot && settings.store.ignoreBotsIcon)) return null; + const gameActivities: Activity[] = []; + + const icons = props.activities.map(activity => { + switch (activity.type) { + case ActivityType.Competing: + case ActivityType.Playing: { + if (!activity.platform) { + gameActivities.push(activity); + return; + } + + const isXbox = activity.platform === "xbox"; + const isPlaystation = /ps\d/.test(activity.platform ?? ""); + const isSamsung = activity.platform === "samsung"; + + let icon: React.ReactNode = ; + + if (isXbox) icon = ; + if (isPlaystation) icon = ; + if (isSamsung) icon = ; + + return ( + { + ({ onMouseEnter, onMouseLeave }) => { + return {icon}; + } + } + ); + } + + case ActivityType.Listening: { + let tooltipText = ""; + if (activity.details && activity.state) { + const artists = (activity.state.split(";") ?? []).map(a => a.trim()); + let songTitle = activity.details; + + for (const artist of artists) { + songTitle = songTitle.replace(`(feat. ${artist})`, ""); + } + + tooltipText = `${songTitle.trim()} - ${artists.join(", ")}`; + } else { + tooltipText = activity.name ?? ""; + } + + return + {({ onMouseEnter, onMouseLeave }) => { + return + + ; + }} + ; + } + default: return; + } + }).filter(Boolean); + + const richPresenceActivities = gameActivities.filter(activity => (activity.assets || activity.details)); + + const gameIcons: React.ReactNode[] = []; + for (const gameActivity of gameActivities) { + const activityIcon = richPresenceActivities.includes(gameActivity) ? + + : ; + + gameActivity && gameIcons.push( + {({ onMouseEnter, onMouseLeave }) => { + return {activityIcon}; + }} + ); + } + + return ( + + {gameIcons.concat(icons)} + + ); + }, +}); diff --git a/src/plugins/activities/style.css b/src/plugins/activities/style.css new file mode 100644 index 0000000000..430a7a39d4 --- /dev/null +++ b/src/plugins/activities/style.css @@ -0,0 +1,70 @@ +.vc-activities-caret-left, +.vc-activities-caret-right { + color: #ddd; +} + +.vc-activities-caret-left { + transform: rotate(90deg); +} + +.vc-activities-caret-right { + transform: rotate(-90deg); +} + +.vc-activities-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-activities-controls [class^="vc-activities-caret-"] { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 3px; + background-color: #ffffff4d; +} + +.vc-activities-controls [class^="vc-activities-caret-"].disabled { + cursor: not-allowed; + opacity: 0.3; +} + +.vc-activities-controls [class^="vc-activities-caret-"]:hover:not(.disabled) { + background: var(--background-modifier-accent); +} + +.vc-activities-controls .carousell { + display: flex; + align-items: center; +} + +.vc-activities-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-activities-controls .carousell .dot:hover:not(.selected) { + opacity: 1; +} + +.vc-activities-controls .carousell .dot.selected { + opacity: 1; + background: var(--dot-color, var(--brand-experiment)); +} + +.vc-activities-controls-tooltip { + --background-floating: var(--background-secondary); +} diff --git a/src/plugins/activities/types.ts b/src/plugins/activities/types.ts new file mode 100644 index 0000000000..ea62b27da1 --- /dev/null +++ b/src/plugins/activities/types.ts @@ -0,0 +1,56 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface ActivityProps { + className: string; + textClassName: string; + emojiClassName: string; + activities: Activity[]; + applicationStream: null; + animate: boolean; + hideEmoji: boolean; + hideTooltip: boolean; +} + +export interface Activity { + created_at: string; + id: string; + name: string; + state?: string; + type: ActivityType; + assets?: Assets; + flags?: number; + platform?: string; + timestamps?: Timestamps; + details?: string; + party?: Party; + session_id?: string; + sync_id?: string; +} + +export enum ActivityType { + Playing = 0, + Streaming = 1, + Listening = 2, + Watching = 3, + CustomStatus = 4, + Competing = 5, +} + +export interface Assets { + small_image?: string; + large_image?: string; + large_text?: string; +} + +export interface Party { + id: string; +} + +export interface Timestamps { + start: string; + end?: string; +}