├── .prettierignore ├── src ├── modules │ ├── comments │ │ ├── contextMenu.less │ │ ├── hideShare.less │ │ ├── comments.less │ │ ├── sortButtons.less │ │ ├── userTags.less │ │ ├── contextMenu.ts │ │ ├── moreReplies.ts │ │ └── userTags.ts │ ├── profileMenu │ │ ├── profileMenu.less │ │ └── profileMenuWindow.less │ ├── customFeed │ │ ├── customFeed.less │ │ └── customFeed.ts │ ├── redirectMode.ts │ ├── bookmarkMode.ts │ ├── users │ │ ├── usernameMode.ts │ │ ├── userPage.ts │ │ ├── users.ts │ │ ├── userOperations.ts │ │ └── userInfo.ts │ ├── app.less │ ├── collapseAwardsMode.ts │ ├── bookmark.less │ ├── header.less │ ├── rightSidebar.ts │ ├── collapseAwards.less │ ├── subs │ │ ├── subs.less │ │ ├── flairWindow.less │ │ ├── flairBar.less │ │ ├── flairWindow.ts │ │ ├── flair.ts │ │ ├── flairBar.ts │ │ └── subs.ts │ ├── sidebar │ │ ├── sidebarSettingsWindow.less │ │ ├── sidebar.less │ │ ├── sections │ │ │ ├── resources.ts │ │ │ ├── games.ts │ │ │ ├── custom.ts │ │ │ ├── subs.ts │ │ │ └── recent.ts │ │ ├── sidebarNavigation.ts │ │ ├── sidebarSection.ts │ │ ├── subFilter.less │ │ ├── sidebarSectionRenderer.ts │ │ ├── sidebar.ts │ │ ├── subFilter.ts │ │ └── sidebarSettingsWindow.ts │ ├── settings │ │ ├── prefs.ts │ │ └── settingsWindow.less │ ├── redirect.less │ ├── biggerFonts.ts │ ├── feed │ │ ├── feedSort.ts │ │ ├── feedSettings │ │ │ └── feedSettingsWindow.less │ │ ├── feedButtons.less │ │ ├── feedLocation.ts │ │ ├── feedRedirect.ts │ │ └── feed.ts │ ├── notifications.less │ ├── biggerFonts.less │ ├── filters │ │ ├── hiddenContentWindow.ts │ │ ├── hiddenContent.less │ │ ├── filtersWindow.less │ │ ├── filters.less │ │ └── hiddenContent.ts │ ├── wideMode.less │ ├── posts │ │ ├── posts.less │ │ └── postsBackplates.less │ ├── toaster.ts │ ├── notifications.ts │ ├── customCSS.ts │ ├── header.ts │ ├── redirect.ts │ ├── scrollToTop.less │ ├── app.ts │ ├── collapseAwards.ts │ ├── wideMode.ts │ └── bookmark.ts ├── _compatibility │ ├── latestMigration.ts │ ├── migration_1_0_0.ts │ ├── migrations.ts │ └── migration_1_2_0.ts ├── typings.d.ts ├── defines.ts ├── utils │ ├── changesObserver.less │ ├── UI │ │ ├── options.less │ │ ├── toggle.ts │ │ ├── toggle.less │ │ ├── input.less │ │ ├── options.ts │ │ └── input.ts │ ├── element.ts │ ├── imageViewer.less │ ├── svg.ts │ ├── window.less │ ├── redditAPI.ts │ ├── changesObserver.ts │ ├── tools.ts │ ├── window.ts │ └── database.ts ├── _debug │ ├── debug.less │ └── debug.ts └── core.ts ├── .husky └── pre-commit ├── public ├── scr │ ├── flairs.png │ ├── page.png │ ├── post.png │ ├── readme.png │ ├── settings.png │ ├── userTags.png │ ├── bookmarks.png │ ├── feedButtons.png │ └── commentsSort.png ├── icon │ ├── originFavicon64x64.png │ └── redditPlusPlusFavicon64x64.png └── descriptions │ ├── RedditPlusPlus.description.txt │ └── RedditPlusPlus.description.ru.txt ├── .gitignore ├── .prettierrc.json ├── resources ├── dragAnchor.svg ├── settingsArrow.svg ├── subFilter.svg ├── scrollButton.svg ├── bookmarkSaved.svg ├── comments │ ├── userTags │ │ ├── blockedIcon.svg │ │ ├── followedButton.svg │ │ ├── followedIcon.svg │ │ ├── blockedButton.svg │ │ ├── warningButton.svg │ │ ├── warningIcon.svg │ │ ├── likedButton.svg │ │ └── likedIcon.svg │ ├── sortButtons │ │ ├── qa.svg │ │ ├── old.svg │ │ └── controversial.svg │ ├── bannedUser.svg │ ├── newUser.svg │ └── shareButton.svg ├── postUnwrapButton.svg ├── feedButtons │ ├── feedButtonRising.svg │ ├── feedButtonTop.svg │ ├── feedButtonNew.svg │ ├── feedButtonHot.svg │ ├── feedButtonBest.svg │ └── feedButtonBest_empty.svg ├── imageCloseButton.svg ├── bookmarkUnsaved.svg ├── inputClear.svg ├── sidebarSubsManager.svg ├── windowCloseButton.svg ├── settingsButton.svg ├── deleteButton.svg ├── showIco.svg ├── hiddenIco.svg ├── contentFilter.svg └── settingsGear.svg ├── config ├── empty.cjs ├── webpack.config.prod.cjs ├── webpack.config.dev.cjs └── webpack.config.base.cjs ├── .editorconfig ├── tsconfig.json ├── redditAPI.md ├── LICENSE ├── README.md └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | public/ -------------------------------------------------------------------------------- /src/modules/comments/contextMenu.less: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/profileMenu/profileMenu.less: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint-staged 5 | -------------------------------------------------------------------------------- /public/scr/flairs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/flairs.png -------------------------------------------------------------------------------- /public/scr/page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/page.png -------------------------------------------------------------------------------- /public/scr/post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/post.png -------------------------------------------------------------------------------- /public/scr/readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/readme.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.code-workspace 4 | .idea 5 | .vscode 6 | *.ai 7 | *.psd 8 | 9 | -------------------------------------------------------------------------------- /public/scr/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/settings.png -------------------------------------------------------------------------------- /public/scr/userTags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/userTags.png -------------------------------------------------------------------------------- /public/scr/bookmarks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/bookmarks.png -------------------------------------------------------------------------------- /public/scr/feedButtons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/feedButtons.png -------------------------------------------------------------------------------- /public/scr/commentsSort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/scr/commentsSort.png -------------------------------------------------------------------------------- /src/modules/comments/hideShare.less: -------------------------------------------------------------------------------- 1 | shreddit-comment-share-button { 2 | display: none !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/profileMenu/profileMenuWindow.less: -------------------------------------------------------------------------------- 1 | .pp_profileMenuElement_tittleContainer { 2 | width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /public/icon/originFavicon64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/icon/originFavicon64x64.png -------------------------------------------------------------------------------- /public/icon/redditPlusPlusFavicon64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnm95/redditPlusPlus/HEAD/public/icon/redditPlusPlusFavicon64x64.png -------------------------------------------------------------------------------- /src/modules/customFeed/customFeed.less: -------------------------------------------------------------------------------- 1 | .pp_customFeed_masthead_ico { 2 | width: 50px !important; 3 | height: 50px !important; 4 | } 5 | -------------------------------------------------------------------------------- /src/_compatibility/latestMigration.ts: -------------------------------------------------------------------------------- 1 | import { migration_1_2_0 } from './migration_1_2_0'; 2 | 3 | export const latestMigration = migration_1_2_0; 4 | -------------------------------------------------------------------------------- /src/modules/redirectMode.ts: -------------------------------------------------------------------------------- 1 | export enum RedirectMode { 2 | Disabled = `Disabled`, 3 | Suggestion = `Suggestion`, 4 | Forced = `Forced` 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/bookmarkMode.ts: -------------------------------------------------------------------------------- 1 | export enum BookmarkMode { 2 | Disabled = `Disabled`, 3 | WhenUpvoted = `When upvoted`, 4 | Always = `Always` 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/users/usernameMode.ts: -------------------------------------------------------------------------------- 1 | export enum UsernameMode { 2 | ProfileName = `Profile name`, 3 | Nickname = `Nickname`, 4 | Both = `Both` 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/app.less: -------------------------------------------------------------------------------- 1 | // ban hint 2 | faceplate-banner { 3 | max-width: 1000px !important; 4 | } 5 | 6 | .pp_hidden { 7 | display: none !important; 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/collapseAwardsMode.ts: -------------------------------------------------------------------------------- 1 | export enum AwardsMode { 2 | Default = `Default behaviour`, 3 | WhenUpvoted = `Show when upvoted`, 4 | RemoveCompletely = `Remove completely` 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 200, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid", 6 | "semi": true, 7 | "singleQuote": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /resources/dragAnchor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /resources/settingsArrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/empty.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is an empty javascript file for webpack to generate a development UserScript without real code. 3 | * So we could make UserScript manager load script file from local file path. 4 | * See webpack.config.dev.js for more details. 5 | */ 6 | -------------------------------------------------------------------------------- /resources/subFilter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/scrollButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/bookmarkSaved.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/comments/userTags/blockedIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/postUnwrapButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/feedButtons/feedButtonRising.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/feedButtons/feedButtonTop.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.less'; 2 | declare module '*.svg'; 3 | 4 | declare const VERSION: string; 5 | declare const DEBUG: boolean; 6 | 7 | declare function GM_getValue(key: string, defaultValue: any): any; 8 | declare function GM_setValue(key: string, value: any): void; 9 | declare function GM_deleteValue(key: string): void; 10 | -------------------------------------------------------------------------------- /resources/feedButtons/feedButtonNew.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/bookmark.less: -------------------------------------------------------------------------------- 1 | .pp_bookmark_hiddenButton { 2 | width: 32px; 3 | height: 32px; 4 | position: absolute; 5 | opacity: 0 !important; 6 | overflow: hidden; 7 | } 8 | 9 | .pp_bookmark_hiddenButton > div { 10 | padding: 0px !important; 11 | } 12 | 13 | .pp_bookmark_post { 14 | margin-right: 5px !important; 15 | } 16 | -------------------------------------------------------------------------------- /resources/comments/sortButtons/qa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/modules/header.less: -------------------------------------------------------------------------------- 1 | #reddit-logo { 2 | text-decoration: none; 3 | } 4 | 5 | #user-drawer-content { 6 | max-height: 90vh; 7 | overflow: auto; 8 | } 9 | 10 | .pp_logo { 11 | width: max-content; 12 | color: var(--shreddit-color-wordmark); 13 | font-size: 22px; 14 | font-weight: 1000; 15 | letter-spacing: -2px; 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/rightSidebar.ts: -------------------------------------------------------------------------------- 1 | export function renderRightSidebar(rightSidebar: Element) { 2 | rightSidebar.className = `right-sidebar min-w-0 w-[316px] max-w-[316px] hidden s:block styled-scrollbars xs:sticky xs:top-[56px] xs:max-h-[calc(100vh-var(--shreddit-header-height)-1px)] xs:overflow-y-auto xs:overflow-x-hidden pp_rightSidebar pp_defaultText`; 3 | } 4 | -------------------------------------------------------------------------------- /resources/imageCloseButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/collapseAwards.less: -------------------------------------------------------------------------------- 1 | .pp_awardButton { 2 | max-width: 100px; 3 | opacity: 1; 4 | transition: all 0.5s !important; 5 | } 6 | 7 | .pp_awardButton_hidden { 8 | display: none; 9 | } 10 | 11 | .pp_awardButton_collapsed { 12 | max-width: 0px !important; 13 | opacity: 0 !important; 14 | visibility: hidden !important; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/subs/subs.less: -------------------------------------------------------------------------------- 1 | .masthead > section > div { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: flex-start; 5 | } 6 | 7 | .masthead > section > div > div:last-child { 8 | align-self: flex-end; 9 | } 10 | 11 | .pp_mastheadSection { 12 | top: -3rem; 13 | 14 | > div { 15 | gap: 1rem; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /resources/comments/userTags/followedButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/comments/userTags/followedIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/comments/userTags/blockedButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset=utf-8 3 | end_of_line=lf 4 | trim_trailing_whitespace=true 5 | insert_final_newline=true 6 | indent_style=space 7 | indent_size=4 8 | 9 | [{.babelrc,.stylelintrc,.eslintrc,jest.config,*.bowerrc,*.jsb3,*.jsb2,*.json,*.yaml,*.yml}] 10 | indent_style=space 11 | indent_size=2 12 | 13 | [{*.js,*.vue,*.ts,*.cjs,.swcrc}] 14 | indent_style=space 15 | indent_size=2 16 | -------------------------------------------------------------------------------- /resources/comments/userTags/warningButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/feedButtons/feedButtonHot.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/defines.ts: -------------------------------------------------------------------------------- 1 | export enum ContentType { 2 | Comment, 3 | Post 4 | } 5 | 6 | export const MAX_LOAD_LAG: number = 2000; 7 | export const MIN_LOAD_LAG: number = 15; 8 | 9 | export const HOUR_SECONDS: number = 60 * 60; 10 | export const DAY_SECONDS: number = HOUR_SECONDS * 24; 11 | 12 | export function secondsToTime(seconds: number): number { 13 | return seconds * 1000; 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/sidebar/sidebarSettingsWindow.less: -------------------------------------------------------------------------------- 1 | .pp_sidebarSettings_sectionTittle { 2 | width: 100%; 3 | display: flex; 4 | align-items: center; 5 | } 6 | 7 | .pp_sidebarSettings_section { 8 | padding: 0rem 3rem; 9 | gap: 8px; 10 | align-items: center; 11 | } 12 | 13 | .pp_sidebarSettings_section > span { 14 | text-wrap-mode: nowrap; 15 | margin-left: 3rem; 16 | } 17 | -------------------------------------------------------------------------------- /resources/comments/bannedUser.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/comments/newUser.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/comments/userTags/warningIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/bookmarkUnsaved.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/inputClear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/sidebarSubsManager.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/settings/prefs.ts: -------------------------------------------------------------------------------- 1 | import { Database } from '../../utils/database'; 2 | 3 | export class PrefsKey { 4 | static COMMENTS_CURRENT_SORT: string = `COMMENTS_CURRENT_SORT`; 5 | static SUB_FILTER: string = `SUB_FILTER`; 6 | static CONTENT_FILTERS: string = `CONTENT_FILTERS`; 7 | static PROFILE_MENU_ELEMENTS: string = `PROFILE_MENU_ELEMENTS`; 8 | } 9 | 10 | export const prefs = new Database(`PREFS`); 11 | -------------------------------------------------------------------------------- /resources/feedButtons/feedButtonBest.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/feedButtons/feedButtonBest_empty.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/subs/flairWindow.less: -------------------------------------------------------------------------------- 1 | .pp_flairWindow_flair { 2 | padding: 0rem 3rem; 3 | gap: 8px; 4 | align-items: center; 5 | } 6 | 7 | .pp_flairWindow_flair > span { 8 | text-wrap-mode: nowrap; 9 | margin-left: 3rem; 10 | } 11 | 12 | .pp_flairWindow_flairContainer { 13 | width: 100%; 14 | display: flex; 15 | align-items: center; 16 | } 17 | 18 | .pp_flairWindow_columnTittle { 19 | margin: 20px 57px 10px 40px; 20 | } 21 | -------------------------------------------------------------------------------- /resources/windowCloseButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@resources/*": ["./resources/*"] 5 | }, 6 | "outDir": "./dist/", 7 | "esModuleInterop": true, 8 | "noImplicitAny": true, 9 | "moduleResolution": "Node", 10 | "module": "ESNext", 11 | "target": "ES2020", 12 | "allowJs": false, 13 | "checkJs": false, 14 | "removeComments": true, 15 | "strict": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/sidebar/sidebar.less: -------------------------------------------------------------------------------- 1 | flex-left-nav-container #pp-settings { 2 | position: absolute; 3 | top: 60px; 4 | z-index: calc(var(--flex-nav-z-index) + 1); 5 | inset-inline-end: -16px; 6 | } 7 | 8 | .pp_sidebar_loadingSection { 9 | max-height: 0px !important; 10 | visibility: hidden !important; 11 | 12 | > details { 13 | max-height: 0px !important; 14 | } 15 | } 16 | 17 | .pp_sidebar_collapsedSection { 18 | max-height: 43px !important; 19 | overflow-y: hidden !important; 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/redirect.less: -------------------------------------------------------------------------------- 1 | .pp_redirectContainer { 2 | position: fixed; 3 | bottom: 70px; 4 | width: 100%; 5 | display: flex; 6 | align-items: flex-end; 7 | justify-content: center; 8 | z-index: 100; 9 | } 10 | 11 | .pp_redirectBox { 12 | background: #000000bf; 13 | width: 600px; 14 | height: 70px; 15 | border-radius: 20px; 16 | color: white; 17 | font-weight: 700; 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | cursor: pointer; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/biggerFonts.ts: -------------------------------------------------------------------------------- 1 | import style from './biggerFonts.less'; 2 | import { css } from './customCSS'; 3 | import { settings } from './settings/settings'; 4 | 5 | if (settings.BIGGER_FONTS.isEnabled()) { 6 | css.addStyle(style); 7 | } 8 | 9 | export function renderBiggerFonts() { 10 | if (settings.BIGGER_FONTS.isEnabled()) { 11 | css.addVar(`--pp-biggerFonts-Content`, `${settings.BIGGER_FONTS_CONTENT_SIZE.get()}px`); 12 | css.addVar(`--pp-biggerFonts-Other`, `${settings.BIGGER_FONTS_OTHER_SIZE.get()}px`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /resources/comments/userTags/likedButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/comments/comments.less: -------------------------------------------------------------------------------- 1 | // ghosted 2 | .pp_muted_avatar { 3 | opacity: 0.5; 4 | } 5 | 6 | .pp_muted_content { 7 | color: var(--pp-color-muted-conent); 8 | transition: color 0.2s; 9 | } 10 | 11 | .pp_muted_content:hover { 12 | color: var(--pp-color-muted-conent-hover); 13 | } 14 | 15 | // vars 16 | 17 | :root { 18 | --pp-color-muted-conent: #a5a5a5; 19 | --pp-color-muted-conent-hover: #636363; 20 | } 21 | :root.theme-dark { 22 | --pp-color-muted-conent: #595959 !important; 23 | --pp-color-muted-conent-hover: #adadad !important; 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/feed/feedSort.ts: -------------------------------------------------------------------------------- 1 | import { FeedLocation } from './feedLocation'; 2 | 3 | export enum FeedSort { 4 | Best = `Best`, 5 | Hot = `Hot`, 6 | New = `New`, 7 | Top = `Top`, 8 | Rising = `Rising` 9 | } 10 | 11 | export function getFeedSorts(location: FeedLocation): Array { 12 | switch (location) { 13 | case FeedLocation.All: 14 | case FeedLocation.Custom: 15 | return Object.values(FeedSort).filter(sort => sort != FeedSort.Best); 16 | default: 17 | return Object.values(FeedSort); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /resources/comments/userTags/likedIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/settingsButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/changesObserver.less: -------------------------------------------------------------------------------- 1 | .pp_changesBannerContainer { 2 | position: absolute; 3 | top: 0px; 4 | width: 900px; 5 | overflow-y: hidden; 6 | opacity: 0; 7 | transition: opacity 0.15s ease-in-out; 8 | } 9 | 10 | .pp_changesBanner { 11 | display: flex; 12 | justify-content: center; 13 | margin: 2rem 15%; 14 | padding: 1rem; 15 | border-radius: 15px; 16 | background-color: #ffd40017; 17 | border: solid 1px #ffd400; 18 | color: #d7b300; 19 | font-weight: 500; 20 | } 21 | 22 | .pp_changesBanner_active { 23 | opacity: 1 !important; 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/users/userPage.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement } from '../../utils/tools'; 2 | import { initializePostObserver } from '../feed/feed'; 3 | import { renderPost } from '../posts/posts'; 4 | 5 | export async function renderUserPage(container: Element) { 6 | const feed = await dynamicElement(() => container.querySelector(`#subgrid-container`)?.querySelector(`shreddit-feed`)); 7 | 8 | // render embedded posts 9 | feed.querySelectorAll(`shreddit-post`).forEach(post => { 10 | renderPost(post); 11 | }); 12 | 13 | // render loaded posts 14 | initializePostObserver(feed); 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/feed/feedSettings/feedSettingsWindow.less: -------------------------------------------------------------------------------- 1 | .pp_feedSettings_overrideSub { 2 | z-index: 20; 3 | position: relative; 4 | } 5 | 6 | .pp_feedSettings_overrideSub::before { 7 | border-radius: 16px; 8 | border: 2px solid #ffc800; 9 | 10 | position: absolute; 11 | content: ''; 12 | top: -8px; 13 | right: -8px; 14 | bottom: -8px; 15 | left: -8px; 16 | z-index: 15; 17 | pointer-events: none; 18 | } 19 | 20 | .pp_feedSettings_overrideTittle { 21 | color: #e1b30d !important; 22 | } 23 | 24 | .pp_ui_disabled { 25 | opacity: 0.5; 26 | filter: grayscale(1); 27 | pointer-events: none; 28 | } 29 | -------------------------------------------------------------------------------- /resources/deleteButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /redditAPI.md: -------------------------------------------------------------------------------- 1 | ## Reddit API 2 | 3 | Some features (like showing a user's karma in comments) require requests to the reddit API. But default unauthorized requests have a limit (100 requests per 10 minutes). According to that, all dependent features are disabled by default. 4 | 5 | You may: 6 | 7 | 1. Keep features disabled. 8 | 2. Enable features and deal with requests limit. 9 | 3. As an experimental way, you probably should install [RES](https://redditenhancementsuite.com/) and then in script settings set the App name to `res` to mimicate. I can't recommend this way as default, but it just works. If you know how to get a unique app name for the script, please let me know. 10 | -------------------------------------------------------------------------------- /src/modules/notifications.less: -------------------------------------------------------------------------------- 1 | click-card > div[slot='content'] { 2 | width: 410px !important; 3 | max-width: 410px !important; 4 | 5 | > div { 6 | max-height: 550px !important; 7 | } 8 | } 9 | 10 | notification-item > a { 11 | width: 100% !important; 12 | } 13 | 14 | div[data-testid='notification-item'] { 15 | > .flex { 16 | padding: 0rem 0rem 0.5rem 0.5rem !important; 17 | width: 100%; 18 | } 19 | 20 | > div > div > button { 21 | display: none !important; 22 | } 23 | } 24 | 25 | div[data-testid='body'] { 26 | font-size: 1rem !important; 27 | line-height: 1.1rem !important; 28 | padding-top: 0.5rem; 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/sidebar/sections/resources.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement } from '../../../utils/tools'; 2 | import { SidebarSectionElements, SidebarSectionRenderer } from '../sidebarSectionRenderer'; 3 | 4 | export class ResourcesRenderer extends SidebarSectionRenderer { 5 | FindContainer(sidebar: HTMLElement, element: HTMLElement): HTMLElement { 6 | return sidebar.querySelector(`summary[aria-controls="RESOURCES"]`); 7 | } 8 | 9 | async GetSectionElements(container: HTMLElement): Promise { 10 | return { 11 | container: container.parentElement.parentElement, 12 | button: container, 13 | bottomLine: null 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /resources/showIco.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/utils/UI/options.less: -------------------------------------------------------------------------------- 1 | .pp_ui_options { 2 | width: min-content; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | gap: 10px; 7 | } 8 | 9 | .pp_ui_options_container { 10 | position: relative; 11 | display: flex; 12 | justify-content: center; 13 | } 14 | 15 | .pp_ui_options_container > span { 16 | width: max-content; 17 | text-align: center; 18 | } 19 | 20 | .pp_ui_options_dots { 21 | position: absolute; 22 | top: 10px; 23 | font-size: 20px; 24 | pointer-events: none; 25 | } 26 | 27 | .pp_ui_options_arrow { 28 | user-select: none; 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | width: 40px; 33 | } 34 | 35 | .pp_ui_options_inversed { 36 | transform: scale(-1, 1); 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/biggerFonts.less: -------------------------------------------------------------------------------- 1 | // common fonts 2 | :is(.text-14-scalable):not(.pp_defaultText .text-14-scalable) { 3 | font-size: var(--pp-biggerFonts-Content) !important; //1rem 4 | line-height: 1.4rem !important; //1.4rem 5 | } 6 | 7 | faceplate-hovercard .text-12 { 8 | font-size: 0.9rem !important; 9 | } 10 | 11 | shreddit-composer > div[role='textbox'] { 12 | font-size: var(--pp-biggerFonts-Content) !important; //1rem 13 | line-height: 1.4rem !important; //1.4rem 14 | } 15 | 16 | // other fonts 17 | :is(.text-12):not(.pp_defaultText .text-12) { 18 | // rating, tittles 19 | font-size: var(--pp-biggerFonts-Other) !important; //1rem 20 | //line-height: 1.4rem !important;//1.4rem 21 | } 22 | 23 | // comments more space 24 | shreddit-comment-action-row { 25 | margin-bottom: 15px !important; 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/filters/hiddenContentWindow.ts: -------------------------------------------------------------------------------- 1 | import { appendElement } from '../../utils/element'; 2 | import { Window } from '../../utils/window'; 3 | import { hiddenContent } from './hiddenContent'; 4 | 5 | export const hiddenContentWindow: Window = new Window('Hidden content', renderFiltersWindow, onCloseWindow); 6 | 7 | function renderFiltersWindow(win: Window, context: any) { 8 | const scroll = appendElement(win.content, `div`, [`pp_window_scrollContent`, `styled-scrollbars`]); 9 | 10 | const elements = appendElement(scroll, `div`, `pp_window_elementsContainer`); 11 | elements.style.margin = `20px 100px`; 12 | 13 | for (const content of hiddenContent) { 14 | elements.prepend(content); 15 | } 16 | } 17 | 18 | function onCloseWindow() { 19 | for (const content of hiddenContent) { 20 | content.remove(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/sidebar/sections/games.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement } from '../../../utils/tools'; 2 | import { SidebarSectionElements, SidebarSectionRenderer } from '../sidebarSectionRenderer'; 3 | 4 | export class GamesRenderer extends SidebarSectionRenderer { 5 | FindContainer(sidebar: HTMLElement, element: HTMLElement): HTMLElement { 6 | return sidebar.querySelector(`faceplate-tracker[noun="games_drawer"]`); 7 | } 8 | 9 | async GetSectionElements(container: HTMLElement): Promise { 10 | await dynamicElement(() => container.querySelector(`games-section-badge-wrapper`)); 11 | 12 | return { 13 | container: container, 14 | button: container.querySelector(`summary[aria-controls="games_section"]`), 15 | bottomLine: this.FindBottomLine(container) 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/UI/toggle.ts: -------------------------------------------------------------------------------- 1 | import { css } from '../../modules/customCSS'; 2 | import { appendElement } from '../element'; 3 | import style from './toggle.less'; 4 | 5 | css.addStyle(style); 6 | 7 | export function renderUIToggle(container: Element, value: boolean, onClick: (state: boolean) => void): HTMLElement { 8 | const toggle = appendElement(container, `div`, `pp_ui_toggle`); 9 | const toggleButton = appendElement(toggle, `button`, `pp_ui_toggle_button`); 10 | toggleButton.classList.toggle(`pp_ui_toggle_active`, value); 11 | appendElement(toggleButton, `div`, `pp_ui_toggle_knob`); 12 | 13 | let state = value; 14 | 15 | toggleButton.addEventListener(`click`, () => { 16 | state = !state; 17 | toggleButton.classList.toggle(`pp_ui_toggle_active`, state); 18 | 19 | onClick(state); 20 | }); 21 | 22 | return toggle; 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/feed/feedButtons.less: -------------------------------------------------------------------------------- 1 | .pp_feedPanel { 2 | width: 100%; 3 | } 4 | 5 | .pp_feedPanel > div { 6 | justify-content: flex-end; 7 | } 8 | 9 | .pp_feedPanel_buttons { 10 | display: flex; 11 | gap: 4px; 12 | width: 100%; 13 | height: 40px; 14 | } 15 | 16 | .pp_feedPanel_settings_container { 17 | display: flex; 18 | align-items: center; 19 | } 20 | 21 | .pp_feedPanel_settings { 22 | color: var(--color-neutral-content-weak); 23 | border-radius: 18px; 24 | display: flex; 25 | align-items: center; 26 | height: 32px; 27 | padding: 0px 8px; 28 | position: relative; 29 | cursor: pointer; 30 | } 31 | 32 | .pp_feedPanel_settings:hover { 33 | background-color: var(--button-color-background-hover); 34 | } 35 | 36 | .pp_feedPanel_settings:active { 37 | background-color: var(--button-color-background-activated); 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/feed/feedLocation.ts: -------------------------------------------------------------------------------- 1 | export enum FeedLocation { 2 | Sub, 3 | Home, 4 | Popular, 5 | All, 6 | Custom 7 | } 8 | 9 | const customRegex = new RegExp(`www.reddit.com/user/.*/m/`); 10 | 11 | export function getFeedLocation(): FeedLocation { 12 | if (window.location.href.includes(`?feed=home`) || window.location.href == `https://www.reddit.com/`) return FeedLocation.Home; 13 | 14 | if (window.location.href.includes(`reddit.com/r/popular/`)) return FeedLocation.Popular; 15 | 16 | if (window.location.href.includes(`reddit.com/r/all/`)) return FeedLocation.All; 17 | 18 | if (customRegex.test(window.location.href)) return FeedLocation.Custom; 19 | 20 | return FeedLocation.Sub; 21 | } 22 | 23 | export function isOverridableLocation(location: FeedLocation) { 24 | return location == FeedLocation.Sub || location == FeedLocation.Custom; 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/wideMode.less: -------------------------------------------------------------------------------- 1 | @media (min-width: 1392px) { 2 | .pp_pageContainer { 3 | margin-right: 300px; 4 | --flex-nav-width: 272px !important; 5 | } 6 | .pp_mainFeed { 7 | width: var(--pp-content-width) !important; 8 | position: relative; 9 | left: var(--pp-content-offset); 10 | } 11 | .pp_mainFeed > div > main { 12 | max-width: var(--pp-content-width) !important; 13 | } 14 | .pp_rightSidebar { 15 | grid-column-start: 3; 16 | order: 10; 17 | } 18 | #right-sidebar-container { 19 | position: fixed; 20 | right: 0px; 21 | margin: 15px 10px 0px 0px; 22 | } 23 | .pp_rightSidebar_contextLookup { 24 | grid-column-start: 3; 25 | order: 10; 26 | position: fixed; 27 | right: 0px; 28 | margin: 15px 10px 0px 0px; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/customFeed/customFeed.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement } from '../../utils/tools'; 2 | import { css } from '../customCSS'; 3 | import style from './customFeed.less'; 4 | 5 | css.addStyle(style); 6 | 7 | export let realCustomFeedTittle: string = null; 8 | 9 | export function getCurrentCustomFeed() { 10 | const raw = window.location.href.split(`/m/`); 11 | return raw.length > 1 ? raw[1].split(`/`)[0] : null; 12 | } 13 | 14 | export async function renderCustomFeed(main: Element) { 15 | const header = await dynamicElement(() => main.querySelector(`custom-feed-header`)); 16 | const icoContainer = await dynamicElement(() => header.shadowRoot?.querySelector(`img`)?.parentElement); 17 | 18 | css.registry(header.shadowRoot); 19 | 20 | icoContainer.classList.add(`pp_customFeed_masthead_ico`); 21 | 22 | realCustomFeedTittle = header.shadowRoot?.querySelector(`.text-18`)?.textContent; 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/element.ts: -------------------------------------------------------------------------------- 1 | export function appendElement(target: Element, name: string, classes: string | Array = null): HTMLElement { 2 | const el = buildElement(name, classes); 3 | target.append(el); 4 | return el; 5 | } 6 | export function prependElement(target: Element, name: string, classes: string | Array = null): HTMLElement { 7 | const el = buildElement(name, classes); 8 | target.prepend(el); 9 | return el; 10 | } 11 | export function buildElement(name: string, classes: string | Array = null): HTMLElement { 12 | const el = document.createElement(name); 13 | 14 | if (classes != null) { 15 | if (typeof classes === `string` && classes) { 16 | el.classList.add(classes); 17 | } else { 18 | for (const c of classes) { 19 | el.classList.add(c); 20 | } 21 | } 22 | } 23 | 24 | return el; 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/comments/sortButtons.less: -------------------------------------------------------------------------------- 1 | .pp_sortDropdown_hidden { 2 | display: none; 3 | } 4 | 5 | .pp_sortButton { 6 | color: var(--color-neutral-content-weak); 7 | font: var(--font-button-sm); 8 | text-wrap: nowrap; 9 | border-radius: 32px; 10 | height: 32px; 11 | padding-left: 10px; 12 | padding-right: 14px; 13 | gap: 4px; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | cursor: pointer; 18 | margin-left: 5px; 19 | } 20 | 21 | .pp_sortButton > span { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | } 26 | 27 | .pp_sortButton:hover { 28 | background-color: var(--color-button-plain-background-hover) !important; 29 | } 30 | 31 | .pp_sortButton_active { 32 | color: var(--color-neutral-content-strong) !important; 33 | background-color: var(--color-secondary-background-selected); 34 | } 35 | -------------------------------------------------------------------------------- /src/_debug/debug.less: -------------------------------------------------------------------------------- 1 | .pp_debug_profilerContainer { 2 | position: fixed; 3 | top: 60px; 4 | margin-left: 10px; 5 | width: 100%; 6 | display: flex; 7 | align-items: flex-end; 8 | justify-content: flex-start; 9 | z-index: 100; 10 | pointer-events: none; 11 | } 12 | 13 | @media (min-width: 1200px) { 14 | .pp_debug_profilerContainer { 15 | margin-left: 275px !important; 16 | } 17 | } 18 | 19 | .pp_debug_profiler { 20 | background: #000000d5; 21 | width: fit-content; 22 | height: fit-content; 23 | padding: 20px; 24 | border-radius: 20px; 25 | color: rgba(255, 255, 255, 0.781); 26 | font-weight: 400; 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | pointer-events: none; 31 | white-space: pre; 32 | } 33 | 34 | .pp_debug_rendered { 35 | border-style: dotted; 36 | border-color: #35ca18; 37 | border-width: 1px; 38 | } 39 | -------------------------------------------------------------------------------- /resources/comments/sortButtons/old.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/modules/posts/posts.less: -------------------------------------------------------------------------------- 1 | .pp_post_shareButton :is(span):not(.flex) { 2 | visibility: hidden !important; 3 | max-width: 0px; 4 | } 5 | 6 | .pp_post_shareButton .text-16 { 7 | margin-right: 0px !important; 8 | } 9 | 10 | .pp_post_tittle { 11 | text-decoration: none !important; 12 | } 13 | 14 | .pp_post_noWrap { 15 | line-clamp: 999; 16 | -webkit-line-clamp: 999; 17 | } 18 | 19 | .pp_post_unwrapContainer { 20 | position: relative; 21 | height: 0px; 22 | bottom: 0px; 23 | display: flex; 24 | justify-content: flex-end; 25 | z-index: 10; 26 | } 27 | 28 | .pp_post_unwrapButton { 29 | position: relative; 30 | background-color: var(--color-button-secondary-background); 31 | width: 46px; 32 | height: 46px; 33 | bottom: 50px; 34 | display: flex; 35 | justify-content: center; 36 | align-items: center; 37 | border-radius: 50px; 38 | color: var(--color-button-secondary-text); 39 | margin-right: 15px; 40 | pointer-events: fill !important; 41 | z-index: 10; 42 | } 43 | -------------------------------------------------------------------------------- /src/_compatibility/migration_1_0_0.ts: -------------------------------------------------------------------------------- 1 | import { pp_log } from '../modules/toaster'; 2 | import { Migration } from './migrations'; 3 | 4 | // from 0.2.x 5 | export const migration_1_0_0 = new Migration(`1.0.0`, () => { 6 | const settingsDatabase = GM_getValue(`SETTINGS_DATABASE`, null) as any; 7 | 8 | if (settingsDatabase == null) return; 9 | 10 | const bookmarks = settingsDatabase[`savedBookmark`]; 11 | if (bookmarks != undefined) { 12 | settingsDatabase[`savedBookmarkPosts`] = bookmarks; 13 | settingsDatabase[`savedBookmarkComments`] = bookmarks; 14 | delete settingsDatabase[`savedBookmark`]; 15 | } 16 | 17 | const hideUnsaved = settingsDatabase[`savedBookmarkHideUnsaved`]; 18 | if (hideUnsaved != undefined) { 19 | settingsDatabase[`savedBookmarkPostsShowAlways`] = !hideUnsaved; 20 | settingsDatabase[`savedBookmarkCommentsShowAlways`] = !hideUnsaved; 21 | delete settingsDatabase[`savedBookmarkHideUnsaved`]; 22 | } 23 | 24 | GM_setValue(`SETTINGS_DATABASE`, settingsDatabase); 25 | }); 26 | -------------------------------------------------------------------------------- /src/modules/toaster.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement } from '../utils/tools'; 2 | 3 | export interface NotifyConfig { 4 | seconds?: number; 5 | color?: string; 6 | } 7 | 8 | export async function notify(message: string, config?: NotifyConfig) { 9 | const { seconds, color } = { seconds: 3, color: null, ...config }; 10 | 11 | let toaster = await dynamicElement(() => document.body?.querySelector(`alert-controller`)?.shadowRoot?.querySelector(`toaster-lite`)); 12 | 13 | let toast = document.createElement(`faceplate-toast`); 14 | toast.classList.add(`theme-rpl`); 15 | if (color != null) { 16 | toast.style.backgroundColor = color; 17 | } 18 | toast.textContent = message; 19 | 20 | toaster.appendChild(toast); 21 | 22 | setTimeout(() => { 23 | toast.setAttribute(`_fading`, ``); 24 | }, seconds * 1000); 25 | } 26 | 27 | export function pp_log(message: string) { 28 | if (DEBUG) { 29 | notify(message, { seconds: 6, color: `#df911d` }); 30 | } 31 | 32 | console.log(`Reddit++: ${message}`); 33 | } 34 | -------------------------------------------------------------------------------- /resources/hiddenIco.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/contentFilter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 lnm95 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/UI/toggle.less: -------------------------------------------------------------------------------- 1 | .pp_ui_toggle { 2 | float: right; 3 | position: relative; 4 | } 5 | 6 | .pp_ui_toggle_active { 7 | justify-content: flex-end !important; 8 | background-color: #0079d3 !important; 9 | } 10 | 11 | .pp_ui_toggle_button { 12 | position: relative; 13 | cursor: pointer; 14 | user-select: none; 15 | overflow: visible; 16 | display: flex; 17 | justify-content: start; 18 | background: transparent; 19 | background-color: var(--checkBox-background); 20 | padding: initial; 21 | height: 24px; 22 | width: 37.5px; 23 | border-radius: 100px; 24 | border: 2px solid transparent; 25 | transition: background-color 0.2s linear; 26 | } 27 | 28 | .pp_ui_toggle_knob { 29 | height: 19.5px; 30 | width: 19.5px; 31 | background-color: #fff; 32 | box-shadow: 33 | 0 0 0 1px rgba(0, 0, 0, 0.1), 34 | 0 2px 3px 0 rgba(0, 0, 0, 0.2); 35 | transition: 0.5s linear; 36 | border-radius: 57%; 37 | } 38 | 39 | :root { 40 | --checkBox-background: #1a1a1b1a; 41 | } 42 | :root.theme-dark { 43 | --checkBox-background: #81818152 !important; 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/sidebar/sections/custom.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement } from '../../../utils/tools'; 2 | import { SidebarSectionElements, SidebarSectionRenderer } from '../sidebarSectionRenderer'; 3 | 4 | export class CustomRenderer extends SidebarSectionRenderer { 5 | FindContainer(sidebar: HTMLElement, element: HTMLElement): HTMLElement { 6 | let container: HTMLElement = null; 7 | 8 | sidebar.querySelectorAll(`faceplate-expandable-section-helper`).forEach(helper => { 9 | const summary = helper.querySelector(`summary[aria-controls="multireddits_section"]`); 10 | 11 | if (summary != null) { 12 | container = helper as HTMLElement; 13 | } 14 | }); 15 | 16 | return container; 17 | } 18 | 19 | async GetSectionElements(container: HTMLElement): Promise { 20 | await dynamicElement(() => container.getAttribute(`open`)); 21 | 22 | return { 23 | container: container, 24 | button: container.querySelector(`summary[aria-controls="multireddits_section"]`), 25 | bottomLine: this.FindBottomLine(container) 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/sidebar/sidebarNavigation.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement, PascalCase } from '../../utils/tools'; 2 | import { settings } from '../settings/settings'; 3 | 4 | export enum SidebarNavigation { 5 | Home = `home`, 6 | Popular = `popular`, 7 | Guides = `guides`, 8 | Explore = `explore`, 9 | All = `all` 10 | } 11 | 12 | export let navigations: Map = new Map([ 13 | [SidebarNavigation.Home, `Home`], 14 | [SidebarNavigation.Popular, `Popular`], 15 | [SidebarNavigation.Guides, `Answers`], 16 | [SidebarNavigation.Explore, `Explore`], 17 | [SidebarNavigation.All, `All`] 18 | ]); 19 | 20 | export async function RenderSidebarNavigations(sidebar: Element) { 21 | if (sidebar == null) { 22 | sidebar = document.body.querySelector(`#left-sidebar-container`); 23 | } 24 | 25 | const section = await dynamicElement(() => sidebar.querySelector(`left-nav-top-section`)); 26 | 27 | Object.values(SidebarNavigation).forEach(name => { 28 | const setting = settings.SIDEBAR_NAV_BUTTON.getChild(PascalCase(name), true); 29 | 30 | section.toggleAttribute(name, setting.isEnabled()); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /config/webpack.config.prod.cjs: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const { merge } = require('webpack-merge'); 3 | const { UserScriptMetaDataPlugin } = require('userscript-metadata-webpack-plugin'); 4 | 5 | const metadata = require('./metadata.cjs'); 6 | const webpackConfig = require('./webpack.config.base.cjs'); 7 | 8 | if (!process.env.npm_config_release) { 9 | metadata.name = { 10 | $: 'Reddit++ Preview', 11 | ru: 'Reddit++ Preview' 12 | }; 13 | 14 | metadata.namespace = metadata.namespace + `Preview`; 15 | } 16 | 17 | const cfg = merge(webpackConfig, { 18 | mode: 'production', 19 | output: { 20 | filename: process.env.npm_config_release ? 'redditPlusPlus.user.js' : 'redditPlusPlus.preview.user.js' 21 | }, 22 | optimization: { 23 | usedExports: true 24 | }, 25 | cache: { 26 | type: 'filesystem', 27 | name: 'prod' 28 | }, 29 | plugins: [ 30 | new UserScriptMetaDataPlugin({ 31 | metadata 32 | }), 33 | new webpack.DefinePlugin({ 34 | VERSION: JSON.stringify(process.env.npm_package_version), 35 | DEBUG: JSON.stringify(false) 36 | }) 37 | ] 38 | }); 39 | 40 | module.exports = cfg; 41 | -------------------------------------------------------------------------------- /src/modules/sidebar/sections/subs.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement } from '../../../utils/tools'; 2 | import { SidebarSectionElements, SidebarSectionRenderer } from '../sidebarSectionRenderer'; 3 | import { renderSubFilter } from '../subFilter'; 4 | 5 | export class SubsRenderer extends SidebarSectionRenderer { 6 | FindContainer(sidebar: HTMLElement, element: HTMLElement): HTMLElement { 7 | let container: HTMLElement = null; 8 | 9 | sidebar.querySelectorAll(`faceplate-expandable-section-helper`).forEach(helper => { 10 | const summary = helper.querySelector(`summary[aria-controls="communities_section"]`); 11 | 12 | if (summary != null) { 13 | container = helper as HTMLElement; 14 | } 15 | }); 16 | 17 | return container; 18 | } 19 | 20 | async GetSectionElements(container: HTMLElement): Promise { 21 | await dynamicElement(() => container.getAttribute(`open`)); 22 | 23 | renderSubFilter(container); 24 | 25 | return { 26 | container: container, 27 | button: container.querySelector(`summary[aria-controls="communities_section"]`), 28 | bottomLine: this.FindBottomLine(container) 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/filters/hiddenContent.less: -------------------------------------------------------------------------------- 1 | .pp_hiddenContent_button { 2 | position: fixed; 3 | width: fit-content; 4 | height: 50px; 5 | bottom: -100px; 6 | border-radius: 12px; 7 | border: solid 2px var(--color-button-secondary-background); 8 | visibility: hidden; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | gap: 8px; 13 | padding: 0px 16px; 14 | cursor: pointer; 15 | user-select: none; 16 | 17 | transition: 18 | bottom 0.4s ease, 19 | background 0.15s ease; 20 | } 21 | 22 | .pp_hiddenContent_button:hover { 23 | background: var(--color-button-secondary-background); 24 | border-color: transparent; 25 | } 26 | 27 | .pp_hiddenContent_button:active { 28 | background: var(--button-color-background-activated); 29 | border-color: transparent; 30 | } 31 | 32 | .pp_hiddenContent_button > svg { 33 | min-width: 16px; 34 | } 35 | 36 | .pp_hiddenContent_button > span { 37 | font-weight: 500; 38 | text-wrap-mode: nowrap; 39 | } 40 | 41 | .pp_hiddenContent_button_visible { 42 | visibility: visible; 43 | bottom: 20px; 44 | } 45 | 46 | :root { 47 | --pp-backgroundButton: #848d9233; 48 | --pp-backgroundButtonActive: #e5ebee6e; 49 | } 50 | :root.theme-dark { 51 | --pp-backgroundButton: #3f484d33 !important; 52 | } 53 | -------------------------------------------------------------------------------- /src/_compatibility/migrations.ts: -------------------------------------------------------------------------------- 1 | import { FORCE_MIGRATIONS } from '../_debug/debug'; 2 | import { notify } from '../modules/toaster'; 3 | import { isLowerVersion } from '../utils/tools'; 4 | 5 | const DATABASE_VERSION: string = `DATABASE_VERSION`; 6 | 7 | export class Migration { 8 | version: string; 9 | action: Function; 10 | previous: Migration; 11 | 12 | constructor(version: string, action: Function, previous: Migration = null) { 13 | this.version = version; 14 | this.action = action; 15 | this.previous = previous; 16 | } 17 | 18 | check() { 19 | const currentVersion = GM_getValue(DATABASE_VERSION, null); 20 | 21 | if (DEBUG && FORCE_MIGRATIONS) { 22 | this.previous?.check(); 23 | this.action(); 24 | notify(`Reddit++ was upgraded to ${this.version} (DEBUG)`); 25 | return; 26 | } 27 | 28 | if (currentVersion == null) { 29 | GM_setValue(DATABASE_VERSION, this.version); 30 | return; 31 | } 32 | 33 | if (isLowerVersion(currentVersion, this.version)) { 34 | this.previous?.check(); 35 | 36 | this.action(); 37 | 38 | GM_setValue(DATABASE_VERSION, this.version); 39 | 40 | notify(`Reddit++ was upgraded to ${this.version}`); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/subs/flairBar.less: -------------------------------------------------------------------------------- 1 | .pp_flair { 2 | border-radius: 20px; 3 | } 4 | 5 | .pp_flairBar { 6 | display: flex; 7 | flex-direction: row; 8 | overflow: hidden; 9 | } 10 | 11 | .pp_flairBar_highlights { 12 | padding-bottom: 10px; 13 | } 14 | 15 | .pp_flairBar_list { 16 | margin-top: 5px !important; 17 | flex-wrap: nowrap !important; 18 | position: relative; 19 | } 20 | 21 | .pp_flairBar_listSmoothed { 22 | transition: left 0.1s ease-out; 23 | } 24 | 25 | .pp_flairBar_bordersContainer { 26 | width: 100%; 27 | display: flex; 28 | justify-content: space-between; 29 | } 30 | 31 | .pp_flairBar_preBorder { 32 | width: 20px; 33 | } 34 | 35 | .pp_flairBar_border { 36 | z-index: 1; 37 | position: absolute; 38 | height: 40px; 39 | width: 20px; 40 | margin-top: 5px; 41 | background: linear-gradient(var(--flair-border-orientation), var(--color-neutral-background), 60%, var(--color-neutral-background-transparent)); 42 | } 43 | 44 | // vars 45 | 46 | .pp_flairBar_border_left { 47 | --flair-border-orientation: 90deg; 48 | } 49 | 50 | .pp_flairBar_border_right { 51 | --flair-border-orientation: 270deg; 52 | } 53 | 54 | :root { 55 | --color-neutral-background-transparent: #fff0; 56 | } 57 | :root.theme-dark { 58 | --color-neutral-background-transparent: #0b141600 !important; 59 | } 60 | -------------------------------------------------------------------------------- /resources/comments/sortButtons/controversial.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/sidebar/sections/recent.ts: -------------------------------------------------------------------------------- 1 | import { dynamicElement } from '../../../utils/tools'; 2 | import { css } from '../../customCSS'; 3 | import { SidebarSectionElements, SidebarSectionRenderer } from '../sidebarSectionRenderer'; 4 | 5 | export class RecentRenderer extends SidebarSectionRenderer { 6 | FindContainer(sidebar: HTMLElement, element: HTMLElement): HTMLElement { 7 | let container: HTMLElement = sidebar.querySelector(`reddit-recent-pages`); 8 | 9 | if (container == null && element.matches(`reddit-recent-pages`)) { 10 | container = element; 11 | } 12 | 13 | return container; 14 | } 15 | 16 | async GetSectionElements(container: HTMLElement): Promise { 17 | const helper = await dynamicElement(() => { 18 | const _helper = container.shadowRoot?.querySelector(`faceplate-expandable-section-helper`); 19 | return _helper?.getAttribute(`open`) != null ? _helper : null; 20 | }); 21 | 22 | const button = await dynamicElement(() => helper?.querySelector(`summary`)); 23 | 24 | css.registry(container.shadowRoot); 25 | helper.classList.add(`pp_defaultText`); 26 | 27 | return { 28 | container: helper, 29 | button: button, 30 | bottomLine: container.querySelector(`hr`) 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/imageViewer.less: -------------------------------------------------------------------------------- 1 | // images 2 | .pp_imageViewable { 3 | cursor: pointer; 4 | } 5 | 6 | // imageViewer 7 | .pp_imageViewer { 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | position: fixed; 12 | top: 0px; 13 | z-index: 10; 14 | cursor: pointer; 15 | width: 100%; 16 | height: 100%; 17 | background-color: #000000b3; 18 | } 19 | 20 | .pp_imageViewer_closeButton { 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | position: fixed; 25 | top: 50px; 26 | z-index: 11; 27 | right: 50px; 28 | cursor: pointer; 29 | width: 50px; 30 | height: 50px; 31 | color: #ffffff9c; 32 | background-color: #00000069; 33 | border-radius: 30px; 34 | } 35 | 36 | .pp_imageViewer_closeButton:hover { 37 | color: #ffffffc7; 38 | } 39 | 40 | .pp_imageViewer_imageContainer { 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | width: 80%; 45 | height: 90%; 46 | } 47 | 48 | .pp_imageViewer_imageContainer:not(.pp_imageViewer_drag) { 49 | transition: transform 0.5s; 50 | } 51 | 52 | .pp_imageViewer_image { 53 | cursor: grab; 54 | object-fit: scale-down; 55 | max-width: 100%; 56 | max-height: 100%; 57 | box-shadow: 0px 0px 20px 3px #14141485; 58 | } 59 | 60 | .pp_imageViewer_image:active { 61 | cursor: grabbing; 62 | } 63 | -------------------------------------------------------------------------------- /config/webpack.config.dev.cjs: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const { merge } = require('webpack-merge'); 4 | const { UserScriptMetaDataPlugin } = require('userscript-metadata-webpack-plugin'); 5 | 6 | const metadata = require('./metadata.cjs'); 7 | const webpackConfig = require('./webpack.config.base.cjs'); 8 | 9 | metadata.name = { 10 | $: 'Reddit++ Debug', 11 | ru: 'Reddit++ Debug' 12 | }; 13 | 14 | metadata.namespace = metadata.namespace + `Debug`; 15 | 16 | metadata.require.push('file://' + path.resolve(__dirname, '../dist/redditPlusPlus.debug.js')); 17 | 18 | const cfg = merge(webpackConfig, { 19 | mode: 'development', 20 | cache: { 21 | type: 'filesystem', 22 | name: 'dev' 23 | }, 24 | entry: { 25 | debug: webpackConfig.entry, 26 | 'dev.user': path.resolve(__dirname, './empty.cjs') 27 | }, 28 | output: { 29 | filename: 'redditPlusPlus.[name].js' 30 | }, 31 | devtool: 'eval-source-map', 32 | watch: true, 33 | watchOptions: { 34 | ignored: /node_modules/ 35 | }, 36 | plugins: [ 37 | new UserScriptMetaDataPlugin({ 38 | metadata 39 | }), 40 | new webpack.DefinePlugin({ 41 | VERSION: JSON.stringify(process.env.npm_package_version), 42 | DEBUG: JSON.stringify(true) 43 | }) 44 | ] 45 | }); 46 | 47 | module.exports = cfg; 48 | -------------------------------------------------------------------------------- /src/modules/comments/userTags.less: -------------------------------------------------------------------------------- 1 | .pp_tagsPanel { 2 | display: flex; 3 | justify-content: space-around; 4 | width: auto; 5 | border-bottom: solid 1px var(--color-neutral-border-weak); 6 | padding: 4px; 7 | gap: 8px; 8 | margin-bottom: 4px; 9 | } 10 | 11 | .pp_tagButton { 12 | cursor: pointer; 13 | display: flex; 14 | align-content: center; 15 | flex-wrap: wrap; 16 | height: 45px; 17 | padding: 4px 20px; 18 | margin: 0px 0px; 19 | color: var(--color-neutral-border-weak); 20 | border-radius: 5px; 21 | } 22 | 23 | .pp_tagButton svg { 24 | width: 20px; 25 | transition: transform 0.15s; 26 | } 27 | 28 | .pp_tagButton:hover svg { 29 | transform: scale(1.2, 1.2); 30 | transition: transform 0.3s; 31 | } 32 | 33 | .pp_tagButton:hover { 34 | background-color: var(--color-neutral-background-hover); 35 | } 36 | 37 | .pp_tagButtonActive:hover { 38 | opacity: 0.8; 39 | } 40 | 41 | .pp_tagHint_offset { 42 | left: 50%; 43 | position: absolute; 44 | } 45 | 46 | .pp_tagHintContainer { 47 | display: flex; 48 | justify-content: center; 49 | //position: fixed; 50 | } 51 | 52 | .pp_tagHint { 53 | display: flex; 54 | align-items: center; 55 | position: absolute; 56 | top: -35px; 57 | height: 25px; 58 | padding: 0px 12px; 59 | color: var(--color-neutral-background-weak); 60 | font: var(--font-small); 61 | background-color: var(--color-neutral-content-strong); 62 | border-radius: 5px; 63 | } 64 | -------------------------------------------------------------------------------- /src/modules/notifications.ts: -------------------------------------------------------------------------------- 1 | import { appendElement } from '../utils/element'; 2 | import { settings } from './settings/settings'; 3 | import { css } from './customCSS'; 4 | 5 | import style from './notifications.less'; 6 | 7 | if (settings.NOTIFY_POPUP.isEnabled()) { 8 | css.addStyle(style); 9 | } 10 | 11 | export function renderNotifications(container: Element) { 12 | container.querySelectorAll(`div[data-testid="notification-item"]`).forEach(item => { 13 | const tittle = item.querySelector(`div[data-testid="title"]`); 14 | 15 | const author = item.querySelector(`.text-secondary-plain`); 16 | if (author.textContent.includes(`replied`)) { 17 | author.textContent = author.textContent.split(`replied`)[0]; 18 | 19 | const subTittle = document.createElement(`div`); 20 | tittle.after(subTittle); 21 | const subTittleContent = appendElement(subTittle, `span`, [`text-secondary-weak`, `font-normal`]); 22 | let sub = item.parentElement.getAttribute(`href`); 23 | sub = sub.replace(`https://reddit.com/r/`, ``); 24 | sub = sub.split(`/`)[0]; 25 | subTittleContent.textContent = `replied in r/${sub}`; 26 | } 27 | 28 | const time = item.querySelector(`faceplate-number`); 29 | 30 | if (time != null) { 31 | const spanAgo = document.createElement(`span`); 32 | spanAgo.textContent = ` ago`; 33 | time.after(spanAgo); 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/svg.ts: -------------------------------------------------------------------------------- 1 | export const NONE_COLOR: string = 'none'; 2 | export const CURRENT_COLOR: string = 'currentColor'; 3 | 4 | export class SVGViewBox { 5 | w: number; 6 | h: number; 7 | } 8 | 9 | export interface SVGConfig { 10 | viewBox?: SVGViewBox; 11 | strokeColor?: string; 12 | fillColor?: string; 13 | } 14 | 15 | const builderContainer = document.createElement('div'); 16 | 17 | export function buildSvg(graphic: any, w: number, h: number, config?: SVGConfig): SVGSVGElement { 18 | const { viewBox, strokeColor, fillColor } = { viewBox: null, strokeColor: CURRENT_COLOR, fillColor: CURRENT_COLOR, ...config }; 19 | 20 | builderContainer.innerHTML = graphic; 21 | 22 | const svg = builderContainer.firstChild as SVGSVGElement; 23 | svg.setAttribute(`width`, `${w}px`); 24 | svg.setAttribute(`height`, `${h}px`); 25 | 26 | if (viewBox != null) { 27 | svg.setAttribute(`viewBox`, `0 0 ${viewBox.w} ${viewBox.h}`); 28 | } 29 | 30 | svg.setAttribute(`fill`, fillColor); 31 | svg.setAttribute(`stroke`, strokeColor); 32 | 33 | return svg; 34 | } 35 | 36 | export function appendSvg(target: Element, graphic: any, w: number, h: number, config: SVGConfig = {}): SVGSVGElement { 37 | const svg = buildSvg(graphic, w, h, config); 38 | target.append(svg); 39 | return svg; 40 | } 41 | 42 | export function prependSvg(target: Element, graphic: any, w: number, h: number, config: SVGConfig = {}): SVGSVGElement { 43 | const svg = buildSvg(graphic, w, h, config); 44 | target.prepend(svg); 45 | return svg; 46 | } 47 | -------------------------------------------------------------------------------- /src/modules/settings/settingsWindow.less: -------------------------------------------------------------------------------- 1 | .pp_settings_subtittle { 2 | font-size: 10px; 3 | font-weight: 700; 4 | letter-spacing: 0.5px; 5 | line-height: 12px; 6 | min-height: 20px; 7 | color: #7c7c7c; 8 | text-transform: uppercase; 9 | border-bottom: 1px solid #edeff1; 10 | margin-top: 1rem; 11 | padding: 0rem 3rem; 12 | } 13 | 14 | .pp_settings_property_oneLine { 15 | height: 2.25rem !important; 16 | } 17 | 18 | .pp_settings_propertyHeader { 19 | display: flex; 20 | flex-direction: column; 21 | margin-left: 3rem; 22 | justify-content: center; 23 | } 24 | 25 | .pp_settings_propertyHeader_tittle { 26 | display: flex; 27 | font-size: 16px; 28 | font-weight: 500; 29 | line-height: 20px; 30 | margin-bottom: 4px; 31 | } 32 | 33 | .pp_no_decoration { 34 | text-decoration: none; 35 | } 36 | .pp_no_decoration:visited { 37 | text-decoration: none; 38 | } 39 | .pp_no_decoration:hover { 40 | text-decoration: none; 41 | } 42 | .pp_no_decoration:active { 43 | text-decoration: none; 44 | } 45 | 46 | .pp_settings_propertyHeader_badge { 47 | font-size: 12px; 48 | margin: 0px 0px 0px 8px; 49 | padding: 0px 4px; 50 | border: 1px solid; 51 | border-radius: 4px; 52 | } 53 | 54 | .pp_settings_propertyHeader_description { 55 | font-size: 12px; 56 | font-weight: 400; 57 | line-height: 16px; 58 | color: #7c7c7c; 59 | } 60 | 61 | .pp_settings_propertyButtonContainer { 62 | display: flex; 63 | align-items: center; 64 | justify-content: flex-end; 65 | flex-grow: 1; 66 | } 67 | -------------------------------------------------------------------------------- /src/modules/customCSS.ts: -------------------------------------------------------------------------------- 1 | class CustomCSS { 2 | rootStylesheet: CSSStyleSheet; 3 | styleSheets: Array; 4 | styleKeys: Set; 5 | sources: Array; 6 | 7 | constructor() { 8 | this.rootStylesheet = new CSSStyleSheet(); 9 | this.styleSheets = [this.rootStylesheet]; 10 | this.styleKeys = new Set(); 11 | this.sources = []; 12 | 13 | this.registry(document); 14 | } 15 | 16 | registry(source: Document | ShadowRoot) { 17 | this.sources.push(source); 18 | 19 | for (const styleSheet of this.styleSheets) { 20 | source.adoptedStyleSheets.push(styleSheet); 21 | } 22 | } 23 | 24 | addStyle(style: string, key: string = null) { 25 | if (key != null) { 26 | if (this.styleKeys.has(key)) return; 27 | 28 | this.styleKeys.add(key); 29 | } 30 | 31 | const styleSheet = new CSSStyleSheet(); 32 | styleSheet.replaceSync(style); 33 | 34 | this.styleSheets.push(styleSheet); 35 | 36 | for (const source of this.sources) { 37 | source.adoptedStyleSheets.push(styleSheet); 38 | } 39 | } 40 | 41 | addRule(rule: string) { 42 | this.rootStylesheet.insertRule(rule, 0); 43 | } 44 | 45 | addVar(name: string, lightValue: string, darkValue: string = null) { 46 | this.addRule(`:root.theme-light { ${name}: ${lightValue} !important;}`); 47 | this.addRule(`:root { ${name}: ${darkValue ?? lightValue};}`); 48 | } 49 | } 50 | 51 | export const css = new CustomCSS(); 52 | -------------------------------------------------------------------------------- /src/modules/sidebar/sidebarSection.ts: -------------------------------------------------------------------------------- 1 | import { SettingBoolProperty, settings } from '../settings/settings'; 2 | import { CustomRenderer } from './sections/custom'; 3 | import { GamesRenderer } from './sections/games'; 4 | import { RecentRenderer } from './sections/recent'; 5 | import { ResourcesRenderer } from './sections/resources'; 6 | import { SubsRenderer } from './sections/subs'; 7 | import { SidebarSectionRenderer } from './sidebarSectionRenderer'; 8 | 9 | export enum SidebarSection { 10 | Games = `Games`, 11 | Custom = `Custom`, 12 | Recent = `Recent`, 13 | Subs = `Subs`, 14 | Resources = `Resources` 15 | } 16 | 17 | export interface SidebarSectionConfig { 18 | tittle: string; 19 | autocollapse: boolean; 20 | setting: SettingBoolProperty; 21 | renderer: SidebarSectionRenderer; 22 | } 23 | 24 | export const sections = new Map([ 25 | [SidebarSection.Games, { tittle: `Games on reddit`, autocollapse: false, setting: settings.SIDEBAR_GAMES, renderer: new GamesRenderer() }], 26 | [SidebarSection.Custom, { tittle: `Custom feeds`, autocollapse: true, setting: settings.SIDEBAR_CUSTOMS, renderer: new CustomRenderer() }], 27 | [SidebarSection.Recent, { tittle: `Recent`, autocollapse: true, setting: settings.SIDEBAR_RECENT, renderer: new RecentRenderer() }], 28 | [SidebarSection.Subs, { tittle: `Communities`, autocollapse: true, setting: settings.SIDEBAR_SUBS, renderer: new SubsRenderer() }], 29 | [SidebarSection.Resources, { tittle: `Resources`, autocollapse: true, setting: settings.SIDEBAR_RESOURCES, renderer: new ResourcesRenderer() }] 30 | ]); 31 | -------------------------------------------------------------------------------- /resources/comments/shareButton.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Reddit++ 3 |
4 | 5 | ## User 6 | 7 | To usage Reddit++ follow next steps: 8 | 9 | 1. Install `tampermonkey`, `violentmonkey` or any other userscript manager. 10 | 2. Go to [script homepage](https://greasyfork.org/en/scripts/490046-reddit) and click install. 11 | 12 | ## Developer 13 | 14 | [That template](https://github.com/trim21/webpack-userscript-template) is used for project. 15 | 16 | Using `Visual Studio Code` is recommended. 17 | 18 | ### Getting started 19 | 20 | Required `Node.js`. 21 | 22 | Command `npm i` to install dependencies 23 | 24 | ### Debug 25 | 26 | Command `npm run debug` to start debug process 27 | 28 | Add generated script-wrapper `redditPlusPlus.dev.user.js` from `/dist` to userscript manager 29 | 30 | Allow local files for userscript manager (extesions > details > allow access to file URLs). 31 | 32 | The latest version of Chrome browser requires allowing Eval (Tampermonkey Dashboard > Settings > Modify existing content security policy (CSP) headers > remove entirely) 33 | 34 | While debug process is running the script automatically refresh in browser 35 | 36 | To stop debug process kill active terminal 37 | 38 | ### Preview 39 | 40 | Command `npm run preview` to build preview of production in `/dist` 41 | 42 | ### Release 43 | 44 | Command `npm run release` to build production in `/public` and automatically patch version 45 | 46 | ### Known issues 47 | 48 | Debug process periodically gones to memory leak that's crash browser. 49 | 50 | Intersection observer cause poor performance in posts with a lot of comments. 51 | -------------------------------------------------------------------------------- /config/webpack.config.base.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const WebpackStringReplacer = require('webpack-string-replacer'); 4 | 5 | const webpackConfig = { 6 | resolve: { 7 | extensions: ['.js', '.ts'], 8 | alias: { 9 | '@resources': path.resolve(__dirname, '../resources') 10 | } 11 | }, 12 | optimization: { 13 | minimize: false, 14 | moduleIds: 'named' 15 | }, 16 | entry: './src/core.ts', 17 | output: { 18 | clean: false, 19 | path: path.resolve(__dirname, process.env.npm_config_release ? '../public' : '../dist') 20 | }, 21 | target: 'web', 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.m?ts$/, 26 | use: { 27 | loader: 'ts-loader' 28 | } 29 | }, 30 | { 31 | test: /\.less$/, 32 | use: ['css-loader', 'less-loader'] 33 | }, 34 | { 35 | test: /\.svg$/, 36 | loader: 'svg-inline-loader' 37 | } 38 | ] 39 | }, 40 | plugins: [] 41 | }; 42 | 43 | // cleanup removed modules 44 | webpackConfig.plugins.push( 45 | new WebpackStringReplacer({ 46 | logAroundPatternMatches: 200, 47 | rules: [ 48 | { 49 | applyStage: 'optimizeChunkAssets', 50 | outputFileInclude: /\.js$/, 51 | replacements: [ 52 | { 53 | pattern: 'if (false) {}', 54 | replacement: '' 55 | } 56 | ] 57 | } 58 | ] 59 | }) 60 | ); 61 | 62 | module.exports = webpackConfig; 63 | -------------------------------------------------------------------------------- /src/modules/users/users.ts: -------------------------------------------------------------------------------- 1 | import { PROFILE_USER_DATA, profiler_comments } from '../../_debug/debug'; 2 | import { Database, DatabaseConfig } from '../../utils/database'; 3 | import { requestAPI } from '../../utils/redditAPI'; 4 | import { pp_log } from '../toaster'; 5 | 6 | export class UserData { 7 | accountId: string; 8 | nick: string; 9 | created: number; 10 | rating: number; 11 | banned: boolean; 12 | } 13 | 14 | export const users = new Database(`USERS`, { isCleanupable: true, validator: userDataValidator, loader: userDataLoader } as DatabaseConfig); 15 | 16 | function userDataValidator(userData: UserData) { 17 | return userData.accountId == undefined; 18 | } 19 | 20 | async function userDataLoader(userId: string): Promise { 21 | let userData = {} as UserData; 22 | 23 | if (userId == `[deleted]`) { 24 | userData.banned = true; 25 | return userData; 26 | } 27 | 28 | const { status, result } = await requestAPI(`/user/${userId}/about.json`); 29 | 30 | if (status != 404 && result == null) { 31 | if (DEBUG && PROFILE_USER_DATA) { 32 | profiler_comments.userDataFailed++; 33 | } 34 | 35 | return userData; 36 | } 37 | 38 | if (status == 404 || result.data?.is_suspended == true || result.data?.is_blocked == true) { 39 | userData.banned = true; 40 | return userData; 41 | } 42 | 43 | userData.rating = (result.data?.link_karma ?? 0) + (result.data?.comment_karma ?? 0) / 2; 44 | if (result.data?.subreddit?.title) { 45 | userData.nick = result.data.subreddit.title; 46 | } 47 | userData.created = result.data?.created ?? 0; 48 | userData.accountId = result.kind + `_` + result.data?.id; 49 | 50 | return userData; 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/comments/contextMenu.ts: -------------------------------------------------------------------------------- 1 | import { DAY_SECONDS, HOUR_SECONDS } from '../../defines'; 2 | import { buildSvg } from '../../utils/svg'; 3 | import { appendElement } from '../../utils/element'; 4 | import { css } from '../customCSS'; 5 | import { settings } from '../settings/settings'; 6 | import { notify } from '../toaster'; 7 | import { BLOCK_OPERATION, FOLLOW_OPERATION } from '../users/userOperations'; 8 | import { USERTAG_CONFIGS, UserTag, UserTagConfig, tags } from './userTags'; 9 | import { renderUserTagsPanel } from './userTagsPanel'; 10 | 11 | import shareButtonSvg from '@resources/comments/shareButton.svg'; 12 | 13 | export function renderContextMenu(comment: Element) { 14 | let contextMenuButton = comment.querySelector(`shreddit-overflow-menu`).shadowRoot; 15 | 16 | css.registry(contextMenuButton); 17 | 18 | const contextMenu = contextMenuButton.querySelector(`faceplate-menu`); 19 | const originButton = contextMenu.querySelector(`faceplate-tracker[noun="report"]`); 20 | 21 | if (settings.HIDE_SHARE.isEnabled()) { 22 | let linkButton = originButton.cloneNode(true) as Element; 23 | linkButton.querySelector(`span .text-14`).textContent = `Copy link`; 24 | originButton.before(linkButton); 25 | 26 | const originIcon = linkButton.querySelector(`svg`); 27 | const shareIcon = buildSvg(shareButtonSvg, 20, 20); 28 | originIcon.replaceWith(shareIcon); 29 | 30 | const permalink = comment.getAttribute(`permalink`); 31 | linkButton.addEventListener(`click`, () => { 32 | navigator.clipboard.writeText(`https://www.reddit.com${permalink}`); 33 | notify(`Link copied`); 34 | }); 35 | } 36 | 37 | // userTags 38 | renderUserTagsPanel(contextMenu, comment.getAttribute(`author`)); 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/header.ts: -------------------------------------------------------------------------------- 1 | import { observeFor } from '../utils/tools'; 2 | import { checkIsRendered, dynamicElement } from '../utils/tools'; 3 | import { appendElement } from '../utils/element'; 4 | import { css } from './customCSS'; 5 | import style from './header.less'; 6 | import { renderNotifications } from './notifications'; 7 | import { settings } from './settings/settings'; 8 | import { renderProfileMenu } from './profileMenu/profileMenu'; 9 | 10 | css.addStyle(style); 11 | 12 | let notificationsInitialized = false; 13 | 14 | export async function renderHeader(container: Element) { 15 | const nav = await dynamicElement(() => container.querySelector(`reddit-header-large`)?.querySelector(`nav`)); 16 | 17 | if (checkIsRendered(nav)) return; 18 | 19 | const userPanel = await dynamicElement(() => nav.querySelector(`span[data-part="inbox"]`)?.parentElement?.parentElement); 20 | 21 | userPanel.classList.add(`pp_userPanel`); 22 | userPanel.addEventListener( 23 | `click`, 24 | () => { 25 | renderProfileMenu(); 26 | }, 27 | { once: true } 28 | ); 29 | 30 | if (settings.NOTIFY_POPUP.isEnabled() && !notificationsInitialized) { 31 | notificationsInitialized = true; 32 | observeFor(`HEADER`, document.body, (element: HTMLElement) => { 33 | if (element.getAttribute(`data-id`) == `notification-container-element` && !checkIsRendered(element)) { 34 | renderNotifications(element); 35 | } 36 | }); 37 | } 38 | 39 | const logo = container.querySelector(`#reddit-logo`); 40 | const logoPP = appendElement(logo, `div`, `pp_logo`); 41 | logoPP.textContent = `++`; 42 | if (DEBUG) { 43 | logoPP.innerHTML = logoPP.textContent + ` (dev ${VERSION})`; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/posts/postsBackplates.less: -------------------------------------------------------------------------------- 1 | article > shreddit-post { 2 | background-color: #00000000 !important; 3 | padding-top: 10px !important; 4 | margin-top: 10px !important; 5 | margin-bottom: 10px !important; 6 | } 7 | 8 | article > shreddit-post::before { 9 | border-radius: 15px !important; 10 | position: absolute; 11 | content: ''; 12 | top: 0; 13 | right: 0; 14 | bottom: 0; 15 | left: 0; 16 | opacity: 0; 17 | z-index: -1; 18 | background: linear-gradient(var(--color-neutral-background-hover), var(--color-neutral-background)); 19 | transition: opacity 0.2s; 20 | } 21 | 22 | article > shreddit-post:hover::before { 23 | opacity: 1; 24 | } 25 | 26 | // override gold 27 | shreddit-post[gold-count]:not(shreddit-post[gold-count='']) { 28 | background-image: linear-gradient(rgba(255, 214, 53, 0.2), rgba(255, 214, 53, 0)) !important; 29 | } 30 | 31 | shreddit-post[gold-count]:not(shreddit-post[gold-count=''])::before { 32 | background: linear-gradient(#fbed2966, var(--color-neutral-background)) !important; 33 | } 34 | 35 | // stickied 36 | .stickied::after { 37 | border-radius: 15px !important; 38 | position: absolute; 39 | content: ''; 40 | top: 0; 41 | right: 0; 42 | bottom: 0; 43 | left: 0; 44 | opacity: 1; 45 | z-index: -2; 46 | background: linear-gradient(var(--stickiedColor), var(--color-neutral-background)) !important; 47 | } 48 | 49 | .stickied::before { 50 | background: linear-gradient(var(--stickiedHoverColor), var(--color-neutral-background)) !important; 51 | } 52 | 53 | :root { 54 | --stickiedColor: #0e8a001c; 55 | --stickiedHoverColor: #18900b3d; 56 | } 57 | :root.theme-dark { 58 | --stickiedColor: #0e8a001c !important; 59 | --stickiedHoverColor: #18900b3d !important; 60 | } 61 | -------------------------------------------------------------------------------- /src/modules/sidebar/subFilter.less: -------------------------------------------------------------------------------- 1 | .pp_subFilter_container { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: space-between; 5 | margin-bottom: 4px; 6 | } 7 | 8 | .pp_subFilter { 9 | border-color: var(--color-neutral-border-weak); 10 | color: var(--color-secondary-weak); 11 | font-weight: 400; 12 | 13 | display: flex; 14 | justify-content: center; 15 | 16 | width: 100%; 17 | height: 40px; 18 | 19 | //outline-color: var(--color-neutral-border-weak); 20 | //outline-width: 1px; 21 | //outline-style: solid; 22 | 23 | color: var(--color-secondary-plain); 24 | } 25 | 26 | .pp_subFilter:hover { 27 | background: var(--color-input-secondary-hover) !important; 28 | } 29 | 30 | .pp_subFilter:focus { 31 | //outline-color:var(--color-neutral-content-weak); 32 | border-color: var(--color-neutral-content-weak) !important; 33 | } 34 | 35 | .pp_subFilter_span { 36 | width: 100%; 37 | margin: 0px 16px; 38 | display: flex; 39 | justify-content: flex-start; 40 | align-items: center; 41 | gap: 6px; 42 | } 43 | 44 | .pp_subFilter_span > span { 45 | display: flex; 46 | justify-content: center; 47 | align-items: center; 48 | } 49 | 50 | .pp_subFilter_input { 51 | width: 100%; 52 | background: 0 0; 53 | border: none; 54 | outline: 0; 55 | text-overflow: ellipsis; 56 | color: var(--color-neutral-content-strong); 57 | font: inherit; 58 | padding: 0px; 59 | margin: 0px; 60 | } 61 | 62 | .pp_pp_subFilter_clearContainer { 63 | position: relative; 64 | width: 0px; 65 | } 66 | 67 | .pp_subFilter_clear { 68 | position: relative; 69 | right: 40px; 70 | top: 4px; 71 | 72 | border-radius: 32px; 73 | width: 32px; 74 | height: 32px; 75 | display: flex; 76 | justify-content: center; 77 | align-items: center; 78 | } 79 | -------------------------------------------------------------------------------- /src/modules/redirect.ts: -------------------------------------------------------------------------------- 1 | import { appendElement } from '../utils/element'; 2 | import { settings } from './settings/settings'; 3 | 4 | import style from './redirect.less'; 5 | import { css } from './customCSS'; 6 | import { checkSortCommentsRedirect } from './comments/sortButtons'; 7 | import { RedirectMode } from './redirectMode'; 8 | 9 | export function checkRedirect(): boolean { 10 | const mode = settings.REDIRECT_MODE.get() as RedirectMode; 11 | 12 | const isOld = window.location.href.includes(`old.reddit.com`); 13 | 14 | let redirect: string = null; 15 | if (isOld) { 16 | redirect = window.location.href.replace(`old.reddit.com`, `reddit.com`); 17 | } 18 | 19 | if (mode == RedirectMode.Forced && redirect != null) { 20 | window.location.assign(redirect); 21 | } 22 | 23 | if (mode == RedirectMode.Suggestion && redirect != null) { 24 | renderSuggestion(redirect); 25 | } 26 | 27 | const commentsSortRedirect = checkSortCommentsRedirect(); 28 | 29 | return redirect != null || commentsSortRedirect; 30 | } 31 | 32 | function renderSuggestion(redirect: string) { 33 | css.addStyle(style); 34 | 35 | let secondsToRedirect = 19; 36 | 37 | const container = appendElement(document.body, `div`, `pp_redirectContainer`); 38 | const box = appendElement(container, `div`, `pp_redirectBox`); 39 | box.textContent = `Click here to redirect on compatible page (${secondsToRedirect})`; 40 | 41 | box.addEventListener(`click`, () => { 42 | window.location.assign(redirect); 43 | }); 44 | 45 | const suggestionId = setInterval(() => { 46 | secondsToRedirect--; 47 | box.textContent = `Click here to redirect on compatible page (${secondsToRedirect})`; 48 | if (secondsToRedirect <= 0) { 49 | clearInterval(suggestionId); 50 | container.remove(); 51 | } 52 | }, 750); 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/scrollToTop.less: -------------------------------------------------------------------------------- 1 | .pp_scrollToTop { 2 | position: fixed; 3 | width: 100px; 4 | height: 100%; 5 | bottom: 0px; 6 | //left: runtime; 7 | background: linear-gradient(0deg, var(--scrollLineColor) 1%, var(--scrollLineTransparentColor) 30%); 8 | display: flex; 9 | justify-content: center; 10 | align-items: flex-end; 11 | padding-bottom: 20px; 12 | cursor: pointer; 13 | color: var(--scrollLineTransparentColor); 14 | opacity: 1; 15 | 16 | transition: 17 | padding-bottom 0.2s ease-in, 18 | color 0.2s ease-in, 19 | opacity 0.5s ease; 20 | 21 | // to avoid overlap with sidebar edge buttons 22 | clip-path: inset(0 0 0 8px); 23 | svg { 24 | padding-left: 8px; 25 | } 26 | } 27 | 28 | .pp_scrollToTop_inverted { 29 | transform: scale(1, -1); 30 | } 31 | 32 | .pp_scrollToTop:hover { 33 | padding-bottom: 50px !important; 34 | color: var(--scrollButtonColor); 35 | 36 | transition: 37 | padding-bottom 0.2s ease-out, 38 | color 0.2s ease-out, 39 | opacity 0.5s ease; 40 | } 41 | 42 | .pp_scrollToTop::before { 43 | position: absolute; 44 | content: ''; 45 | top: 0; 46 | right: 0; 47 | bottom: 0; 48 | left: 0; 49 | opacity: 0; 50 | z-index: -1; 51 | background: linear-gradient(0deg, var(--scrollLineColor) 5%, var(--scrollLineTransparentColor) 50%); 52 | transition: opacity 0.2s ease-in; 53 | } 54 | 55 | .pp_scrollToTop:hover::before { 56 | opacity: 1; 57 | transition: opacity 0.2s ease-out; 58 | } 59 | 60 | :root { 61 | --scrollLineColor: #e5ebee6e; 62 | --scrollLineTransparentColor: #e5ebee00; 63 | --scrollButtonColor: #c9d1d4c2; 64 | } 65 | :root.theme-dark { 66 | --scrollLineColor: #3f484d33 !important; 67 | --scrollLineTransparentColor: #30343600 !important; 68 | --scrollButtonColor: #969a9c !important; 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/UI/input.less: -------------------------------------------------------------------------------- 1 | .pp_ui_input_container { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: row; 5 | justify-content: space-between; 6 | } 7 | 8 | .pp_ui_input_button { 9 | border-color: var(--color-neutral-border-weak); 10 | color: var(--color-secondary-weak); 11 | font-weight: 400; 12 | 13 | display: flex; 14 | justify-content: center; 15 | 16 | width: 100%; 17 | height: 40px; 18 | 19 | //outline-color: var(--color-neutral-border-weak); 20 | //outline-width: 1px; 21 | //outline-style: solid; 22 | 23 | color: var(--color-secondary-plain); 24 | } 25 | 26 | .pp_ui_input_button:hover { 27 | background: var(--color-input-secondary-hover) !important; 28 | } 29 | 30 | .pp_ui_input_button:focus { 31 | //outline-color:var(--color-neutral-content-weak); 32 | border-color: var(--color-neutral-content-weak) !important; 33 | } 34 | 35 | .pp_ui_input_panel { 36 | width: 100%; 37 | margin: 0px 16px; 38 | display: flex; 39 | justify-content: flex-start; 40 | align-items: center; 41 | gap: 6px; 42 | } 43 | 44 | .pp_ui_input_icon { 45 | display: flex; 46 | justify-content: center; 47 | align-items: center; 48 | } 49 | 50 | .pp_ui_input_span { 51 | width: 100%; 52 | } 53 | 54 | .pp_ui_input { 55 | width: 100%; 56 | background: 0 0; 57 | border: none; 58 | outline: 0; 59 | text-overflow: ellipsis; 60 | color: var(--color-neutral-content-strong); 61 | font: inherit; 62 | padding: 0px; 63 | margin: 0px; 64 | } 65 | 66 | .pp_ui_input_clearContainer { 67 | position: relative; 68 | width: 0px; 69 | } 70 | 71 | .pp_ui_input_clearButton { 72 | position: relative; 73 | right: 40px; 74 | top: 4px; 75 | 76 | border-radius: 32px; 77 | width: 32px; 78 | height: 32px; 79 | display: flex; 80 | justify-content: center; 81 | align-items: center; 82 | } 83 | -------------------------------------------------------------------------------- /src/utils/window.less: -------------------------------------------------------------------------------- 1 | .pp_window_container { 2 | cursor: pointer; 3 | position: fixed; 4 | top: 0px; 5 | z-index: 10; 6 | width: 100%; 7 | height: 100%; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | background-color: #000000b3; 12 | } 13 | 14 | .pp_window { 15 | cursor: auto; 16 | display: flex; 17 | flex-direction: column; 18 | width: 900px; 19 | height: fit-content; 20 | min-height: 200px; 21 | max-height: 75%; 22 | border-radius: 15px; 23 | background-color: var(--color-neutral-background); 24 | box-shadow: 0px 0px 50px 0px #00000070; 25 | } 26 | 27 | .pp_window_tittleContainer { 28 | height: 48px; 29 | margin: 1rem; 30 | display: flex; 31 | flex-direction: row; 32 | justify-content: space-between; 33 | align-items: center; 34 | } 35 | 36 | .pp_window_tittle { 37 | margin-left: 1rem; 38 | } 39 | 40 | .pp_window_closeButton { 41 | margin: 1rem; 42 | } 43 | 44 | .pp_window_content { 45 | display: flex; 46 | flex-direction: column; 47 | overflow-y: overlay; 48 | } 49 | 50 | .pp_window_footer { 51 | height: 2rem; 52 | min-height: 2rem; 53 | } 54 | 55 | .pp_window_elementsContainer { 56 | display: flex; 57 | flex-direction: column; 58 | padding: 0px; 59 | margin: 20px 40px; 60 | gap: 0.5rem; 61 | } 62 | 63 | .pp_window_element { 64 | display: flex; 65 | flex-direction: row; 66 | justify-content: flex-end; 67 | height: 3rem; 68 | } 69 | 70 | .pp_window_elementsContainer > .pp_window_element:hover { 71 | background-color: var(--color-neutral-background-hover); 72 | border-radius: 15px; 73 | } 74 | 75 | .pp_window_controlArea { 76 | width: 200px; 77 | min-width: 200px; 78 | display: flex; 79 | justify-content: center; 80 | align-items: center; 81 | } 82 | 83 | .pp_window_scrollContent { 84 | overflow-y: scroll; 85 | } 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reddit-plus-plus", 3 | "description": "Userscript for reddit.com", 4 | "version": "1.2.3", 5 | "author": { 6 | "name": "lnm95" 7 | }, 8 | "sideEffects": [ 9 | ".src/_debug/debug.ts", 10 | "*.less" 11 | ], 12 | "scripts": { 13 | "format": "prettier -w ./", 14 | "debug": "webpack --config config/webpack.config.dev.cjs", 15 | "preview": "webpack --config config/webpack.config.prod.cjs ", 16 | "release": "cross-env npm_config_release=true webpack --config config/webpack.config.prod.cjs ", 17 | "postrelease": "npm version patch --no-git-tag-version", 18 | "prepare": "husky install", 19 | "lint-staged": "lint-staged" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/lnm95/redditPlusPlus" 24 | }, 25 | "private": true, 26 | "lint-staged": { 27 | "*.{js,jsx,ts,tsx,json}": [ 28 | "prettier --ignore-path ./.prettierignore --write " 29 | ] 30 | }, 31 | "devDependencies": { 32 | "@types/greasemonkey": "^4.0.7", 33 | "@types/jquery": "^3.5.30", 34 | "@types/node": "^20.13.0", 35 | "browserslist": "^4.23.0", 36 | "cross-env": "^7.0.3", 37 | "css-loader": "^7.1.2", 38 | "css-minimizer-webpack-plugin": "^7.0.0", 39 | "husky": "^9.0.11", 40 | "less": "^4.2.0", 41 | "less-loader": "^12.2.0", 42 | "lint-staged": "^15.2.5", 43 | "prettier": "^3.2.5", 44 | "style-loader": "^4.0.0", 45 | "svg-inline-loader": "^0.8.2", 46 | "ts-loader": "^9.5.1", 47 | "typescript": "^5.4.5", 48 | "userscript-metadata-webpack-plugin": "^0.4.0", 49 | "webpack": "^5.91.0", 50 | "webpack-cli": "^5.1.4", 51 | "webpack-merge": "^5.10.0", 52 | "webpack-sources": "^3.2.3", 53 | "webpack-string-replacer": "^0.0.20" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/modules/app.ts: -------------------------------------------------------------------------------- 1 | import { checkIsRendered, dynamicElement } from '../utils/tools'; 2 | import { renderWideMode } from './wideMode'; 3 | 4 | import style from './app.less'; 5 | import { css } from './customCSS'; 6 | import { renderSidebar } from './sidebar/sidebar'; 7 | import { renderBiggerFonts } from './biggerFonts'; 8 | import { renderFeed } from './feed/feed'; 9 | import { notify } from './toaster'; 10 | import { renderComments } from './comments/comments'; 11 | import { renderScrollToTop } from './scrollToTop'; 12 | import { renderUserPage } from './users/userPage'; 13 | import { clearHiddenContentButton } from './filters/hiddenContent'; 14 | import { closeAllWindows } from '../utils/window'; 15 | import { renderRightSidebar } from './rightSidebar'; 16 | 17 | export async function renderApp() { 18 | css.addStyle(style, `app`); 19 | 20 | const app = await dynamicElement(() => document.body.querySelector(`shreddit-app`)?.querySelector(`.grid-container`)); 21 | 22 | if (checkIsRendered(app)) return; 23 | 24 | clearHiddenContentButton(); 25 | 26 | closeAllWindows(); 27 | 28 | if (window.location.href.includes(`/user/`) && !window.location.href.includes(`/m/`)) { 29 | renderUserPage(document.body); 30 | } else { 31 | renderFeed(document.body); 32 | } 33 | 34 | renderComments(document.body); 35 | 36 | const leftSidebar = await dynamicElement(() => document.body.querySelector(`#left-sidebar-container`), 3000); 37 | 38 | renderSidebar(leftSidebar); 39 | 40 | const pageContainer = leftSidebar.parentElement; 41 | pageContainer.classList.add(`pp_pageContainer`); 42 | 43 | const mainFeed = pageContainer.querySelector(`.subgrid-container`); 44 | mainFeed.classList.add(`pp_mainFeed`); 45 | 46 | const rightSidebar = await dynamicElement(() => document.body.querySelector(`#right-sidebar-container`)); 47 | renderRightSidebar(rightSidebar); 48 | 49 | renderWideMode(pageContainer, rightSidebar); 50 | 51 | renderBiggerFonts(); 52 | } 53 | -------------------------------------------------------------------------------- /src/modules/filters/filtersWindow.less: -------------------------------------------------------------------------------- 1 | .pp_filter_list { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 0px; 5 | margin: 20px 40px; 6 | gap: 0.5rem; 7 | list-style: none; 8 | } 9 | 10 | .pp_filter_element { 11 | display: flex; 12 | flex-direction: row; 13 | justify-content: space-between; 14 | align-items: center; 15 | height: 3.5rem; 16 | 17 | border: solid 2px; 18 | border-radius: 15px; 19 | 20 | box-shadow: var(--filterShadowColor) 0px 2px 4px 0px; 21 | } 22 | 23 | :root { 24 | --filterShadowColor: #d9d9d9; 25 | } 26 | :root.theme-dark { 27 | --filterShadowColor: #5d5d5d !important; 28 | } 29 | 30 | .pp_filter_dragged { 31 | opacity: 0.25; 32 | } 33 | 34 | .pp_filter_element > div { 35 | width: 100%; 36 | padding: 0px 12px; 37 | display: flex; 38 | justify-content: space-between; 39 | align-items: center; 40 | gap: 12px; 41 | } 42 | 43 | .pp_filter_element_dragAnchor { 44 | cursor: grab; 45 | display: flex; 46 | justify-content: center; 47 | align-items: center; 48 | min-width: 48px; 49 | height: 40px; 50 | color: #8a8f91; 51 | } 52 | 53 | .pp_window_elementsContainer > .pp_filter_element:hover { 54 | background-color: var(--color-neutral-background-hover); 55 | } 56 | 57 | .pp_filter_element_colorPicker { 58 | cursor: pointer; 59 | width: 36px; 60 | height: 40px; 61 | margin: 2px; 62 | } 63 | 64 | .pp_filter_element_colorPicker > input { 65 | height: 100%; 66 | } 67 | 68 | .pp_filter_element_toggles { 69 | display: flex; 70 | flex-direction: column; 71 | gap: 2px; 72 | } 73 | 74 | .pp_filter_element_toggles > div { 75 | display: flex; 76 | flex-direction: row; 77 | justify-content: space-between; 78 | align-items: center; 79 | gap: 8px; 80 | } 81 | 82 | .pp_filter_element_toggles > div > span { 83 | text-wrap-mode: nowrap; 84 | } 85 | 86 | .pp_filter_addButton { 87 | //border: solid 2px red; 88 | //background-color: red; 89 | 90 | height: 3rem; 91 | border-radius: 15px; 92 | } 93 | -------------------------------------------------------------------------------- /src/modules/users/userOperations.ts: -------------------------------------------------------------------------------- 1 | import { getCookie } from '../../utils/tools'; 2 | import { notify } from '../toaster'; 3 | import { users } from './users'; 4 | 5 | class UserOperation { 6 | key: string; 7 | enable: string; 8 | disable: string; 9 | getInput(state: boolean, accountId: string) {} 10 | 11 | run(state: boolean, userId: string) { 12 | let userData = users.get(userId); 13 | 14 | const body = { 15 | csrf_token: getCookie(`csrf_token`), 16 | operation: this.key, 17 | variables: { 18 | input: this.getInput(state, userData.accountId) 19 | } 20 | }; 21 | 22 | fetch(`https://www.reddit.com/svc/shreddit/graphql`, { 23 | method: `post`, 24 | headers: new Headers({ 25 | Accept: `application/json`, 26 | 'Content-Type': `application/json` 27 | }), 28 | body: JSON.stringify(body) 29 | }) 30 | .then(r => r.json()) 31 | .then(result => { 32 | if (result != null && result.errors?.message) { 33 | notify(result.errors.message); 34 | } 35 | }); 36 | } 37 | } 38 | 39 | class FollowOperation extends UserOperation { 40 | key: string = `UpdateProfileFollowState`; 41 | enable: string = `FOLLOWED`; 42 | disable: string = `NONE`; 43 | 44 | getInput(state: boolean, accountId: string) { 45 | return { 46 | accountId: accountId, 47 | state: state ? this.enable : this.disable 48 | }; 49 | } 50 | } 51 | 52 | class BlockOperation extends UserOperation { 53 | key: string = `UpdateRedditorBlockState`; 54 | enable: string = `BLOCKED`; 55 | disable: string = `NONE`; 56 | 57 | getInput(state: boolean, accountId: string) { 58 | return { 59 | redditorId: accountId, 60 | blockState: state ? this.enable : this.disable 61 | }; 62 | } 63 | } 64 | 65 | export const FOLLOW_OPERATION = new FollowOperation(); 66 | export const BLOCK_OPERATION = new BlockOperation(); 67 | -------------------------------------------------------------------------------- /src/modules/comments/moreReplies.ts: -------------------------------------------------------------------------------- 1 | import { PROFILE_USER_DATA, profiler_comments } from '../../_debug/debug'; 2 | import { checkIsRendered } from '../../utils/tools'; 3 | import { settings } from '../settings/settings'; 4 | import { pp_log } from '../toaster'; 5 | 6 | export function renderMoreReplies(comment: Element) { 7 | if (settings.UNWRAP_MORE_REPLIES.isDisabled()) return; 8 | 9 | if (comment.getAttribute(`collapsed`) != null) return; 10 | 11 | for (const moreReplies of comment.childNodes) { 12 | if (moreReplies instanceof HTMLElement) { 13 | // loadable replies 14 | if (moreReplies.matches(`faceplate-partial`) && moreReplies.getAttribute(`src`)?.includes(`/more-comments/`) && !checkIsRendered(moreReplies)) { 15 | if (DEBUG && PROFILE_USER_DATA) { 16 | profiler_comments.moreRepliesRendered++; 17 | } 18 | 19 | moreReplies.click(); 20 | 21 | let refreshTicks = 0; 22 | const refreshAwaiter = setInterval(() => { 23 | refreshTicks++; 24 | 25 | if (moreReplies.parentNode == null) { 26 | clearInterval(refreshAwaiter); 27 | setTimeout(() => { 28 | renderMoreReplies(comment); 29 | }, 50); 30 | return; 31 | } 32 | if (refreshTicks > 60) { 33 | clearInterval(refreshAwaiter); 34 | pp_log(`Unable load more replies`); 35 | 36 | if (DEBUG && PROFILE_USER_DATA) { 37 | profiler_comments.moreRepliesFailed++; 38 | } 39 | } 40 | }, 100); 41 | } 42 | 43 | // redirectable replies 44 | if (moreReplies.matches(`a`) && moreReplies.getAttribute(`slot`) == `more-comments-permalink`) { 45 | moreReplies.querySelector(`.text-secondary-weak`).textContent = `More replies in single thread`; 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/redditAPI.ts: -------------------------------------------------------------------------------- 1 | import { settings } from '../modules/settings/settings'; 2 | import { notify, pp_log } from '../modules/toaster'; 3 | 4 | const tooManyRequestStatus: number = 429; 5 | let tooManyRequestTimeout: number = null; 6 | 7 | export async function requestAPI(api: string): Promise { 8 | try { 9 | if (tooManyRequestTimeout != null) { 10 | if (Date.now() > tooManyRequestTimeout) { 11 | tooManyRequestTimeout = null; 12 | } else { 13 | return { status: tooManyRequestStatus, result: null }; 14 | } 15 | } 16 | 17 | const headers = new Headers({ 18 | Accept: 'text/vnd.reddit.partial+html, text/html;q=0.9', 19 | 'Content-Type': 'application/x-www-form-urlencoded' 20 | }); 21 | 22 | const url = new URL(`https://www.reddit.com${api}`); 23 | 24 | const appName = settings.API_APP.get(); 25 | 26 | if (appName != null && appName.length > 0) { 27 | url.search = new URLSearchParams({ app: appName }).toString(); 28 | } 29 | 30 | const response = await fetch(url, { credentials: 'include', method: `get`, headers: headers }); 31 | 32 | if (!response.ok) { 33 | pp_log(`${api} request failed with code ${response.status} : ${response.statusText}`); 34 | 35 | if (response.status == tooManyRequestStatus) { 36 | const resetSeconds = parseInt(response.headers.get(`x-ratelimit-reset`)); 37 | tooManyRequestTimeout = Date.now() + resetSeconds * 1000 + 500; 38 | 39 | if (settings.API_WARNINGS.isEnabled()) { 40 | notify(`API request hit a limit. Disable "API requests" features or set correct App name or wait ${resetSeconds} seconds`, { seconds: 15 }); 41 | } 42 | } 43 | 44 | return { status: response.status, result: null }; 45 | } 46 | 47 | const json = await response.json(); 48 | 49 | return { status: response.status, result: json }; 50 | } catch (e) { 51 | pp_log(`${api} request failed with error: ${e}`); 52 | 53 | return { status: `error`, result: null }; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/modules/collapseAwards.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, MAX_LOAD_LAG } from '../defines'; 2 | import { dynamicElement } from '../utils/tools'; 3 | import style from './collapseAwards.less'; 4 | import { AwardsMode } from './collapseAwardsMode'; 5 | import { css } from './customCSS'; 6 | import { settings } from './settings/settings'; 7 | 8 | css.addStyle(style); 9 | 10 | export async function renderCollapseAward(target: Element, contentType: ContentType) { 11 | const mode = settings.COLLAPSE_AWARDS.get() as AwardsMode; 12 | 13 | if (mode == AwardsMode.Default) return; 14 | 15 | css.addStyle(style, `collapseAwards`); 16 | 17 | const awardButton = contentType == ContentType.Comment ? target.querySelector(`award-button`) : target.shadowRoot.querySelector(`award-button`); 18 | 19 | if (awardButton == null) return; 20 | 21 | if (mode == AwardsMode.RemoveCompletely) { 22 | awardButton.remove(); 23 | return; 24 | } 25 | 26 | if (awardButton.getAttribute(`count`) == `0`) { 27 | if (contentType == ContentType.Post) { 28 | css.registry(target.shadowRoot); 29 | } 30 | 31 | const targetContainer = contentType == ContentType.Comment ? target.querySelector(`shreddit-comment-action-row`)?.shadowRoot : target?.shadowRoot; 32 | const upVoteButton = await dynamicElement(() => targetContainer?.querySelector(`button[upvote]`), MAX_LOAD_LAG); 33 | 34 | if (upVoteButton == null) return; 35 | 36 | awardButton.classList.toggle(`pp_awardButton_hidden`, isCollapsed(upVoteButton)); 37 | awardButton.classList.toggle(`pp_awardButton_collapsed`, isCollapsed(upVoteButton)); 38 | setTimeout(() => { 39 | awardButton.classList.add(`pp_awardButton`); 40 | }, 500); 41 | 42 | upVoteButton.addEventListener(`click`, () => { 43 | awardButton.classList.toggle(`pp_awardButton_hidden`, isCollapsed(upVoteButton)); 44 | 45 | setTimeout(() => { 46 | awardButton.classList.toggle(`pp_awardButton_collapsed`, isCollapsed(upVoteButton)); 47 | }, 10); 48 | }); 49 | } 50 | } 51 | 52 | function isCollapsed(upVoteButton: Element): boolean { 53 | return upVoteButton.getAttribute(`aria-pressed`) != `true`; 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/changesObserver.ts: -------------------------------------------------------------------------------- 1 | import { css } from '../modules/customCSS'; 2 | import { appendElement } from './element'; 3 | 4 | import style from './changesObserver.less'; 5 | 6 | css.addStyle(style); 7 | 8 | export class ChangesObserver { 9 | private changes = 0; 10 | private bannerContainer: Element = null; 11 | 12 | CreateSource(defaultValue: any): ChangesSource { 13 | return new PlainChangesSource(this, defaultValue); 14 | } 15 | 16 | Reset() { 17 | this.changes = 0; 18 | } 19 | 20 | HasChanges() { 21 | return this.changes != 0; 22 | } 23 | 24 | OnChange(value: number) { 25 | this.changes += value; 26 | 27 | if (this.bannerContainer != null) { 28 | this.bannerContainer.classList.toggle(`pp_changesBanner_active`, this.HasChanges()); 29 | } 30 | } 31 | 32 | RenderBanner(container: Element) { 33 | if (this.bannerContainer == null) { 34 | this.bannerContainer = appendElement(container, `div`, `pp_changesBannerContainer`); 35 | const banner = appendElement(this.bannerContainer, `div`, `pp_changesBanner`); 36 | banner.textContent = `Page will be reloaded to apply new settings`; 37 | } else { 38 | container.append(this.bannerContainer); 39 | this.bannerContainer.classList.toggle(`pp_changesBanner_active`, false); 40 | } 41 | } 42 | } 43 | 44 | export abstract class ChangesSource { 45 | observer: ChangesObserver; 46 | 47 | constructor(observer: ChangesObserver) { 48 | this.observer = observer; 49 | } 50 | 51 | abstract Capture(value: any): void; 52 | } 53 | 54 | export class PlainChangesSource extends ChangesSource { 55 | defaultValue: any; 56 | isChanged: boolean = false; 57 | 58 | constructor(observer: ChangesObserver, defaultValue: any) { 59 | super(observer); 60 | this.defaultValue = defaultValue; 61 | } 62 | 63 | Capture(value: any): void { 64 | if (this.defaultValue != value && !this.isChanged) { 65 | this.isChanged = true; 66 | this.observer.OnChange(1); 67 | } 68 | 69 | if (this.defaultValue == value && this.isChanged) { 70 | this.isChanged = false; 71 | this.observer.OnChange(-1); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/modules/wideMode.ts: -------------------------------------------------------------------------------- 1 | import wideModeStyle from './wideMode.less'; 2 | import { css } from './customCSS'; 3 | import { settings } from './settings/settings'; 4 | import { observeFor } from '../utils/tools'; 5 | import { notify } from './toaster'; 6 | import { renderRightSidebar } from './rightSidebar'; 7 | 8 | function safePixels(value: string): string { 9 | return `${parseInt(value)}px`; 10 | } 11 | 12 | export function renderWideMode(pageContainer: Element, rightSidebar: Element) { 13 | if (settings.WIDE_MODE.isDisabled()) return; 14 | 15 | css.addStyle(wideModeStyle, `wideMode`); 16 | 17 | css.addVar(`--pp-content-width`, safePixels(settings.CONTENT_WIDTH.get())); 18 | 19 | css.addVar(`--pp-content-offset`, safePixels(settings.CONTENT_OFFSET.get())); 20 | 21 | // prevent additional render when rightbar already moved 22 | if (rightSidebar.parentNode == pageContainer) { 23 | return; 24 | } 25 | 26 | renderRightSidebar(rightSidebar); 27 | 28 | const originContainer = rightSidebar.parentElement; 29 | 30 | let isWideMode = !(window.innerWidth >= 1392); 31 | 32 | const mainContainer = pageContainer.querySelector(`.main-container`); 33 | mainContainer.className = `main-container gap-lg w-full`; 34 | 35 | // fix for context lookup 36 | observeFor(`WIDEMODE_PAGE`, pageContainer, renderContextPopup, false); 37 | observeFor(`WIDEMODE_CONTEXT`, originContainer, renderContextPopup, false); 38 | 39 | function renderContextPopup(element: HTMLElement): boolean { 40 | if (element.classList.contains(`rounded-[16px]`)) { 41 | element.classList.add(`pp_rightSidebar_contextLookup`); 42 | 43 | if (window.innerWidth < 1392 && element.parentNode != rightSidebar.parentNode) { 44 | rightSidebar.after(element); 45 | } 46 | } 47 | 48 | return false; 49 | } 50 | 51 | refreshAppRender(); 52 | window.addEventListener('resize', event => { 53 | refreshAppRender(); 54 | }); 55 | 56 | function refreshAppRender() { 57 | if (window.innerWidth >= 1392 && !isWideMode) { 58 | pageContainer.prepend(rightSidebar); 59 | 60 | isWideMode = true; 61 | } 62 | 63 | if (window.innerWidth < 1392 && isWideMode) { 64 | originContainer.append(rightSidebar); 65 | isWideMode = false; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/modules/filters/filters.less: -------------------------------------------------------------------------------- 1 | .pp_hidden_comment > [slot='commentAvatar'] { 2 | display: none; 3 | } 4 | 5 | .pp_hidden_comment > [slot='commentMeta'] { 6 | display: none; 7 | } 8 | 9 | .pp_hidden_comment > [slot='comment'] { 10 | display: none; 11 | } 12 | 13 | .pp_hidden_comment > [slot='actionRow'] { 14 | display: none; 15 | } 16 | 17 | .pp_hidden_button { 18 | position: relative; 19 | left: -32px; 20 | display: flex; 21 | align-items: center; 22 | padding: 0px 16px; 23 | width: fit-content; 24 | gap: 8px; 25 | background-color: var(--color-neutral-background); 26 | } 27 | 28 | .pp_blured_content { 29 | filter: opacity(50%) saturate(50%) blur(6px); 30 | max-height: 40px !important; 31 | user-select: none; 32 | cursor: pointer; 33 | overflow-y: hidden; 34 | } 35 | 36 | .pp_blured_content_area { 37 | position: absolute; 38 | content: ''; 39 | top: 0; 40 | right: 0; 41 | bottom: 0; 42 | left: 0; 43 | opacity: 0; 44 | z-index: 1; 45 | } 46 | 47 | .pp_blured_content:hover { 48 | filter: opacity(75%) saturate(75%) blur(4px); 49 | } 50 | 51 | .pp_blured_content_animator { 52 | max-height: 9999px; 53 | transition: 54 | max-height 1s ease-in, 55 | filter 0.2s ease; 56 | } 57 | 58 | .pp_blured_button_container { 59 | display: flex; 60 | justify-content: center; 61 | max-height: 0px; 62 | } 63 | 64 | .pp_blured_button { 65 | //background-color: runtime 66 | border-radius: 8px; 67 | z-index: 1; 68 | pointer-events: none; 69 | position: relative; 70 | top: 6px; 71 | height: 100%; 72 | max-width: 75%; 73 | box-shadow: 0px 0px 0px 2px #ffffff61; 74 | } 75 | 76 | .pp_blured_button_content { 77 | display: flex; 78 | align-items: center; 79 | padding: 0px 16px; 80 | gap: 6px; 81 | color: #ffffff; 82 | filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.5)); 83 | } 84 | 85 | .pp_blured_button_content > svg { 86 | min-width: 16px; 87 | } 88 | 89 | .pp_blured_button_content > span { 90 | display: block; 91 | overflow: hidden; 92 | padding: 6px; 93 | width: 100%; 94 | height: 100%; 95 | white-space: nowrap; 96 | font-weight: 500; 97 | text-overflow: ellipsis; 98 | //text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.582); 99 | } 100 | -------------------------------------------------------------------------------- /src/modules/sidebar/sidebarSectionRenderer.ts: -------------------------------------------------------------------------------- 1 | import { checkIsRendered } from '../../utils/tools'; 2 | import { SettingBoolProperty } from '../settings/settings'; 3 | 4 | export class SidebarSectionElements { 5 | public container: Element; 6 | public button: Element; 7 | public bottomLine: Element; 8 | } 9 | 10 | export abstract class SidebarSectionRenderer { 11 | abstract FindContainer(sidebar: HTMLElement, element: HTMLElement): HTMLElement; 12 | 13 | abstract GetSectionElements(container: HTMLElement): Promise; 14 | 15 | async Render(container: HTMLElement, autocollapse: boolean, setting: SettingBoolProperty) { 16 | if (checkIsRendered(container)) return; 17 | 18 | container.classList.add(`pp_sidebar_loadingSection`); 19 | 20 | const section: SidebarSectionElements = await this.GetSectionElements(container); 21 | 22 | container.classList.remove(`pp_sidebar_loadingSection`); 23 | 24 | if (setting.isEnabled()) { 25 | if (autocollapse) { 26 | const settingCollapsed = setting.getChild(`Collapsed`, false); 27 | 28 | const details = section.container.querySelector(`details`); 29 | 30 | if (settingCollapsed.isEnabled()) { 31 | section.container.toggleAttribute(`open`, false); 32 | details.classList.add(`pp_sidebar_collapsedSection`); 33 | } 34 | 35 | section.button.addEventListener(`click`, (e: MouseEvent) => { 36 | const button = e.currentTarget as Element; 37 | // hack because event may be called before aria-expanded was changed 38 | setTimeout(() => { 39 | const isCollapsed = button.getAttribute(`aria-expanded`) === 'false'; 40 | 41 | settingCollapsed.switch(isCollapsed); 42 | }, 10); 43 | 44 | details.classList.toggle(`pp_sidebar_collapsedSection`, false); 45 | }); 46 | } 47 | } else { 48 | section.container.remove(); 49 | section.bottomLine?.remove(); 50 | } 51 | } 52 | 53 | FindBottomLine(container: Element): Element { 54 | let bottomLine = container.nextElementSibling; 55 | 56 | while (bottomLine != null && !bottomLine.matches(`hr`)) { 57 | bottomLine = bottomLine.nextElementSibling; 58 | } 59 | 60 | return bottomLine; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/modules/subs/flairWindow.ts: -------------------------------------------------------------------------------- 1 | import { appendElement } from '../../utils/element'; 2 | import { renderUIOptions } from '../../utils/UI/options'; 3 | import { renderUIToggle } from '../../utils/UI/toggle'; 4 | import { Window } from '../../utils/window'; 5 | import { css } from '../customCSS'; 6 | import { getFlairData, renderFlair, setFlairData } from './flair'; 7 | import { renderFlairBar } from './flairBar'; 8 | import style from './flairWindow.less'; 9 | import { FLAIR_BANNED, FLAIR_BLURED, FLAIR_HIDDEN, subs } from './subs'; 10 | 11 | css.addStyle(style); 12 | 13 | export const flairsWindow: Window = new Window('Flairs settings', renderFlairsWindow, closeFlairsWindow); 14 | 15 | class FlairWindowContext { 16 | sub: string; 17 | } 18 | 19 | const visabilityOptions = [`Show`, `Blur`, `Hide`]; 20 | 21 | function renderFlairsWindow(win: Window, context: FlairWindowContext) { 22 | const scroll = appendElement(win.content, `div`, [`pp_window_scrollContent`, `styled-scrollbars`]); 23 | 24 | const elements = appendElement(scroll, `div`, `pp_window_elementsContainer`); 25 | 26 | const subData = subs.get(context.sub); 27 | for (const flair of subData.flairs) { 28 | const panel = appendElement(elements, `div`, [`pp_window_element`, `pp_flairWindow_flair`]); 29 | 30 | const flairContainer = appendElement(panel, `div`, `pp_flairWindow_flairContainer`); 31 | renderFlair(flairContainer, context.sub, flair); 32 | 33 | // flair bar toggle 34 | const onBarSpan = appendElement(panel, `span`); 35 | onBarSpan.textContent = `Flairs bar:`; 36 | 37 | renderUIToggle(panel, !getFlairData(context.sub, flair.text, FLAIR_HIDDEN), state => { 38 | setFlairData(context.sub, flair.text, FLAIR_HIDDEN, !state); 39 | }); 40 | 41 | const feedSpan = appendElement(panel, `span`); 42 | feedSpan.textContent = `Feed:`; 43 | 44 | // feed visability options 45 | const isBlured = getFlairData(context.sub, flair.text, FLAIR_BLURED); 46 | const isBanned = getFlairData(context.sub, flair.text, FLAIR_BANNED); 47 | 48 | const visability = isBanned ? 2 : isBlured ? 1 : 0; 49 | 50 | renderUIOptions(panel, visability, visabilityOptions, index => { 51 | setFlairData(context.sub, flair.text, FLAIR_BLURED, false); 52 | setFlairData(context.sub, flair.text, FLAIR_BANNED, false); 53 | 54 | if (index == 1) { 55 | setFlairData(context.sub, flair.text, FLAIR_BLURED, true); 56 | } 57 | 58 | if (index == 2) { 59 | setFlairData(context.sub, flair.text, FLAIR_BANNED, true); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function closeFlairsWindow() { 66 | const main = document.body.querySelector(`#main-content`); 67 | 68 | renderFlairBar(main); 69 | } 70 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | import { latestMigration } from './_compatibility/latestMigration'; 2 | import { observeFor } from './utils/tools'; 3 | import { renderApp } from './modules/app'; 4 | import { renderHeader } from './modules/header'; 5 | import { checkRedirect } from './modules/redirect'; 6 | import { notify, pp_log } from './modules/toaster'; 7 | import { MAX_LOAD_LAG } from './defines'; 8 | import { dynamicElement } from './utils/tools'; 9 | import { checkSortCommentsRedirect } from './modules/comments/sortButtons'; 10 | import { initializeFeedRedirect } from './modules/feed/feedRedirect'; 11 | import { renderScrollToTop } from './modules/scrollToTop'; 12 | 13 | // *********************************************************************************************************************** 14 | // ********************************************** ENTRY POINT ************************************************************ 15 | // *********************************************************************************************************************** 16 | 17 | startRedditPlusPlus(); 18 | 19 | async function startRedditPlusPlus() { 20 | const documentBody = await dynamicElement(() => (document.head != null && document.body != null ? document.body : null)); 21 | 22 | // check dublicates 23 | let pp_meta = document.head.querySelector(`meta[name="reddit-plus-plus"]`); 24 | if (pp_meta != null) { 25 | notify(`Reddit++ ran more than once. Check out the userscript manager to disable dublicates.`, { seconds: 10 }); 26 | return; 27 | } 28 | 29 | pp_meta = document.createElement(`meta`); 30 | pp_meta.setAttribute(`name`, `reddit-plus-plus`); 31 | pp_meta.setAttribute(`version`, VERSION); 32 | document.head.append(pp_meta); 33 | 34 | latestMigration.check(); 35 | 36 | if (checkRedirect()) { 37 | return; 38 | } 39 | 40 | initializeFeedRedirect(); 41 | 42 | const pp_app = await dynamicElement(() => documentBody.querySelector(`shreddit-app`), MAX_LOAD_LAG); 43 | if (pp_app == null || pp_app.getAttribute(`devicetype`) != `desktop`) { 44 | pp_log(`Reddit++ was stopped for a non compatible page`); 45 | return; 46 | } 47 | 48 | renderHeader(documentBody); 49 | 50 | renderApp(); 51 | renderScrollToTop(); 52 | 53 | observeFor(`CORE`, documentBody, element => { 54 | // header 55 | if (element.matches(`reddit-header-large`) == true) { 56 | renderHeader(element.parentElement); 57 | } 58 | 59 | // content 60 | const isSubPage = element.matches(`shreddit-app`) == true; 61 | const isMainPage = element.classList.contains(`grid-container`) && element.parentElement.matches(`shreddit-app`) == true; 62 | 63 | if (isSubPage || isMainPage) { 64 | renderApp(); 65 | renderScrollToTop(); 66 | checkSortCommentsRedirect(); 67 | } 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /src/modules/sidebar/sidebar.ts: -------------------------------------------------------------------------------- 1 | import { appendElement, prependElement } from '../../utils/element'; 2 | import { appendSvg } from '../../utils/svg'; 3 | import { dynamicElement, observeFor } from '../../utils/tools'; 4 | import { css } from '../customCSS'; 5 | import settingGearSvg from '@resources/settingsGear.svg'; 6 | import style from './sidebar.less'; 7 | import { sidebarSettingsWindow } from './sidebarSettingsWindow'; 8 | import { sections, SidebarSection, SidebarSectionConfig } from './sidebarSection'; 9 | import { RenderSidebarNavigations } from './sidebarNavigation'; 10 | 11 | css.addStyle(style); 12 | 13 | export async function renderSidebar(sidebar: Element) { 14 | sidebar.classList.add(`pp_defaultText`); 15 | 16 | RenderSidebarNavigations(sidebar); 17 | 18 | RenderSettingsButton(sidebar); 19 | 20 | // render sections 21 | const renderedSections = new Map(sections); 22 | 23 | observeFor(`SIDEBAR`, sidebar, (element: HTMLElement) => { 24 | renderedSections.forEach((config, section, map) => { 25 | const sectionContainer = config.renderer.FindContainer(sidebar as HTMLElement, element); 26 | 27 | if (sectionContainer != null) { 28 | config.renderer.Render(sectionContainer, config.autocollapse, config.setting); 29 | 30 | map.delete(section); 31 | } 32 | }); 33 | 34 | if (renderedSections.size == 0) { 35 | return true; 36 | } 37 | }); 38 | } 39 | 40 | async function RenderSettingsButton(sidebar: Element) { 41 | const flexSidebar = await dynamicElement(() => sidebar.querySelector(`#flex-left-nav-container`)); 42 | 43 | const settingsButtonContainer = prependElement(flexSidebar, `div`); 44 | settingsButtonContainer.setAttribute(`id`, `pp-settings`); 45 | 46 | const settingsButtonTooltip = appendElement(settingsButtonContainer, `rpl-tooltip`); 47 | settingsButtonTooltip.setAttribute(`placement`, `right`); 48 | settingsButtonTooltip.setAttribute(`content`, `Reddit++ sidebar settings`); 49 | settingsButtonTooltip.setAttribute(`appearance`, `inverted`); 50 | settingsButtonTooltip.style.cssText = `--show-delay: 750ms; --hide-delay: 50ms`; 51 | 52 | const settingsButton = appendElement(settingsButtonTooltip, `button`); 53 | settingsButton.className = `bg-neutral-background shadow-xs 54 | button-small px-[var(--rem6)] 55 | button-bordered 56 | icon 57 | items-center justify-center 58 | button inline-flex `; 59 | 60 | settingsButton.addEventListener(`click`, () => sidebarSettingsWindow.open()); 61 | 62 | const settingsButtonSpan = appendElement(settingsButton, `span`, [`flex`, `items-center`, `justify-center`]); 63 | const settingsButtonSpanSpan = appendElement(settingsButtonSpan, `span`, `flex`); 64 | const settingsButtonSvg = appendSvg(settingsButtonSpanSpan, settingGearSvg, 16, 16); 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/UI/options.ts: -------------------------------------------------------------------------------- 1 | import { css } from '../../modules/customCSS'; 2 | import { buildSvg } from '../svg'; 3 | import { appendElement } from '../element'; 4 | import arrowSvg from '@resources/settingsArrow.svg'; 5 | 6 | import style from './options.less'; 7 | import { notify } from '../../modules/toaster'; 8 | 9 | css.addStyle(style); 10 | 11 | export function renderUIOptions(container: Element, index: number, values: Array, onChange: (index: number) => void): HTMLElement { 12 | let currentIndex = index; 13 | 14 | const options = appendElement(container, `div`, `pp_ui_options`); 15 | 16 | const leftButton = appendElement(options, `div`, [`pp_ui_options_arrow`, `pp_ui_options_inversed`, `button`, `button-plain`, `button-medium`, `px-[var(--rem8)]`]); 17 | const leftButtonSvg = buildSvg(arrowSvg, 20, 20); 18 | leftButton.append(leftButtonSvg); 19 | 20 | const contentContainer = appendElement(options, `div`, `pp_ui_options_container`); 21 | 22 | const content = appendElement(contentContainer, `span`, [`text-secondary`, `font-normal`]); 23 | 24 | let largerValue = values[0]; 25 | let largerLetters = largerValue.length; 26 | for (const val of values) { 27 | if (val.length > largerLetters) { 28 | largerValue = val; 29 | largerLetters = val.length; 30 | } 31 | } 32 | content.textContent = largerValue; 33 | const rect = content.getBoundingClientRect(); 34 | content.style.minWidth = `${rect.width}px`; 35 | 36 | content.textContent = values[index]; 37 | 38 | const dots = appendElement(contentContainer, `span`, [`pp_ui_options_dots`, `text-secondary`, `font-normal`]); 39 | dots.textContent = getDots(); 40 | 41 | const rightButton = appendElement(options, `div`, [`pp_ui_options_arrow`, `button`, `button-plain`, `button-medium`, `px-[var(--rem8)]`]); 42 | const rightButtonSvg = buildSvg(arrowSvg, 20, 20); 43 | rightButton.append(rightButtonSvg); 44 | 45 | leftButton.addEventListener(`click`, e => { 46 | currentIndex--; 47 | 48 | if (currentIndex < 0) { 49 | currentIndex = values.length - 1; 50 | } 51 | 52 | change(); 53 | }); 54 | 55 | rightButton.addEventListener(`click`, e => { 56 | currentIndex++; 57 | 58 | if (currentIndex >= values.length) { 59 | currentIndex = 0; 60 | } 61 | 62 | change(); 63 | }); 64 | 65 | function change() { 66 | content.textContent = values[currentIndex]; 67 | dots.textContent = getDots(); 68 | 69 | onChange(currentIndex); 70 | } 71 | 72 | function getDots() { 73 | let i: number = 0; 74 | let result: string = ``; 75 | 76 | while (i < values.length) { 77 | result += i == currentIndex ? `•` : `◦`; 78 | i++; 79 | } 80 | 81 | return result; 82 | } 83 | 84 | return options; 85 | } 86 | -------------------------------------------------------------------------------- /src/_debug/debug.ts: -------------------------------------------------------------------------------- 1 | import { css } from '../modules/customCSS'; 2 | import { notify } from '../modules/toaster'; 3 | import { dynamicElement } from '../utils/tools'; 4 | import { appendElement } from '../utils/element'; 5 | import style from './debug.less'; 6 | 7 | class DebugProfiler { 8 | box: HTMLElement = null; 9 | stats: Array = []; 10 | 11 | databases: Map = new Map(); 12 | 13 | constructor() { 14 | setTimeout(() => { 15 | if (this.stats.length > 0 && window.location == window.parent.location) { 16 | this.render(); 17 | } 18 | }, 1000); 19 | } 20 | 21 | async render() { 22 | if (!SHOW_PROFILER) return; 23 | 24 | const documentBody = await dynamicElement(() => document.body); 25 | 26 | const container = appendElement(documentBody, `div`, `pp_debug_profilerContainer`); 27 | this.box = appendElement(container, `div`, `pp_debug_profiler`); 28 | 29 | setInterval(() => { 30 | let data: string = `Reddit++ Profiler\r\n`; 31 | for (const stat of this.stats) { 32 | data += `---${stat?.constructor?.name ?? `undefined`}---\r\n`; 33 | 34 | for (const [key, value] of Object.entries(stat)) { 35 | data += `${key}: ${value}\r\n`; 36 | } 37 | } 38 | 39 | if (this.databases.size > 0) { 40 | data += `---Databases---\r\n`; 41 | 42 | for (const [key, value] of this.databases) { 43 | data += `${key}: ${value}\r\n`; 44 | } 45 | } 46 | 47 | this.box.textContent = data; 48 | }, 500); 49 | } 50 | } 51 | 52 | class DynamicElementStat { 53 | dynamicElement: number = 0; 54 | observeFor: number = 0; 55 | } 56 | 57 | class CommentsStat { 58 | observedRoots: number = 0; 59 | observedChilds: number = 0; 60 | moreRepliesRendered: number = 0; 61 | moreRepliesFailed: number = 0; 62 | userDataLoading: number = 0; 63 | userDataFailed: number = 0; 64 | } 65 | 66 | css.addStyle(style); 67 | 68 | if (!DEBUG) { 69 | notify(`DEBUG IN PROD, THAT'S INVALID BEHAVIOUR. USE "DEBUG" KEYWORD TO PREVENT IT`, { seconds: 9999, color: `#ff0000` }); 70 | } 71 | 72 | export const SHOW_RENDERED_POSTS: boolean = false; 73 | export const SHOW_RENDERED_COMMENTS: boolean = false; 74 | 75 | export const SHOW_LOGS: boolean = true; 76 | export const SHOW_PROFILER: boolean = true; 77 | 78 | export const PROFILE_DYNAMIC_ELEMENTS: boolean = true; 79 | export const PROFILE_USER_DATA: boolean = true; 80 | 81 | export const FORCE_MIGRATIONS: boolean = false; 82 | 83 | export const profiler = new DebugProfiler(); 84 | 85 | export const profiler_dynamicElements = new DynamicElementStat(); 86 | export const profiler_comments = new CommentsStat(); 87 | 88 | if (PROFILE_DYNAMIC_ELEMENTS) { 89 | profiler.stats.push(profiler_dynamicElements); 90 | } 91 | 92 | if (PROFILE_USER_DATA) { 93 | profiler.stats.push(profiler_comments); 94 | } 95 | -------------------------------------------------------------------------------- /src/modules/subs/flair.ts: -------------------------------------------------------------------------------- 1 | import { appendElement } from '../../utils/element'; 2 | import { flairs } from './subs'; 3 | 4 | export class FlairData { 5 | text: string; 6 | color: string; 7 | background: string; 8 | richtext: Array; 9 | } 10 | 11 | export class RichElement { 12 | t: string; 13 | e: string; 14 | u: string; 15 | a: string; 16 | } 17 | 18 | export function getFlairData(sub: string, flair: string, category: string): boolean { 19 | const flairsData = flairs.get(sub) as any; 20 | 21 | return flairsData[category]?.includes(flair) ?? false; 22 | } 23 | 24 | export function setFlairData(sub: string, flair: string, category: string, value: boolean) { 25 | const flairData = flairs.get(sub) as any; 26 | 27 | let categoryArray: Array = flairData[category] as Array; 28 | 29 | if (categoryArray == undefined || categoryArray == null) { 30 | categoryArray = []; 31 | } 32 | 33 | if (value) { 34 | categoryArray.push(flair); 35 | } else { 36 | categoryArray = categoryArray.filter(f => f != flair); 37 | } 38 | 39 | flairData[category] = categoryArray; 40 | flairs.set(sub, flairData); 41 | } 42 | 43 | export function renderFlair(conatiner: Element, sub: string, flair: FlairData, minified: boolean = false) { 44 | const a = appendElement(conatiner, `a`, `no-decoration`) as HTMLAnchorElement; 45 | a.href = `/r/` + sub + `/?f=flair_name%3A%22` + flair.text + `%22`; 46 | 47 | const span = appendElement(a, `span`, [ 48 | `bg-tone-4`, 49 | `inline-block`, 50 | `truncate`, 51 | `max-w-full`, 52 | `text-12`, 53 | `font-normal`, 54 | `box-border`, 55 | `px-[6px]`, 56 | `pp_flair`, 57 | `leading-4`, 58 | `max-w-full`, 59 | `py-xs`, 60 | `!px-sm`, 61 | `leading-4`, 62 | `h-xl`, 63 | `inline-flex` 64 | ]); 65 | 66 | if (minified) { 67 | span.className = `bg-tone-4 inline-block truncate max-w-full text-12 font-normal align-text-bottom box-border px-[6px] rounded-[20px] leading-4 relative top-[-0.25rem] xs:top-[-2px] my-2xs xs:mb-sm py-0 `; 68 | } 69 | 70 | span.classList.add(flair.color == `light` ? `text-global-white` : `text-global-black`); 71 | span.style.backgroundColor = flair.background; 72 | 73 | for (const richElement of flair.richtext) { 74 | if (richElement.e == `text`) { 75 | const content = document.createTextNode(richElement.t); 76 | span.appendChild(content); 77 | } 78 | if (richElement.e == `emoji`) { 79 | const fimg = document.createElement(`faceplate-img`); 80 | fimg.classList.add(`flair-image`); 81 | fimg.setAttribute(`loading`, `lazy`); 82 | fimg.setAttribute(`width`, `16`); 83 | fimg.setAttribute(`height`, `16`); 84 | fimg.setAttribute(`src`, richElement.u); 85 | fimg.setAttribute(`alt`, richElement.a); 86 | span.appendChild(fimg); 87 | } 88 | } 89 | 90 | if (flair.richtext.length == 0) { 91 | const content = document.createTextNode(flair.text); 92 | span.appendChild(content); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /public/descriptions/RedditPlusPlus.description.txt: -------------------------------------------------------------------------------- 1 |
2 | Reddit's updates may break the userscript so feel free to report bugs. 3 | 4 | The userscript has a lot of boilerplate code because webpack is used. To see source code visit github 5 |

Compatibility

6 |
    7 |
  • Script tested on chrome/firefox with tampermonkey/violentmonkey. Ability to work on other configurations are not guaranteed.
  • 8 |
  • Mobile version are not supported.
  • 9 |
  • Old version are not supported.
  • 10 |
  • Community themes are not supported.
  • 11 |
12 | 13 |

Features

14 |
    15 |

    Common

    16 |
  • The right sidebar moved to align the feed at center.wide mode
  • 17 |
  • Make comments/posts text bigger.
  • 18 |
  • Filter comments/posts by keyword or regular expressions. Posts may also be filtered by flairs.
  • 19 |
  • Image viewer (zoom instead open in new tab/redirect)
  • 20 |
  • Saved bookmark (unwrap from context menu).bookmarks
  • 21 |
  • Scroll to top button
  • 22 |
  • Autocollapse awards
  • 23 |
  • Redirect from old.reddit links to modern reddit.
  • 24 | 25 |

    Left sidebar

    26 |
  • Collapsed state of categories is now savable.
  • 27 |
  • Hide specific categories (like Recent) via settings.
  • 28 |
  • Community filter.
  • 29 | 30 |

    Subreddits

    31 |
  • Unwrap feed buttons (Hot/New/Top)feedButtons
  • 32 |
  • Override default feeds (globally or for individual subreddits).
  • 33 |
  • Flairs bar for faster navigation.
  • 34 | 35 |

    Posts

    36 |
  • Gradient background.
  • 37 |
  • Selectable text.
  • 38 |
  • Unwrap text posts by button.post
  • 39 | 40 |

    Comments

    41 |
  • Unwrap comments sort buttons, remember the latest used sort.commentsSort
  • 42 |
  • User info (karma, leaf mark for new users, replacing profile names with nicknames).
  • 43 |
  • User tags (cosmetic: liked, warned; active: follow, block).userTags
  • 44 |
  • Autocollapse AutoModerator's comments.
  • 45 |
  • Unwrap "more replies".
  • 46 |
47 | 48 |

Settings

49 |
    50 |
  • Features settings:
  • 51 | Features setting 52 |
  • Flairs settings (per subreddit):
  • 53 | Flairs settings 54 |
-------------------------------------------------------------------------------- /src/modules/sidebar/subFilter.ts: -------------------------------------------------------------------------------- 1 | import { checkIsRendered, dynamicElement } from '../../utils/tools'; 2 | import { appendElement } from '../../utils/element'; 3 | import { PrefsKey, prefs } from '../settings/prefs'; 4 | 5 | import subFilterSvg from '@resources/subFilter.svg'; 6 | import subsManagerSvg from '@resources/sidebarSubsManager.svg'; 7 | import { notify } from '../toaster'; 8 | import { InputParams, renderUIInput } from '../../utils/UI/input'; 9 | import { appendSvg, CURRENT_COLOR, NONE_COLOR } from '../../utils/svg'; 10 | import { settings } from '../settings/settings'; 11 | 12 | let filter: Map = null; 13 | 14 | export async function renderSubFilter(container: Element) { 15 | if (checkIsRendered(container, `pp-sub-filter`)) return; 16 | 17 | if (settings.SUB_FILTER.isDisabled()) return; 18 | 19 | const manageSubsLink = (await dynamicElement(() => container.querySelector(`.left-nav-manage-communities-link`))) as HTMLElement; 20 | 21 | // filter database 22 | if (filter != null) { 23 | filter.clear(); 24 | } else { 25 | filter = new Map(); 26 | } 27 | 28 | const subsContainer = await dynamicElement(() => container.querySelector(`left-nav-communities-controller`)?.shadowRoot?.querySelector(`.items-container`)); 29 | 30 | subsContainer.querySelectorAll(`left-nav-community-item`).forEach(sub => { 31 | filter.set(sub.getAttribute(`prefixedname`).replace(`r/`, ``).toLowerCase(), sub as HTMLElement); 32 | }); 33 | 34 | // filter value 35 | let initValue = prefs.get(PrefsKey.SUB_FILTER); 36 | if (initValue == null || initValue instanceof Object) { 37 | initValue = ``; 38 | } 39 | 40 | const input = renderUIInput( 41 | container, 42 | `Filter`, 43 | initValue, 44 | value => { 45 | onChangeFilter(value); 46 | }, 47 | { 48 | icon: subFilterSvg, 49 | iconConfig: { strokeColor: NONE_COLOR, fillColor: CURRENT_COLOR }, 50 | cleanButton: true, 51 | filter: (input: string) => input.trim() 52 | } 53 | ); 54 | 55 | // minify the create sub button 56 | 57 | manageSubsLink.style.width = `65px`; 58 | manageSubsLink.style.paddingRight = `10px`; 59 | const createSubText = await dynamicElement(() => manageSubsLink.querySelector(`.text-14`)); 60 | createSubText.remove(); 61 | manageSubsLink.replaceWith(input); 62 | input.prepend(manageSubsLink); 63 | 64 | const manageSubsLinkIco = manageSubsLink.querySelector(`svg`); 65 | const manageSubsLinkIcoContainer = manageSubsLink.querySelector(`svg`).parentElement; 66 | manageSubsLinkIco.remove(); 67 | 68 | appendSvg(manageSubsLinkIcoContainer, subsManagerSvg, 20, 20); 69 | 70 | const inputButton = input.querySelector(`.pp_ui_input_button`); 71 | inputButton.addEventListener(`focus`, () => { 72 | manageSubsLink.style.display = `none`; 73 | }); 74 | inputButton.addEventListener(`focusout`, () => { 75 | manageSubsLink.style.display = `block`; 76 | }); 77 | 78 | // init 79 | 80 | onChangeFilter(initValue); 81 | } 82 | 83 | function onChangeFilter(value: string) { 84 | prefs.set(PrefsKey.SUB_FILTER, value); 85 | 86 | filter.forEach((item, sub) => { 87 | if (sub.includes(value.toLowerCase())) { 88 | item.style.removeProperty(`display`); 89 | } else { 90 | item.style.display = `none`; 91 | } 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /src/modules/comments/userTags.ts: -------------------------------------------------------------------------------- 1 | import { MAX_LOAD_LAG } from '../../defines'; 2 | import { Database } from '../../utils/database'; 3 | import { dynamicElement } from '../../utils/tools'; 4 | import { settings } from '../settings/settings'; 5 | 6 | import followedIcon from '@resources/comments/userTags/followedIcon.svg'; 7 | import likedIcon from '@resources/comments/userTags/likedIcon.svg'; 8 | import warningIcon from '@resources/comments/userTags/warningIcon.svg'; 9 | import blockedIcon from '@resources/comments/userTags/blockedIcon.svg'; 10 | 11 | import followedButton from '@resources/comments/userTags/followedButton.svg'; 12 | import likedButton from '@resources/comments/userTags/likedButton.svg'; 13 | import warningButton from '@resources/comments/userTags/warningButton.svg'; 14 | import blockedButton from '@resources/comments/userTags/blockedButton.svg'; 15 | import { buildSvg } from '../../utils/svg'; 16 | 17 | import style from './userTags.less'; 18 | import { css } from '../customCSS'; 19 | import { pp_log } from '../toaster'; 20 | 21 | export class UserTag { 22 | static FOLLOWED: string = `Followed`; 23 | static LIKED: string = `Liked`; 24 | static WARNING: string = `Warning`; 25 | static BLOCKED: string = `Blocked`; 26 | } 27 | // 28 | export class UserTagConfig { 29 | priority: number; 30 | addHint: string; 31 | removeHint: string; 32 | color: string; 33 | icon: any; 34 | button: any; 35 | } 36 | 37 | export const USERTAG_CONFIGS = new Map([ 38 | [UserTag.FOLLOWED, { priority: 100, addHint: `Follow`, removeHint: `Unfollow`, color: `#0b7ed3`, icon: followedIcon, button: followedButton }], 39 | [UserTag.LIKED, { priority: 2, addHint: `Tag as liked`, removeHint: `Remove liked tag`, color: `#C95A54`, icon: likedIcon, button: likedButton }], 40 | [UserTag.WARNING, { priority: 1, addHint: `Tag as warned`, removeHint: `Remove warned tag`, color: `#D4A343`, icon: warningIcon, button: warningButton }], 41 | [UserTag.BLOCKED, { priority: 0, addHint: `Block`, removeHint: `Unblock`, color: `#663988`, icon: blockedIcon, button: blockedButton }] 42 | ]); 43 | 44 | class UserTagsData { 45 | tags: Array; 46 | blockCooldown: number; 47 | } 48 | 49 | export const tags: Database = new Database(`TAGS`); 50 | 51 | export async function renderUserTags(comment: Element) { 52 | if (settings.USER_TAGS.isDisabled()) return; 53 | 54 | css.addStyle(style, `userTags`); 55 | 56 | const userId = comment.getAttribute(`author`); 57 | if (userId == null) return; 58 | 59 | const tagsData = tags.get(userId); 60 | const tagsContainer = await dynamicElement(() => comment.querySelector(`div[pp-anchor="tags"]`), MAX_LOAD_LAG); 61 | 62 | // comment not rendered 63 | if (tagsContainer == null) { 64 | return; 65 | } 66 | 67 | // clear old tags 68 | tagsContainer.parentNode.querySelectorAll(`svg[userTag="true"]`).forEach(tag => { 69 | tag.remove(); 70 | }); 71 | 72 | if (tagsData.tags != undefined) { 73 | for (const tag of tagsData.tags) { 74 | renderNextTag(tag); 75 | } 76 | } 77 | 78 | function renderNextTag(tag: string) { 79 | const config = USERTAG_CONFIGS.get(tag); 80 | 81 | const tagSvg = buildSvg(config.icon, 20, 20); 82 | tagSvg.setAttribute(`userTag`, `true`); 83 | tagSvg.setAttribute(`viewBox`, `-4 -4 20 20`); 84 | tagSvg.style.color = config.color; 85 | tagsContainer.after(tagSvg); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /resources/settingsGear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/descriptions/RedditPlusPlus.description.ru.txt: -------------------------------------------------------------------------------- 1 |
2 | Обновления Reddit могут поломать скрипт, так что не стесняйтесь сообщать о багах. 3 | 4 | Скрипт содержит много бойлерплейт кода т.к. собирается с помощью webpack. Исходный код можно посмотреть на github 5 |

Совместимость

6 |
    7 |
  • Работа скрипта проверялась только на chrome/firefox с расширением tampermonkey. Работоспособность других конфигураций не гарантируется.
  • 8 |
  • Мобильная версия сайта не поддерживается.
  • 9 |
  • Старые версии (old.reddit, new.reddit) не поддерживаются.
  • 10 |
  • Community themes не поддерживаются.
  • 11 |
12 | 13 |

Описание

14 |
    15 |

    Общее

    16 |
  • Правый сайдбар перемещён, чтобы контент был централизован.common view
  • 17 |
  • Увеличение шрифтов в комментариях и постах.
  • 18 |
  • Фильтрация комментариев и постов по ключевым словам или регулярным выражениям. Посты также можно фильтровать по флеирам.
  • 19 |
  • Увеличение изображений без перехода на другую вкладку.
  • 20 |
  • Возможность вынести закладку сохранёного из контекстного меню.bookmarks
  • 21 |
  • Автоматическое сворачивание "Awards".
  • 22 |
  • Редирект ссылок с старой версией на актуальную версию.
  • 23 | 24 |

    Левый садбар

    25 |
  • Сохранение свернутых категорий.
  • 26 |
  • Сокрытие категорий (через настройки).
  • 27 |
  • Фильтр сообществ.
  • 28 | 29 |

    Сабреддиты

    30 |
  • Кнопки лент Hot/New/TopfeedButtons
  • 31 |
  • Переопределение лент по умолчанию (глобально или для конкретных сабов).
  • 32 |
  • Флеир бар для быстрой навигации.
  • 33 | 34 |

    Посты

    35 |
  • Градиентный фон.
  • 36 |
  • Выделяемый текст.
  • 37 |
  • Развёртывание текстовых постов по кнопке.post
  • 38 | 39 |

    Комментарии

    40 |
  • Кнопки сортировки комментариев, сохранение сортировки комментариев.commentsSort
  • 41 |
  • Информация о пользователе (рейтинг, индикация новых пользователей, замена имени пользователя на никнеймы).
  • 42 |
  • Теги пользователей (декоративные: liked, warned; функциональные: follow, block).userTags
  • 43 |
  • Автоматическое сворачивание комментариев от AutoModerator.
  • 44 |
  • Развёртывание "more replies".
  • 45 |
46 | 47 |

Настройки

48 |
    49 |
  • Настройки функций скрипта:
  • 50 | Features setting 51 |
  • Настройки флеиров (для каждого сабредита):
  • 52 | Flairs settings 53 |
-------------------------------------------------------------------------------- /src/utils/UI/input.ts: -------------------------------------------------------------------------------- 1 | import { css } from '../../modules/customCSS'; 2 | import { appendSvg, buildSvg, CURRENT_COLOR, NONE_COLOR, SVGConfig } from '../svg'; 3 | import { appendElement } from '../element'; 4 | import inputClearSvg from '@resources/inputClear.svg'; 5 | import style from './input.less'; 6 | 7 | css.addStyle(style); 8 | 9 | export interface InputParams { 10 | icon?: any; 11 | iconConfig?: SVGConfig; 12 | cleanButton?: boolean; 13 | alignCenter?: boolean; 14 | filter?: (input: string) => string; 15 | } 16 | 17 | export function renderUIInput(container: Element, placeholder: string, value: string, onChange: (value: string) => void, params?: InputParams): HTMLElement { 18 | const { icon, iconConfig, cleanButton, alignCenter, filter } = { 19 | icon: null, 20 | iconConfig: { strokeColor: CURRENT_COLOR, fillColor: NONE_COLOR }, 21 | cleanButton: false, 22 | alignCenter: false, 23 | filter: (input: string) => input, 24 | ...params 25 | }; 26 | 27 | const inputContainer = appendElement(container, `div`, `pp_ui_input_container`); 28 | 29 | const inputButton = appendElement(inputContainer, `div`, [`pp_ui_input_button`, `button`, `button-bordered`]); 30 | inputButton.setAttribute(`tabindex`, `0`); 31 | 32 | const inputShadowRoot = inputButton.attachShadow({ mode: 'open' }); 33 | css.registry(inputShadowRoot); 34 | 35 | const inputPanel = appendElement(inputButton, `span`, [`pp_ui_input_panel`, `flex`, `items-center`, `justify-center`]); 36 | inputShadowRoot.appendChild(inputPanel); 37 | 38 | if (icon != null) { 39 | const inputIconSpan = appendElement(inputPanel, `span`, `pp_ui_input_icon`); 40 | appendSvg(inputIconSpan, icon, 16, 16, iconConfig); 41 | } 42 | 43 | const inputSpan = appendElement(inputPanel, `span`, `pp_ui_input_span`); 44 | 45 | if (cleanButton == true) { 46 | inputSpan.style.marginRight = `22px`; 47 | } 48 | 49 | const input = appendElement(inputSpan, `input`, `pp_ui_input`) as HTMLInputElement; 50 | input.type = `text`; 51 | input.placeholder = placeholder; 52 | 53 | if (alignCenter == true) { 54 | input.style.textAlign = `center`; 55 | } 56 | 57 | if (value != null && value.length > 0) { 58 | input.value = value; 59 | } 60 | 61 | let clearButton: HTMLElement = null; 62 | if (cleanButton == true) { 63 | const clearContainer = appendElement(inputContainer, `div`, `pp_ui_input_clearContainer`); 64 | clearButton = appendElement(clearContainer, `button`, [`pp_ui_input_clearButton`, `button-plain`]); 65 | clearButton.classList.toggle(`pp_hidden`, (input.value?.length ?? 0) == 0); 66 | const clearIcon = buildSvg(inputClearSvg, 16, 16); 67 | clearButton.append(clearIcon); 68 | 69 | clearButton.addEventListener(`click`, () => { 70 | input.value = filter(``); 71 | onChange(input.value); 72 | 73 | clearButton.classList.toggle(`pp_hidden`, true); 74 | }); 75 | } 76 | 77 | input.addEventListener(`input`, () => { 78 | const value = filter(input.value.trim()); 79 | onChange(value); 80 | 81 | if (cleanButton == true) { 82 | clearButton.classList.toggle(`pp_hidden`, value.length == 0); 83 | } 84 | }); 85 | 86 | input.addEventListener(`focusout`, () => { 87 | const value = filter(input.value.trim()); 88 | 89 | if (value != input.value) { 90 | input.value = value; 91 | } 92 | 93 | if (cleanButton == true) { 94 | clearButton.classList.toggle(`pp_hidden`, value.length == 0); 95 | } 96 | }); 97 | 98 | return inputContainer; 99 | } 100 | -------------------------------------------------------------------------------- /src/_compatibility/migration_1_2_0.ts: -------------------------------------------------------------------------------- 1 | import { BookmarkMode } from '../modules/bookmarkMode'; 2 | import { AwardsMode } from '../modules/collapseAwardsMode'; 3 | import { FeedData, subsFeedData } from '../modules/feed/feed'; 4 | import { FeedSort } from '../modules/feed/feedSort'; 5 | import { RedirectMode } from '../modules/redirectMode'; 6 | import { UsernameMode } from '../modules/users/usernameMode'; 7 | import { Database } from '../utils/database'; 8 | import { migration_1_0_0 } from './migration_1_0_0'; 9 | import { Migration } from './migrations'; 10 | 11 | export const migration_1_2_0 = new Migration( 12 | `1.2.0`, 13 | () => { 14 | const settingsDatabase = GM_getValue(`SETTINGS_DATABASE`, null) as any; 15 | 16 | if (settingsDatabase != null) { 17 | // bookmarks storage rework 18 | if (settingsDatabase.savedBookmarkPosts != undefined && typeof settingsDatabase.savedBookmarkPosts !== `string`) { 19 | settingsDatabase.savedBookmarkPosts = ( 20 | settingsDatabase.savedBookmarkPostsShowAlways == true ? BookmarkMode.Always : settingsDatabase.savedBookmarkPosts == true ? BookmarkMode.WhenUpvoted : BookmarkMode.Disabled 21 | ).toString(); 22 | } 23 | 24 | delete settingsDatabase.savedBookmarkPostsShowAlways; 25 | 26 | if (settingsDatabase.savedBookmarkComments != undefined && typeof settingsDatabase.savedBookmarkComments !== `string`) { 27 | settingsDatabase.savedBookmarkComments = ( 28 | settingsDatabase.savedBookmarkCommentsShowAlways == true ? BookmarkMode.Always : settingsDatabase.savedBookmarkComments == true ? BookmarkMode.WhenUpvoted : BookmarkMode.Disabled 29 | ).toString(); 30 | } 31 | 32 | delete settingsDatabase.savedBookmarkCommentsShowAlways; 33 | 34 | // redirect storage rework 35 | if (settingsDatabase.redirectSuggestion != undefined || settingsDatabase.redirectForced != undefined) { 36 | settingsDatabase.redirectMode = 37 | settingsDatabase.redirectForced == true ? RedirectMode.Forced : settingsDatabase.redirectSuggestion == true ? RedirectMode.Suggestion : RedirectMode.Disabled; 38 | } 39 | 40 | delete settingsDatabase.redirectSuggestion; 41 | delete settingsDatabase.redirectForced; 42 | 43 | // awards storage rework 44 | if (settingsDatabase.collapseAwards != undefined && typeof settingsDatabase.collapseAwards !== `string`) { 45 | settingsDatabase.collapseAwards = ( 46 | settingsDatabase.collapseAwardsCompletely == true ? AwardsMode.RemoveCompletely : settingsDatabase.collapseAwards == true ? AwardsMode.WhenUpvoted : AwardsMode.Default 47 | ).toString(); 48 | } 49 | 50 | delete settingsDatabase.collapseAwardsCompletely; 51 | 52 | // username 53 | if (settingsDatabase.showNames != undefined && typeof settingsDatabase.showNames !== `string`) { 54 | settingsDatabase.usernameMode = settingsDatabase.showNames == true ? UsernameMode.Nickname : UsernameMode.ProfileName; 55 | 56 | delete settingsDatabase.showNames; 57 | } 58 | 59 | GM_setValue(`SETTINGS_DATABASE`, settingsDatabase); 60 | GM_setValue(`SETTINGS_REFRESHED`, Date.now()); 61 | } 62 | 63 | // migrate old sort settings 64 | const subSettings = new Database(`SUBS_SETTINGS`); 65 | 66 | subSettings.forEach((key, value) => { 67 | if (value.defaultFeed != undefined && value.defaultFeed in FeedSort) { 68 | const currentFeedData = subsFeedData.get(key); 69 | 70 | if (currentFeedData == null) { 71 | subsFeedData.set(key, { 72 | redirect: true, 73 | defaultSort: value.defaultFeed, 74 | hiddenSort: [] 75 | } as FeedData); 76 | } 77 | } 78 | }); 79 | 80 | subSettings.wipe(); 81 | }, 82 | migration_1_0_0 83 | ); 84 | -------------------------------------------------------------------------------- /src/modules/feed/feedRedirect.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentCustomFeed } from '../customFeed/customFeed'; 2 | import { getCurrentSub } from '../subs/subs'; 3 | import { customFeedData, defaultFeedData, defaultSorts, generateFeedHref, subsFeedData, subsLatestSort } from './feed'; 4 | import { FeedLocation, getFeedLocation } from './feedLocation'; 5 | 6 | export interface RedirectConfig { 7 | tittle: string; 8 | descriptionLabel: string; 9 | isOverridable: boolean; 10 | isOptional: boolean; 11 | } 12 | 13 | export const redirectConfigs = new Map([ 14 | [FeedLocation.Sub, { tittle: `Subreddits`, descriptionLabel: `Subreddits`, isOverridable: true, isOptional: true }], 15 | [FeedLocation.Home, { tittle: `Home`, descriptionLabel: `Home`, isOverridable: false, isOptional: false }], 16 | [FeedLocation.Popular, { tittle: `Popular`, descriptionLabel: `r/Popular`, isOverridable: false, isOptional: false }], 17 | [FeedLocation.All, { tittle: `All`, descriptionLabel: `r/All`, isOverridable: false, isOptional: false }], 18 | [FeedLocation.Custom, { tittle: `Custom feeds`, descriptionLabel: `Custom`, isOverridable: true, isOptional: false }] 19 | ]); 20 | 21 | function isRedirectable() { 22 | if (window.location.href == `https://www.reddit.com/`) return true; 23 | 24 | if (window.location.href.includes(`/?f=flair_name`)) return false; 25 | 26 | if (window.location.href.includes(`?feed=home`)) { 27 | return window.location.href.includes(`reddit.com/?feed=home`); 28 | } 29 | 30 | const subSplit = window.location.href.split(`/r/`); 31 | if (subSplit.length == 2) { 32 | return subSplit[1].split(`/`).length <= 2; 33 | } 34 | 35 | const customSplit = window.location.href.split(`/m/`); 36 | if (customSplit.length == 2) { 37 | return customSplit[1].split(`/`).length <= 2; 38 | } 39 | 40 | return false; 41 | } 42 | 43 | export function initializeFeedRedirect() { 44 | checkFeedRedirect(); 45 | 46 | const observeUrlChange = () => { 47 | let oldHref = document.location.href; 48 | const body = document.querySelector('body'); 49 | const observer = new MutationObserver(mutations => { 50 | if (oldHref !== document.location.href) { 51 | oldHref = document.location.href; 52 | 53 | checkFeedRedirect(); 54 | } 55 | }); 56 | observer.observe(body, { childList: true, subtree: true }); 57 | }; 58 | 59 | window.onload = observeUrlChange; 60 | } 61 | 62 | function checkFeedRedirect() { 63 | if (!isRedirectable()) return; 64 | 65 | const location = getFeedLocation(); 66 | const data = defaultFeedData.get(FeedLocation[location]); 67 | const defaultSort = defaultSorts.get(location); 68 | 69 | if (location == FeedLocation.Sub) { 70 | const sub = getCurrentSub(); 71 | const latest = subsLatestSort.get(sub); 72 | const subData = subsFeedData.get(sub); 73 | 74 | if (subData != null) { 75 | if (subData.defaultSort == latest || !subData.redirect) return; 76 | 77 | document.location.replace(`${document.location.href}${subData.defaultSort.toString().toLowerCase()}/`); 78 | return; 79 | } 80 | 81 | if (!data.redirect) return; 82 | 83 | if (latest != null && data.defaultSort != latest) { 84 | document.location.replace(`${document.location.href}${subData.defaultSort.toString().toLowerCase()}/`); 85 | return; 86 | } 87 | } 88 | 89 | if (location == FeedLocation.Custom) { 90 | const custom = getCurrentCustomFeed(); 91 | const customData = customFeedData.get(custom); 92 | 93 | if (customData != null) { 94 | if (customData.defaultSort == defaultSort || !customData.redirect) return; 95 | 96 | document.location.replace(`${document.location.href}${customData.defaultSort.toString().toLowerCase()}/`); 97 | return; 98 | } 99 | } 100 | 101 | if (data.defaultSort == defaultSort) return; 102 | 103 | document.location.replace(`https://www.reddit.com${generateFeedHref(location, data.defaultSort)}`); 104 | } 105 | -------------------------------------------------------------------------------- /src/modules/subs/flairBar.ts: -------------------------------------------------------------------------------- 1 | import { MAX_LOAD_LAG } from '../../defines'; 2 | import { dynamicElement } from '../../utils/tools'; 3 | import { appendElement } from '../../utils/element'; 4 | import { css } from '../customCSS'; 5 | import { settings } from '../settings/settings'; 6 | import { renderFlair } from './flair'; 7 | import style from './flairBar.less'; 8 | import { flairs, getCurrentSub, subs } from './subs'; 9 | 10 | css.addStyle(style); 11 | 12 | export async function renderFlairBar(main: Element) { 13 | if (settings.FLAIR_BAR.isDisabled()) return; 14 | 15 | let feedContent = await dynamicElement(() => main?.querySelector(`shreddit-title`)?.parentElement, MAX_LOAD_LAG); 16 | 17 | // skip render for non feed page 18 | if (feedContent == null) return; 19 | 20 | const subHighlights = main?.querySelector(`community-highlight-carousel`) as HTMLElement; 21 | 22 | if (subHighlights != null) { 23 | feedContent = subHighlights; 24 | } 25 | 26 | const prevFlairMenu = feedContent.parentElement?.querySelector(`.pp_flairBar`)?.parentElement; 27 | if (prevFlairMenu != null) { 28 | prevFlairMenu.remove(); 29 | } 30 | 31 | const sub = getCurrentSub(); 32 | 33 | // load data 34 | const subData = await subs.getWithLoader(sub); 35 | const flairsData = flairs.get(sub); 36 | 37 | // skip render when sub haven't flairs 38 | if (subData.flairs == undefined || subData.flairs.length == 0) return; 39 | 40 | const flairMenuContainer = document.createElement(`div`); 41 | feedContent.before(flairMenuContainer); 42 | 43 | const flairMenu = appendElement(flairMenuContainer, `div`, `pp_flairBar`); 44 | 45 | if (subHighlights != null) { 46 | flairMenu.classList.add(`pp_flairBar_highlights`); 47 | } 48 | 49 | const ul = appendElement(flairMenu, `ul`, [`p-0`, `m-0`, `list-none`, `gap-xs`, `flex`, `flex-row`, `pp_flairBar_list`]); 50 | let flairsRendered = 0; 51 | 52 | for (const flair of subData.flairs) { 53 | if (flairsData.hidden != undefined && flairsData.hidden.includes(flair.text)) continue; 54 | 55 | const li = appendElement(ul, `li`, `max-w-full`); 56 | 57 | renderFlair(li, sub, flair); 58 | 59 | flairsRendered++; 60 | } 61 | 62 | // prevent render empty menu 63 | if (flairsRendered == 0) { 64 | flairMenuContainer.remove(); 65 | return; 66 | } 67 | 68 | // borders 69 | const borderContainer = document.createElement(`div`); 70 | borderContainer.classList.add(`pp_flairBar_bordersContainer`); 71 | flairMenuContainer.prepend(borderContainer); 72 | 73 | const borderLeftC = appendElement(borderContainer, `div`, `pp_flairBar_preBorder`); 74 | const borderLeft = appendElement(borderLeftC, `div`, [`pp_flairBar_border`, `pp_flairBar_border_left`]); 75 | borderLeft.textContent = ` `; 76 | const borderRightC = appendElement(borderContainer, `div`, `pp_flairBar_preBorder`); 77 | const borderRight = appendElement(borderRightC, `div`, [`pp_flairBar_border`, `pp_flairBar_border_right`]); 78 | borderRight.textContent = ` `; 79 | 80 | const hr = document.createElement(`hr`); 81 | hr.classList.add(`border-0`, `border-b-sm`, `border-solid`, `border-b-neutral-border-weak`); 82 | flairMenuContainer.prepend(hr); 83 | 84 | const mymx = document.createElement(`div`); 85 | mymx.classList.add(`my-xs`, `mx-2xs0`); 86 | flairMenuContainer.prepend(mymx); 87 | 88 | // navigation 89 | ul.style.left = `25px`; 90 | const ulRect = ul.getBoundingClientRect(); 91 | const menuRect = flairMenu.getBoundingClientRect(); 92 | 93 | flairMenu.addEventListener(`mousemove`, e => { 94 | onMoveOverFlairs(e, ul, flairMenu); 95 | }); 96 | 97 | if (ulRect.width > menuRect.width * 1.72) { 98 | ul.classList.add(`pp_flairBar_listSmoothed`); 99 | } 100 | } 101 | 102 | function onMoveOverFlairs(e: MouseEvent, ul: HTMLElement, flairMenu: HTMLElement) { 103 | const ulRect = ul.getBoundingClientRect(); 104 | const menuRect = flairMenu.getBoundingClientRect(); 105 | 106 | if (ulRect.width < menuRect.width) { 107 | ul.style.left = `25px`; 108 | return; 109 | } 110 | 111 | let scale = (e.clientX - (menuRect.x + 25)) / (menuRect.right - 25 - (menuRect.x + 25)); 112 | scale = Math.max(0, Math.min(scale, 1)); 113 | 114 | ul.style.left = `${Math.round(25 - (ulRect.width - menuRect.width + 50) * scale)}px`; 115 | } 116 | -------------------------------------------------------------------------------- /src/utils/tools.ts: -------------------------------------------------------------------------------- 1 | import { PROFILE_DYNAMIC_ELEMENTS, profiler_dynamicElements } from '../_debug/debug'; 2 | 3 | const DYNAMIC_ELEMENT_FREQUENCY: number = 10; 4 | 5 | export async function dynamicElement(elementRequest: Function, maxLifetime: number = 0): Promise { 6 | return new Promise(resolve => { 7 | let element = elementRequest(); 8 | 9 | if (element != null) { 10 | return resolve(element); 11 | } 12 | 13 | if (DEBUG && PROFILE_DYNAMIC_ELEMENTS) { 14 | profiler_dynamicElements.dynamicElement++; 15 | } 16 | 17 | let time = maxLifetime / DYNAMIC_ELEMENT_FREQUENCY; 18 | 19 | const intervalId = setInterval(() => { 20 | element = elementRequest(); 21 | let forced = false; 22 | if (maxLifetime > 0) { 23 | time--; 24 | if (time < 0) forced = true; 25 | } 26 | 27 | if (element != null || forced) { 28 | if (DEBUG && PROFILE_DYNAMIC_ELEMENTS) { 29 | profiler_dynamicElements.dynamicElement--; 30 | } 31 | 32 | clearInterval(intervalId); 33 | return resolve(element); 34 | } 35 | }, DYNAMIC_ELEMENT_FREQUENCY); 36 | }); 37 | } 38 | 39 | interface ObserveAction { 40 | (elment: HTMLElement): void | boolean; 41 | } 42 | 43 | const observeForInstances = new Map(); 44 | 45 | export function observeFor(name: string, root: Element, action: ObserveAction, includeChilds: boolean = true) { 46 | if (name && observeForInstances.has(name)) { 47 | observeForInstances.get(name).disconnect(); 48 | observeForInstances.delete(name); 49 | 50 | if (DEBUG && PROFILE_DYNAMIC_ELEMENTS) { 51 | profiler_dynamicElements.observeFor--; 52 | } 53 | } 54 | 55 | const result = action(root as HTMLElement); 56 | 57 | if (result != undefined && result == true) { 58 | return; 59 | } 60 | 61 | if (DEBUG && PROFILE_DYNAMIC_ELEMENTS) { 62 | profiler_dynamicElements.observeFor++; 63 | } 64 | 65 | let observer = new MutationObserver(mutations => { 66 | for (const mutation of mutations) { 67 | for (const node of mutation.addedNodes) { 68 | if (observer && node instanceof HTMLElement) { 69 | const result = action(node); 70 | 71 | if (result != undefined && result == true) { 72 | observer.disconnect(); 73 | observer = null; 74 | 75 | if (DEBUG && PROFILE_DYNAMIC_ELEMENTS) { 76 | profiler_dynamicElements.observeFor--; 77 | } 78 | } 79 | } 80 | } 81 | } 82 | }); 83 | 84 | observer.observe(root, { childList: true, subtree: includeChilds }); 85 | 86 | if (name) { 87 | observeForInstances.set(name, observer); 88 | } 89 | } 90 | 91 | export function checkIsRendered(node: Element, key: string = `pp-rendered`) { 92 | if (node == null || node.getAttribute(key) != null) { 93 | return true; 94 | } else { 95 | node.setAttribute(key, ``); 96 | return false; 97 | } 98 | } 99 | 100 | export function getCookie(key: string) { 101 | return document.cookie 102 | .split(`; `) 103 | .find(row => row.startsWith(key)) 104 | ?.split(`=`)[1]; 105 | } 106 | 107 | export function isLowerVersion(a: string, b: string): boolean { 108 | if (a == b) return false; 109 | 110 | const a_array = a.split(`.`); 111 | const b_array = b.split(`.`); 112 | 113 | for (const i of [0, 1, 2]) { 114 | const a_i = parseInt(a_array[i]); 115 | const b_i = parseInt(b_array[i]); 116 | if (a_i != b_i) return a_i < b_i; 117 | } 118 | 119 | return false; 120 | } 121 | 122 | export function animate(action: Function, seconds: number, step: number = 10) { 123 | let ticks = (seconds * 1000) / step; 124 | let timer = setInterval(() => { 125 | action(); 126 | 127 | ticks--; 128 | if (ticks < 0) { 129 | clearInterval(timer); 130 | } 131 | }, step); 132 | } 133 | 134 | export function PascalCase(input: string): string { 135 | if (!input) { 136 | return input; 137 | } 138 | 139 | return input.charAt(0).toUpperCase() + input.slice(1); 140 | } 141 | -------------------------------------------------------------------------------- /src/modules/filters/hiddenContent.ts: -------------------------------------------------------------------------------- 1 | import { appendElement, buildElement } from '../../utils/element'; 2 | import { appendSvg, CURRENT_COLOR, NONE_COLOR } from '../../utils/svg'; 3 | import { css } from '../customCSS'; 4 | import hiddenIcoSvg from '@resources/hiddenIco.svg'; 5 | import style from './hiddenContent.less'; 6 | import { pp_log } from '../toaster'; 7 | import { hiddenContentWindow } from './hiddenContentWindow'; 8 | import { dynamicElement } from '../../utils/tools'; 9 | import { settings } from '../settings/settings'; 10 | 11 | css.addStyle(style); 12 | 13 | export const hiddenContent = new Array(); 14 | let totalHiddentContent: number = 0; 15 | let renderedHiddentContent: number = 0; 16 | 17 | let renderTimer: ReturnType = null; 18 | 19 | let hiddenContentButton: HTMLElement = null; 20 | let hiddenContentSpan: HTMLElement = null; 21 | let contentBlock: HTMLElement = null; 22 | let sidebarBlock: HTMLElement = null; 23 | 24 | export function registerHiddenContent(content: Element) { 25 | hiddenContent.push(content); 26 | totalHiddentContent++; 27 | 28 | if (hiddenContent.length > parseInt(settings.FILTERED_CONTENT_MAX_COUNT.get())) { 29 | hiddenContent.splice(0, 1); 30 | } 31 | 32 | updateHiddenContentButton(); 33 | } 34 | 35 | export function clearHiddenContentButton() { 36 | if (hiddenContentButton != null) { 37 | hiddenContentButton.remove(); 38 | 39 | hiddenContent.length = 0; 40 | totalHiddentContent = 0; 41 | renderedHiddentContent = 0; 42 | } 43 | } 44 | 45 | async function renderHiddenContentButton() { 46 | contentBlock = (await dynamicElement(() => document.body.querySelector(`.main-container`))) as HTMLElement; 47 | sidebarBlock = (await dynamicElement(() => document.body.querySelector(`#right-sidebar-contents`))) as HTMLElement; 48 | const main = contentBlock.parentElement; 49 | 50 | if (hiddenContentButton == null) { 51 | hiddenContentButton = buildElement(`div`, [`pp_hiddenContent_button`, `text-neutral-content-weak`]); 52 | 53 | const icon = appendSvg(hiddenContentButton, hiddenIcoSvg, 16, 16, { strokeColor: CURRENT_COLOR, fillColor: NONE_COLOR }); 54 | hiddenContentSpan = appendElement(hiddenContentButton, `span`); 55 | 56 | window.addEventListener('resize', event => { 57 | checkScreenWidth(); 58 | }); 59 | 60 | hiddenContentButton.addEventListener(`click`, event => { 61 | hiddenContentWindow.open(); 62 | }); 63 | } else { 64 | hiddenContentButton.classList.toggle(`pp_hiddenContent_button_visible`, false); 65 | } 66 | 67 | main.parentElement.append(hiddenContentButton); 68 | 69 | setTimeout(() => { 70 | hiddenContentButton.classList.add(`pp_hiddenContent_button_visible`); 71 | }, 250); 72 | } 73 | 74 | function calculateRenderTime() { 75 | const maxRenderTime = 250; 76 | const delta = totalHiddentContent - renderedHiddentContent; 77 | return delta > 0 ? maxRenderTime / delta : maxRenderTime; 78 | } 79 | 80 | async function updateHiddenContentButton() { 81 | if (hiddenContentButton == null || hiddenContentButton.parentElement == null) { 82 | await renderHiddenContentButton(); 83 | } 84 | 85 | if (totalHiddentContent == 1) { 86 | hiddenContentSpan.textContent = `1 post`; 87 | renderedHiddentContent = 1; 88 | } else if (renderedHiddentContent < totalHiddentContent && renderTimer == null) { 89 | renderTimer = setTimeout(() => { 90 | renderedHiddentContent++; 91 | hiddenContentSpan.textContent = `${renderedHiddentContent} posts`; 92 | 93 | renderTimer = null; 94 | 95 | updateHiddenContentButton(); 96 | }, calculateRenderTime()); 97 | } 98 | 99 | checkScreenWidth(); 100 | } 101 | 102 | function checkScreenWidth() { 103 | const isWide = settings.WIDE_MODE.isEnabled(); 104 | 105 | const width = hiddenContentButton.getBoundingClientRect().width + 10; 106 | const left = isWide ? contentBlock.getBoundingClientRect().right : sidebarBlock.getBoundingClientRect().right; 107 | const right = isWide ? sidebarBlock.getBoundingClientRect().left : window.innerWidth - 16; 108 | const charOffset = totalHiddentContent.toString().length * 3; 109 | hiddenContentButton.style.left = `${(left + right) / 2 - (50 + charOffset)}px`; 110 | 111 | hiddenContentButton.classList.toggle(`pp_hiddenContent_button_visible`, right - left > width); 112 | } 113 | -------------------------------------------------------------------------------- /src/utils/window.ts: -------------------------------------------------------------------------------- 1 | import { css } from '../modules/customCSS'; 2 | import { CURRENT_COLOR, NONE_COLOR, buildSvg } from './svg'; 3 | import { appendElement } from './element'; 4 | import style from './window.less'; 5 | 6 | import closeWindowButtonSvg from '@resources/windowCloseButton.svg'; 7 | 8 | css.addStyle(style); 9 | 10 | let currentWindows: Array = []; 11 | 12 | export interface WindowRenderer { 13 | (window: Window, context: any): void; 14 | } 15 | 16 | export function closeAllWindows() { 17 | while (currentWindows.length > 0) { 18 | currentWindows[currentWindows.length - 1].close(); 19 | } 20 | } 21 | 22 | export class Window { 23 | tittleContent: string; 24 | render: WindowRenderer; 25 | onClose: Function; 26 | container: Element; 27 | tittle: Element; 28 | content: Element; 29 | closeButton: Element; 30 | 31 | constructor(tittle: string, render: WindowRenderer, onClose: Function = null) { 32 | this.tittleContent = tittle; 33 | this.render = render; 34 | this.onClose = onClose; 35 | this.container = null; 36 | this.content = null; 37 | this.closeButton = null; 38 | } 39 | 40 | build() { 41 | this.container = document.createElement(`div`); 42 | this.container.classList.add(`pp_window_container`); 43 | 44 | let isContainerDown: boolean = false; 45 | this.container.addEventListener('mousedown', e => { 46 | isContainerDown = e.target == this.container; 47 | }); 48 | 49 | this.container.addEventListener('click', e => { 50 | if (isContainerDown && e.target == this.container) { 51 | this.close(); 52 | } 53 | }); 54 | 55 | const win = appendElement(this.container, `div`, `pp_window`); 56 | 57 | const tittleContainer = appendElement(win, `div`, `pp_window_tittleContainer`); 58 | const tittleDiv = appendElement(tittleContainer, `div`, [`pp_window_tittle`, `flex`, `flex-row`]); 59 | this.tittle = appendElement(tittleDiv, `span`, [`text-24`, `font-semibold`]); 60 | this.tittle.textContent = this.tittleContent; 61 | 62 | this.closeButton = appendElement(tittleContainer, `div`, [`pp_window_closeButton`, `flex`, `items-center`]); 63 | this.closeButton = appendElement(this.closeButton, `button`, [`button`, `icon`, `inline-flex`, `items-center`, `justify-center`, `button-small`, `button-secondary`, `px-[var(--rem6)]`]); 64 | this.closeButton.setAttribute(`tittle`, `Close ${this.tittleContent}`); 65 | this.closeButton.addEventListener('click', e => { 66 | this.close(); 67 | }); 68 | 69 | this.closeButton = appendElement(this.closeButton, `span`, [`flex`, `items-center`, `justify-center`]); 70 | this.closeButton = appendElement(this.closeButton, `span`, [`flex`]); 71 | 72 | const svg = buildSvg(closeWindowButtonSvg, 16, 16, { strokeColor: NONE_COLOR }); 73 | this.closeButton.append(svg); 74 | 75 | appendElement(win, `hr`, `border-b-neutral-border-weak`); 76 | 77 | this.content = appendElement(win, `div`, `pp_window_content`); 78 | 79 | appendElement(win, `div`, `pp_window_footer`).textContent = ` `; 80 | } 81 | 82 | open(context: any = null) { 83 | if (this.container == null) { 84 | this.build(); 85 | } 86 | 87 | for (const w of currentWindows) { 88 | w.container.remove(); 89 | } 90 | 91 | currentWindows.push(this); 92 | 93 | document.body.appendChild(this.container); 94 | document.body.parentElement.style.overflow = 'hidden'; 95 | 96 | this.render(this, context); 97 | } 98 | 99 | close() { 100 | this.container.remove(); 101 | 102 | currentWindows.splice( 103 | currentWindows.findIndex(w => w == this), 104 | 1 105 | ); 106 | 107 | if (currentWindows.length <= 0) { 108 | document.body.parentElement.style.overflow = 'visible'; 109 | } 110 | 111 | // cleanup content 112 | while (this.content.firstChild) { 113 | this.content.removeChild(this.content.lastChild); 114 | } 115 | 116 | // show previous window 117 | if (currentWindows.length > 0) { 118 | const previous = currentWindows[currentWindows.length - 1]; 119 | 120 | document.body.appendChild(previous.container); 121 | } 122 | 123 | if (this.onClose != null) { 124 | this.onClose(); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/utils/database.ts: -------------------------------------------------------------------------------- 1 | import { profiler } from '../_debug/debug'; 2 | import { DAY_SECONDS, HOUR_SECONDS, secondsToTime } from '../defines'; 3 | 4 | export interface ICleanupableData { 5 | timestamp: number; 6 | } 7 | 8 | // Return true when data need to refresh 9 | interface DataValidator { 10 | (originData: T): boolean; 11 | } 12 | 13 | interface DataLoader { 14 | (id: string): Promise; 15 | } 16 | 17 | export class DatabaseConfig { 18 | isCleanupable?: boolean; 19 | validator?: DataValidator; 20 | loader?: DataLoader; 21 | factory?: Function; 22 | } 23 | 24 | interface WithLoaderResult { 25 | (isLoaded: boolean): void; 26 | } 27 | 28 | interface DatabaseIterator { 29 | (key: string, value: any): void; 30 | } 31 | 32 | export class DatabaseFactory { 33 | static Null: Function = function (id: string): any { 34 | return null; 35 | }; 36 | static EmptyObject: Function = function (id: string): any { 37 | return {}; 38 | }; 39 | } 40 | 41 | export class Database { 42 | databaseKey: string; 43 | refreshKey: string; 44 | cleanupKey: string; 45 | isCleanupable: boolean; 46 | factory: Function; 47 | 48 | validator: DataValidator; 49 | loader: DataLoader; 50 | 51 | data: { [id: string]: T }; 52 | refreshed: number; 53 | 54 | constructor(name: string, config: DatabaseConfig = new DatabaseConfig()) { 55 | this.databaseKey = name + `_DATABASE`; 56 | this.refreshKey = name + `_REFRESHED`; 57 | this.cleanupKey = name + `_CLEANUP`; 58 | this.isCleanupable = config?.isCleanupable ?? false; 59 | this.validator = config?.validator ?? null; 60 | this.loader = config?.loader ?? null; 61 | this.factory = config?.factory ?? DatabaseFactory.EmptyObject; 62 | this.refresh(); 63 | 64 | // cleanup database 65 | if (this.isCleanupable && GM_getValue(this.cleanupKey, 0) < Date.now()) { 66 | const items = Object.entries(this.data).length; 67 | const timestampLimit = Date.now() - secondsToTime(DAY_SECONDS * (1000 / items)); 68 | 69 | this.data = Object.fromEntries(Object.entries(this.data).filter(([key, value]) => (value as ICleanupableData).timestamp > timestampLimit)); 70 | this.refreshed = Date.now(); 71 | 72 | GM_setValue(this.databaseKey, this.data); 73 | GM_setValue(this.refreshKey, this.refreshed); 74 | GM_setValue(this.cleanupKey, Date.now() + 1000 * HOUR_SECONDS); 75 | } 76 | } 77 | 78 | refresh() { 79 | const lastRefreshed = GM_getValue(this.refreshKey, 0); 80 | if (this.data == undefined || this.refreshed < lastRefreshed) { 81 | this.refreshed = lastRefreshed; 82 | this.data = GM_getValue(this.databaseKey, {}); 83 | 84 | if (DEBUG) { 85 | profiler.databases.set(this.databaseKey.replace(`_DATABASE`, ``), Object.entries(this.data).length); 86 | } 87 | } 88 | } 89 | 90 | get(id: string): T { 91 | this.refresh(); 92 | 93 | const raw = this.data[id]; 94 | 95 | return (raw == undefined ? this.factory(id) : raw) as T; 96 | } 97 | 98 | forEach(iterator: DatabaseIterator): void { 99 | this.refresh(); 100 | 101 | Object.keys(this.data).forEach(key => { 102 | iterator(key, this.data[key]); 103 | }); 104 | } 105 | 106 | async getWithLoader(id: string, onLoaded: WithLoaderResult = null): Promise { 107 | this.refresh(); 108 | 109 | const raw = this.data[id]; 110 | let data = (raw == undefined ? this.factory(id) : raw) as T; 111 | let isLoaded = false; 112 | 113 | if (this.validator(data)) { 114 | data = await this.loader(id); 115 | 116 | this.set(id, data); 117 | 118 | isLoaded = true; 119 | } 120 | 121 | if (onLoaded != null) { 122 | onLoaded(isLoaded); 123 | } 124 | 125 | return data as T; 126 | } 127 | 128 | set(id: string, value: T) { 129 | this.refresh(); 130 | 131 | if (this.isCleanupable) { 132 | (value as ICleanupableData).timestamp = Date.now(); 133 | } 134 | 135 | this.data[id] = value; 136 | this.refreshed = Date.now(); 137 | 138 | GM_setValue(this.databaseKey, this.data); 139 | GM_setValue(this.refreshKey, this.refreshed); 140 | } 141 | 142 | wipe() { 143 | GM_deleteValue(this.databaseKey); 144 | GM_deleteValue(this.refreshKey); 145 | GM_deleteValue(this.cleanupKey); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/modules/sidebar/sidebarSettingsWindow.ts: -------------------------------------------------------------------------------- 1 | import { ChangesObserver } from '../../utils/changesObserver'; 2 | import { appendElement } from '../../utils/element'; 3 | import { PascalCase } from '../../utils/tools'; 4 | import { renderUIToggle } from '../../utils/UI/toggle'; 5 | import { Window } from '../../utils/window'; 6 | import { css } from '../customCSS'; 7 | import { settings } from '../settings/settings'; 8 | import { navigations, SidebarNavigation } from './sidebarNavigation'; 9 | import { sections } from './sidebarSection'; 10 | 11 | import style from './sidebarSettingsWindow.less'; 12 | 13 | css.addStyle(style); 14 | 15 | export const sidebarSettingsWindow: Window = new Window('Sidebar settings', renderSettingsWindow, closeSettingsWindow); 16 | 17 | const changes = new ChangesObserver(); 18 | 19 | function renderSettingsWindow(win: Window, context: any) { 20 | changes.Reset(); 21 | changes.RenderBanner(win.content); 22 | 23 | const scroll = appendElement(win.content, `div`, [`pp_window_scrollContent`, `styled-scrollbars`]); 24 | 25 | const elements = appendElement(scroll, `div`, `pp_window_elementsContainer`); 26 | 27 | // filter 28 | { 29 | const propertyArea = appendElement(elements, `div`, `pp_window_element`); 30 | 31 | const header = appendElement(propertyArea, `div`, `pp_settings_propertyHeader`); 32 | const tittle = appendElement(header, `div`, `pp_settings_propertyHeader_tittle`); 33 | tittle.textContent = `Communities filter`; 34 | propertyArea.classList.add(`pp_settings_property_oneLine`); 35 | 36 | const buttonContainer = appendElement(propertyArea, `div`, `pp_settings_propertyButtonContainer`); 37 | 38 | const controlArea = appendElement(buttonContainer, `div`, `pp_window_controlArea`); 39 | 40 | const changesSource = changes.CreateSource(settings.SUB_FILTER.isEnabled()); 41 | 42 | renderUIToggle(controlArea, settings.SUB_FILTER.isEnabled(), (state: boolean) => { 43 | settings.SUB_FILTER.switch(state); 44 | changesSource.Capture(settings.SUB_FILTER.isEnabled()); 45 | }); 46 | } 47 | 48 | // sections 49 | const subtittleSections = appendElement(elements, `h3`, `pp_settings_subtittle`); 50 | subtittleSections.textContent = `Sections`; 51 | 52 | sections.forEach((config, section) => { 53 | const propertyArea = appendElement(elements, `div`, `pp_window_element`); 54 | 55 | const header = appendElement(propertyArea, `div`, `pp_settings_propertyHeader`); 56 | const tittle = appendElement(header, `div`, [`text-12`, `text-secondary-weak`, `tracking-widest`]); //`pp_settings_propertyHeader_tittle` 57 | tittle.textContent = config.tittle.toUpperCase(); 58 | propertyArea.classList.add(`pp_settings_property_oneLine`); 59 | 60 | const buttonContainer = appendElement(propertyArea, `div`, `pp_settings_propertyButtonContainer`); 61 | 62 | const controlArea = appendElement(buttonContainer, `div`, `pp_window_controlArea`); 63 | 64 | const changesSource = changes.CreateSource(config.setting.isEnabled()); 65 | 66 | renderUIToggle(controlArea, config.setting.isEnabled(), (state: boolean) => { 67 | config.setting.switch(state); 68 | changesSource.Capture(config.setting.isEnabled()); 69 | }); 70 | }); 71 | 72 | // navigations 73 | const subtittleNavigations = appendElement(elements, `h3`, `pp_settings_subtittle`); 74 | subtittleNavigations.textContent = `Navigation buttons`; 75 | 76 | navigations.forEach((tittleText, navigaton) => { 77 | const propertyArea = appendElement(elements, `div`, `pp_window_element`); 78 | 79 | const header = appendElement(propertyArea, `div`, `pp_settings_propertyHeader`); 80 | const tittle = appendElement(header, `div`, `pp_settings_propertyHeader_tittle`); 81 | tittle.textContent = tittleText; 82 | propertyArea.classList.add(`pp_settings_property_oneLine`); 83 | 84 | const buttonContainer = appendElement(propertyArea, `div`, `pp_settings_propertyButtonContainer`); 85 | 86 | const controlArea = appendElement(buttonContainer, `div`, `pp_window_controlArea`); 87 | 88 | const navSetting = settings.SIDEBAR_NAV_BUTTON.getChild(PascalCase(navigaton), true); 89 | 90 | const changesSource = changes.CreateSource(navSetting.isEnabled()); 91 | 92 | renderUIToggle(controlArea, navSetting.isEnabled(), (state: boolean) => { 93 | navSetting.switch(state); 94 | 95 | changesSource.Capture(navSetting.isEnabled()); 96 | }); 97 | }); 98 | } 99 | 100 | function closeSettingsWindow() { 101 | if (changes.HasChanges()) { 102 | settings.nextRevision(); 103 | 104 | window.location.reload(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/modules/subs/subs.ts: -------------------------------------------------------------------------------- 1 | import { Database, DatabaseConfig, ICleanupableData } from '../../utils/database'; 2 | import { notify, pp_log } from '../toaster'; 3 | import { renderFlairBar } from './flairBar'; 4 | import { css } from '../customCSS'; 5 | import style from './subs.less'; 6 | import { FlairData } from './flair'; 7 | import { checkIsRendered, dynamicElement } from '../../utils/tools'; 8 | import { flairsWindow } from './flairWindow'; 9 | import { MAX_LOAD_LAG } from '../../defines'; 10 | import { settings } from '../settings/settings'; 11 | import { requestAPI } from '../../utils/redditAPI'; 12 | import { FeedSort } from '../feed/feedSort'; 13 | 14 | css.addStyle(style); 15 | 16 | // naming a bit weird just to save compatibility 17 | export const FLAIR_HIDDEN: string = `hidden`; 18 | export const FLAIR_BLURED: string = `blured`; 19 | export const FLAIR_BANNED: string = `banned`; 20 | 21 | class SubFlairsData { 22 | hidden: Array; 23 | blured: Array; 24 | banned: Array; 25 | } 26 | 27 | class SubData implements ICleanupableData { 28 | timestamp: number; 29 | 30 | flairs: Array; 31 | } 32 | 33 | export const flairs: Database = new Database(`FLAIRS`); 34 | export const subs: Database = new Database(`SUBS`, { isCleanupable: true, validator: subDataValidator, loader: subDataLoader } as DatabaseConfig); 35 | 36 | function subDataValidator(subData: SubData) { 37 | return subData.flairs == undefined; 38 | } 39 | 40 | async function subDataLoader(sub: string): Promise { 41 | let subData = { flairs: [] } as SubData; 42 | 43 | const { status, result } = await requestAPI(`/r/${sub}/api/link_flair_v2.json`); 44 | 45 | if (result != null && result.message == null) { 46 | for (const loadedFlair of result) { 47 | const flair = { text: loadedFlair.text, color: loadedFlair.text_color, background: loadedFlair.background_color, richtext: loadedFlair.richtext } as FlairData; 48 | 49 | subData.flairs.push(flair); 50 | } 51 | 52 | return subData; 53 | } 54 | 55 | return subData; 56 | } 57 | 58 | export function getCurrentSub(): string { 59 | const raw = window.location.href.split(`reddit.com/r/`); 60 | return raw.length > 1 ? raw[1].split(`/`)[0] : null; 61 | } 62 | 63 | export async function renderSub(main: Element) { 64 | // skip page without feed 65 | const checkIsFeed = await dynamicElement(() => main.querySelector(`shreddit-feed-error-banner`), MAX_LOAD_LAG); 66 | if (checkIsFeed == null) return; 67 | 68 | renderMasthead(main); 69 | 70 | renderFlairBar(main); 71 | 72 | renderHighlights(main); 73 | } 74 | 75 | async function renderMasthead(main: Element) { 76 | const masthead = await dynamicElement(() => main.parentElement.parentElement.querySelector(`.masthead`)); 77 | 78 | if (checkIsRendered(masthead)) return; 79 | 80 | masthead.querySelector(`section`).classList.add(`pp_mastheadSection`); 81 | 82 | document.body.addEventListener(`click`, renderContextMenu); 83 | } 84 | 85 | async function renderHighlights(main: Element) { 86 | if (settings.COLLAPSE_HIGHLIGHTS.isDisabled()) return; 87 | 88 | const highlightButton = await dynamicElement(() => main?.querySelector(`community-highlight-carousel`)?.shadowRoot?.querySelector(`button`), MAX_LOAD_LAG * 5); 89 | 90 | if (highlightButton != null) { 91 | (highlightButton as HTMLElement).click(); 92 | } 93 | } 94 | 95 | function renderContextMenu(e: MouseEvent) { 96 | const targetElement = e.target as Element; 97 | 98 | if (targetElement.matches(`shreddit-subreddit-header-buttons`) != true) return; 99 | 100 | if (checkIsRendered(targetElement)) return; 101 | 102 | const controlMenu = targetElement.shadowRoot.querySelector(`shreddit-subreddit-overflow-control`).shadowRoot.querySelector(`faceplate-menu`); 103 | 104 | const originButton = controlMenu.querySelector(`li`); 105 | 106 | // flairs settings 107 | const menuFlairsButton = originButton.cloneNode(true) as Element; 108 | menuFlairsButton.querySelector(`.text-14`).textContent = `Flairs settings`; 109 | controlMenu.prepend(menuFlairsButton); 110 | 111 | const sub = getCurrentSub(); 112 | 113 | menuFlairsButton.addEventListener(`click`, () => { 114 | flairsWindow.open({ sub: sub }); 115 | }); 116 | 117 | // about 118 | const link = document.createElement(`a`); 119 | link.href = `https://www.reddit.com/` + targetElement.getAttribute(`prefixed-name`) + `/about/`; 120 | link.classList.add(`no-underline`); 121 | controlMenu.prepend(link); 122 | 123 | const menuAboutButton = originButton.cloneNode(true) as Element; 124 | menuAboutButton.querySelector(`.text-14`).textContent = `About`; 125 | link.prepend(menuAboutButton); 126 | } 127 | -------------------------------------------------------------------------------- /src/modules/users/userInfo.ts: -------------------------------------------------------------------------------- 1 | import { buildSvg } from '../../utils/svg'; 2 | import { dynamicElement } from '../../utils/tools'; 3 | import { settings } from '../settings/settings'; 4 | import { UserData, users } from './users'; 5 | import { PROFILE_USER_DATA, profiler_comments } from '../../_debug/debug'; 6 | import newUserSvg from '@resources/comments/newUser.svg'; 7 | import bannedUserSvg from '@resources/comments/bannedUser.svg'; 8 | import { ContentType, DAY_SECONDS } from '../../defines'; 9 | import { UsernameMode } from './usernameMode'; 10 | import { appendElement, prependElement } from '../../utils/element'; 11 | 12 | const NEWUSER_SECONDS_SHIFT = DAY_SECONDS * 64; 13 | 14 | let loadQueueLock = false; 15 | 16 | export async function renderUserInfo(userId: string, nickName: Element, tagsAnchor: Element, infoAnchor: Element, contentType: ContentType) { 17 | const usernameMode = settings.USERNAME_MODE.get() as UsernameMode; 18 | if (settings.USER_INFO.isDisabled() && usernameMode == UsernameMode.ProfileName) return; 19 | 20 | if (DEBUG && PROFILE_USER_DATA) { 21 | profiler_comments.userDataLoading++; 22 | } 23 | 24 | await dynamicElement(() => (loadQueueLock ? null : true)); 25 | 26 | loadQueueLock = true; 27 | 28 | const userData: UserData = await users.getWithLoader(userId, isLoaded => { 29 | if (isLoaded) { 30 | setTimeout( 31 | () => { 32 | loadQueueLock = false; 33 | }, 34 | 16 + Math.random() * 32 35 | ); 36 | } else { 37 | loadQueueLock = false; 38 | } 39 | }); 40 | 41 | if (DEBUG && PROFILE_USER_DATA) { 42 | profiler_comments.userDataLoading--; 43 | } 44 | 45 | if (usernameMode != UsernameMode.ProfileName && userData.nick != undefined && userData.nick) { 46 | const maxSymbols = parseInt(settings.USERNAME_MAX_SIMBOLS.get()); 47 | nickName.textContent = maxSymbols <= 0 || userData.nick.length < maxSymbols ? userData.nick : userData.nick.slice(0, maxSymbols - 2) + `...`; 48 | 49 | if (usernameMode == UsernameMode.Both) { 50 | if (userId == nickName.textContent) { 51 | nickName.textContent = `u/${nickName.textContent}`; 52 | } else if (contentType == ContentType.Comment) { 53 | const commentHeader = nickName.parentElement?.parentElement?.parentElement?.parentElement?.parentElement; 54 | const flair = commentHeader.querySelector(`author-flair-event-handler`); 55 | let profileContainer = null; 56 | if (flair != null) { 57 | profileContainer = flair.parentElement; 58 | } else { 59 | profileContainer = appendElement(commentHeader, `div`, [`flex`, `flex-none`, `flex-row`, `items-center`, `flex-nowrap`, `gap-2xs`, `pt-[2px]`]); 60 | } 61 | 62 | const profileName = prependElement(profileContainer, `div`, [`font-bold`, `text-neutral-content-strong`, `text-12`]); 63 | profileName.textContent = `u/${userId}`; 64 | profileName.style.color = `#696969`; 65 | } else { 66 | const profileName = prependElement(nickName.parentElement, `div`); 67 | profileName.textContent = `| u/${userId}`; 68 | nickName.after(profileName); 69 | } 70 | } 71 | } 72 | 73 | if (settings.USER_INFO.isEnabled()) { 74 | const rating = document.createElement(`div`); 75 | rating.classList.add(`text-neutral-content-weak`, `text-12`); 76 | 77 | if (userData.rating != undefined) { 78 | rating.textContent = userData.rating < 10000 ? `${Math.round(userData.rating / 100) / 10}K` : `${Math.round(userData.rating / 1000)}K`; 79 | 80 | infoAnchor.after(rating); 81 | 82 | const point = document.createElement(`span`); 83 | if (contentType == ContentType.Comment) { 84 | point.classList.add(`inline-block`, `my-0`, `mx-2xs`, `text-12`, `text-neutral-content-weak`); 85 | } else { 86 | point.classList.add(`inline-block`, `my-0`, `created-separator`, `text-neutral-content-weak`); 87 | } 88 | 89 | point.textContent = `•`; 90 | rating.after(point); 91 | } 92 | 93 | if (userData.created != undefined && userData.created > Date.now() / 1000 - NEWUSER_SECONDS_SHIFT) { 94 | const newSvg = buildSvg(newUserSvg, 20, 20); 95 | newSvg.setAttribute(`viewBox`, `-2 -2 20 20`); 96 | 97 | tagsAnchor.before(newSvg); 98 | } 99 | 100 | if (userData.banned != undefined && userData.banned) { 101 | const newSvg = buildSvg(bannedUserSvg, 20, 20); 102 | newSvg.setAttribute(`viewBox`, `-2 -2 20 20`); 103 | 104 | tagsAnchor.before(newSvg); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/modules/feed/feed.ts: -------------------------------------------------------------------------------- 1 | import { MAX_LOAD_LAG } from '../../defines'; 2 | import { Database, DatabaseFactory } from '../../utils/database'; 3 | import { dynamicElement } from '../../utils/tools'; 4 | import { renderCustomFeed } from '../customFeed/customFeed'; 5 | import { renderPost } from '../posts/posts'; 6 | import { getCurrentSub, renderSub } from '../subs/subs'; 7 | import { renderFeedButtons } from './feedButtons'; 8 | import { FeedLocation, getFeedLocation } from './feedLocation'; 9 | import { redirectConfigs } from './feedRedirect'; 10 | import { FeedSort } from './feedSort'; 11 | 12 | export class FeedData { 13 | redirect: boolean; 14 | defaultSort: FeedSort; 15 | hiddenSort: Array; 16 | } 17 | 18 | export const defaultSorts = new Map([ 19 | [FeedLocation.Home, FeedSort.Best], 20 | [FeedLocation.Popular, FeedSort.Best], 21 | [FeedLocation.All, FeedSort.Hot], 22 | [FeedLocation.Sub, FeedSort.Best], 23 | [FeedLocation.Custom, FeedSort.Hot] 24 | ]); 25 | 26 | export const defaultFeedData: Database = new Database(`DEFAULT_FEED_DATA`, { 27 | factory: function (id: string) { 28 | const location: FeedLocation = FeedLocation[id as keyof typeof FeedLocation]; 29 | const config = redirectConfigs.get(location); 30 | 31 | return { redirect: !config.isOptional, defaultSort: defaultSorts.get(location), hiddenSort: [] } as FeedData; 32 | } 33 | }); 34 | 35 | export const customFeedData: Database = new Database(`CUSTOM_FEED_DATA`, { factory: DatabaseFactory.Null }); 36 | export const subsFeedData: Database = new Database(`SUBS_FEED_DATA`, { factory: DatabaseFactory.Null }); 37 | export const subsLatestSort: Database = new Database(`SUBS_LATEST_SORT`, { factory: DatabaseFactory.Null }); 38 | 39 | let postObserver: MutationObserver = null; 40 | 41 | export async function renderFeed(container: Element) { 42 | const main = await dynamicElement(() => container.querySelector(`#subgrid-container`)); 43 | 44 | // render embedded posts 45 | main.querySelectorAll(`shreddit-post`).forEach(post => { 46 | renderPost(post); 47 | }); 48 | 49 | const initialPostsObserver = new MutationObserver(mutations => { 50 | for (const mutation of mutations) { 51 | for (const node of mutation.addedNodes) { 52 | if (node instanceof HTMLElement && node.matches(`shreddit-post`)) { 53 | renderPost(node); 54 | } 55 | } 56 | } 57 | }); 58 | 59 | initialPostsObserver.observe(main, { childList: true, subtree: true }); 60 | 61 | setTimeout(() => { 62 | initialPostsObserver.disconnect(); 63 | }, MAX_LOAD_LAG); 64 | 65 | // render loaded posts 66 | initializePostObserver(main); 67 | 68 | const location = getFeedLocation(); 69 | switch (location) { 70 | case FeedLocation.Sub: 71 | renderSub(main); 72 | break; 73 | case FeedLocation.Custom: 74 | renderCustomFeed(main); 75 | break; 76 | } 77 | 78 | renderFeedButtons(main); 79 | } 80 | 81 | export function initializePostObserver(target: Element) { 82 | if (postObserver != null) { 83 | postObserver.disconnect(); 84 | } 85 | 86 | postObserver = new MutationObserver(mutations => { 87 | for (const mutation of mutations) { 88 | for (const node of mutation.addedNodes) { 89 | if (node instanceof HTMLElement) { 90 | if (node.matches(`faceplate-batch`)) { 91 | node.querySelectorAll(`shreddit-post`).forEach(post => { 92 | renderPost(post); 93 | }); 94 | } 95 | 96 | // load r/all posts 97 | if (node.matches(`article`)) { 98 | renderPost(node.querySelector(`shreddit-post`)); 99 | } 100 | } 101 | } 102 | } 103 | }); 104 | 105 | postObserver.observe(target, { childList: true, subtree: true }); 106 | } 107 | 108 | export function generateFeedHref(location: FeedLocation, sort: FeedSort): string { 109 | const lowerCaseSort = sort.toString().toLowerCase(); 110 | 111 | switch (location) { 112 | case FeedLocation.Sub: 113 | return `/r/${getCurrentSub()}/${lowerCaseSort}/`; 114 | case FeedLocation.Home: 115 | return `/${lowerCaseSort}/?feed=home`; 116 | case FeedLocation.Popular: 117 | return `/r/popular/${lowerCaseSort}/`; 118 | case FeedLocation.All: 119 | return `/r/all/${lowerCaseSort}/`; 120 | case FeedLocation.Custom: 121 | let split = window.location.href.split(`//www.reddit.com`); 122 | return split.length >= 2 ? `${split[1]}${lowerCaseSort}/` : `/404/`; 123 | default: 124 | return `/404/`; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/modules/bookmark.ts: -------------------------------------------------------------------------------- 1 | import { buildSvg } from '../utils/svg'; 2 | import { css } from './customCSS'; 3 | import { settings } from './settings/settings'; 4 | import style from './bookmark.less'; 5 | 6 | import bookmarkSavedSvg from '@resources/bookmarkSaved.svg'; 7 | import bookmarkUnsavedSvg from '@resources/bookmarkUnsaved.svg'; 8 | import { dynamicElement } from '../utils/tools'; 9 | import { BookmarkMode } from './bookmarkMode'; 10 | 11 | css.addStyle(style); 12 | 13 | export function renderCommentBookmark(comment: Element, forced: boolean = false) { 14 | const mode = settings.SAVED_BOOKMARK_COMMENTS.get() as BookmarkMode; 15 | 16 | if (mode == BookmarkMode.Disabled) return; 17 | 18 | const contextMenuButton = comment.querySelector(`shreddit-overflow-menu`)?.shadowRoot?.querySelector(`rpl-dropdown`); 19 | 20 | const saveButton = contextMenuButton.querySelector(`.save-comment-menu-button`); 21 | const saveButtonContent = saveButton.querySelector(`.text-14`); 22 | 23 | saveButton.addEventListener(`click`, () => { 24 | renderCommentBookmark(comment, true); 25 | }); 26 | 27 | let isSaved = saveButtonContent.textContent == `Remove from saved`; 28 | 29 | if (forced) { 30 | isSaved = true; 31 | } 32 | 33 | if (isSaved || forced || mode == BookmarkMode.Always) { 34 | const downVoteButton = comment.querySelector(`shreddit-comment-action-row`)?.shadowRoot?.querySelector(`button[downvote]`); 35 | css.registry(comment.querySelector(`shreddit-comment-action-row`)?.shadowRoot); 36 | 37 | const bookmarkButton = downVoteButton.cloneNode(true) as Element; 38 | downVoteButton.after(bookmarkButton); 39 | 40 | let bookmarkSvg = bookmarkButton.querySelector(`svg`); 41 | bookmarkSvg = replaceBookmarkIcon(bookmarkSvg, isSaved); 42 | 43 | bookmarkButton.addEventListener(`click`, () => { 44 | isSaved = !isSaved; 45 | bookmarkSvg = replaceBookmarkIcon(bookmarkSvg, isSaved); 46 | }); 47 | 48 | bookmarkButton.append(saveButton); 49 | 50 | saveButton.classList.add(`pp_bookmark_hiddenButton`); 51 | } 52 | } 53 | 54 | export async function renderBookmarkPost(post: Element, forced: boolean = false, forcedValue: boolean | void = undefined) { 55 | const mode = settings.SAVED_BOOKMARK_POSTS.get() as BookmarkMode; 56 | 57 | if (mode == BookmarkMode.Disabled) return; 58 | 59 | const contextMenu = await dynamicElement(() => post.querySelector(`shreddit-post-overflow-menu`)?.shadowRoot?.querySelector(`rpl-dropdown`)?.querySelector(`faceplate-menu`), 3000); 60 | 61 | if (contextMenu == undefined) { 62 | return; 63 | } 64 | 65 | let isSaved: boolean = true; 66 | let saveButton: Element = null; 67 | contextMenu.querySelectorAll(`li`).forEach(element => { 68 | const buttonSpan = element.querySelector(`.text-14`); 69 | 70 | if (buttonSpan.textContent == `Save`) { 71 | isSaved = false; 72 | } 73 | if (buttonSpan.textContent == `Save` || buttonSpan.textContent == `Remove from saved`) { 74 | saveButton = element; 75 | } 76 | }); 77 | 78 | // just refresh bookmark button 79 | if (saveButton == null) { 80 | const upVoteButton = post.shadowRoot?.querySelector(`button[upvote]`); 81 | const bookmarkButton = post.shadowRoot?.querySelector(`button[bookmark]`); 82 | bookmarkButton.className = upVoteButton.className; 83 | bookmarkButton.classList.add(`pp_bookmark_post`); 84 | return; 85 | } 86 | 87 | saveButton.addEventListener(`click`, () => { 88 | renderBookmarkPost(post, true, true); 89 | }); 90 | 91 | const upVoteButton = post.shadowRoot?.querySelector(`button[upvote]`); 92 | upVoteButton.addEventListener(`click`, () => { 93 | renderBookmarkPost(post, true); 94 | }); 95 | 96 | if (forcedValue != undefined) { 97 | isSaved = forcedValue as boolean; 98 | } 99 | 100 | if (isSaved || forced || mode == BookmarkMode.Always) { 101 | const downVoteButton = post.shadowRoot?.querySelector(`button[downvote]`); 102 | 103 | const bookmarkButton = downVoteButton.cloneNode(true) as Element; 104 | bookmarkButton.classList.add(`pp_bookmark_post`); 105 | bookmarkButton.removeAttribute(`disabled`); 106 | bookmarkButton.removeAttribute(`downvote`); 107 | bookmarkButton.setAttribute(`bookmark`, ``); 108 | downVoteButton.after(bookmarkButton); 109 | 110 | let bookmarkSvg = bookmarkButton.querySelector(`svg`); 111 | bookmarkSvg = replaceBookmarkIcon(bookmarkSvg, isSaved); 112 | 113 | bookmarkButton.addEventListener(`click`, () => { 114 | isSaved = !isSaved; 115 | bookmarkSvg = replaceBookmarkIcon(bookmarkSvg, isSaved); 116 | }); 117 | 118 | bookmarkButton.append(saveButton); 119 | 120 | saveButton.classList.add(`pp_bookmark_hiddenButton`); 121 | } 122 | } 123 | 124 | function replaceBookmarkIcon(originSvg: Element, isSaved: boolean): SVGSVGElement { 125 | const newSvg = buildSvg(isSaved ? bookmarkSavedSvg : bookmarkUnsavedSvg, 20, 20) as SVGSVGElement; 126 | newSvg.setAttribute(`width`, `16px`); 127 | newSvg.setAttribute(`height`, `16px`); 128 | 129 | originSvg.replaceWith(newSvg); 130 | 131 | return newSvg; 132 | } 133 | --------------------------------------------------------------------------------