diff --git a/src/plugins/betterSessions/README.md b/src/plugins/betterSessions/README.md new file mode 100644 index 00000000..cf13e6c5 --- /dev/null +++ b/src/plugins/betterSessions/README.md @@ -0,0 +1,5 @@ +# BetterSessions + +Enhances the sessions (devices) menu. Allows you to view exact timestamps, give each session a custom name, and receive notifications about new sessions. + +![](https://github.com/Vendicated/Vencord/assets/9750071/4a44b617-bb8f-4dcb-93f1-b7d2575ed3d8) diff --git a/src/plugins/betterSessions/components/RenameButton.tsx b/src/plugins/betterSessions/components/RenameButton.tsx new file mode 100644 index 00000000..a0c95a6f --- /dev/null +++ b/src/plugins/betterSessions/components/RenameButton.tsx @@ -0,0 +1,37 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { openModal } from "@utils/modal"; +import { Button } from "@webpack/common"; + +import { SessionInfo } from "../types"; +import { RenameModal } from "./RenameModal"; + +export function RenameButton({ session, state }: { session: SessionInfo["session"], state: [string, React.Dispatch>]; }) { + return ( + + ); +} diff --git a/src/plugins/betterSessions/components/RenameModal.tsx b/src/plugins/betterSessions/components/RenameModal.tsx new file mode 100644 index 00000000..1c5783c0 --- /dev/null +++ b/src/plugins/betterSessions/components/RenameModal.tsx @@ -0,0 +1,94 @@ +/* + * 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 { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal"; +import { Button, Forms, React, TextInput } from "@webpack/common"; +import { KeyboardEvent } from "react"; + +import { SessionInfo } from "../types"; +import { getDefaultName, savedSessionsCache, saveSessionsToDataStore } from "../utils"; + +export function RenameModal({ props, session, state }: { props: ModalProps, session: SessionInfo["session"], state: [string, React.Dispatch>]; }) { + const [title, setTitle] = state; + const [value, setValue] = React.useState(savedSessionsCache.get(session.id_hash)?.name ?? ""); + + function onSaveClick() { + savedSessionsCache.set(session.id_hash, { name: value, isNew: false }); + if (value !== "") { + setTitle(`${value}*`); + } else { + setTitle(getDefaultName(session.client_info)); + } + + saveSessionsToDataStore(); + props.onClose(); + } + + return ( + + + Rename + + + + New device name + ) => { + if (e.key === "Enter") { + onSaveClick(); + } + }} + /> + + + + + + + + + ); +} diff --git a/src/plugins/betterSessions/components/icons.tsx b/src/plugins/betterSessions/components/icons.tsx new file mode 100644 index 00000000..bd745e76 --- /dev/null +++ b/src/plugins/betterSessions/components/icons.tsx @@ -0,0 +1,106 @@ +/* + * 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 { LazyComponent } from "@utils/react"; +import { findByCode } from "@webpack"; +import { SVGProps } from "react"; + +export const DiscordIcon = (props: React.PropsWithChildren>) => ( + + + +); + +export const ChromeIcon = (props: React.PropsWithChildren>) => ( + + + + + + +); + +export const EdgeIcon = (props: React.PropsWithChildren>) => ( + + + +); + +export const FirefoxIcon = (props: React.PropsWithChildren>) => ( + + + +); + +export const IEIcon = (props: React.PropsWithChildren>) => ( + + + +); + +export const OperaIcon = (props: React.PropsWithChildren>) => ( + + + +); + +export const SafariIcon = (props: React.PropsWithChildren>) => ( + + + +); + +export const UnknownIcon = (props: React.PropsWithChildren>) => ( + + + +); + +export const MobileIcon = LazyComponent(() => findByCode("M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38")); diff --git a/src/plugins/betterSessions/index.tsx b/src/plugins/betterSessions/index.tsx new file mode 100644 index 00000000..bb79870d --- /dev/null +++ b/src/plugins/betterSessions/index.tsx @@ -0,0 +1,227 @@ +/* + * 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 { showNotification } from "@api/Notifications"; +import { definePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { findByPropsLazy, findExportedComponentLazy } from "@webpack"; +import { React, RestAPI, Tooltip } from "@webpack/common"; + +import { RenameButton } from "./components/RenameButton"; +import { Session, SessionInfo } from "./types"; +import { fetchNamesFromDataStore, getDefaultName, GetOsColor, GetPlatformIcon, savedSessionsCache, saveSessionsToDataStore } from "./utils"; + +const AuthSessionsStore = findByPropsLazy("getSessions"); +const UserSettingsModal = findByPropsLazy("saveAccountChanges", "open"); + +const TimestampClasses = findByPropsLazy("timestampTooltip", "blockquoteContainer"); +const SessionIconClasses = findByPropsLazy("sessionIcon"); + +const BlobMask = findExportedComponentLazy("BlobMask"); + +const settings = definePluginSettings({ + backgroundCheck: { + type: OptionType.BOOLEAN, + description: "Check for new sessions in the background, and display notifications when they are detected", + default: false, + restartNeeded: true + }, + checkInterval: { + description: "How often to check for new sessions in the background (if enabled), in minutes", + type: OptionType.NUMBER, + default: 20, + restartNeeded: true + } +}); + +export default definePlugin({ + name: "BetterSessions", + description: "Enhances the sessions (devices) menu. Allows you to view exact timestamps, give each session a custom name, and receive notifications about new sessions.", + authors: [Devs.amia], + + settings: settings, + + patches: [ + { + find: "Messages.AUTH_SESSIONS_SESSION_LOG_OUT", + replacement: [ + // Replace children with a single label with state + { + match: /({variant:"eyebrow",className:\i\.sessionInfoRow,children:).{70,110}{children:"\\xb7"}\),\(0,\i\.\i\)\("span",{children:\i\[\d+\]}\)\]}\)\]/, + replace: "$1$self.renderName(arguments[0])" + }, + { + match: /({variant:"text-sm\/medium",className:\i\.sessionInfoRow,children:.{70,110}{children:"\\xb7"}\),\(0,\i\.\i\)\("span",{children:)(\i\[\d+\])}/, + replace: "$1$self.renderTimestamp({ ...arguments[0], timeLabel: $2 })}" + }, + // Replace the icon + { + match: /\.currentSession:null\),children:\[(?<=,icon:(\i)\}.+?)/, + replace: "$& $self.renderIcon({ ...arguments[0], DeviceIcon: $1 }), false &&" + } + ] + }, + { + // Add the ability to change BlobMask's lower badge height + // (it allows changing width so we can mirror that logic) + find: "this.getBadgePositionInterpolation(", + replacement: { + match: /(\i\.animated\.rect,{id:\i,x:48-\(\i\+8\)\+4,y:)28(,width:\i\+8,height:)24,/, + replace: (_, leftPart, rightPart) => `${leftPart} 48 - ((this.props.lowerBadgeHeight ?? 16) + 8) + 4 ${rightPart} (this.props.lowerBadgeHeight ?? 16) + 8,` + } + } + ], + + renderName: ErrorBoundary.wrap(({ session }: SessionInfo) => { + const savedSession = savedSessionsCache.get(session.id_hash); + + const state = React.useState(savedSession?.name ? `${savedSession.name}*` : getDefaultName(session.client_info)); + const [title, setTitle] = state; + + // Show a "NEW" badge if the session is seen for the first time + return ( + <> + {title} + {(savedSession == null || savedSession.isNew) && ( +
+ NEW +
+ )} + + + ); + }, { noop: true }), + + renderTimestamp: ErrorBoundary.wrap(({ session, timeLabel }: { session: Session, timeLabel: string; }) => { + return ( + + {props => ( + + {timeLabel} + + )} + + ); + }, { noop: true }), + + renderIcon: ErrorBoundary.wrap(({ session, DeviceIcon }: { session: Session, DeviceIcon: React.ComponentType; }) => { + const PlatformIcon = GetPlatformIcon(session.client_info.platform); + + return ( + + + + } + lowerBadgeWidth={20} + lowerBadgeHeight={20} + > +
+ +
+
+ ); + }, { noop: true }), + + async checkNewSessions() { + const data = await RestAPI.get({ + url: "/auth/sessions" + }); + + for (const session of data.body.user_sessions) { + if (savedSessionsCache.has(session.id_hash)) continue; + + savedSessionsCache.set(session.id_hash, { name: "", isNew: true }); + showNotification({ + title: "BetterSessions", + body: `New session:\n${session.client_info.os} · ${session.client_info.platform} · ${session.client_info.location}`, + permanent: true, + onClick: () => UserSettingsModal.open("Sessions") + }); + } + + saveSessionsToDataStore(); + }, + + flux: { + USER_SETTINGS_ACCOUNT_RESET_AND_CLOSE_FORM() { + const lastFetchedHashes: string[] = AuthSessionsStore.getSessions().map((session: SessionInfo["session"]) => session.id_hash); + + // Add new sessions to cache + lastFetchedHashes.forEach(idHash => { + if (!savedSessionsCache.has(idHash)) savedSessionsCache.set(idHash, { name: "", isNew: false }); + }); + + // Delete removed sessions from cache + if (lastFetchedHashes.length > 0) { + savedSessionsCache.forEach((_, idHash) => { + if (!lastFetchedHashes.includes(idHash)) savedSessionsCache.delete(idHash); + }); + } + + // Dismiss the "NEW" badge of all sessions. + // Since the only way for a session to be marked as "NEW" is going to the Devices tab, + // closing the settings means they've been viewed and are no longer considered new. + savedSessionsCache.forEach(data => { + data.isNew = false; + }); + saveSessionsToDataStore(); + } + }, + + async start() { + await fetchNamesFromDataStore(); + + this.checkNewSessions(); + if (settings.store.backgroundCheck) { + this.checkInterval = setInterval(this.checkNewSessions, settings.store.checkInterval * 60 * 1000); + } + }, + + stop() { + clearInterval(this.checkInterval); + } +}); diff --git a/src/plugins/betterSessions/types.ts b/src/plugins/betterSessions/types.ts new file mode 100644 index 00000000..9026d531 --- /dev/null +++ b/src/plugins/betterSessions/types.ts @@ -0,0 +1,32 @@ +/* + * 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 . +*/ + +export interface SessionInfo { + session: { + id_hash: string; + approx_last_used_time: Date; + client_info: { + os: string; + platform: string; + location: string; + }; + }, + current?: boolean; +} + +export type Session = SessionInfo["session"]; diff --git a/src/plugins/betterSessions/utils.ts b/src/plugins/betterSessions/utils.ts new file mode 100644 index 00000000..3015dc47 --- /dev/null +++ b/src/plugins/betterSessions/utils.ts @@ -0,0 +1,90 @@ +/* + * 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 { DataStore } from "@api/index"; +import { UserStore } from "@webpack/common"; + +import { ChromeIcon, DiscordIcon, EdgeIcon, FirefoxIcon, IEIcon, MobileIcon, OperaIcon, SafariIcon, UnknownIcon } from "./components/icons"; +import { SessionInfo } from "./types"; + +const getDataKey = () => `BetterSessions_savedSessions_${UserStore.getCurrentUser().id}`; + +export const savedSessionsCache: Map = new Map(); + +export function getDefaultName(clientInfo: SessionInfo["session"]["client_info"]) { + return `${clientInfo.os} · ${clientInfo.platform}`; +} + +export function saveSessionsToDataStore() { + return DataStore.set(getDataKey(), savedSessionsCache); +} + +export async function fetchNamesFromDataStore() { + const savedSessions = await DataStore.get>(getDataKey()) || new Map(); + savedSessions.forEach((data, idHash) => { + savedSessionsCache.set(idHash, data); + }); +} + +export function GetOsColor(os: string) { + switch (os) { + case "Windows Mobile": + case "Windows": + return "#55a6ef"; // Light blue + case "Linux": + return "#cdcd31"; // Yellow + case "Android": + return "#7bc958"; // Green + case "Mac OS X": + case "iOS": + return ""; // Default to white/black (theme-dependent) + default: + return "#f3799a"; // Pink + } +} + +export function GetPlatformIcon(platform: string) { + switch (platform) { + case "Discord Android": + case "Discord iOS": + case "Discord Client": + return DiscordIcon; + case "Android Chrome": + case "Chrome iOS": + case "Chrome": + return ChromeIcon; + case "Edge": + return EdgeIcon; + case "Firefox": + return FirefoxIcon; + case "Internet Explorer": + return IEIcon; + case "Opera Mini": + case "Opera": + return OperaIcon; + case "Mobile Safari": + case "Safari": + return SafariIcon; + case "BlackBerry": + case "Facebook Mobile": + case "Android Mobile": + return MobileIcon; + default: + return UnknownIcon; + } +}