commit d48d85f66fae7277ecf1f35db667b1f7f6e40464 Author: Sean Date: Wed Jan 22 12:56:58 2025 -0800 initial commit to README diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d53bc7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +.git + +/node_modules/ +/tmp/ +/build/ +/coverage/ +/.coverage/ +/.eslintcache +/.env +/deploy.sh +/.vs/ +yarn-error.log* +/junit.xml + +/dist/ +/static/ +/public/ +/dist/ +/soapbox.zip + +.idea +.DS_Store + +# Custom build files +/custom/**/* +!/custom/* +/custom/*.* +!/custom/.gitkeep +!/custom/**/.gitkeep + +# surge.sh +/CNAME +/AUTH +/CORS +/ROUTER diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7b1cff6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# EditorConfig is awesome: https://EditorConfig.org +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b116e6d --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +NODE_ENV=development +# BACKEND_URL="https://example.com" +# PROXY_HTTPS_INSECURE=false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..256b5ff --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +/node_modules/** +/dist/** +/static/** +/public/** +/tmp/** +/coverage/** +/custom/** diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..12ddcf8 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,338 @@ +{ + "root": true, + "extends": [ + "eslint:recommended", + "plugin:import/typescript", + "plugin:compat/recommended", + "plugin:tailwindcss/recommended" + ], + "env": { + "browser": true, + "node": true, + "es6": true, + "jest": true + }, + "globals": { + "ATTACHMENT_HOST": false + }, + "plugins": [ + "jsdoc", + "react", + "jsx-a11y", + "import", + "promise", + "react-hooks", + "@typescript-eslint", + "formatjs" + ], + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "jsx": true + }, + "ecmaVersion": 2018 + }, + "settings": { + "react": { + "version": "detect" + }, + "import/ignore": [ + "node_modules", + "\\.(css|scss|json)$" + ], + "import/resolver": { + "typescript": true, + "node": true + }, + "polyfills": [ + "es:all", + "fetch", + "IntersectionObserver", + "Promise", + "ResizeObserver", + "URL", + "URLSearchParams" + ], + "tailwindcss": { + "config": "tailwind.config.ts" + } + }, + "rules": { + "brace-style": "error", + "comma-dangle": [ + "error", + "always-multiline" + ], + "comma-spacing": [ + "warn", + { + "before": false, + "after": true + } + ], + "comma-style": [ + "warn", + "last" + ], + "import/no-duplicates": "error", + "space-before-function-paren": [ + "error", + "never" + ], + "space-infix-ops": "error", + "space-in-parens": [ + "error", + "never" + ], + "keyword-spacing": "error", + "dot-notation": "error", + "eqeqeq": "error", + "indent": [ + "error", + 2, + { + "SwitchCase": 1, + "ignoredNodes": [ + "TemplateLiteral" + ] + } + ], + "jsx-quotes": [ + "error", + "prefer-single" + ], + "key-spacing": [ + "error", + { + "mode": "minimum" + } + ], + "no-catch-shadow": "error", + "no-cond-assign": "error", + "no-console": [ + "warn", + { + "allow": [ + "error", + "warn" + ] + } + ], + "no-extra-semi": "error", + "no-const-assign": "error", + "no-fallthrough": "error", + "no-irregular-whitespace": "error", + "no-loop-func": "error", + "no-mixed-spaces-and-tabs": "error", + "no-nested-ternary": "error", + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "react-inlinesvg" + ], + "message": "Use the SvgIcon component instead." + } + ] + } + ], + "no-trailing-spaces": "warn", + "no-undef": "error", + "no-unreachable": "error", + "no-unused-expressions": "error", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "vars": "all", + "args": "none", + "ignoreRestSiblings": true + } + ], + "no-useless-escape": "warn", + "no-var": "error", + "object-curly-spacing": [ + "error", + "always" + ], + "padded-blocks": [ + "error", + { + "classes": "always" + } + ], + "prefer-const": "error", + "quotes": [ + "error", + "single" + ], + "semi": "error", + "space-unary-ops": [ + "error", + { + "words": true, + "nonwords": false + } + ], + "strict": "off", + "valid-typeof": "error", + "react/jsx-boolean-value": "error", + "react/jsx-closing-bracket-location": [ + "error", + "line-aligned" + ], + "react/jsx-curly-spacing": "error", + "react/jsx-equals-spacing": "error", + "react/jsx-first-prop-new-line": [ + "error", + "multiline-multiprop" + ], + "react/jsx-indent": [ + "error", + 2 + ], + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-undef": "error", + "react/jsx-tag-spacing": "error", + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/jsx-wrap-multilines": "error", + "react/no-multi-comp": "off", + "react/no-string-refs": "error", + "react/self-closing-comp": "error", + "jsx-a11y/accessible-emoji": "warn", + "jsx-a11y/alt-text": "warn", + "jsx-a11y/anchor-has-content": "warn", + "jsx-a11y/anchor-is-valid": [ + "warn", + { + "components": [ + "Link", + "NavLink" + ], + "specialLink": [ + "to" + ], + "aspect": [ + "noHref", + "invalidHref", + "preferButton" + ] + } + ], + "jsx-a11y/aria-activedescendant-has-tabindex": "warn", + "jsx-a11y/aria-props": "warn", + "jsx-a11y/aria-proptypes": "warn", + "jsx-a11y/aria-role": "warn", + "jsx-a11y/aria-unsupported-elements": "warn", + "jsx-a11y/heading-has-content": "warn", + "jsx-a11y/html-has-lang": "warn", + "jsx-a11y/iframe-has-title": "warn", + "jsx-a11y/img-redundant-alt": "warn", + "jsx-a11y/interactive-supports-focus": "warn", + "jsx-a11y/label-has-for": "off", + "jsx-a11y/mouse-events-have-key-events": "warn", + "jsx-a11y/no-access-key": "warn", + "jsx-a11y/no-distracting-elements": "warn", + "jsx-a11y/no-noninteractive-element-interactions": [ + "warn", + { + "handlers": [ + "onClick" + ] + } + ], + "jsx-a11y/no-onchange": "warn", + "jsx-a11y/no-redundant-roles": "warn", + "jsx-a11y/no-static-element-interactions": [ + "warn", + { + "handlers": [ + "onClick" + ] + } + ], + "jsx-a11y/role-has-required-aria-props": "warn", + "jsx-a11y/role-supports-aria-props": "off", + "jsx-a11y/scope": "warn", + "jsx-a11y/tabindex-no-positive": "warn", + "import/extensions": ["error", "ignorePackages"], + "import/newline-after-import": "error", + "import/no-extraneous-dependencies": "error", + "import/no-unresolved": "error", + "import/no-webpack-loader-syntax": "error", + "import/order": [ + "error", + { + "groups": [ + "builtin", + "external", + "internal", + "parent", + "sibling", + "index", + "object", + "type" + ], + "newlines-between": "always", + "alphabetize": { + "order": "asc" + } + } + ], + "@typescript-eslint/member-delimiter-style": "error", + "promise/catch-or-return": "error", + "react-hooks/rules-of-hooks": "error", + "tailwindcss/classnames-order": [ + "error", + { + "classRegex": "^(base|container|icon|item|list|outer|wrapper)?[c|C]lass(Name)?$", + "config": "tailwind.config.ts" + } + ], + "tailwindcss/migration-from-tailwind-2": "error", + + + "formatjs/enforce-default-message": "error", + "formatjs/enforce-id": "error", + "formatjs/no-literal-string-in-jsx": "error" + }, + "overrides": [ + { + "files": [ + "**/*.ts", + "**/*.tsx" + ], + "rules": { + "no-undef": "off", + "space-before-function-paren": "off" + }, + "parser": "@typescript-eslint/parser" + }, + { + "files": [ + "src/components/ui/**/*" + ], + "rules": { + "jsdoc/require-jsdoc": [ + "error", + { + "publicOnly": true, + "require": { + "ArrowFunctionExpression": true, + "ClassDeclaration": true, + "ClassExpression": true, + "FunctionDeclaration": true, + "FunctionExpression": true, + "MethodDefinition": true + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4157440 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +CHANGELOG.md merge=union \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..249d2e2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository + +github: soapbox-pub +liberapay: soapbox +custom: "https://soapbox.pub/donate/" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1763143 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +/node_modules/ +/tmp/ +/build/ +/coverage/ +/.coverage/ +/.eslintcache +/.env +/deploy.sh +/.vs/ +yarn-error.log* +/junit.xml +*.timestamp-* +*.bundled_* + +/dist/ +/static/ +/public/ +/dist/ +/soapbox.zip + +.idea +.DS_Store + +# Custom build files +/custom/**/* +!/custom/* +/custom/*.* +!/custom/.gitkeep +!/custom/**/.gitkeep + +# surge.sh +/CNAME +/AUTH +/CORS +/ROUTER diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..d2bfc91 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,52 @@ +image: node:22 + +default: + interruptible: true + +stages: + - build + - deploy + +build: + stage: build + before_script: + - yarn install --ignore-scripts + - apt-get update -y && apt-get install -y zip + script: + - yarn lint + - yarn i18n && git diff --quiet || (echo "Locale files are out of date. Please run `yarn i18n`" && exit 1) + - NODE_ENV=production yarn build + - cp dist/index.html dist/404.html + - cd dist && zip -r ../soapbox.zip . && cd .. + artifacts: + paths: + - soapbox.zip + +review: + stage: deploy + environment: + name: review/$CI_COMMIT_REF_NAME + url: https://$CI_COMMIT_REF_SLUG.git.soapbox.pub + before_script: + - apt-get update -y && apt-get install -y unzip + script: + - unzip soapbox.zip -d dist + - npx -y surge dist $CI_COMMIT_REF_SLUG.git.soapbox.pub + allow_failure: true + when: manual + +pages: + stage: deploy + before_script: + - apt-get update -y && apt-get install -y unzip + script: + # artifacts are kept between jobs + - unzip soapbox.zip -d public + variables: + NODE_ENV: production + artifacts: + paths: + - public + only: + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME \ No newline at end of file diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md new file mode 100644 index 0000000..579c32f --- /dev/null +++ b/.gitlab/issue_templates/Bug.md @@ -0,0 +1,7 @@ +### Environment + +* Soapbox version: +* Backend (Mastodon, Pleroma, etc): +* Browser/OS: + +### Bug description diff --git a/.gitlab/merge_request_templates/BeforeAndAfter.md b/.gitlab/merge_request_templates/BeforeAndAfter.md new file mode 100644 index 0000000..6e457a7 --- /dev/null +++ b/.gitlab/merge_request_templates/BeforeAndAfter.md @@ -0,0 +1,8 @@ +## Summary + + + +## Screenshots (if appropriate): +| Before | After | +| ------ | ----- | +| | | diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md new file mode 100644 index 0000000..8a61929 --- /dev/null +++ b/.gitlab/merge_request_templates/Default.md @@ -0,0 +1,5 @@ +## Summary + + + +## Screenshots (if appropriate): diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..f2bf425 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,11 @@ +# This configuration file was automatically generated by Gitpod. +# Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) +# and commit this file to your remote git repository to share the goodness with others. + +# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart + +tasks: + - init: yarn install && yarn run build + command: yarn run start + + diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..36af219 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..fc508d7 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,8 @@ +{ + "*.js": "eslint --cache", + "*.cjs": "eslint --cache", + "*.mjs": "eslint --cache", + "*.ts": "eslint --cache", + "*.tsx": "eslint --cache", + "src/styles/**/*.scss": "stylelint" +} diff --git a/.madgerc b/.madgerc new file mode 100644 index 0000000..7d1dcdc --- /dev/null +++ b/.madgerc @@ -0,0 +1,3 @@ +{ + "fileExtensions": ["ts", "tsx"] +} \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000..f2e8095 --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,19 @@ +{ + "extends": ["stylelint-config-standard-scss"], + "rules": { + "alpha-value-notation": null, + "at-rule-no-unknown": null, + "at-rule-empty-line-before": ["always", { "ignore": ["after-comment", "first-nested", "inside-block", "blockless-after-same-name-blockless", "blockless-after-blockless"] }], + "color-function-notation": null, + "custom-property-pattern": null, + "declaration-block-no-redundant-longhand-properties": null, + "declaration-empty-line-before": "never", + "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }], + "no-descending-specificity": null, + "no-duplicate-selectors": null, + "no-invalid-position-at-import-rule": null, + "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}], + "scss/operator-no-unspaced": null, + "selector-class-pattern": null + } +} diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..f8ab203 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 22.11.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d1762aa --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss", + "stylelint.vscode-stylelint", + "wix.vscode-import-cost", + "redhat.vscode-yaml" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3eef301 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "css.validate": false, + "editor.insertSpaces": true, + "editor.tabSize": 2, + "files.associations": { + "*.conf.template": "properties" + }, + "files.eol": "\n", + "files.insertFinalNewline": false, + "json.schemas": [ + { + "fileMatch": [".lintstagedrc.json"], + "url": "https://json.schemastore.org/lintstagedrc.schema.json" + }, + { + "fileMatch": ["renovate.json"], + "url": "https://docs.renovatebot.com/renovate-schema.json" + } + ], + "scss.validate": false, + "typescript.tsdk": "node_modules/typescript/lib", + "path-intellisense.extensionOnImport": true, + "javascript.preferences.importModuleSpecifierEnding": "js", + "javascript.preferences.importModuleSpecifier": "non-relative", + "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.preferences.importModuleSpecifier": "non-relative" +} diff --git a/.vscode/soapbox.code-snippets b/.vscode/soapbox.code-snippets new file mode 100644 index 0000000..b31d50f --- /dev/null +++ b/.vscode/soapbox.code-snippets @@ -0,0 +1,58 @@ +{ + // Place your Soapbox workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "React component": { + "scope": "typescriptreact", + "prefix": ["component", "react component"], + "body": [ + "import React from 'react';", + "", + "interface I${1:Component} {", + "}", + "", + "/** ${1:Component} component. */", + "const ${1:Component}: React.FC = () => {", + " return (", + " <>", + " );", + "};", + "", + "export default ${1:Component};" + ], + "description": "React component" + }, + "React component test": { + "scope": "typescriptreact", + "prefix": ["test", "component test", "react component test"], + "body": [ + "import React from 'react';", + "", + "import { render, screen } from 'soapbox/jest/test-helpers';", + "", + "import ${1:Component} from '${2:..}';", + "", + "describe('<${1:Component} />', () => {", + " it('renders', () => {", + " render(<${1:Component} />);", + "", + " expect(screen.getByTestId('${3:test}')).toBeInTheDocument();", + " });", + "});" + ], + "description": "React component test" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0c531d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,415 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Hashtags: let users follow hashtags (Mastodon, Akkoma). +- Posts: Support posts filtering on recent Mastodon versions +- Reactions: Support custom emoji reactions +- Compatibility: Support Mastodon v2 timeline filters. +- Compatibility: Preliminary support for Ditto backend. +- Compatibility: Support Firefish. +- Posts: Support dislikes on Friendica. +- UI: added a character counter to some textareas. +- UI: added new experience for viewing Media +- Hotkeys: Added `/` as a hotkey for search field. + +### Changed +- Posts: truncate Nostr pubkeys in reply mentions. +- Posts: upgraded emoji picker component. +- Posts: improved design of threads. +- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist. +- UI: added sticky column header. +- UI: add specific zones the user can drag-and-drop files. +- UI: disable toast notifications for API errors. +- Chats: Display year for older messages creation date. + +### Fixed +- Posts: fixed emojis being cut off in reactions modal. +- Posts: fix audio player progress bar visibility. +- Posts: fix audio player avatar aspect ratio for non-square avatars. +- Posts: added missing gap in pending status. +- Compatibility: fixed quote posting compatibility with custom Pleroma forks. +- Profile: fix "load more" button height on account gallery page. +- 18n: fixed Chinese language being detected from the browser. +- Conversations: fixed pagination (Mastodon). +- Compatibility: fix version parsing for Friendica. +- UI: fixed various overflow issues related to long usernames. +- UI: fixed display of Markdown code blocks in the reply indicator. +- Auth: fixed too many API requests when the server has an error. +- Auth: Don't display "username or e-mail" if username is not allowed. + +## [3.2.0] - 2023-02-15 + +### Added +- Admin: redirect the homepage to any URL. +- Compatibility: added compatibility with Friendica. +- Posts: bot badge on statuses from bot accounts. +- Compatibility: improved browser support for older browsers. +- Events: allow to repost events in event menu. +- Profile: Add RSS link to user profiles. +- Reactions: adds support for reacting to chat messages. +- Groups: initial support for groups. +- Profile: add RSS link to user profiles. +- Chats: reset chat message field height after sending a message. +- Admin: allow to manage announcements. + +### Changed +- Chats: improved display of media attachments. +- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away. +- Posts: increased font size of focused status in threads. +- Posts: let "mute conversation" be clicked from any feed, not just noficiations. +- Posts: display all emoji reactions. +- Reactions: improved UI of reactions on statuses. +- Profile: make verified badge more prominent, overlapping with avatar. + +### Fixed +- Admin: fixed hover card in reports modal shows reporter not reportee +- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load. +- Chats: don't display "copy" button for messages without text. +- Posts: don't have to click the play button twice for embedded videos. +- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header. +- Modals: fix media modal automatically switching to video. +- Navigation: profile dropdown erratic behavior. +- Posts: fix posts filtering. + +### Removed +- Admin: single user mode. Now the homepage can be redirected to any URL. + +## [3.1.0] - 2023-01-13 + +### Added +- Compatibility: rudimentary support for Takahē. +- UI: added backdrop blur behind modals. +- Admin: let admins configure media preview for attachment thumbnails. +- Login: accept `?server` param in external login, eg `fe.soapbox.pub/login/external?server=gleasonator.com`. +- Backups: restored Pleroma backups functionality. +- Export: restored "Export data" to CSV. + +### Changed +- Posts: letterbox images to 19:6 again. +- Status Info: moved context (repost, pinned) to improve UX. +- Posts: remove file icon from empty link previews. +- Settings: moved "Import data" under settings. +- Composer: add more descriptive discard confirmation message. + +### Fixed +- Layout: use accent color for "floating action button" (mobile compose button). +- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker. +- Datepicker: correctly default to the current year. +- Scheduled posts: fix page crashing on deleting a scheduled post. +- Events: don't crash when searching for a location. +- Search: fixes an abort error when using the navbar search component. +- Posts: fix monospace font in Markdown code blocks. +- Modals: fix action buttons overflow +- Editing: don't insert edited posts to the top of the feed. +- Editing: don't display edited posts as pending posts. +- Modals: close modal when navigating to a different page. +- Modals: fix "View context" button in media modal. +- Posts: let unauthenticated users to translate posts if allowed by backend. +- Chats: fix jumpy scrollbar. +- Composer: fix alignment of icon in submit button. +- Login: add a border around QR codes. +- Composer: don't display action button in reply indicator. + +## [3.0.0] - 2022-12-25 + +### Added +- Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon). +- Events: ability to create, view, and comment on Events (on Rebased). +- Onboarding: display an introduction wizard to newly registered accounts. +- Posts: translate foreign language posts into your native language (on Rebased, Mastodon; if configured by the admin). +- Posts: ability to view quotes of a post (on Rebased). +- Posts: hover the "replying to" line to see a preview card of the parent post. +- Chats: ability to leave a chat (on Rebased, Truth Social). +- Chats: ability to disable chats for yourself. +- Layout: added right-to-left support for Arabic, Hebrew, Persian, and Central Kurdish languages. +- Composer: support custom emoji categories. +- Search: ability to search posts from a specific account (on Pleroma, Rebased). +- Theme: auto-detect system theme by default. +- Profile: remove a specific user from your followers (on Rebased, Mastodon). +- Suggestions: ability to view all suggested profiles. +- Feeds: display suggested accounts in Home feed (optional by admin). +- Compatibility: added compatibility with Truth Social, Fedibird, Pixelfed, Akkoma, and Glitch. +- Developers: added Test feed, Service Worker debugger, and Network Error preview. +- Reports: display server rules in reports. Let users select rule violations when submitting a report. +- Admin: added Theme Editor, a GUI for customizing the color scheme. +- Admin: custom badges. Admins can add non-federating badges to any user's profile (on Rebased, Pleroma). +- Admin: consolidated user dropdown actions (verify/suggest/etc) into a unified "Moderate User" modal. +- i18n: updated translations for Italian, Polish, Arabic, Hebrew, and German. +- Toast: added the ability to dismiss toast notifications. + +### Changed +- UI: the whole UI has been overhauled both inside and out. 97% of the codebase has been rewritten to TypeScript, and a new component library has been introduced with Tailwind CSS. +- Chats: redesigned chats. Includes an improved desktop UI, unified chat widget, expanding textarea, and autosuggestions. +- Lists: ability to edit and delete a list. +- Settings: unified settings under one path with separate sections. +- Posts: changed the thumbs-up icon to a heart. +- Posts: move instance favicon beside username instead of post timestamp. +- Posts: changed the behavior of content warnings. CWs and sensitive media are unified into one design. +- Posts: redesigned interaction counters to use text instead of icons. +- Posts: letterbox images taller than 1:1. +- Profile: overhauled user profiles to be consistent with the rest of the UI. +- Composer: move emoji button alongside other composer buttons, add numerical counter. +- Birthdays: move today's birthdays out of notifications into right sidebar. +- Performance: improve scrolling/navigation between feeds by using a virtual window library. +- Admin: reorganize UI into 3-column layout. +- Admin: include external link to frontend repo for the running commit. +- Toast: redesigned toast notifications. + +### Removed +- Theme: Halloween theme. +- Settings: advanced notification settings. +- Settings: dyslexic mode. +- Settings: demetricator. +- Profile: ability to set and view private notes on an account. +- Feeds: per-feed filters for replies, media, etc. +- Backup and export functionality (for now). +- Posts: hide non-emoji images embedded in post content. + +### Security +- Glitch Social: fixed XSS vulnerability on Glitch Social where custom emojis could be exploited to embed a script tag. + +## [2.0.0] - 2022-05-01 +### Added +- Quote Posting: repost with comment on Fedibird and Rebased. +- Profile: ability to feature other users on your profile (on Rebased, Mastodon). +- Profile: ability to add location to the user's profile (on Rebased, Truth Social). +- Birthdays: ability to add a birthday to your profile (on Rebased, Pleroma). +- Birthdays: support for age-gated registration if configured by the admin (on Rebased, Pleroma). +- Birthdays: display today's birthdays in notifications. +- Notifications: added unread badge to favicon when user has notifications. +- Notifications: display full attachments in notifications instead of links. +- Search: added a dedicated search page with prefilled suggestions. +- Compatibility: improved support for Mastodon, added support for Mitra. +- Ethereum: Metamask sign-in with Mitra. +- i18n: added Shavian alphabet (`en-Shaw`) transliteration. +- i18n: added Icelandic translation. + +### Changed +- Feeds: added gaps between posts in feeds. +- Feeds: automatically load new posts when scrolled to the top of the feed. +- Layout: improved design of top navigation bar. +- Layout: add left sidebar navigation. +- Icons: replaced Fork Awesome icons with Tabler icons. +- Posts: moved mentions out of the post content into an area above the post for replies (on Pleroma and Rebased - Mastodon falls back to the old behavior). +- Composer: use graphical ring counter for character count. + +### Fixed +- Multi-Account: fix switching between profiles on different servers with the same local username. + +## [1.3.0] - 2021-07-02 +### Changed +- Layout: show right sidebar on all pages. +- Statuses: improve display of multiple rich media items. +- Statuses: let media be cropped less (when dimensions are provided). +- Profile metadata: show only 4 by default, let items be added and removed. + +### Fixed +- Performance: fixed various performance issues, especially related to the post composer and chats. +- Composer: fixed upload form style on light theme. +- Composer: fixed emoji search when a custom emoji was invalid. +- Composer: fixed uploaded images sometimes being turned sideways. +- Chats: fix "Message" button on intermediate screen sizes. +- Chats: filter out invalid chats. +- Notifications: fixed notification counter on Brave Android (and possibly others). +- Localization: fixed hardcoded strings. +- Lists: fixed frontend issues related to lists (there are still backend issues). +- Modals: fixed unauthorized modal style. +- Hotkeys: remove unused hotkeys, fix broken ones. +- Sidebar: fix alignment of icons. +- Various iOS fixes. + +### Added +- Statuses: added greentext support, configurable site-wide by admin. +- Statuses: added Mastodon's audio player. +- Statuses: indicate > 4 attachments. +- Statuses: display tombstones in place of deleted posts (to not break threads). +- Composer: added blurhash to upload form. +- Localization: support localization of About pages, Promo Panel items, and Link Footer items. +- Localization: display labels for default emoji reactions. +- Alerts: return detailed error for 502. +- Profile: support hidden stats. +- Profile: support blocking notifications from people you don't follow. +- Notifications: support account move notification. +- Timelines: let Fediverse explanation box be dismissed. +- Admin: optimistic user deletion. +- Admin: add monthly active users count to dashboard. +- Admin: add user retention % to dashboard. + +## [1.2.3] - 2021-04-18 +### Changed +- Twemoji now bundled + +### Fixed +- Redirect user after registration +- Delete invalid auth users from browser +- Uploaded files ending in .blob + +## [1.2.2] - 2021-04-13 +### Fixed +- verify_credentials infinite loop bug +- Emoji reacts not being sent through notifications +- Contrast of Polls + +### Added +- Configurable FQN for local accounts +- Polish translations + +## [1.2.1] - 2021-04-06 +### Fixed +- "View context" button on videos +- Login page successfully redirects Home + +## [1.2.0] - 2021-04-02 +### Added +- Remote follow button +- Display "Bot" tag for bot users +- Ability to view remote timelines +- Admin interface +- Integrated moderation features +- Multiple account support +- Verification (blue checkmark) +- Better support for follow requests +- Improve feedback when registering a new account +- Ability to import Mutes from CSV +- Add server information page +- "Follow" button is more responsive +- Portuguese translations + +### Fixed +- Heart reaction works on Pleroma >= 2.3.0 +- Pagination of Blocks and Mutes + +## [1.1.0] - 2020-10-05 +### Fixed +- General user interface and ease-of-use improvements for both mobile and desktop +- General loading and performance improvements, including shrinking bundle size +- GIF handling: AutoPlayGif Preference support, including avatars and profile banners +- Sidebar menu browser compatibility +- React 17.x compatibility +- Timeline jumping during scroll +- Collapse of compose modal after privacy scope change +- Media attachment rendering +- Thread view reply post rendering +- Thread view scroll to selected post rendering +- Bookmarking of posts +- Edit Profile: checkbox handling +- Edit Profile: multi-line bio with link support +- Muted Users: posts of muted users now appear in profile view +- Forms: security issue resolved with POST method on all forms +- Internationalization: increased elements that are internationalizable +- Composer: Forcing the scope to default after settings save. + +### Added +- Chats, currently one-to-one, evolving with Pleroma BE capabilities, including: + - Initiate chat via `Message` button on profile + - Up to 4 open foreground chat windows in desktop, with open/minimize/close and notification counter + - Browser tab notification counter includes total chat and post notifications + - Chats list with total chats notification counter and audio notification toggle + - Unique chat audio notification + - Add attachment + - Delete chat message + - Report chat account + - Chats icon with notification counter in top navbar in mobile view + - Chats marked read on chat hover or on chat key event +- Audio player for audio uploads, including ogg, oga, and wav support +- Integration with Patron recurring donations platform +- Profile hover panels, with click to Follow/Unfollow +- Posts: Favicon of user's home instance included on post +- Soapbox configuration page, including: + - Site preview, including light/dark theme toggle rendering + - Logo + - Brand color using color picker + - Copyright footer + - Promo panel custom links for timeline pages + - Home footer custom links for static pages + - Editable JSON based configuration option +- Themes: Light/dark theme toggle in top navbar +- Themes: Halloween mode in Preferences page +- Markdown support in post composer, as default +- Loading indicator general improvements +- Polls: Add media attachments +- Polls: Mouseover hint on poll compose radiobutton to teach single/multi-choice poll type toggling +- Polls: Remove blank poll by either toggling Poll icon or by removing poll options +- Registration: Support for `Account approval required` setting in Pleroma AdminFE, via dynamic `Why do you want to join?` textarea on registration page +- Filtering: `Muted Words` menu item and page +- Filtering: Direct messages filter toggle on Home timeline +- Floating top navbar during scroll +- Import Data: `Import follows` and `import blocks` +- Profile: Media panel +- Media: Media gallery thumbnails +- Media: Any media type as attachment +- General documentation improvements +- Delete Account feature for user self-deletion in Security page +- Registration: Captcha reload on image click +- Fediverse timeline explanation accordion toggle +- Tests: React reducers tests +- Profile: Max profile meta fields defined by Pleroma BE capability +- Profile: Verified user checkbox +- Admin: Reports counter and top navbar element for admin accounts, linked to Pleroma AdminFE +- [Renovate.json](https://docs.renovatebot.com/configuration-options/) support + +### Changed +- Revoke OAuth token on logout +- Home sidebar rearrangement +- Compose form icons +- User event notifications: improved rendering and added color coding +- Home timeline: `Show reposts` filter toggle default to `off` +- Direct Messages: Changed API usage from `conversations` to `direct` +- Project documentation management system, using CI +- Documentation: site customization and installation on sub-domain +- Redux update + +### Removed +- FontAwesome dependencies, with full switch to ForkAwesome +- Requirement for use of soapbox.json for configuration +- Direct Message links from menus, partial deprecation due to chats + +## [1.0.0] - 2020-06-15 +### Added +- Emoji reactions. +- Ability to set brand color in soapbox.json. +- Security UI. +- Proper i18n support. +- Link to AdminFE. +- Password reset. +- Ability to edit profile fields. +- Many new automated tests. + +### Changed +- Overhauled theming system to use native CSS variables. +- Reorganized folder structure. +- Redesigned post composer. +- All references to "Gab" removed. +- Disable notification sounds by default. +- Rename 'Favourite' to 'Like' +- Improve design of floating compose button. +- Force media to have a static height, fixing jumpy timelines. + +### Fixed +- Composer: Move cursor to end of text. +- Composer: Tagging yourself in replies. +- Composer: State issues between compose modal and inline composer. +- AutoPlayGif for images in posts. +- Handle registration when email confirmation is required. +- Ability to add non-follows to Lists. +- Don't hide locked accounts from non-followers. +- Delete + Redraft errors. +- Preferences: Display name limitations removed. +- Hide "Embed" functionality from menus. +- Only show 'Trends' and 'Who To Follow' when supported by the backend. +- Hide reposted media from account media tab. + +## [0.9.0] - 2020-04-30 +### Added +- Initial beta release. + +[Unreleased]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v1.0.0...develop +[Unreleased patch]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v1.0.0...stable/1.0.x +[1.0.0]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v0.9.0...v1.0.0 +[0.9.0]: https://gitlab.com/soapbox-pub/soapbox/-/tags/v0.9.0 diff --git a/COFE_OF_CONDUCT.md b/COFE_OF_CONDUCT.md new file mode 100644 index 0000000..1ba85c5 --- /dev/null +++ b/COFE_OF_CONDUCT.md @@ -0,0 +1,49 @@ +``` + o$$$$$$oo + o$" "$oo + $ o""""$o "$o + "$ o "o "o $ + "$ $o $ $ o$ + "$ o$"$ o$ + "$ooooo$$ $ o$ + o$ """ $ " $$$ " $ + o$ $o $$" " " + $$ $ " $ $$$o"$ o o$" + $" o "" $ $" " o" $$ + $o " " $ o$" o" o$" + "$o $$ $ o" o$$" + ""o$o"$" $oo" o$" + o$$ $ $$$ o$$ + o" o oo"" "" "$o + o$o" "" $ + $" " o" " " " "o + $$ " " o$ o$o " $ + o$ $ $ o$$ " " "" + o $ $" " "o o$ + $ o $o$oo$"" + $o $ o o o"$$ + $o o $ $ "$o + $o $ o $ $ "o + $ $ "o $ "o"$o + $ " o $ o $$ + $o$o$o$o$$o$$$o$$o$o$$o$$o$$$o$o$o$o$o$o$o$o$o$ooo + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$o + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ " $$$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ "$$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ o$$$$" + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ooooo$$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$" + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$""""" + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ + $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$" +"$o$o$o$o$o$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$" + "$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$" + "$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$""" + """"""""""""""""""""""""""""""""""""""""""""""""""""" +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa790e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:21 as build +WORKDIR /app +COPY package.json . +COPY yarn.lock . +RUN yarn +COPY . . +ARG NODE_ENV=production +RUN yarn build + +FROM nginx:stable-alpine +EXPOSE 5000 +ENV PORT=5000 +ENV FALLBACK_PORT=4444 +ENV BACKEND_URL=http://localhost:4444 +ENV CSP= +COPY installation/docker.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/dist /usr/share/nginx/html diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..bb15e6d --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,18 @@ +FROM node:21 + +RUN apt-get update &&\ + apt-get install -y inotify-tools &&\ + # clean up apt + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV NODE_ENV=development + +COPY package.json . +COPY yarn.lock . +RUN yarn + +COPY . . + +ENV DEVSERVER_URL=http://0.0.0.0:3036 +CMD yarn dev \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..501809f --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +![Soapbox Screenshot](soapbox-screenshot.png) + +**Wokebox** is customizable open-source software that puts the power of social media in the hands of the people. Based on 'soapbox-fe' except woke and maintained by people who don't know what they're doing. + + +# On The Fediverse + +Wokebox is the **frontend** (what users see) + +> 💡 If you're starting a new server, I wouldn't start here. +> + +# Try It Out + +Wokebox is based on Soapbox. You can use soapbox with **any existing Mastodon/Pleroma server**: + +- [fe.soapbox.pub](https://fe.soapbox.pub) - enter your server's domain name to use Soapbox on any server! + + +> 💡 If using Pleroma, it's likely Wokebox won't work right. + + +# Project Philosophy + +This is like Soapbox, but woke. Do you use strictly worse software for ideology reasons? I guess if you're on fedi this is all but guaranteed. + +We know that you (a discerning fediverse user) would never use anything or consume content that has problematic origins/influences. That's why this web app is optimized for thinkpad users running openBSD. + +# License & Credits + +Wokebox is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Wokebox is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with Wokebox. If not, see . diff --git a/app.json b/app.json new file mode 100644 index 0000000..bd168fb --- /dev/null +++ b/app.json @@ -0,0 +1,7 @@ +{ + "name": "Soapbox", + "description": "Software for the next generation of social media.", + "keywords": ["fediverse"], + "website": "https://soapbox.pub", + "stack": "container" +} diff --git a/compose-dev.yaml b/compose-dev.yaml new file mode 100644 index 0000000..2359ac5 --- /dev/null +++ b/compose-dev.yaml @@ -0,0 +1,10 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile.dev + image: soapbox-dev + ports: + - "3036:3036" + volumes: + - .:/app \ No newline at end of file diff --git a/custom/.gitkeep b/custom/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/custom/instance/.gitkeep b/custom/instance/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/custom/locales/.gitkeep b/custom/locales/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/custom/modules/.gitkeep b/custom/modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..071fa9c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Soapbox Docs + +Read the Soapbox documentation here: https://docs.soapbox.pub/soapbox/ \ No newline at end of file diff --git a/heroku.yml b/heroku.yml new file mode 100644 index 0000000..8eec25b --- /dev/null +++ b/heroku.yml @@ -0,0 +1,3 @@ +build: + docker: + web: Dockerfile diff --git a/index.html b/index.html new file mode 100644 index 0000000..9dc7abc --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + <%- snippets %> + + +
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/installation/docker.conf.template b/installation/docker.conf.template new file mode 100644 index 0000000..f8d6ee3 --- /dev/null +++ b/installation/docker.conf.template @@ -0,0 +1,118 @@ +# Soapbox Nginx for Docker. +# It's intended to be used by the official nginx image, which has templating functionality. +# Mount at: `/etc/nginx/templates/default.conf.template` + +map_hash_bucket_size 128; + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# ActivityPub routing. +map $http_accept $activitypub_location { + default @soapbox; + "application/activity+json" @backend; + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' @backend; +} + +proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m inactive=7d max_size=1g; + +# Fake backend for when BACKEND_URL isn't defined. +server { + listen ${FALLBACK_PORT}; + listen [::]:${FALLBACK_PORT}; + + location / { + add_header Content-Type "application/json" always; + return 404 '{"error": "Not implemented"}'; + } +} + +server { + listen ${PORT}; + listen [::]:${PORT}; + + keepalive_timeout 70; + sendfile on; + client_max_body_size 80m; + + root /usr/share/nginx/html; + + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml image/x-icon; + + add_header Strict-Transport-Security "max-age=31536000" always; + + # Content Security Policy (CSP) + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + add_header Content-Security-Policy "${CSP}"; + + # Fallback route. + # Try static files, then fall back to the SPA. + location / { + try_files $uri @soapbox; + } + + # Backend routes. + # These are routes to the backend's API and important rendered pages. + location ~ ^/(api|oauth|auth|admin|pghero|sidekiq|manifest.webmanifest|media|nodeinfo|unsubscribe|.well-known/(webfinger|host-meta|nodeinfo|change-password)|@(.+)/embed$) { + try_files /dev/null @backend; + } + + # Backend ActivityPub routes. + # Conditionally send to the backend by Accept header. + location ~ ^/(inbox|users|@(.+)) { + try_files /dev/null $activitypub_location; + } + + # Soapbox build files. + # New builds produce hashed filenames, so these should be cached heavily. + location /packs { + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Strict-Transport-Security "max-age=31536000" always; + } + + # Soapbox ServiceWorker. + location = /sw.js { + add_header Cache-Control "public, max-age=0"; + add_header Strict-Transport-Security "max-age=31536000" always; + } + + # Soapbox SPA (Single Page App). + location @soapbox { + try_files /index.html /dev/null; + } + + # Proxy to the backend. + location @backend { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Proxy ""; + proxy_pass_header Server; + + proxy_pass "${BACKEND_URL}"; + proxy_buffering on; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_cache CACHE; + proxy_cache_valid 200 7d; + proxy_cache_valid 410 24h; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + add_header X-Cached $upstream_cache_status; + add_header Strict-Transport-Security "max-age=31536000" always; + + tcp_nodelay on; + } +} diff --git a/installation/mastodon.conf b/installation/mastodon.conf new file mode 100644 index 0000000..5b001c1 --- /dev/null +++ b/installation/mastodon.conf @@ -0,0 +1,196 @@ +# Nginx configuration for Soapbox atop Mastodon. +# Adapted from: https://github.com/mastodon/mastodon/blob/b4d373a3df2752d9f8bdc0d7f02350528f3789b2/dist/nginx.conf +# +# Edit this file to change occurrences of "example.com" to your own domain. + +# Note: if this line causes an error, move it to nginx.conf +# https://github.com/nginx-proxy/nginx-proxy/issues/1365#issuecomment-668421898 +map_hash_bucket_size 128; + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# ActivityPub routing. +map $http_accept $activitypub_location { + default @soapbox; + "application/activity+json" @mastodon; + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' @mastodon; +} + +upstream backend { + server 127.0.0.1:3000 fail_timeout=0; +} + +upstream streaming { + server 127.0.0.1:4000 fail_timeout=0; +} + +proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m inactive=7d max_size=1g; + +server { + listen 80; + listen [::]:80; + server_name example.com; + root /opt/soapbox; + location /.well-known/acme-challenge/ { allow all; } + location / { return 301 https://$host$request_uri; } +} + +server { + # Uncomment these lines once you acquire a certificate: + # listen 443 ssl http2; + # listen [::]:443 ssl http2; + server_name example.com; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # Uncomment these lines once you acquire a certificate: + # ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + + keepalive_timeout 70; + sendfile on; + client_max_body_size 80m; + + root /opt/soapbox; + + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml image/x-icon; + + add_header Strict-Transport-Security "max-age=31536000" always; + + # Content Security Policy (CSP) + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + add_header Content-Security-Policy "base-uri 'none'; default-src 'none'; font-src 'self'; img-src 'self' https: data: blob:; style-src 'self' 'unsafe-inline'; media-src 'self' https: data:; frame-src 'self' https:; manifest-src 'self'; connect-src 'self' data: blob:; script-src 'self'; child-src 'self'; worker-src 'self';"; + + # Fallback route. + # Try static files, then fall back to the SPA. + location / { + try_files /dev/null @static-files; + } + + # Mastodon backend routes. + # These are routes to Mastodon's API and important rendered pages. + location ~ ^/(api|inbox|oauth|auth|admin|pghero|sidekiq|manifest.webmanifest|media|nodeinfo|unsubscribe|.well-known/(webfinger|host-meta|nodeinfo|change-password)|@(.+)/embed$) { + try_files /dev/null @mastodon; + } + + # Mastodon ActivityPub routes. + # Conditionally send to Mastodon by Accept header. + location ~ ^/(users|@(.+)) { + try_files /dev/null $activitypub_location; + } + + # Soapbox & Mastodon (frontend) build files. + # New builds produce hashed filenames, so these should be cached heavily. + location /packs { + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Strict-Transport-Security "max-age=31536000" always; + try_files $uri @mastodon-packs; + } + + # Mastodon Media + location /system { + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Strict-Transport-Security "max-age=31536000" always; + try_files $uri @mastodon-packs; + } + + # Soapbox configuration files. + # Enable CORS so we can fetch them. + location /instance { + add_header Access-Control-Allow-Origin "*"; + + if ($request_method = 'OPTIONS') { + add_header Access-Control-Allow-Origin "*"; + return 204; + } + } + + # Soapbox ServiceWorker. + location = /sw.js { + add_header Cache-Control "public, max-age=0"; + add_header Strict-Transport-Security "max-age=31536000" always; + } + + # Soapbox SPA (Single Page App). + location @soapbox { + try_files /index.html /dev/null; + } + + # Mastodon public files (fallback to Soapbox SPA). + # https://github.com/mastodon/mastodon/tree/main/public + location @mastodon-public { + root /home/mastodon/live/public; + try_files $uri @soapbox; + } + + # Like Mastodon public, without fallback to SPA. + location @mastodon-packs { + root /home/mastodon/live/public; + } + + # Soapbox & Mastodon static files. + # Try Soapbox first, Mastodon, then fall back to the SPA. + location @static-files { + try_files $uri @mastodon-public; + } + + # Proxy to Mastodon's Ruby on Rails backend. + location @mastodon { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Proxy ""; + proxy_pass_header Server; + + proxy_pass http://backend; + proxy_buffering on; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_cache CACHE; + proxy_cache_valid 200 7d; + proxy_cache_valid 410 24h; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + add_header X-Cached $upstream_cache_status; + add_header Strict-Transport-Security "max-age=31536000" always; + + tcp_nodelay on; + } + + # Mastodon's Node.js streaming server. + location ^~ /api/v1/streaming { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Proxy ""; + + proxy_pass http://streaming; + proxy_buffering off; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + tcp_nodelay on; + } + + error_page 500 501 502 503 504 /500.html; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..67ca75a --- /dev/null +++ b/package.json @@ -0,0 +1,187 @@ +{ + "name": "soapbox", + "displayName": "Soapbox", + "version": "3.2.0", + "type": "module", + "description": "Soapbox frontend for the Fediverse.", + "homepage": "https://soapbox.pub/", + "repository": { + "type": "git", + "url": "https://gitlab.com/soapbox-pub/soapbox" + }, + "keywords": [ + "fediverse", + "pleroma" + ], + "bugs": { + "url": "https://gitlab.com/soapbox-pub/soapbox/-/issues" + }, + "funding": { + "type": "lightning", + "url": "lightning:alex@alexgleason.me" + }, + "scripts": { + "start": "vite serve", + "dev": "vite serve", + "build": "vite build --emptyOutDir", + "preview": "vite preview", + "i18n": "formatjs extract 'src/**/*.{ts,tsx}' --ignore '**/*.d.ts' --out-file build/messages.json && formatjs compile build/messages.json --out-file src/locales/en.json", + "test": "vitest", + "lint": "npm run lint:js", + "lint:js": "eslint --ext .js,.jsx,.cjs,.mjs,.ts,.tsx . --cache", + "prepare": "husky install" + }, + "license": "AGPL-3.0-or-later", + "browserslist": [ + "> 1%", + "last 2 versions", + "not dead" + ], + "dependencies": { + "@akryum/flexsearch-es": "^0.7.32", + "@emoji-mart/data": "^1.2.1", + "@floating-ui/react": "^0.26.0", + "@fontsource/amiri": "^5.1.0", + "@fontsource/cairo": "^5.1.0", + "@fontsource/inter": "^5.0.0", + "@fontsource/noto-sans-javanese": "^5.1.0", + "@fontsource/roboto-mono": "^5.0.0", + "@fontsource/tajawal": "^5.1.0", + "@fontsource/vazirmatn": "^5.1.0", + "@lexical/clipboard": "^0.18.0", + "@lexical/hashtag": "^0.18.0", + "@lexical/link": "^0.18.0", + "@lexical/react": "^0.18.0", + "@lexical/selection": "^0.18.0", + "@lexical/utils": "^0.18.0", + "@mkljczk/react-hotkeys": "^1.2.2", + "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify", + "@reach/combobox": "^0.18.0", + "@reach/menu-button": "^0.18.0", + "@reach/popover": "^0.18.0", + "@reach/rect": "^0.18.0", + "@reach/tabs": "^0.18.0", + "@reduxjs/toolkit": "^2.0.1", + "@sentry/browser": "^8.34.0", + "@sentry/react": "^8.34.0", + "@sentry/types": "^8.34.0", + "@soapbox/weblock": "npm:@jsr/soapbox__weblock", + "@std/semver": "npm:@jsr/std__semver", + "@tabler/icons": "^3.19.0", + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@tanstack/react-query": "^5.59.13", + "@types/escape-html": "^1.0.1", + "@types/http-link-header": "^1.0.3", + "@types/leaflet": "^1.8.0", + "@types/lodash": "^4.14.180", + "@types/object-assign": "^4.0.30", + "@types/path-browserify": "^1.0.0", + "@types/react": "^18.3.9", + "@types/react-color": "^3.0.6", + "@types/react-dom": "^18.3.0", + "@types/react-helmet": "^6.1.5", + "@types/react-motion": "^0.0.40", + "@types/react-router-dom": "^5.3.3", + "@types/react-sparklines": "^1.7.2", + "@types/react-swipeable-views": "^0.13.1", + "@types/redux-mock-store": "^1.0.6", + "@types/semver": "^7.3.9", + "@webbtc/webln-types": "^3.0.0", + "autoprefixer": "^10.4.15", + "blurhash": "^2.0.0", + "bowser": "^2.11.0", + "browserslist": "^4.16.6", + "clsx": "^2.0.0", + "comlink": "^4.4.1", + "cssnano": "^6.0.0", + "detect-passive-events": "^2.0.0", + "emoji-mart": "^5.6.0", + "es-toolkit": "^1.27.0", + "eslint-plugin-formatjs": "^5.2.2", + "exifr": "^7.1.3", + "graphemesplit": "^2.4.4", + "html-react-parser": "^5.0.0", + "http-link-header": "^1.0.2", + "immer": "^10.0.0", + "immutable": "^4.2.1", + "intl-messageformat": "10.5.11", + "intl-pluralrules": "^2.0.0", + "isomorphic-dompurify": "^2.3.0", + "leaflet": "^1.8.0", + "lexical": "^0.18.0", + "line-awesome": "^1.3.0", + "mini-css-extract-plugin": "^2.6.0", + "nostr-tools": "^2.3.0", + "path-browserify": "^1.0.1", + "postcss": "^8.4.29", + "punycode": "^2.1.1", + "qrcode.react": "^3.1.0", + "react": "^18.3.1", + "react-color": "^2.19.3", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.11", + "react-helmet": "^6.1.0", + "react-hot-toast": "^2.4.0", + "react-inlinesvg": "^4.0.0", + "react-intl": "^7.0.1", + "react-motion": "^0.5.2", + "react-overlays": "^0.9.0", + "react-redux": "^9.0.4", + "react-router-dom": "^5.3.0", + "react-router-dom-v5-compat": "^6.6.2", + "react-simple-pull-to-refresh": "^1.3.3", + "react-sparklines": "^1.7.0", + "react-sticky-box": "^2.0.0", + "react-swipeable-views": "^0.14.0", + "react-virtuoso": "^4.10.4", + "redux": "^5.0.0", + "redux-thunk": "^3.1.0", + "reselect": "^5.0.0", + "sass": "^1.79.5", + "stringz": "^2.0.0", + "type-fest": "^4.0.0", + "typescript": "^5.6.2", + "vite": "^6.0.2", + "vite-plugin-compile-time": "^0.3.2", + "vite-plugin-html": "^3.2.2", + "vite-plugin-static-copy": "^2.2.0", + "websocket-ts": "^2.1.5", + "zod": "^3.23.5", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@formatjs/cli": "^6.3.11", + "@gitbeaker/node": "^35.8.0", + "@jedmao/redux-mock-store": "^3.0.5", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^14.0.0", + "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "^14.5.1", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "@vitejs/plugin-react-swc": "^3.7.2", + "eslint": "^8.49.0", + "eslint-import-resolver-typescript": "^3.6.0", + "eslint-plugin-compat": "^4.2.0", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsdoc": "^48.0.0", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-tailwindcss": "^3.17.5", + "fake-indexeddb": "^5.0.0", + "husky": "^9.0.0", + "jsdom": "^24.0.0", + "lint-staged": ">=10", + "rollup-plugin-visualizer": "^5.12.0", + "stylelint": "^16.10.0", + "stylelint-config-standard-scss": "^13.1.0", + "tailwindcss": "^3.4.13", + "vite-plugin-checker": "^0.8.0", + "vite-plugin-pwa": "^0.21.1", + "vitest": "^2.1.8" + } +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..4fd7597 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,10 @@ +/** @type {import('postcss-load-config').ConfigFn} */ +const config = ({ env }) => ({ + plugins: { + tailwindcss: {}, + autoprefixer: {}, + cssnano: env === 'production' ? {} : false, + }, +}); + +module.exports = config; \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..806c09d --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "extends": [ + "config:base", + ":preserveSemverRanges" + ], + "rebaseWhen": "conflicted" +} diff --git a/scripts/do-release.ts b/scripts/do-release.ts new file mode 100644 index 0000000..7fb69ca --- /dev/null +++ b/scripts/do-release.ts @@ -0,0 +1,31 @@ +import { Gitlab } from '@gitbeaker/node'; + +import { getChanges } from './lib/changelog.ts'; + +const { + CI_COMMIT_TAG, + CI_JOB_TOKEN, + CI_PROJECT_ID, +} = process.env; + +const api = new Gitlab({ + host: 'https://gitlab.com', + jobToken: CI_JOB_TOKEN, +}); + +async function main() { + await api.Releases.create(CI_PROJECT_ID!, { + name: CI_COMMIT_TAG, + tag_name: CI_COMMIT_TAG, + description: '## Changelog\n\n' + getChanges(CI_COMMIT_TAG!), + assets: { + links: [{ + name: 'Build', + url: `https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/${CI_COMMIT_TAG}/download?job=build-production`, + link_type: 'package', + }], + }, + }); +} + +main(); \ No newline at end of file diff --git a/scripts/lib/changelog.ts b/scripts/lib/changelog.ts new file mode 100644 index 0000000..d784ebd --- /dev/null +++ b/scripts/lib/changelog.ts @@ -0,0 +1,32 @@ +import fs from 'fs'; +import { join } from 'path'; + +/** Parse the changelog into an object. */ +function parseChangelog(changelog: string): Record { + const result: Record = {}; + + let currentVersion: string; + changelog.split('\n').forEach(line => { + const match = line.match(/^## \[([\d.]+)\](?: - [\d-]+)?$/); + if (match) { + currentVersion = match[1]; + } else if (currentVersion) { + result[currentVersion] = (result[currentVersion] || '') + line + '\n'; + } + }); + + return result; +} + +/** Get Markdown changes for a specific version. */ +function getChanges(version: string) { + version = version.replace('v', ''); + const content = fs.readFileSync(join(__dirname, '..', '..', 'CHANGELOG.md'), 'utf8'); + const parsed = parseChangelog(content); + return (parsed[version] || '').trim(); +} + +export { + parseChangelog, + getChanges, +}; \ No newline at end of file diff --git a/soapbox-screenshot.png b/soapbox-screenshot.png new file mode 100644 index 0000000..c5e01e3 Binary files /dev/null and b/soapbox-screenshot.png differ diff --git a/src/__fixtures__/account-moved.json b/src/__fixtures__/account-moved.json new file mode 100644 index 0000000..dbb1949 --- /dev/null +++ b/src/__fixtures__/account-moved.json @@ -0,0 +1,46 @@ +{ + "id": "106801667066418367", + "username": "benis911", + "acct": "benis911", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2021-08-22T00:00:00.000Z", + "note": "", + "url": "https://mastodon.social/@benis911", + "avatar": "https://mastodon.social/avatars/original/missing.png", + "avatar_static": "https://mastodon.social/avatars/original/missing.png", + "header": "https://mastodon.social/headers/original/missing.png", + "header_static": "https://mastodon.social/headers/original/missing.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 5, + "last_status_at": "2022-02-23", + "moved": { + "id": "107945464165013501", + "username": "alex", + "acct": "alex@fedibird.com", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2020-01-27T00:00:00.000Z", + "note": "

", + "url": "https://fedibird.com/@alex", + "avatar": "https://mastodon.social/avatars/original/missing.png", + "avatar_static": "https://mastodon.social/avatars/original/missing.png", + "header": "https://mastodon.social/headers/original/missing.png", + "header_static": "https://mastodon.social/headers/original/missing.png", + "followers_count": 1, + "following_count": 1, + "statuses_count": 5, + "last_status_at": null, + "emojis": [], + "fields": [] + }, + "emojis": [], + "fields": [] +} diff --git a/src/__fixtures__/account-with-emojis.json b/src/__fixtures__/account-with-emojis.json new file mode 100644 index 0000000..19025e1 --- /dev/null +++ b/src/__fixtures__/account-with-emojis.json @@ -0,0 +1,140 @@ +{ + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason 😂 :soapbox: :ablobcatrainbow:", + "emojis": [ + { + "shortcode": "ablobcatrainbow", + "static_url": "https://gleasonator.com/emoji/blobcat/ablobcatrainbow.png", + "url": "https://gleasonator.com/emoji/blobcat/ablobcatrainbow.png", + "visible_in_picker": false + }, + { + "shortcode": "soapbox", + "static_url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", + "url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", + "visible_in_picker": false + } + ], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox :ablobcatrainbow:", + "value": "https://soapbox.pub :soapbox:" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2476, + "following_count": 1584, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-03-12T16:35:10", + "locked": false, + "note": "I create Fediverse software that empowers people online. :soapbox:

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox :ablobcatrainbow:", + "value": "https://soapbox.pub :soapbox:" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online. :soapbox:\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23674, + "url": "https://gleasonator.com/users/alex", + "username": "alex" +} diff --git a/src/__fixtures__/accounts.json b/src/__fixtures__/accounts.json new file mode 100644 index 0000000..f108801 --- /dev/null +++ b/src/__fixtures__/accounts.json @@ -0,0 +1,182 @@ +{ + "9w1HhmenIAKBHJiUs4":{ + "header_static":"https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "display_name_html":"Alex Gleason", + "bot":false, + "display_name":"Alex Gleason", + "created_at":"2020-06-12T21:47:28.000Z", + "locked":false, + "emojis":[ + + ], + "header":"https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "url":"https://gleasonator.com/users/alex", + "note":"Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "acct":"alex@gleasonator.com", + "avatar_static":"https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "username":"alex", + "avatar":"https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "fields":[ + { + "name":"Website", + "value":"https://alexgleason.me", + "name_emojified":"Website", + "value_emojified":"https://alexgleason.me", + "value_plain":"https://alexgleason.me" + }, + { + "name":"Pleroma+Soapbox", + "value":"https://soapbox.pub", + "name_emojified":"Pleroma+Soapbox", + "value_emojified":"https://soapbox.pub", + "value_plain":"https://soapbox.pub" + }, + { + "name":"Email", + "value":"alex@alexgleason.me", + "name_emojified":"Email", + "value_emojified":"alex@alexgleason.me", + "value_plain":"alex@alexgleason.me" + }, + { + "name":"Gender identity", + "value":"Soyboy", + "name_emojified":"Gender identity", + "value_emojified":"Soyboy", + "value_plain":"Soyboy" + } + ], + "pleroma":{ + "hide_follows":false, + "hide_followers_count":false, + "background_image":null, + "confirmation_pending":false, + "is_moderator":false, + "hide_follows_count":false, + "hide_followers":false, + "relationship":{ + "showing_reblogs":true, + "followed_by":false, + "subscribing":false, + "blocked_by":false, + "requested":false, + "domain_blocking":false, + "following":false, + "endorsed":false, + "blocking":false, + "muting":false, + "id":"9w1HhmenIAKBHJiUs4", + "muting_notifications":false + }, + "tags":[ + + ], + "hide_favorites":true, + "is_admin":false, + "skip_thread_containment":false + }, + "source":{ + "fields":[ + + ], + "note":"Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma":{ + "actor_type":"Person", + "discoverable":false + }, + "sensitive":false + }, + "id":"9w1HhmenIAKBHJiUs4", + "note_emojified":"Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements." + }, + "9w1HhmenIAKBHJiUs5":{ + "header_static":"https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "display_name_html":"Alex Gleason", + "bot":false, + "display_name":"Alex Gleason", + "created_at":"2020-06-12T21:47:28.000Z", + "locked":false, + "emojis":[ + + ], + "header":"https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "url":"https://gleasonator.com/users/alex", + "note":"Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "acct":"alex@gleasonator.com", + "avatar_static":"https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "username":"alex", + "avatar":"https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "fields":[ + { + "name":"Website", + "value":"https://alexgleason.me", + "name_emojified":"Website", + "value_emojified":"https://alexgleason.me", + "value_plain":"https://alexgleason.me" + }, + { + "name":"Pleroma+Soapbox", + "value":"https://soapbox.pub", + "name_emojified":"Pleroma+Soapbox", + "value_emojified":"https://soapbox.pub", + "value_plain":"https://soapbox.pub" + }, + { + "name":"Email", + "value":"alex@alexgleason.me", + "name_emojified":"Email", + "value_emojified":"alex@alexgleason.me", + "value_plain":"alex@alexgleason.me" + }, + { + "name":"Gender identity", + "value":"Soyboy", + "name_emojified":"Gender identity", + "value_emojified":"Soyboy", + "value_plain":"Soyboy" + } + ], + "pleroma":{ + "hide_follows":false, + "hide_followers_count":false, + "background_image":null, + "confirmation_pending":false, + "is_moderator":false, + "hide_follows_count":false, + "hide_followers":false, + "relationship":{ + "showing_reblogs":true, + "followed_by":false, + "subscribing":false, + "blocked_by":false, + "requested":false, + "domain_blocking":false, + "following":false, + "endorsed":false, + "blocking":false, + "muting":false, + "id":"9w1HhmenIAKBHJiUs5", + "muting_notifications":false + }, + "tags":[ + + ], + "hide_favorites":true, + "is_admin":false, + "skip_thread_containment":false + }, + "source":{ + "fields":[ + + ], + "note":"Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma":{ + "actor_type":"Person", + "discoverable":false + }, + "sensitive":false + }, + "id":"9w1HhmenIAKBHJiUs5", + "note_emojified":"Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements." + } +} diff --git a/src/__fixtures__/accounts_counter_follow.json b/src/__fixtures__/accounts_counter_follow.json new file mode 100644 index 0000000..52bccc9 --- /dev/null +++ b/src/__fixtures__/accounts_counter_follow.json @@ -0,0 +1,7 @@ +{ + "9vMAje101ngtjlMj7w": { + "followers_count": 2, + "following_count": 3, + "statuses_count": 2 + } +} diff --git a/src/__fixtures__/accounts_counter_initial.json b/src/__fixtures__/accounts_counter_initial.json new file mode 100644 index 0000000..ce9b327 --- /dev/null +++ b/src/__fixtures__/accounts_counter_initial.json @@ -0,0 +1,7 @@ +{ + "9vMAje101ngtjlMj7w": { + "followers_count": 2, + "following_count": 2, + "statuses_count": 2 + } +} diff --git a/src/__fixtures__/accounts_counter_unfollow.json b/src/__fixtures__/accounts_counter_unfollow.json new file mode 100644 index 0000000..98bbaaa --- /dev/null +++ b/src/__fixtures__/accounts_counter_unfollow.json @@ -0,0 +1,7 @@ +{ + "9vMAje101ngtjlMj7w": { + "followers_count": 2, + "following_count": 1, + "statuses_count": 2 + } +} diff --git a/src/__fixtures__/admin_api_frontend_config.json b/src/__fixtures__/admin_api_frontend_config.json new file mode 100644 index 0000000..ee37450 --- /dev/null +++ b/src/__fixtures__/admin_api_frontend_config.json @@ -0,0 +1,55 @@ +{ + "configs": [ + { + "group": ":pleroma", + "key": ":frontend_configurations", + "value": [ + { + "tuple": [ + ":soapbox_fe", + { + "logo": "blob:http://localhost:3036/0cdfa863-6889-4199-b870-4942cedd364f", + "banner": "blob:http://localhost:3036/a835afed-6078-45bd-92b4-7ffd858c3eca", + "brandColor": "#254f92", + "customCss": [ + "/instance/static/custom.css" + ], + "promoPanel": { + "items": [ + { + "icon": "globe", + "text": "blog", + "url": "https://teci.world/blog" + }, + { + "icon": "globe", + "text": "book", + "url": "https://teci.world/book" + } + ] + }, + "extensions": { + "patron": false + }, + "defaultSettings": { + "autoPlayGif": false + }, + "navlinks": { + "homeFooter": [ + { + "title": "about", + "url": "/instance/about/index.html" + }, + { + "title": "tos", + "url": "/instance/about/tos.html" + } + ] + } + } + ] + } + ] + } + ] +} diff --git a/src/__fixtures__/akkoma-instance.json b/src/__fixtures__/akkoma-instance.json new file mode 100644 index 0000000..a4da0dc --- /dev/null +++ b/src/__fixtures__/akkoma-instance.json @@ -0,0 +1,105 @@ +{ + "approval_required": false, + "avatar_upload_limit": 2000000, + "background_image": "https://fe.disroot.org/images/city.jpg", + "background_upload_limit": 4000000, + "banner_upload_limit": 4000000, + "description": "FEDIsroot - Federated social network powered by Pleroma (open beta)", + "description_limit": 5000, + "email": "admin@example.lan", + "languages": [ + "en" + ], + "max_toot_chars": 5000, + "pleroma": { + "metadata": { + "account_activation_required": false, + "features": [ + "pleroma_api", + "akkoma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "v2_suggestions", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + "editing", + "media_proxy", + "relay", + "pleroma_emoji_reactions", + "exposable_reactions", + "profile_directory", + "custom_emoji_reactions", + "pleroma:get:main/ostatus" + ], + "federation": { + "enabled": true, + "exclusions": false, + "mrf_hashtag": { + "federated_timeline_removal": [], + "reject": [], + "sensitive": [ + "nsfw" + ] + }, + "mrf_object_age": { + "actions": [ + "delist", + "strip_followers" + ], + "threshold": 604800 + }, + "mrf_policies": [ + "ObjectAgePolicy", + "TagPolicy", + "HashtagPolicy", + "InlineQuotePolicy" + ], + "quarantined_instances": [], + "quarantined_instances_info": { + "quarantined_instances": {} + } + }, + "fields_limits": { + "max_fields": 10, + "max_remote_fields": 20, + "name_length": 512, + "value_length": 2048 + }, + "post_formats": [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode", + "text/x.misskeymarkdown" + ], + "privileged_staff": false + }, + "stats": { + "mau": 83 + }, + "vapid_public_key": null + }, + "poll_limits": { + "max_expiration": 31536000, + "max_option_chars": 200, + "max_options": 20, + "min_expiration": 0 + }, + "registrations": false, + "stats": { + "domain_count": 6972, + "status_count": 8081, + "user_count": 357 + }, + "thumbnail": "https://fe.disroot.org/instance/thumbnail.jpeg", + "title": "FEDIsroot", + "upload_limit": 16000000, + "uri": "https://fe.disroot.org", + "urls": { + "streaming_api": "wss://fe.disroot.org" + }, + "version": "2.7.2 (compatible; Akkoma 3.3.1-0-gaf90a4e51)" +} diff --git a/src/__fixtures__/announcements.json b/src/__fixtures__/announcements.json new file mode 100644 index 0000000..20e1960 --- /dev/null +++ b/src/__fixtures__/announcements.json @@ -0,0 +1,44 @@ +[ + { + "id": "1", + "content": "

Updated to Soapbox v3.

", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-06-15T18:47:14.190Z", + "updated_at": "2022-06-15T18:47:18.339Z", + "read": true, + "mentions": [], + "statuses": [], + "tags": [], + "emojis": [], + "reactions": [ + { + "name": "📈", + "count": 476, + "me": true + } + ] + }, + { + "id": "2", + "content": "

Rolled back to Soapbox v2 for now.

", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-07-13T11:11:50.628Z", + "updated_at": "2022-07-13T11:11:50.628Z", + "read": true, + "mentions": [], + "statuses": [], + "tags": [], + "emojis": [], + "reactions": [ + { + "name": "📉", + "count": 420, + "me": false + } + ] + } +] \ No newline at end of file diff --git a/src/__fixtures__/app.json b/src/__fixtures__/app.json new file mode 100644 index 0000000..18b5ba2 --- /dev/null +++ b/src/__fixtures__/app.json @@ -0,0 +1,15 @@ +{ + "vapid_key": "BHczIFh4Wn3Q_7wDgehaB8Ti3Uu8BoyOgXxkOVuEJRuEqxtd9TAno8K9ycz4myiQ1ruiyVfG6xT1JLeXtpxDzUs", + "token_type": "Bearer", + "client_secret": "cm_8Zip_UYyYq1DPQ-CRFUolrz894MmWYUC0aeVcklM", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "created_at": 1594764335, + "name": "SoapboxFE_2020-07-14T22:05:17.054Z", + "client_id": "bjiy8AxGKXXesfZcyp_iN-uQVE6Cnl03efWoSdOPh9M", + "expires_in": 600, + "scope": "read write follow push admin", + "refresh_token": "IXoCKCsZi3ZCuCjIkeadvEoHRdqOYHklZmv9jvkJ5VA", + "website": null, + "id": "134", + "access_token": "XSkQFSV1R_IvycQmw_uD5z6hQmNyuhh9PtMQbv8TgG8" +} diff --git a/src/__fixtures__/blocks.json b/src/__fixtures__/blocks.json new file mode 100644 index 0000000..42e8753 --- /dev/null +++ b/src/__fixtures__/blocks.json @@ -0,0 +1,8 @@ +[ + { + "id": "22", + "username": "twoods", + "acct": "twoods", + "display_name": "Tiger Woods" + } +] diff --git a/src/__fixtures__/config_db.json b/src/__fixtures__/config_db.json new file mode 100644 index 0000000..240164b --- /dev/null +++ b/src/__fixtures__/config_db.json @@ -0,0 +1,2735 @@ +{ + "configs": [ + { + "group": ":phoenix", + "key": ":format_encoders", + "value": [ + { + "tuple": [ + ":json", + "Jason" + ] + } + ] + }, + { + "group": ":phoenix", + "key": ":json_library", + "value": "Jason" + }, + { + "group": ":phoenix", + "key": ":filter_parameters", + "value": [ + "password", + "confirm" + ] + }, + { + "group": ":phoenix", + "key": ":stacktrace_depth", + "value": 20 + }, + { + "group": ":logger", + "key": ":ex_syslogger", + "value": [ + { + "tuple": [ + ":level", + ":debug" + ] + }, + { + "tuple": [ + ":ident", + "pleroma" + ] + }, + { + "tuple": [ + ":format", + "$metadata[$level] $message" + ] + }, + { + "tuple": [ + ":metadata", + [ + ":request_id" + ] + ] + } + ] + }, + { + "group": ":logger", + "key": ":console", + "value": [ + { + "tuple": [ + ":level", + ":debug" + ] + }, + { + "tuple": [ + ":metadata", + [ + ":request_id" + ] + ] + }, + { + "tuple": [ + ":format", + "[$level] $message\n" + ] + } + ] + }, + { + "group": ":floki", + "key": ":html_parser", + "value": "Floki.HTMLParser.FastHtml" + }, + { + "group": ":tzdata", + "key": ":http_client", + "value": "Pleroma.HTTP.Tzdata" + }, + { + "group": ":http_signatures", + "key": ":adapter", + "value": "Pleroma.Signature" + }, + { + "group": ":prometheus", + "key": "Pleroma.Web.Endpoint.MetricsExporter", + "value": [ + { + "tuple": [ + ":path", + "/api/pleroma/app_metrics" + ] + } + ] + }, + { + "group": ":ueberauth", + "key": "Ueberauth", + "value": [ + { + "tuple": [ + ":base_path", + "/oauth" + ] + }, + { + "tuple": [ + ":providers", + [] + ] + } + ] + }, + { + "group": ":esshd", + "key": ":enabled", + "value": false + }, + { + "group": ":cors_plug", + "key": ":max_age", + "value": 86400 + }, + { + "group": ":cors_plug", + "key": ":methods", + "value": [ + "POST", + "PUT", + "DELETE", + "GET", + "PATCH", + "OPTIONS" + ] + }, + { + "group": ":cors_plug", + "key": ":expose", + "value": [ + "Link", + "X-RateLimit-Reset", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-Request-Id", + "Idempotency-Key" + ] + }, + { + "group": ":cors_plug", + "key": ":credentials", + "value": true + }, + { + "group": ":cors_plug", + "key": ":headers", + "value": [ + "Authorization", + "Content-Type", + "Idempotency-Key" + ] + }, + { + "group": ":mime", + "key": ":types", + "value": { + "application/activity+json": [ + "activity+json" + ], + "application/jrd+json": [ + "jrd+json" + ], + "application/ld+json": [ + "activity+json" + ], + "application/xml": [ + "xml" + ], + "application/xrd+xml": [ + "xrd+xml" + ] + } + }, + { + "group": ":quack", + "key": ":level", + "value": ":warn" + }, + { + "group": ":quack", + "key": ":meta", + "value": [ + ":all" + ] + }, + { + "group": ":quack", + "key": ":webhook_url", + "value": "https://hooks.slack.com/services/YOUR-KEY-HERE" + }, + { + "db": [ + ":subject", + ":public_key", + ":private_key" + ], + "group": ":web_push_encryption", + "key": ":vapid_details", + "value": [ + { + "tuple": [ + ":subject", + "mailto:alex@alexgleason.me" + ] + }, + { + "tuple": [ + ":public_key", + "BAlKFlwdC-9z36ObeNyiIRdGT0luMx-SDEQzrsIRLWvcspqMU7oIhT9HbgTo2gNt8lhtKoOyiQEH9IQqUxwmBp0" + ] + }, + { + "tuple": [ + ":private_key", + "o6y0A1DtjJGURKJ2RH4BLAHuqG8RcD1rDqxrUOo8wIw" + ] + } + ] + }, + { + "group": ":ex_aws", + "key": ":http_client", + "value": "Pleroma.HTTP.ExAws" + }, + { + "db": [ + ":access_key_id", + ":secret_access_key", + ":scheme", + ":host", + ":region" + ], + "group": ":ex_aws", + "key": ":s3", + "value": [ + { + "tuple": [ + ":access_key_id", + "3WJHLX5DH6LQT5NKXKU2" + ] + }, + { + "tuple": [ + ":secret_access_key", + "6Zdlw6XKtmlvvj1to1B25YlEpBAG5ahEs2ExaEqBG4k" + ] + }, + { + "tuple": [ + ":scheme", + "https://" + ] + }, + { + "tuple": [ + ":host", + "sfo2.digitaloceanspaces.com" + ] + }, + { + "tuple": [ + ":region", + "sfo2" + ] + } + ] + }, + { + "db": [ + ":default_signer" + ], + "group": ":joken", + "key": ":default_signer", + "value": "AvRdJr2XiCKeLDrU33rsKA1nTzu1aHypRDpRDCmN00oSHM8+f7Z9BkilF6nWwwv6" + }, + { + "group": ":pleroma", + "key": ":ecto_repos", + "value": [ + "Pleroma.Repo" + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Captcha", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":seconds_valid", + 300 + ] + }, + { + "tuple": [ + ":method", + "Pleroma.Captcha.Native" + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Captcha.Kocaptcha", + "value": [ + { + "tuple": [ + ":endpoint", + "https://captcha.kotobank.ch" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":emoji", + "value": [ + { + "tuple": [ + ":shortcode_globs", + [ + "/emoji/custom/**/*.png" + ] + ] + }, + { + "tuple": [ + ":pack_extensions", + [ + ".png", + ".gif" + ] + ] + }, + { + "tuple": [ + ":groups", + [ + { + "tuple": [ + ":Custom", + [ + "/emoji/*.png", + "/emoji/**/*.png" + ] + ] + } + ] + ] + }, + { + "tuple": [ + ":default_manifest", + "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json" + ] + }, + { + "tuple": [ + ":shared_pack_cache_seconds_per_file", + 60 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":uri_schemes", + "value": [ + { + "tuple": [ + ":valid_schemes", + [ + "https", + "http", + "dat", + "dweb", + "gopher", + "hyper", + "ipfs", + "ipns", + "irc", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + "xmpp" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":http", + "value": [ + { + "tuple": [ + ":proxy_url", + null + ] + }, + { + "tuple": [ + ":send_user_agent", + true + ] + }, + { + "tuple": [ + ":user_agent", + ":default" + ] + }, + { + "tuple": [ + ":adapter", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":welcome", + "value": [ + { + "tuple": [ + ":direct_message", + [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":sender_nickname", + null + ] + }, + { + "tuple": [ + ":message", + null + ] + } + ] + ] + }, + { + "tuple": [ + ":chat_message", + [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":sender_nickname", + null + ] + }, + { + "tuple": [ + ":message", + null + ] + } + ] + ] + }, + { + "tuple": [ + ":email", + [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":sender", + null + ] + }, + { + "tuple": [ + ":subject", + "Welcome to <%= instance_name %>" + ] + }, + { + "tuple": [ + ":html", + "Welcome to <%= instance_name %>" + ] + }, + { + "tuple": [ + ":text", + "Welcome to <%= instance_name %>" + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":feed", + "value": [ + { + "tuple": [ + ":post_title", + { + ":max_length": 100, + ":omission": "..." + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":markup", + "value": [ + { + "tuple": [ + ":allow_inline_images", + true + ] + }, + { + "tuple": [ + ":allow_headings", + false + ] + }, + { + "tuple": [ + ":allow_tables", + false + ] + }, + { + "tuple": [ + ":allow_fonts", + false + ] + }, + { + "tuple": [ + ":scrub_policy", + [ + "Pleroma.HTML.Scrubber.Default", + "Pleroma.HTML.Transform.MediaProxy" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":assets", + "value": [ + { + "tuple": [ + ":mascots", + [ + { + "tuple": [ + ":pleroma_fox_tan", + { + ":mime_type": "image/png", + ":url": "/images/pleroma-fox-tan-smol.png" + } + ] + }, + { + "tuple": [ + ":pleroma_fox_tan_shy", + { + ":mime_type": "image/png", + ":url": "/images/pleroma-fox-tan-shy.png" + } + ] + } + ] + ] + }, + { + "tuple": [ + ":default_mascot", + ":pleroma_fox_tan" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":manifest", + "value": [ + { + "tuple": [ + ":icons", + [ + { + ":src": "/static/logo.png", + ":type": "image/png" + } + ] + ] + }, + { + "tuple": [ + ":theme_color", + "#282c37" + ] + }, + { + "tuple": [ + ":background_color", + "#191b22" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":activitypub", + "value": [ + { + "tuple": [ + ":unfollow_blocked", + true + ] + }, + { + "tuple": [ + ":outgoing_blocks", + true + ] + }, + { + "tuple": [ + ":follow_handshake_timeout", + 500 + ] + }, + { + "tuple": [ + ":note_replies_output_limit", + 5 + ] + }, + { + "tuple": [ + ":sign_object_fetches", + true + ] + }, + { + "tuple": [ + ":authorized_fetch_mode", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":streamer", + "value": [ + { + "tuple": [ + ":workers", + 3 + ] + }, + { + "tuple": [ + ":overflow_workers", + 2 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":user", + "value": [ + { + "tuple": [ + ":deny_follow_blocked", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_normalize_markup", + "value": [ + { + "tuple": [ + ":scrub_policy", + "Pleroma.HTML.Scrubber.Default" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_rejectnonpublic", + "value": [ + { + "tuple": [ + ":allow_followersonly", + false + ] + }, + { + "tuple": [ + ":allow_direct", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_hellthread", + "value": [ + { + "tuple": [ + ":delist_threshold", + 10 + ] + }, + { + "tuple": [ + ":reject_threshold", + 20 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_simple", + "value": [ + { + "tuple": [ + ":media_removal", + [] + ] + }, + { + "tuple": [ + ":media_nsfw", + [] + ] + }, + { + "tuple": [ + ":federated_timeline_removal", + [] + ] + }, + { + "tuple": [ + ":report_removal", + [] + ] + }, + { + "tuple": [ + ":reject", + [] + ] + }, + { + "tuple": [ + ":followers_only", + [] + ] + }, + { + "tuple": [ + ":accept", + [] + ] + }, + { + "tuple": [ + ":avatar_removal", + [] + ] + }, + { + "tuple": [ + ":banner_removal", + [] + ] + }, + { + "tuple": [ + ":reject_deletes", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_keyword", + "value": [ + { + "tuple": [ + ":reject", + [] + ] + }, + { + "tuple": [ + ":federated_timeline_removal", + [] + ] + }, + { + "tuple": [ + ":replace", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_subchain", + "value": [ + { + "tuple": [ + ":match_actor", + {} + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_activity_expiration", + "value": [ + { + "tuple": [ + ":days", + 365 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_vocabulary", + "value": [ + { + "tuple": [ + ":accept", + [] + ] + }, + { + "tuple": [ + ":reject", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_object_age", + "value": [ + { + "tuple": [ + ":threshold", + 604800 + ] + }, + { + "tuple": [ + ":actions", + [ + ":delist", + ":strip_followers" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.MediaProxy.Invalidation.Http", + "value": [ + { + "tuple": [ + ":method", + ":purge" + ] + }, + { + "tuple": [ + ":headers", + [] + ] + }, + { + "tuple": [ + ":options", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.MediaProxy.Invalidation.Script", + "value": [ + { + "tuple": [ + ":script_path", + null + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":chat", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":gopher", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":ip", + { + "tuple": [ + 0, + 0, + 0, + 0 + ] + } + ] + }, + { + "tuple": [ + ":port", + 9999 + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.Metadata", + "value": [ + { + "tuple": [ + ":providers", + [ + "Pleroma.Web.Metadata.Providers.OpenGraph", + "Pleroma.Web.Metadata.Providers.TwitterCard", + "Pleroma.Web.Metadata.Providers.RelMe", + "Pleroma.Web.Metadata.Providers.Feed" + ] + ] + }, + { + "tuple": [ + ":unfurl_nsfw", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.Preload", + "value": [ + { + "tuple": [ + ":providers", + [ + "Pleroma.Web.Preload.Providers.Instance" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":http_security", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":sts", + false + ] + }, + { + "tuple": [ + ":sts_max_age", + 31536000 + ] + }, + { + "tuple": [ + ":ct_max_age", + 2592000 + ] + }, + { + "tuple": [ + ":referrer_policy", + "same-origin" + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.User", + "value": [ + { + "tuple": [ + ":restricted_nicknames", + [ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "check_password", + "dev", + "friend-requests", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "ostatus_subscribe", + "pleroma", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "user-search", + "user_exists", + "users", + "web", + "verify_credentials", + "update_credentials", + "relationships", + "search", + "confirmation_resend", + "mfa" + ] + ] + }, + { + "tuple": [ + ":email_blacklist", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Oban", + "value": [ + { + "tuple": [ + ":repo", + "Pleroma.Repo" + ] + }, + { + "tuple": [ + ":log", + false + ] + }, + { + "tuple": [ + ":queues", + [ + { + "tuple": [ + ":activity_expiration", + 10 + ] + }, + { + "tuple": [ + ":federator_incoming", + 50 + ] + }, + { + "tuple": [ + ":federator_outgoing", + 50 + ] + }, + { + "tuple": [ + ":web_push", + 50 + ] + }, + { + "tuple": [ + ":mailer", + 10 + ] + }, + { + "tuple": [ + ":transmogrifier", + 20 + ] + }, + { + "tuple": [ + ":scheduled_activities", + 10 + ] + }, + { + "tuple": [ + ":background", + 5 + ] + }, + { + "tuple": [ + ":remote_fetcher", + 2 + ] + }, + { + "tuple": [ + ":attachments_cleanup", + 5 + ] + }, + { + "tuple": [ + ":new_users_digest", + 1 + ] + } + ] + ] + }, + { + "tuple": [ + ":plugins", + [ + "Oban.Plugins.Pruner" + ] + ] + }, + { + "tuple": [ + ":crontab", + [ + { + "tuple": [ + "0 0 * * *", + "Pleroma.Workers.Cron.ClearOauthTokenWorker" + ] + }, + { + "tuple": [ + "0 * * * *", + "Pleroma.Workers.Cron.StatsWorker" + ] + }, + { + "tuple": [ + "* * * * *", + "Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker" + ] + }, + { + "tuple": [ + "0 0 * * 0", + "Pleroma.Workers.Cron.DigestEmailsWorker" + ] + }, + { + "tuple": [ + "0 0 * * *", + "Pleroma.Workers.Cron.NewUsersDigestWorker" + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":workers", + "value": [ + { + "tuple": [ + ":retries", + [ + { + "tuple": [ + ":federator_incoming", + 5 + ] + }, + { + "tuple": [ + ":federator_outgoing", + 5 + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Formatter", + "value": [ + { + "tuple": [ + ":class", + false + ] + }, + { + "tuple": [ + ":rel", + "ugc" + ] + }, + { + "tuple": [ + ":new_window", + false + ] + }, + { + "tuple": [ + ":truncate", + false + ] + }, + { + "tuple": [ + ":strip_prefix", + false + ] + }, + { + "tuple": [ + ":extra", + true + ] + }, + { + "tuple": [ + ":validate_tld", + ":no_scheme" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":ldap", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":host", + "localhost" + ] + }, + { + "tuple": [ + ":port", + 389 + ] + }, + { + "tuple": [ + ":ssl", + false + ] + }, + { + "tuple": [ + ":sslopts", + [] + ] + }, + { + "tuple": [ + ":tls", + false + ] + }, + { + "tuple": [ + ":tlsopts", + [] + ] + }, + { + "tuple": [ + ":base", + "dc=example,dc=com" + ] + }, + { + "tuple": [ + ":uid", + "cn" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":auth", + "value": [ + { + "tuple": [ + ":enforce_oauth_admin_scope_usage", + true + ] + }, + { + "tuple": [ + ":oauth_consumer_strategies", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Emails.UserEmail", + "value": [ + { + "tuple": [ + ":logo", + null + ] + }, + { + "tuple": [ + ":styling", + { + ":background_color": "#2C3645", + ":content_background_color": "#1B2635", + ":header_color": "#d8a070", + ":link_color": "#d8a070", + ":text_color": "#b9b9ba", + ":text_muted_color": "#b9b9ba" + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Emails.NewUsersDigestEmail", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.ScheduledActivity", + "value": [ + { + "tuple": [ + ":daily_user_limit", + 25 + ] + }, + { + "tuple": [ + ":total_user_limit", + 300 + ] + }, + { + "tuple": [ + ":enabled", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":email_notifications", + "value": [ + { + "tuple": [ + ":digest", + { + ":active": false, + ":inactivity_threshold": 7, + ":interval": 7 + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":oauth2", + "value": [ + { + "tuple": [ + ":token_expires_in", + 600 + ] + }, + { + "tuple": [ + ":issue_new_refresh_token", + true + ] + }, + { + "tuple": [ + ":clean_expired_tokens", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":rate_limit", + "value": [ + { + "tuple": [ + ":authentication", + { + "tuple": [ + 60000, + 15 + ] + } + ] + }, + { + "tuple": [ + ":timeline", + { + "tuple": [ + 500, + 3 + ] + } + ] + }, + { + "tuple": [ + ":search", + [ + { + "tuple": [ + 1000, + 10 + ] + }, + { + "tuple": [ + 1000, + 30 + ] + } + ] + ] + }, + { + "tuple": [ + ":app_account_creation", + { + "tuple": [ + 1800000, + 25 + ] + } + ] + }, + { + "tuple": [ + ":relations_actions", + { + "tuple": [ + 10000, + 10 + ] + } + ] + }, + { + "tuple": [ + ":relation_id_action", + { + "tuple": [ + 60000, + 2 + ] + } + ] + }, + { + "tuple": [ + ":statuses_actions", + { + "tuple": [ + 10000, + 15 + ] + } + ] + }, + { + "tuple": [ + ":status_id_action", + { + "tuple": [ + 60000, + 3 + ] + } + ] + }, + { + "tuple": [ + ":password_reset", + { + "tuple": [ + 1800000, + 5 + ] + } + ] + }, + { + "tuple": [ + ":account_confirmation_resend", + { + "tuple": [ + 8640000, + 5 + ] + } + ] + }, + { + "tuple": [ + ":ap_routes", + { + "tuple": [ + 60000, + 15 + ] + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.ActivityExpiration", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Plugs.RemoteIp", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":web_cache_ttl", + "value": [ + { + "tuple": [ + ":activity_pub", + null + ] + }, + { + "tuple": [ + ":activity_pub_question", + 30000 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":modules", + "value": [ + { + "tuple": [ + ":runtime_dir", + "instance/modules" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":connections_pool", + "value": [ + { + "tuple": [ + ":reclaim_multiplier", + 0.1 + ] + }, + { + "tuple": [ + ":connection_acquisition_wait", + 250 + ] + }, + { + "tuple": [ + ":connection_acquisition_retries", + 5 + ] + }, + { + "tuple": [ + ":max_connections", + 250 + ] + }, + { + "tuple": [ + ":max_idle_time", + 30000 + ] + }, + { + "tuple": [ + ":retry", + 0 + ] + }, + { + "tuple": [ + ":await_up_timeout", + 5000 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":pools", + "value": [ + { + "tuple": [ + ":federation", + [ + { + "tuple": [ + ":size", + 50 + ] + }, + { + "tuple": [ + ":max_waiting", + 10 + ] + } + ] + ] + }, + { + "tuple": [ + ":media", + [ + { + "tuple": [ + ":size", + 50 + ] + }, + { + "tuple": [ + ":max_waiting", + 10 + ] + } + ] + ] + }, + { + "tuple": [ + ":upload", + [ + { + "tuple": [ + ":size", + 25 + ] + }, + { + "tuple": [ + ":max_waiting", + 5 + ] + } + ] + ] + }, + { + "tuple": [ + ":default", + [ + { + "tuple": [ + ":size", + 10 + ] + }, + { + "tuple": [ + ":max_waiting", + 2 + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":hackney_pools", + "value": [ + { + "tuple": [ + ":federation", + [ + { + "tuple": [ + ":max_connections", + 50 + ] + }, + { + "tuple": [ + ":timeout", + 150000 + ] + } + ] + ] + }, + { + "tuple": [ + ":media", + [ + { + "tuple": [ + ":max_connections", + 50 + ] + }, + { + "tuple": [ + ":timeout", + 150000 + ] + } + ] + ] + }, + { + "tuple": [ + ":upload", + [ + { + "tuple": [ + ":max_connections", + 25 + ] + }, + { + "tuple": [ + ":timeout", + 300000 + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":restrict_unauthenticated", + "value": [ + { + "tuple": [ + ":timelines", + { + ":federated": ":if_instance_is_private", + ":local": ":if_instance_is_private" + } + ] + }, + { + "tuple": [ + ":profiles", + { + ":local": ":if_instance_is_private", + ":remote": ":if_instance_is_private" + } + ] + }, + { + "tuple": [ + ":activities", + { + ":local": ":if_instance_is_private", + ":remote": ":if_instance_is_private" + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf", + "value": [ + { + "tuple": [ + ":policies", + "Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy" + ] + }, + { + "tuple": [ + ":transparency", + true + ] + }, + { + "tuple": [ + ":transparency_exclusions", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":instances_favicons", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.Auth.Authenticator", + "value": "Pleroma.Web.Auth.PleromaAuthenticator" + }, + { + "group": ":pleroma", + "key": "Pleroma.Emails.Mailer", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":adapter", + "Swoosh.Adapters.Local" + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.ApiSpec.CastAndValidate", + "value": [ + { + "tuple": [ + ":strict", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Uploaders.S3", + "value": [ + { + "tuple": [ + ":streaming_enabled", + true + ] + }, + { + "tuple": [ + ":public_endpoint", + "https://media.gleasonator.com" + ] + }, + { + "tuple": [ + ":bucket", + "gleasonator-media" + ] + } + ] + }, + { + "db": [ + ":enabled" + ], + "group": ":pleroma", + "key": ":static_fe", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + } + ] + }, + { + "db": [ + ":enabled", + ":redirect_on_failure" + ], + "group": ":pleroma", + "key": ":media_proxy", + "value": [ + { + "tuple": [ + ":invalidation", + [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":provider", + "Pleroma.Web.MediaProxy.Invalidation.Script" + ] + } + ] + ] + }, + { + "tuple": [ + ":proxy_opts", + [ + { + "tuple": [ + ":redirect_on_failure", + false + ] + }, + { + "tuple": [ + ":max_body_length", + 26214400 + ] + }, + { + "tuple": [ + ":http", + [ + { + "tuple": [ + ":follow_redirect", + true + ] + }, + { + "tuple": [ + ":pool", + ":media" + ] + } + ] + ] + } + ] + ] + }, + { + "tuple": [ + ":whitelist", + [] + ] + }, + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":redirect_on_failure", + true + ] + } + ] + }, + { + "db": [ + ":name", + ":email", + ":notify_email", + ":limit", + ":registrations_open", + ":rewrite_policy", + ":max_pinned_statuses", + ":federating", + ":static_dir" + ], + "group": ":pleroma", + "key": ":instance", + "value": [ + { + "tuple": [ + ":description", + "Pleroma: An efficient and flexible fediverse server" + ] + }, + { + "tuple": [ + ":background_image", + "/images/city.jpg" + ] + }, + { + "tuple": [ + ":instance_thumbnail", + "/instance/thumbnail.jpeg" + ] + }, + { + "tuple": [ + ":description_limit", + 5000 + ] + }, + { + "tuple": [ + ":chat_limit", + 5000 + ] + }, + { + "tuple": [ + ":remote_limit", + 100000 + ] + }, + { + "tuple": [ + ":upload_limit", + 16000000 + ] + }, + { + "tuple": [ + ":avatar_upload_limit", + 2000000 + ] + }, + { + "tuple": [ + ":background_upload_limit", + 4000000 + ] + }, + { + "tuple": [ + ":banner_upload_limit", + 4000000 + ] + }, + { + "tuple": [ + ":poll_limits", + { + ":max_expiration": 31536000, + ":max_option_chars": 200, + ":max_options": 20, + ":min_expiration": 0 + } + ] + }, + { + "tuple": [ + ":invites_enabled", + false + ] + }, + { + "tuple": [ + ":account_activation_required", + false + ] + }, + { + "tuple": [ + ":account_approval_required", + false + ] + }, + { + "tuple": [ + ":federation_incoming_replies_max_depth", + 100 + ] + }, + { + "tuple": [ + ":federation_reachability_timeout_days", + 7 + ] + }, + { + "tuple": [ + ":federation_publisher_modules", + [ + "Pleroma.Web.ActivityPub.Publisher" + ] + ] + }, + { + "tuple": [ + ":allow_relay", + true + ] + }, + { + "tuple": [ + ":public", + true + ] + }, + { + "tuple": [ + ":quarantined_instances", + [] + ] + }, + { + "tuple": [ + ":managed_config", + true + ] + }, + { + "tuple": [ + ":allowed_post_formats", + [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" + ] + ] + }, + { + "tuple": [ + ":autofollowed_nicknames", + [] + ] + }, + { + "tuple": [ + ":attachment_links", + false + ] + }, + { + "tuple": [ + ":max_report_comment_size", + 1000 + ] + }, + { + "tuple": [ + ":safe_dm_mentions", + false + ] + }, + { + "tuple": [ + ":healthcheck", + false + ] + }, + { + "tuple": [ + ":remote_post_retention_days", + 90 + ] + }, + { + "tuple": [ + ":skip_thread_containment", + true + ] + }, + { + "tuple": [ + ":limit_to_local_content", + ":unauthenticated" + ] + }, + { + "tuple": [ + ":user_bio_length", + 5000 + ] + }, + { + "tuple": [ + ":user_name_length", + 100 + ] + }, + { + "tuple": [ + ":max_account_fields", + 10 + ] + }, + { + "tuple": [ + ":max_remote_account_fields", + 20 + ] + }, + { + "tuple": [ + ":account_field_name_length", + 512 + ] + }, + { + "tuple": [ + ":account_field_value_length", + 2048 + ] + }, + { + "tuple": [ + ":registration_reason_length", + 500 + ] + }, + { + "tuple": [ + ":external_user_synchronization", + true + ] + }, + { + "tuple": [ + ":extended_nickname_format", + true + ] + }, + { + "tuple": [ + ":cleanup_attachments", + false + ] + }, + { + "tuple": [ + ":multi_factor_authentication", + [ + { + "tuple": [ + ":totp", + [ + { + "tuple": [ + ":digits", + 6 + ] + }, + { + "tuple": [ + ":period", + 30 + ] + } + ] + ] + }, + { + "tuple": [ + ":backup_codes", + [ + { + "tuple": [ + ":number", + 5 + ] + }, + { + "tuple": [ + ":length", + 16 + ] + } + ] + ] + } + ] + ] + }, + { + "tuple": [ + ":show_reactions", + true + ] + }, + { + "tuple": [ + ":name", + "Soapbox FE Demo" + ] + }, + { + "tuple": [ + ":email", + "alex@alexgleason.me" + ] + }, + { + "tuple": [ + ":notify_email", + "alex@alexgleason.me" + ] + }, + { + "tuple": [ + ":limit", + 5000 + ] + }, + { + "tuple": [ + ":registrations_open", + true + ] + }, + { + "tuple": [ + ":rewrite_policy", + "Pleroma.Web.ActivityPub.MRF.SimplePolicy" + ] + }, + { + "tuple": [ + ":max_pinned_statuses", + 10 + ] + }, + { + "tuple": [ + ":federating", + false + ] + }, + { + "tuple": [ + ":static_dir", + "instance/static" + ] + } + ] + }, + { + "db": [ + ":uploads" + ], + "group": ":pleroma", + "key": "Pleroma.Uploaders.Local", + "value": [ + { + "tuple": [ + ":uploads", + "uploads" + ] + } + ] + }, + { + "db": [ + ":parsers" + ], + "group": ":pleroma", + "key": ":rich_media", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":ignore_hosts", + [] + ] + }, + { + "tuple": [ + ":ignore_tld", + [ + "local", + "localdomain", + "lan" + ] + ] + }, + { + "tuple": [ + ":ttl_setters", + [ + "Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl" + ] + ] + }, + { + "tuple": [ + ":parsers", + [ + "Pleroma.Web.RichMedia.Parsers.OEmbed", + "Pleroma.Web.RichMedia.Parsers.TwitterCard" + ] + ] + } + ] + }, + { + "db": [ + ":uploader" + ], + "group": ":pleroma", + "key": "Pleroma.Upload", + "value": [ + { + "tuple": [ + ":filters", + [ + "Pleroma.Upload.Filter.Dedupe" + ] + ] + }, + { + "tuple": [ + ":link_name", + false + ] + }, + { + "tuple": [ + ":proxy_remote", + false + ] + }, + { + "tuple": [ + ":proxy_opts", + [ + { + "tuple": [ + ":redirect_on_failure", + false + ] + }, + { + "tuple": [ + ":max_body_length", + 26214400 + ] + }, + { + "tuple": [ + ":http", + [ + { + "tuple": [ + ":follow_redirect", + true + ] + }, + { + "tuple": [ + ":pool", + ":upload" + ] + } + ] + ] + } + ] + ] + }, + { + "tuple": [ + ":filename_display_max_length", + 30 + ] + }, + { + "tuple": [ + ":uploader", + "Pleroma.Uploaders.Local" + ] + } + ] + }, + { + "db": [ + ":soapbox_fe" + ], + "group": ":pleroma", + "key": ":frontend_configurations", + "value": [ + { + "tuple": [ + ":pleroma_fe", + { + ":alwaysShowSubjectInput": true, + ":background": "/images/city.jpg", + ":collapseMessageWithSubject": false, + ":disableChat": false, + ":greentext": false, + ":hideFilteredStatuses": false, + ":hideMutedPosts": false, + ":hidePostStats": false, + ":hideSitename": false, + ":hideUserStats": false, + ":loginMethod": "password", + ":logo": "/static/logo.png", + ":logoMargin": ".1em", + ":logoMask": true, + ":minimalScopesMode": false, + ":noAttachmentLinks": false, + ":nsfwCensorImage": "", + ":postContentType": "text/plain", + ":redirectRootLogin": "/main/friends", + ":redirectRootNoLogin": "/main/all", + ":scopeCopy": true, + ":showFeaturesPanel": true, + ":showInstanceSpecificPanel": false, + ":sidebarRight": false, + ":subjectLineBehavior": "email", + ":theme": "pleroma-dark", + ":webPushNotifications": false + } + ] + }, + { + "tuple": [ + ":masto_fe", + { + ":showInstanceSpecificPanel": true + } + ] + }, + { + "tuple": [ + ":soapbox_fe", + { + "brandColor": "#0e9066", + "copyright": "♥2020. Copying is an act of love. Please copy and share.", + "customCss": [], + "navlinks": { + "homeFooter": [] + }, + "promoPanel": { + "items": [] + } + } + ] + } + ] + } + ], + "need_reboot": false +} diff --git a/src/__fixtures__/fedibird-account.json b/src/__fixtures__/fedibird-account.json new file mode 100644 index 0000000..07bbd70 --- /dev/null +++ b/src/__fixtures__/fedibird-account.json @@ -0,0 +1,35 @@ +{ + "id": "66768", + "username": "alex", + "acct": "alex", + "display_name": "", + "locked": false, + "bot": false, + "cat": false, + "discoverable": false, + "group": false, + "created_at": "2020-01-27T00:00:00.000Z", + "note": "

", + "url": "https://fedibird.com/@alex", + "avatar": "https://fedibird.com/avatars/original/missing.png", + "avatar_static": "https://fedibird.com/avatars/original/missing.png", + "header": "https://fedibird.com/headers/original/missing.png", + "header_static": "https://fedibird.com/headers/original/missing.png", + "followers_count": 1, + "following_count": 1, + "subscribing_count": 0, + "statuses_count": 5, + "last_status_at": "2022-02-20", + "emojis": [], + "fields": [], + "other_settings": { + "birthday": "1993-07-03", + "location": "Texas, USA", + "noindex": false, + "hide_network": false, + "hide_statuses_count": false, + "hide_following_count": false, + "hide_followers_count": false, + "enable_reaction": true + } +} diff --git a/src/__fixtures__/fedibird-instance.json b/src/__fixtures__/fedibird-instance.json new file mode 100644 index 0000000..31e17e3 --- /dev/null +++ b/src/__fixtures__/fedibird-instance.json @@ -0,0 +1,185 @@ +{ + "uri": "fedibird.com", + "title": "Fedibird", + "short_description": "多くの独自機能を備えた、連合志向の汎用Mastodonサーバです。Fediverseの活動拠点としてご利用ください。", + "description": "多くの独自機能を備えた、連合志向の汎用Mastodonサーバです。Fediverseの活動拠点としてご利用ください。", + "email": "support@fedibird.com", + "version": "3.4.1", + "urls": { + "streaming_api": "wss://fedibird.com" + }, + "stats": { + "user_count": 1964, + "status_count": 4590304, + "domain_count": 9024 + }, + "thumbnail": "https://s3.fedibird.com/site_uploads/files/000/000/001/original/fedibird_hero_image.png", + "languages": [ + "ja" + ], + "registrations": true, + "approval_required": false, + "invites_enabled": true, + "configuration": { + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23, + "min_expiration": 60, + "max_expiration": 37152000, + "supported_expires_actions": [ + "delete", + "mark" + ] + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heif", + "image/heic", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/ogg", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "image_size_limit": 10485760, + "image_matrix_limit": 16777216, + "video_size_limit": 41943040, + "video_frame_rate_limit": 60, + "video_matrix_limit": 2304000 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 50, + "min_expiration": 300, + "max_expiration": 2629746 + }, + "emoji_reactions": { + "max_reactions": 20 + } + }, + "feature_quote": true, + "fedibird_capabilities": [ + "favourite_hashtag", + "favourite_domain", + "favourite_list", + "status_expire", + "follow_no_delivery", + "follow_hashtag", + "subscribe_account", + "subscribe_domain", + "subscribe_keyword", + "timeline_home_visibility", + "timeline_no_local", + "timeline_domain", + "timeline_group", + "timeline_group_directory", + "visibility_mutual", + "visibility_limited", + "emoji_reaction", + "misskey_birthday", + "misskey_location" + ], + "contact_account": { + "id": "1", + "username": "noellabo", + "acct": "noellabo", + "display_name": "のえる", + "locked": false, + "bot": false, + "cat": false, + "discoverable": true, + "group": false, + "created_at": "2019-08-15T00:00:00.000Z", + "note": "

主に、Fediverseへの関心に基づいた投稿を行うアカウントです。DTP・印刷に関する話をしたり、同人の話をしたり、カレーをブーストしたりします。

Mastodonサーバ『Fedibird』の管理者アカウントでもあります。ご連絡は当アカウントへ、サーバインフォメーションについては https://fedibird.com/about/more@info を参照してください。

", + "url": "https://fedibird.com/@noellabo", + "avatar": "https://s3.fedibird.com/accounts/avatars/000/000/001/original/6ef3b7f18f726755.png", + "avatar_static": "https://s3.fedibird.com/accounts/avatars/000/000/001/original/6ef3b7f18f726755.png", + "header": "https://s3.fedibird.com/accounts/headers/000/000/001/original/6a5a51722c094835.jpg", + "header_static": "https://s3.fedibird.com/accounts/headers/000/000/001/original/6a5a51722c094835.jpg", + "followers_count": 1560, + "following_count": 758, + "subscribing_count": 121, + "statuses_count": 61325, + "last_status_at": "2022-02-24", + "emojis": [ + { + "shortcode": "liberapay", + "url": "https://s3.fedibird.com/custom_emojis/images/000/025/634/original/5b8620742973f844.png", + "static_url": "https://s3.fedibird.com/custom_emojis/images/000/025/634/static/5b8620742973f844.png", + "visible_in_picker": true + }, + { + "shortcode": "mastodon", + "url": "https://s3.fedibird.com/custom_emojis/images/000/008/396/original/1317b6f8efcf8318.png", + "static_url": "https://s3.fedibird.com/custom_emojis/images/000/008/396/static/1317b6f8efcf8318.png", + "visible_in_picker": true + } + ], + "fields": [ + { + "name": ":liberapay: Liberapay", + "value": "https://liberapay.com/noellabo", + "verified_at": "2020-10-22T03:04:43.206+00:00" + }, + { + "name": ":mastodon: DTP-Mstdn.jp", + "value": "https://dtp-mstdn.jp/@noellabo", + "verified_at": "2020-05-23T00:14:02.232+00:00" + }, + { + "name": "別宅", + "value": "https://gorone.xyz/@noellabo", + "verified_at": "2021-08-11T07:48:53.479+00:00" + }, + { + "name": "bluesky community", + "value": "https://mastodon.blueskycommunity.net/@noellabo", + "verified_at": "2021-11-13T04:28:30.593+00:00" + } + ], + "other_settings": { + "birthday": null, + "location": "埼玉県", + "cat_ears_color": "#d5c5c0", + "noindex": false, + "hide_network": false, + "hide_statuses_count": false, + "hide_following_count": false, + "hide_followers_count": false, + "enable_reaction": true + } + }, + "rules": [ + { + "id": "2", + "text": "日本の法律と社会規範に従った行動を心がけてください" + }, + { + "id": "3", + "text": "不快や脅威に対してはブロック・ミュート・フィルターで距離をとってください" + }, + { + "id": "1", + "text": "投稿する際は、適切な公開範囲・CW・閲覧注意を使用してください" + } + ] +} diff --git a/src/__fixtures__/fedibird-quote-of-quote-post.json b/src/__fixtures__/fedibird-quote-of-quote-post.json new file mode 100644 index 0000000..c00c818 --- /dev/null +++ b/src/__fixtures__/fedibird-quote-of-quote-post.json @@ -0,0 +1,109 @@ +{ + "id": "107673570598783346", + "created_at": "2022-01-23T20:05:01.372Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://fedibird.com/users/alex/statuses/107673570598783346", + "url": "https://fedibird.com/@alex/107673570598783346", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "emoji_reactions_count": 0, + "emoji_reactions": [], + "content": "

test quote of a quote
QT: https://fedibird.com/@alex/107673570082615319

", + "quote_id": "107673570082615319", + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "66768", + "username": "alex", + "acct": "alex", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2020-01-27T00:00:00.000Z", + "note": "

", + "url": "https://fedibird.com/@alex", + "avatar": "https://fedibird.com/avatars/original/missing.png", + "avatar_static": "https://fedibird.com/avatars/original/missing.png", + "header": "https://fedibird.com/headers/original/missing.png", + "header_static": "https://fedibird.com/headers/original/missing.png", + "followers_count": 0, + "following_count": 1, + "subscribing_count": 0, + "statuses_count": 3, + "last_status_at": "2022-01-23", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null, + "quote": { + "id": "107673570082615319", + "created_at": "2022-01-23T20:04:53.494Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://fedibird.com/users/alex/statuses/107673570082615319", + "url": "https://fedibird.com/@alex/107673570082615319", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "emoji_reactions_count": 0, + "emoji_reactions": [], + "content": "

test quote
QT: https://fedibird.com/@alex/107673569214329435

", + "quote_id": "107673569214329435", + "quote": null, + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "66768", + "username": "alex", + "acct": "alex", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2020-01-27T00:00:00.000Z", + "note": "

", + "url": "https://fedibird.com/@alex", + "avatar": "https://fedibird.com/avatars/original/missing.png", + "avatar_static": "https://fedibird.com/avatars/original/missing.png", + "header": "https://fedibird.com/headers/original/missing.png", + "header_static": "https://fedibird.com/headers/original/missing.png", + "followers_count": 0, + "following_count": 1, + "subscribing_count": 0, + "statuses_count": 3, + "last_status_at": "2022-01-23", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + } +} diff --git a/src/__fixtures__/fedibird-quote-post.json b/src/__fixtures__/fedibird-quote-post.json new file mode 100644 index 0000000..610ab45 --- /dev/null +++ b/src/__fixtures__/fedibird-quote-post.json @@ -0,0 +1,108 @@ +{ + "id": "107673570082615319", + "created_at": "2022-01-23T20:04:53.494Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://fedibird.com/users/alex/statuses/107673570082615319", + "url": "https://fedibird.com/@alex/107673570082615319", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "emoji_reactions_count": 0, + "emoji_reactions": [], + "content": "

test quote
QT: https://fedibird.com/@alex/107673569214329435

", + "quote_id": "107673569214329435", + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "66768", + "username": "alex", + "acct": "alex", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2020-01-27T00:00:00.000Z", + "note": "

", + "url": "https://fedibird.com/@alex", + "avatar": "https://fedibird.com/avatars/original/missing.png", + "avatar_static": "https://fedibird.com/avatars/original/missing.png", + "header": "https://fedibird.com/headers/original/missing.png", + "header_static": "https://fedibird.com/headers/original/missing.png", + "followers_count": 0, + "following_count": 1, + "subscribing_count": 0, + "statuses_count": 3, + "last_status_at": "2022-01-23", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null, + "quote": { + "id": "107673569214329435", + "created_at": "2022-01-23T20:04:40.249Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://fedibird.com/users/alex/statuses/107673569214329435", + "url": "https://fedibird.com/@alex/107673569214329435", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "emoji_reactions_count": 0, + "emoji_reactions": [], + "content": "

test post

", + "quote": null, + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "66768", + "username": "alex", + "acct": "alex", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2020-01-27T00:00:00.000Z", + "note": "

", + "url": "https://fedibird.com/@alex", + "avatar": "https://fedibird.com/avatars/original/missing.png", + "avatar_static": "https://fedibird.com/avatars/original/missing.png", + "header": "https://fedibird.com/headers/original/missing.png", + "header_static": "https://fedibird.com/headers/original/missing.png", + "followers_count": 0, + "following_count": 1, + "subscribing_count": 0, + "statuses_count": 3, + "last_status_at": "2022-01-23", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + } +} diff --git a/src/__fixtures__/friendica-instance.json b/src/__fixtures__/friendica-instance.json new file mode 100644 index 0000000..cb6902d --- /dev/null +++ b/src/__fixtures__/friendica-instance.json @@ -0,0 +1,46 @@ +{ + "uri": "https://ica.mkljczk.pl", + "title": "Friendica Social Network", + "short_description": "", + "description": "", + "email": "me@mkljczk.pl", + "version": "2022.05-dev", + "urls": null, + "stats": { + "user_count": 0, + "status_count": 0, + "domain_count": 0 + }, + "thumbnail": "https://ica.mkljczk.plimages/friendica-32.png", + "languages": [ + "pl" + ], + "max_toot_chars": 200000, + "registrations": true, + "approval_required": false, + "invites_enabled": false, + "contact_account": { + "id": "2", + "username": "nofriend", + "acct": "nofriend", + "display_name": "marcin mikołajczak", + "locked": true, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-02-19T14:51:00.000Z", + "note": "", + "url": "https://ica.mkljczk.pl/profile/nofriend", + "avatar": "https://ica.mkljczk.pl/photo/contact/300/68a16c11-1262-1103-d40b-806159848009?ts=1645292106", + "avatar_static": "https://ica.mkljczk.pl/photo/contact/300/68a16c11-1262-1103-d40b-806159848009?ts=1645292106", + "header": "https://ica.mkljczk.pl/photo/header/68a16c11-1262-1103-d40b-806159848009?ts=1645292106", + "header_static": "https://ica.mkljczk.pl/photo/header/68a16c11-1262-1103-d40b-806159848009?ts=1645292106", + "followers_count": 0, + "following_count": 1, + "statuses_count": 0, + "last_status_at": "2022-02-20", + "emojis": [], + "fields": [] + }, + "rules": [] +} diff --git a/src/__fixtures__/friendica-status.json b/src/__fixtures__/friendica-status.json new file mode 100644 index 0000000..fc64e43 --- /dev/null +++ b/src/__fixtures__/friendica-status.json @@ -0,0 +1,53 @@ +{ + "id": "106", + "created_at": "2022-02-19T18:19:40.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "fa", + "uri": "https://ica.mkljczk.pl/objects/68a16c11-4262-1134-bc4e-0db298374337", + "url": "https://ica.mkljczk.pl/display/68a16c11-4262-1134-bc4e-0db298374337", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": true, + "content": "Hello to Friendica from fe.soapbox.pub!", + "reblog": null, + "application": { + "name": "Soapbox FE" + }, + "account": { + "id": "95", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": true, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2022-02-19T18:17:43.000Z", + "note": "", + "url": "https://ica.mkljczk.pl/profile/alex", + "avatar": "https://ica.mkljczk.pl/photo/contact/300/68a16c11-1862-1134-4779-f98088458845?ts=1645294804", + "avatar_static": "https://ica.mkljczk.pl/photo/contact/300/68a16c11-1862-1134-4779-f98088458845?ts=1645294804", + "header": "https://ica.mkljczk.pl/photo/header/68a16c11-1862-1134-4779-f98088458845?ts=1645294804", + "header_static": "https://ica.mkljczk.pl/photo/header/68a16c11-1862-1134-4779-f98088458845?ts=1645294804", + "followers_count": 0, + "following_count": 0, + "statuses_count": 2, + "last_status_at": "2022-02-19", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null +} diff --git a/src/__fixtures__/gotosocial-account.json b/src/__fixtures__/gotosocial-account.json new file mode 100644 index 0000000..3700bc4 --- /dev/null +++ b/src/__fixtures__/gotosocial-account.json @@ -0,0 +1,27 @@ +{ + "id": "00YSECR4P7E64BD5MBA639PRVT", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": false, + "bot": false, + "created_at": "2022-02-23T22:43:55Z", + "note": "

My GoToSocial profile

", + "url": "http://localhost/@alex", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 1, + "last_status_at": "2022-02-23T22:54:14Z", + "emojis": [], + "fields": [], + "source": { + "privacy": "unlisted", + "language": "en", + "note": "

My GoToSocial profile

", + "fields": [] + } +} diff --git a/src/__fixtures__/gotosocial-instance.json b/src/__fixtures__/gotosocial-instance.json new file mode 100644 index 0000000..fdaf4c9 --- /dev/null +++ b/src/__fixtures__/gotosocial-instance.json @@ -0,0 +1,42 @@ +{ + "uri": "http://localhost", + "title": "localhost", + "description": "", + "short_description": "", + "email": "", + "version": "0.2.0 31935ee", + "registrations": true, + "approval_required": true, + "invites_enabled": false, + "urls": { + "streaming_api": "wss://localhost" + }, + "stats": { + "domain_count": 0, + "status_count": 1, + "user_count": 1 + }, + "thumbnail": "", + "contact_account": { + "id": "", + "username": "", + "acct": "", + "display_name": "", + "locked": false, + "bot": false, + "created_at": "", + "note": "", + "url": "", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": "", + "emojis": null, + "fields": null + }, + "max_toot_chars": 5000 +} diff --git a/src/__fixtures__/gotosocial-status.json b/src/__fixtures__/gotosocial-status.json new file mode 100644 index 0000000..3546482 --- /dev/null +++ b/src/__fixtures__/gotosocial-status.json @@ -0,0 +1,50 @@ +{ + "id": "01FWMCNM07GGDV8HF40NZ9YTGR", + "created_at": "2022-02-23T22:54:14Z", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "http://localhost/users/alex/statuses/01FWMCNM07GGDV8HF40NZ9YTGR", + "url": "http://localhost/@alex/statuses/01FWMCNM07GGDV8HF40NZ9YTGR", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

Hello GoToSocial!

", + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "account": { + "id": "00YSECR4P7E64BD5MBA639PRVT", + "username": "alex", + "acct": "alex", + "display_name": "alex", + "locked": false, + "bot": false, + "created_at": "2022-02-23T22:43:55Z", + "note": "", + "url": "http://localhost/@alex", + "avatar": "", + "avatar_static": "", + "header": "", + "header_static": "", + "followers_count": 0, + "following_count": 0, + "statuses_count": 1, + "last_status_at": "2022-02-23T22:54:14Z", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null, + "text": "Hello GoToSocial!" +} diff --git a/src/__fixtures__/group-truthsocial.json b/src/__fixtures__/group-truthsocial.json new file mode 100644 index 0000000..63d8b14 --- /dev/null +++ b/src/__fixtures__/group-truthsocial.json @@ -0,0 +1,19 @@ +{ + "avatar": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg", + "avatar_static": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg", + "created_at": "2023-03-08T00:00:00.000Z", + "discoverable": true, + "display_name": "PATRIOT PATRIOTS", + "domain": null, + "group_visibility": "everyone", + "header": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png", + "header_static": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png", + "id": "109989480368015378", + "members_count": 1, + "membership_required": true, + "note": "patriots 900000001", + "owner": { + "id": "424023483294040" + }, + "tags": [] +} \ No newline at end of file diff --git a/src/__fixtures__/intlMessages.json b/src/__fixtures__/intlMessages.json new file mode 100644 index 0000000..4b8a45b --- /dev/null +++ b/src/__fixtures__/intlMessages.json @@ -0,0 +1,962 @@ +{ + "default": { + "account.add_or_remove_from_list": "Add or Remove from lists", + "account.badges.bot": "Bot", + "account.block": "Block @{name}", + "account.block_domain": "Hide everything from {domain}", + "account.blocked": "Blocked", + "account.direct": "Direct message @{name}", + "account.domain_blocked": "Domain hidden", + "account.edit_profile": "Edit profile", + "account.endorse": "Feature on profile", + "account.follow": "Follow", + "account.followers": "Followers", + "account.followers.empty": "No one follows this user yet.", + "account.follows": "Follows", + "account.follows.empty": "This user doesn\"t follow anyone yet.", + "account.follows_you": "Follows you", + "account.hide_reblogs": "Hide reposts from @{name}", + "account.link_verified_on": "Ownership of this link was checked on {date}", + "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.", + "account.login": "Log in", + "account.media": "Media", + "account.member_since": "Joined {date}", + "account.mention": "Mention", + "account.message": "Message", + "account.moved_to": "{name} has moved to:", + "account.mute": "Mute @{name}", + "account.mute_notifications": "Mute notifications from @{name}", + "account.muted": "Muted", + "account.posts": "Posts", + "account.posts_with_replies": "Posts and replies", + "account.profile": "Profile", + "account.register": "Sign up", + "account.report": "Report @{name}", + "account.requested": "Awaiting approval. Click to cancel follow request", + "account.share": "Share @{name}\"s profile", + "account.show_reblogs": "Show reposts from @{name}", + "account.unblock": "Unblock @{name}", + "account.unblock_domain": "Unhide {domain}", + "account.unendorse": "Don\"t feature on profile", + "account.unfollow": "Unfollow", + "account.unmute": "Unmute @{name}", + "account.unmute_notifications": "Unmute notifications from @{name}", + "account_gallery.none": "No media to show.", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", + "audio.close": "Close audio", + "audio.expand": "Expand audio", + "audio.hide": "Hide audio", + "audio.mute": "Mute", + "audio.pause": "Pause", + "audio.play": "Play", + "audio.unmute": "Unmute", + "boost_modal.combo": "You can press {combo} to skip this next time", + "bundle_column_error.body": "Something went wrong while loading this page.", + "bundle_column_error.retry": "Try again", + "bundle_column_error.title": "Network error", + "bundle_modal_error.close": "Close", + "bundle_modal_error.message": "Something went wrong while loading this page.", + "bundle_modal_error.retry": "Try again", + "column.blocks": "Blocked users", + "column.community": "Local timeline", + "column.direct": "Direct messages", + "column.domain_blocks": "Hidden domains", + "column.edit_profile": "Edit profile", + "column.filters": "Muted words", + "column.follow_requests": "Follow requests", + "column.groups": "Groups", + "column.home": "Home", + "column.lists": "Lists", + "column.mutes": "Muted users", + "column.notifications": "Alerts", + "column.preferences": "Preferences", + "column.public": "Federated timeline", + "column.security": "Security", + "column_back_button.label": "Back", + "column_header.hide_settings": "Hide settings", + "column_header.show_settings": "Show settings", + "column_subheading.settings": "Settings", + "community.column_settings.media_only": "Media Only", + "compose_form.direct_message_warning": "This post will only be sent to the mentioned users.", + "compose_form.direct_message_warning_learn_more": "Learn more", + "compose_form.hashtag_warning": "This post won\"t be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "What\"s on your mind?", + "compose_form.poll.add_option": "Add a choice", + "compose_form.poll.duration": "Poll duration", + "compose_form.poll.option_placeholder": "Choice {number}", + "compose_form.poll.remove_option": "Delete", + "compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.", + "compose_form.publish": "Publish", + "compose_form.publish_loud": "{publish}!", + "compose_form.sensitive.hide": "Mark media as sensitive", + "compose_form.sensitive.marked": "Media is marked as sensitive", + "compose_form.sensitive.unmarked": "Media is not marked as sensitive", + "compose_form.spoiler.marked": "Text is hidden behind warning", + "compose_form.spoiler.unmarked": "Text is not hidden", + "compose_form.spoiler_placeholder": "Write your warning here", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.block_and_report": "Block & Report", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this post?", + "confirmations.delete_list.confirm": "Delete", + "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?", + "confirmations.domain_block.confirm": "Hide entire domain", + "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "confirmations.redraft.confirm": "Delete & redraft", + "confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.", + "confirmations.reply.confirm": "Reply", + "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", + "confirmations.unfollow.confirm": "Unfollow", + "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "donate": "Donate", + "edit_profile.fields.avatar_label": "Avatar", + "edit_profile.fields.bio_label": "Bio", + "edit_profile.fields.bot_label": "This is a bot account", + "edit_profile.fields.display_name_label": "Display name", + "edit_profile.fields.header_label": "Header", + "edit_profile.fields.locked_label": "Lock account", + "edit_profile.fields.meta_fields.content_placeholder": "Content", + "edit_profile.fields.meta_fields.label_placeholder": "Label", + "edit_profile.fields.meta_fields_label": "Profile metadata", + "edit_profile.hints.avatar": "PNG, GIF or JPG. Will be downscaled to {size}", + "edit_profile.hints.bot": "This account mainly performs automated actions and might not be monitored", + "edit_profile.hints.header": "PNG, GIF or JPG. Will be downscaled to {size}", + "edit_profile.hints.locked": "Requires you to manually approve followers", + "edit_profile.hints.meta_fields": "You can have up to {count, plural, one {# item} other {# items}} displayed as a table on your profile", + "edit_profile.save": "Save", + "embed.instructions": "Embed this post on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", + "emoji_button.activity": "Activity", + "emoji_button.custom": "Custom", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insert emoji", + "emoji_button.nature": "Nature", + "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.recent": "Frequently used", + "emoji_button.search": "Search...", + "emoji_button.search_results": "Search results", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.account_timeline": "No posts here!", + "empty_column.account_unavailable": "Profile unavailable", + "empty_column.blocks": "You haven\"t blocked any users yet.", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.direct": "You don\"t have any direct messages yet. When you send or receive one, it will show up here.", + "empty_column.domain_blocks": "There are no hidden domains yet.", + "empty_column.favourited_statuses": "You don\"t have any liked posts yet. When you like one, it will show up here.", + "empty_column.favourites": "No one has liked this post yet. When someone does, they will show up here.", + "empty_column.filters": "You haven\"t created any muted words yet.", + "empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.", + "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", + "empty_column.home.local_tab": "the {site_title} tab", + "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", + "empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.", + "empty_column.mutes": "You haven\"t muted any users yet.", + "empty_column.notifications": "You don\"t have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up", + "fediverse_tab.explanation_box.explanation": "{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka 'servers'). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don\"t like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.", + "fediverse_tab.explanation_box.title": "What is the Fediverse?", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.heading": "Getting started", + "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).", + "group.members.empty": "This group does not has any members.", + "group.removed_accounts.empty": "This group does not has any removed accounts.", + "groups.card.join": "Join", + "groups.card.members": "Members", + "groups.card.roles.admin": "You\"re an admin", + "groups.card.roles.member": "You\"re a member", + "groups.card.view": "View", + "groups.create": "Create group", + "groups.form.coverImage": "Upload new banner image (optional)", + "groups.form.coverImageChange": "Banner image selected", + "groups.form.create": "Create group", + "groups.form.description": "Description", + "groups.form.title": "Title", + "groups.form.update": "Update group", + "groups.removed_accounts": "Removed Accounts", + "groups.tab_admin": "Manage", + "groups.tab_featured": "Featured", + "groups.tab_member": "Member", + "hashtag.column_header.tag_mode.all": "and {additional}", + "hashtag.column_header.tag_mode.any": "or {additional}", + "hashtag.column_header.tag_mode.none": "without {additional}", + "home.column_settings.basic": "Basic", + "home.column_settings.show_reblogs": "Show reposts", + "home.column_settings.show_replies": "Show replies", + "home_column.lists": "Lists", + "home_column_header.fediverse": "Fediverse", + "home_column_header.home": "Home", + "intervals.full.days": "{number, plural, one {# day} other {# days}}", + "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", + "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", + "keyboard_shortcuts.back": "to navigate back", + "keyboard_shortcuts.blocked": "to open blocked users list", + "keyboard_shortcuts.boost": "to repost", + "keyboard_shortcuts.column": "to focus a post in one of the columns", + "keyboard_shortcuts.compose": "to focus the compose textarea", + "keyboard_shortcuts.direct": "to open direct messages column", + "keyboard_shortcuts.down": "to move down in the list", + "keyboard_shortcuts.enter": "to open post", + "keyboard_shortcuts.favourite": "to like", + "keyboard_shortcuts.favourites": "to open likes list", + "keyboard_shortcuts.heading": "Keyboard shortcuts", + "keyboard_shortcuts.home": "to open home timeline", + "keyboard_shortcuts.hotkey": "Hotkey", + "keyboard_shortcuts.legend": "to display this legend", + "keyboard_shortcuts.mention": "to mention author", + "keyboard_shortcuts.muted": "to open muted users list", + "keyboard_shortcuts.my_profile": "to open your profile", + "keyboard_shortcuts.notifications": "to open notifications column", + "keyboard_shortcuts.pinned": "to open pinned posts list", + "keyboard_shortcuts.profile": "to open author\"s profile", + "keyboard_shortcuts.reply": "to reply", + "keyboard_shortcuts.requests": "to open follow requests list", + "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.start": "to open 'get started' column", + "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", + "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", + "keyboard_shortcuts.toot": "to start a new post", + "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search", + "keyboard_shortcuts.up": "to move up in the list", + "lightbox.close": "Close", + "lightbox.next": "Next", + "lightbox.previous": "Previous", + "lightbox.view_context": "View context", + "list.click_to_add": "Click here to add people", + "list_adder.header_title": "Add or Remove from Lists", + "lists.account.add": "Add to list", + "lists.account.remove": "Remove from list", + "lists.delete": "Delete list", + "lists.edit": "Edit list", + "lists.edit.submit": "Change title", + "lists.new.create": "Add list", + "lists.new.create_title": "Add list", + "lists.new.save_title": "Save Title", + "lists.new.title_placeholder": "New list title", + "lists.search": "Search among people you follow", + "lists.subheading": "Your lists", + "lists.view_all": "View all lists", + "loading_indicator.label": "Loading...", + "login.fields.password_placeholder": "Password", + "login.fields.username_placeholder": "Username", + "login.log_in": "Log in", + "login.reset_password_hint": "Trouble logging in?", + "media_gallery.toggle_visible": "Hide", + "missing_indicator.label": "Not found", + "missing_indicator.sublabel": "This resource could not be found", + "morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.", + "morefollows.following_label": "…and {count} more {count, plural, one {follow} other {follows}} on remote sites.", + "mute_modal.hide_notifications": "Hide notifications from this user?", + "navigation_bar.admin_settings": "Admin settings", + "navigation_bar.soapbox_config": "Soapbox config", + "navigation_bar.blocks": "Blocked users", + "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.compose": "Compose new post", + "navigation_bar.direct": "Direct messages", + "navigation_bar.discover": "Discover", + "navigation_bar.domain_blocks": "Hidden domains", + "navigation_bar.edit_profile": "Edit profile", + "navigation_bar.favourites": "Likes", + "navigation_bar.filters": "Muted words", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "About this server", + "navigation_bar.keyboard_shortcuts": "Hotkeys", + "navigation_bar.lists": "Lists", + "navigation_bar.logout": "Logout", + "navigation_bar.messages": "Messages", + "navigation_bar.mutes": "Muted users", + "navigation_bar.personal": "Personal", + "navigation_bar.pins": "Pinned posts", + "navigation_bar.preferences": "Preferences", + "navigation_bar.public_timeline": "Federated timeline", + "navigation_bar.security": "Security", + "notification.pleroma:emoji_reaction": "{name} reacted to your post", + "notification.favourite": "{name} liked your post", + "notification.follow": "{name} followed you", + "notification.mention": "{name} mentioned you", + "notification.poll": "A poll you have voted in has ended", + "notification.reblog": "{name} reposted your post", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Desktop notifications", + "notifications.column_settings.favourite": "Likes:", + "notifications.column_settings.filter_bar.advanced": "Display all categories", + "notifications.column_settings.filter_bar.category": "Quick filter bar", + "notifications.column_settings.filter_bar.show": "Show", + "notifications.column_settings.follow": "New followers:", + "notifications.column_settings.mention": "Mentions:", + "notifications.column_settings.poll": "Poll results:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.reblog": "Reposts:", + "notifications.column_settings.show": "Show in column", + "notifications.column_settings.sound": "Play sound", + "notifications.filter.all": "All", + "notifications.filter.boosts": "Reposts", + "notifications.filter.favourites": "Likes", + "notifications.filter.follows": "Follows", + "notifications.filter.mentions": "Mentions", + "notifications.filter.polls": "Poll results", + "notifications.group": "{count} notifications", + "notifications.queue_label": "Click to see {count} new {count, plural, one {notification} other {notifications}}", + "pinned_statuses.none": "No pins to show.", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", + "poll_button.add_poll": "Add a poll", + "poll_button.remove_poll": "Remove poll", + "preferences.fields.auto_play_gif_label": "Auto-play animated GIFs", + "preferences.fields.auto_play_video_label": "Auto-play videos", + "preferences.fields.boost_modal_label": "Show confirmation dialog before reposting", + "preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post", + "preferences.fields.demetricator_label": "Use Demetricator", + "preferences.fields.dyslexic_font_label": "Dyslexic mode", + "preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings", + "preferences.fields.language_label": "Language", + "preferences.fields.privacy_label": "Post privacy", + "preferences.fields.reduce_motion_label": "Reduce motion in animations", + "preferences.fields.system_font_label": "Use system\"s default font", + "preferences.fields.theme_label": "Theme", + "preferences.fields.unfollow_modal_label": "Show confirmation dialog before unfollowing someone", + "preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.", + "preferences.hints.privacy_followers_only": "Only show to followers", + "preferences.hints.privacy_public": "Everyone can see", + "preferences.hints.privacy_unlisted": "Everyone can see, but not listed on public timelines", + "preferences.options.privacy_followers_only": "Followers-only", + "preferences.options.privacy_public": "Public", + "preferences.options.privacy_unlisted": "Unlisted", + "preferences.options.theme_dark": "Dark", + "preferences.options.theme_light": "Light", + "privacy.change": "Adjust post privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not post to public timelines", + "privacy.unlisted.short": "Unlisted", + "regeneration_indicator.label": "Loading…", + "regeneration_indicator.sublabel": "Your home feed is being prepared!", + "registration.agreement": "I agree to the {tos}.", + "registration.fields.confirm_placeholder": "Password (again)", + "registration.fields.email_placeholder": "E-Mail address", + "registration.fields.password_placeholder": "Password", + "registration.fields.username_placeholder": "Username", + "registration.lead": "With an account on {instance} you\"ll be able to follow people on any server in the fediverse.", + "registration.sign_up": "Sign up", + "registration.tos": "Terms of Service", + "relative_time.days": "{number}d", + "relative_time.hours": "{number}h", + "relative_time.just_now": "now", + "relative_time.minutes": "{number}m", + "relative_time.seconds": "{number}s", + "reply_indicator.cancel": "Cancel", + "report.block": "Block {target}", + "report.block_hint": "Do you also want to block this account?", + "report.forward": "Forward to {target}", + "report.forward_hint": "The account is from another server. Send a copy of the report there as well?", + "report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting {target}", + "search.placeholder": "Search", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.full_text": "Simple text returns posts you have written, favorited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "post", + "search_popout.tips.user": "user", + "search_results.accounts": "People", + "search_results.hashtags": "Hashtags", + "search_results.statuses": "Posts", + "search_results.top": "Top", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "security.fields.email.label": "Email address", + "security.fields.new_password.label": "New password", + "security.fields.old_password.label": "Current password", + "security.fields.password.label": "Password", + "security.fields.password_confirmation.label": "New password (again)", + "security.headers.tokens": "Sessions", + "security.headers.update_email": "Change Email", + "security.headers.update_password": "Change Password", + "security.submit": "Save changes", + "security.tokens.revoke": "Revoke", + "security.update_email.fail": "Update email failed.", + "security.update_email.success": "Email successfully updated.", + "security.update_password.fail": "Update password failed.", + "security.update_password.success": "Password successfully updated.", + "signup_panel.subtitle": "Sign up now to discuss what's happening.", + "signup_panel.title": "New to {site_title}?", + "status.admin_account": "Open moderation interface for @{name}", + "status.admin_status": "Open this post in the moderation interface", + "status.block": "Block @{name}", + "status.cancel_reblog_private": "Un-repost", + "status.cannot_reblog": "This post cannot be reposted", + "status.copy": "Copy link to post", + "status.delete": "Delete", + "status.detailed_status": "Detailed conversation view", + "status.direct": "Direct message @{name}", + "status.embed": "Embed", + "status.favourite": "Like", + "status.filtered": "Filtered", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Mention @{name}", + "status.more": "More", + "status.mute": "Mute @{name}", + "status.mute_conversation": "Mute conversation", + "status.open": "Expand this post", + "status.pin": "Pin on profile", + "status.pinned": "Pinned post", + "status.read_more": "Read more", + "status.reblog": "Repost", + "status.reblog_private": "Repost to original audience", + "status.reblogged_by": "{name} reposted", + "status.reblogs.empty": "No one has reposted this post yet. When someone does, they will show up here.", + "status.redraft": "Delete & re-draft", + "status.remove_account_from_group": "Remove account from group", + "status.remove_post_from_group": "Remove post from group", + "status.reply": "Reply", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_warning": "Sensitive content", + "status.share": "Share", + "status.show_less": "Show less", + "status.show_less_all": "Show less for all", + "status.show_more": "Show more", + "status.show_more_all": "Show more for all", + "status.show_thread": "Show thread", + "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", + "status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}", + "suggestions.dismiss": "Dismiss suggestion", + "tabs_bar.apps": "Apps", + "tabs_bar.home": "Home", + "tabs_bar.news": "News", + "tabs_bar.notifications": "Alerts", + "tabs_bar.post": "Post", + "tabs_bar.search": "Discover", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", + "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", + "trends.title": "Trends", + "ui.beforeunload": "Your draft will be lost if you leave.", + "unauthorized_modal.footer": "Already have an account? {login}.", + "unauthorized_modal.text": "You need to be logged in to do that.", + "unauthorized_modal.title": "Sign up for {site_title}", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Add media attachment", + "upload_error.limit": "File upload limit exceeded.", + "upload_error.poll": "File upload not allowed with polls.", + "upload_form.description": "Describe for the visually impaired", + "upload_form.focus": "Change preview", + "upload_form.undo": "Delete", + "upload_progress.label": "Uploading...", + "video.close": "Close video", + "video.exit_fullscreen": "Exit full screen", + "video.expand": "Expand video", + "video.fullscreen": "Full screen", + "video.hide": "Hide video", + "video.mute": "Mute sound", + "video.pause": "Pause", + "video.play": "Play", + "video.unmute": "Unmute sound", + "who_to_follow.title": "Who To Follow" + }, + "account.add_or_remove_from_list": "Add or Remove from lists", + "account.badges.bot": "Bot", + "account.block": "Block @{name}", + "account.block_domain": "Hide everything from {domain}", + "account.blocked": "Blocked", + "account.direct": "Direct message @{name}", + "account.domain_blocked": "Domain hidden", + "account.edit_profile": "Edit profile", + "account.endorse": "Feature on profile", + "account.follow": "Follow", + "account.followers": "Followers", + "account.followers.empty": "No one follows this user yet.", + "account.follows": "Follows", + "account.follows.empty": "This user doesn\"t follow anyone yet.", + "account.follows_you": "Follows you", + "account.hide_reblogs": "Hide reposts from @{name}", + "account.link_verified_on": "Ownership of this link was checked on {date}", + "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.", + "account.login": "Log in", + "account.media": "Media", + "account.member_since": "Joined {date}", + "account.mention": "Mention", + "account.message": "Message", + "account.moved_to": "{name} has moved to:", + "account.mute": "Mute @{name}", + "account.mute_notifications": "Mute notifications from @{name}", + "account.muted": "Muted", + "account.posts": "Posts", + "account.posts_with_replies": "Posts and replies", + "account.profile": "Profile", + "account.register": "Sign up", + "account.report": "Report @{name}", + "account.requested": "Awaiting approval. Click to cancel follow request", + "account.share": "Share @{name}\"s profile", + "account.show_reblogs": "Show reposts from @{name}", + "account.unblock": "Unblock @{name}", + "account.unblock_domain": "Unhide {domain}", + "account.unendorse": "Don\"t feature on profile", + "account.unfollow": "Unfollow", + "account.unmute": "Unmute @{name}", + "account.unmute_notifications": "Unmute notifications from @{name}", + "account_gallery.none": "No media to show.", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", + "audio.close": "Close audio", + "audio.expand": "Expand audio", + "audio.hide": "Hide audio", + "audio.mute": "Mute", + "audio.pause": "Pause", + "audio.play": "Play", + "audio.unmute": "Unmute", + "boost_modal.combo": "You can press {combo} to skip this next time", + "bundle_column_error.body": "Something went wrong while loading this page.", + "bundle_column_error.retry": "Try again", + "bundle_column_error.title": "Network error", + "bundle_modal_error.close": "Close", + "bundle_modal_error.message": "Something went wrong while loading this page.", + "bundle_modal_error.retry": "Try again", + "column.blocks": "Blocked users", + "column.community": "Local timeline", + "column.direct": "Direct messages", + "column.domain_blocks": "Hidden domains", + "column.edit_profile": "Edit profile", + "column.filters": "Muted words", + "column.follow_requests": "Follow requests", + "column.groups": "Groups", + "column.home": "Home", + "column.lists": "Lists", + "column.mutes": "Muted users", + "column.notifications": "Alerts", + "column.preferences": "Preferences", + "column.public": "Federated timeline", + "column.security": "Security", + "column_back_button.label": "Back", + "column_header.hide_settings": "Hide settings", + "column_header.show_settings": "Show settings", + "column_subheading.settings": "Settings", + "community.column_settings.media_only": "Media Only", + "compose_form.direct_message_warning": "This post will only be sent to the mentioned users.", + "compose_form.direct_message_warning_learn_more": "Learn more", + "compose_form.hashtag_warning": "This post won\"t be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "What\"s on your mind?", + "compose_form.poll.add_option": "Add a choice", + "compose_form.poll.duration": "Poll duration", + "compose_form.poll.option_placeholder": "Choice {number}", + "compose_form.poll.remove_option": "Delete", + "compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.", + "compose_form.publish": "Publish", + "compose_form.publish_loud": "{publish}!", + "compose_form.sensitive.hide": "Mark media as sensitive", + "compose_form.sensitive.marked": "Media is marked as sensitive", + "compose_form.sensitive.unmarked": "Media is not marked as sensitive", + "compose_form.spoiler.marked": "Text is hidden behind warning", + "compose_form.spoiler.unmarked": "Text is not hidden", + "compose_form.spoiler_placeholder": "Write your warning here", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.block_and_report": "Block & Report", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this post?", + "confirmations.delete_list.confirm": "Delete", + "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?", + "confirmations.domain_block.confirm": "Hide entire domain", + "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "confirmations.redraft.confirm": "Delete & redraft", + "confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.", + "confirmations.reply.confirm": "Reply", + "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", + "confirmations.unfollow.confirm": "Unfollow", + "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "donate": "Donate", + "edit_profile.fields.avatar_label": "Avatar", + "edit_profile.fields.bio_label": "Bio", + "edit_profile.fields.bot_label": "This is a bot account", + "edit_profile.fields.display_name_label": "Display name", + "edit_profile.fields.header_label": "Header", + "edit_profile.fields.locked_label": "Lock account", + "edit_profile.fields.meta_fields.content_placeholder": "Content", + "edit_profile.fields.meta_fields.label_placeholder": "Label", + "edit_profile.fields.meta_fields_label": "Profile metadata", + "edit_profile.hints.avatar": "PNG, GIF or JPG. Will be downscaled to {size}", + "edit_profile.hints.bot": "This account mainly performs automated actions and might not be monitored", + "edit_profile.hints.header": "PNG, GIF or JPG. Will be downscaled to {size}", + "edit_profile.hints.locked": "Requires you to manually approve followers", + "edit_profile.hints.meta_fields": "You can have up to {count, plural, one {# item} other {# items}} displayed as a table on your profile", + "edit_profile.save": "Save", + "embed.instructions": "Embed this post on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", + "emoji_button.activity": "Activity", + "emoji_button.custom": "Custom", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insert emoji", + "emoji_button.nature": "Nature", + "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.recent": "Frequently used", + "emoji_button.search": "Search...", + "emoji_button.search_results": "Search results", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.account_timeline": "No posts here!", + "empty_column.account_unavailable": "Profile unavailable", + "empty_column.blocks": "You haven\"t blocked any users yet.", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.direct": "You don\"t have any direct messages yet. When you send or receive one, it will show up here.", + "empty_column.domain_blocks": "There are no hidden domains yet.", + "empty_column.favourited_statuses": "You don\"t have any liked posts yet. When you like one, it will show up here.", + "empty_column.favourites": "No one has liked this post yet. When someone does, they will show up here.", + "empty_column.filters": "You haven\"t created any muted words yet.", + "empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.", + "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", + "empty_column.home.local_tab": "the {site_title} tab", + "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", + "empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.", + "empty_column.mutes": "You haven\"t muted any users yet.", + "empty_column.notifications": "You don\"t have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up", + "fediverse_tab.explanation_box.explanation": "{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka 'servers'). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don\"t like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.", + "fediverse_tab.explanation_box.title": "What is the Fediverse?", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.heading": "Getting started", + "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).", + "group.members.empty": "This group does not has any members.", + "group.removed_accounts.empty": "This group does not has any removed accounts.", + "groups.card.join": "Join", + "groups.card.members": "Members", + "groups.card.roles.admin": "You\"re an admin", + "groups.card.roles.member": "You\"re a member", + "groups.card.view": "View", + "groups.create": "Create group", + "groups.form.coverImage": "Upload new banner image (optional)", + "groups.form.coverImageChange": "Banner image selected", + "groups.form.create": "Create group", + "groups.form.description": "Description", + "groups.form.title": "Title", + "groups.form.update": "Update group", + "groups.removed_accounts": "Removed Accounts", + "groups.tab_admin": "Manage", + "groups.tab_featured": "Featured", + "groups.tab_member": "Member", + "hashtag.column_header.tag_mode.all": "and {additional}", + "hashtag.column_header.tag_mode.any": "or {additional}", + "hashtag.column_header.tag_mode.none": "without {additional}", + "home.column_settings.basic": "Basic", + "home.column_settings.show_reblogs": "Show reposts", + "home.column_settings.show_replies": "Show replies", + "home_column.lists": "Lists", + "home_column_header.fediverse": "Fediverse", + "home_column_header.home": "Home", + "intervals.full.days": "{number, plural, one {# day} other {# days}}", + "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", + "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", + "keyboard_shortcuts.back": "to navigate back", + "keyboard_shortcuts.blocked": "to open blocked users list", + "keyboard_shortcuts.boost": "to repost", + "keyboard_shortcuts.column": "to focus a post in one of the columns", + "keyboard_shortcuts.compose": "to focus the compose textarea", + "keyboard_shortcuts.direct": "to open direct messages column", + "keyboard_shortcuts.down": "to move down in the list", + "keyboard_shortcuts.enter": "to open post", + "keyboard_shortcuts.favourite": "to like", + "keyboard_shortcuts.favourites": "to open likes list", + "keyboard_shortcuts.heading": "Keyboard shortcuts", + "keyboard_shortcuts.home": "to open home timeline", + "keyboard_shortcuts.hotkey": "Hotkey", + "keyboard_shortcuts.legend": "to display this legend", + "keyboard_shortcuts.mention": "to mention author", + "keyboard_shortcuts.muted": "to open muted users list", + "keyboard_shortcuts.my_profile": "to open your profile", + "keyboard_shortcuts.notifications": "to open notifications column", + "keyboard_shortcuts.pinned": "to open pinned posts list", + "keyboard_shortcuts.profile": "to open author\"s profile", + "keyboard_shortcuts.reply": "to reply", + "keyboard_shortcuts.requests": "to open follow requests list", + "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.start": "to open 'get started' column", + "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", + "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", + "keyboard_shortcuts.toot": "to start a new post", + "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search", + "keyboard_shortcuts.up": "to move up in the list", + "lightbox.close": "Close", + "lightbox.next": "Next", + "lightbox.previous": "Previous", + "lightbox.view_context": "View context", + "list.click_to_add": "Click here to add people", + "list_adder.header_title": "Add or Remove from Lists", + "lists.account.add": "Add to list", + "lists.account.remove": "Remove from list", + "lists.delete": "Delete list", + "lists.edit": "Edit list", + "lists.edit.submit": "Change title", + "lists.new.create": "Add list", + "lists.new.create_title": "Add list", + "lists.new.save_title": "Save Title", + "lists.new.title_placeholder": "New list title", + "lists.search": "Search among people you follow", + "lists.subheading": "Your lists", + "lists.view_all": "View all lists", + "loading_indicator.label": "Loading...", + "login.fields.password_placeholder": "Password", + "login.fields.username_placeholder": "Username", + "login.log_in": "Log in", + "login.reset_password_hint": "Trouble logging in?", + "media_gallery.toggle_visible": "Hide", + "missing_indicator.label": "Not found", + "missing_indicator.sublabel": "This resource could not be found", + "morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.", + "morefollows.following_label": "…and {count} more {count, plural, one {follow} other {follows}} on remote sites.", + "mute_modal.hide_notifications": "Hide notifications from this user?", + "navigation_bar.admin_settings": "Admin settings", + "navigation_bar.soapbox_config": "Soapbox config", + "navigation_bar.blocks": "Blocked users", + "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.compose": "Compose new post", + "navigation_bar.direct": "Direct messages", + "navigation_bar.discover": "Discover", + "navigation_bar.domain_blocks": "Hidden domains", + "navigation_bar.edit_profile": "Edit profile", + "navigation_bar.favourites": "Likes", + "navigation_bar.filters": "Muted words", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "About this server", + "navigation_bar.keyboard_shortcuts": "Hotkeys", + "navigation_bar.lists": "Lists", + "navigation_bar.logout": "Logout", + "navigation_bar.messages": "Messages", + "navigation_bar.mutes": "Muted users", + "navigation_bar.personal": "Personal", + "navigation_bar.pins": "Pinned posts", + "navigation_bar.preferences": "Preferences", + "navigation_bar.public_timeline": "Federated timeline", + "navigation_bar.security": "Security", + "notification.pleroma:emoji_reaction": "{name} reacted to your post", + "notification.favourite": "{name} liked your post", + "notification.follow": "{name} followed you", + "notification.mention": "{name} mentioned you", + "notification.poll": "A poll you have voted in has ended", + "notification.reblog": "{name} reposted your post", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Desktop notifications", + "notifications.column_settings.favourite": "Likes:", + "notifications.column_settings.filter_bar.advanced": "Display all categories", + "notifications.column_settings.filter_bar.category": "Quick filter bar", + "notifications.column_settings.filter_bar.show": "Show", + "notifications.column_settings.follow": "New followers:", + "notifications.column_settings.mention": "Mentions:", + "notifications.column_settings.poll": "Poll results:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.reblog": "Reposts:", + "notifications.column_settings.show": "Show in column", + "notifications.column_settings.sound": "Play sound", + "notifications.filter.all": "All", + "notifications.filter.boosts": "Reposts", + "notifications.filter.favourites": "Likes", + "notifications.filter.follows": "Follows", + "notifications.filter.mentions": "Mentions", + "notifications.filter.polls": "Poll results", + "notifications.group": "{count} notifications", + "notifications.queue_label": "Click to see {count} new {count, plural, one {notification} other {notifications}}", + "pinned_statuses.none": "No pins to show.", + "poll.closed": "Closed", + "poll.refresh": "Refresh", + "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", + "poll.vote": "Vote", + "poll_button.add_poll": "Add a poll", + "poll_button.remove_poll": "Remove poll", + "preferences.fields.auto_play_gif_label": "Auto-play animated GIFs", + "preferences.fields.boost_modal_label": "Show confirmation dialog before reposting", + "preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post", + "preferences.fields.demetricator_label": "Use Demetricator", + "preferences.fields.dyslexic_font_label": "Dyslexic mode", + "preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings", + "preferences.fields.language_label": "Language", + "preferences.fields.privacy_label": "Post privacy", + "preferences.fields.reduce_motion_label": "Reduce motion in animations", + "preferences.fields.system_font_label": "Use system\"s default font", + "preferences.fields.theme_label": "Theme", + "preferences.fields.unfollow_modal_label": "Show confirmation dialog before unfollowing someone", + "preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.", + "preferences.hints.privacy_followers_only": "Only show to followers", + "preferences.hints.privacy_public": "Everyone can see", + "preferences.hints.privacy_unlisted": "Everyone can see, but not listed on public timelines", + "preferences.options.privacy_followers_only": "Followers-only", + "preferences.options.privacy_public": "Public", + "preferences.options.privacy_unlisted": "Unlisted", + "preferences.options.theme_dark": "Dark", + "preferences.options.theme_light": "Light", + "privacy.change": "Adjust post privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not post to public timelines", + "privacy.unlisted.short": "Unlisted", + "regeneration_indicator.label": "Loading…", + "regeneration_indicator.sublabel": "Your home feed is being prepared!", + "registration.agreement": "I agree to the {tos}.", + "registration.fields.confirm_placeholder": "Password (again)", + "registration.fields.email_placeholder": "E-Mail address", + "registration.fields.password_placeholder": "Password", + "registration.fields.username_placeholder": "Username", + "registration.lead": "With an account on {instance} you\"ll be able to follow people on any server in the fediverse.", + "registration.sign_up": "Sign up", + "registration.tos": "Terms of Service", + "registration.privacy": "Privacy Policy", + "registration.acceptance": "By registering, you agree to the {terms} and {privacy}.", + "registration.reason": "Reason for Joining", + "relative_time.days": "{number}d", + "relative_time.hours": "{number}h", + "relative_time.just_now": "now", + "relative_time.minutes": "{number}m", + "relative_time.seconds": "{number}s", + "reply_indicator.cancel": "Cancel", + "report.block": "Block {target}", + "report.block_hint": "Do you also want to block this account?", + "report.forward": "Forward to {target}", + "report.forward_hint": "The account is from another server. Send a copy of the report there as well?", + "report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting {target}", + "search.placeholder": "Search", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.full_text": "Simple text returns posts you have written, favorited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "post", + "search_popout.tips.user": "user", + "search_results.accounts": "People", + "search_results.hashtags": "Hashtags", + "search_results.statuses": "Posts", + "search_results.top": "Top", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "security.fields.email.label": "Email address", + "security.fields.new_password.label": "New password", + "security.fields.old_password.label": "Current password", + "security.fields.password.label": "Password", + "security.fields.password_confirmation.label": "New password (again)", + "security.headers.tokens": "Sessions", + "security.headers.update_email": "Change Email", + "security.headers.update_password": "Change Password", + "security.submit": "Save changes", + "security.tokens.revoke": "Revoke", + "security.update_email.fail": "Update email failed.", + "security.update_email.success": "Email successfully updated.", + "security.update_password.fail": "Update password failed.", + "security.update_password.success": "Password successfully updated.", + "signup_panel.subtitle": "Sign up now to discuss what's happening.", + "signup_panel.title": "New to {site_title}?", + "status.admin_account": "Open moderation interface for @{name}", + "status.admin_status": "Open this post in the moderation interface", + "status.block": "Block @{name}", + "status.cancel_reblog_private": "Un-repost", + "status.cannot_reblog": "This post cannot be reposted", + "status.copy": "Copy link to post", + "status.delete": "Delete", + "status.detailed_status": "Detailed conversation view", + "status.direct": "Direct message @{name}", + "status.embed": "Embed", + "status.favourite": "Like", + "status.filtered": "Filtered", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Mention @{name}", + "status.more": "More", + "status.mute": "Mute @{name}", + "status.mute_conversation": "Mute conversation", + "status.open": "Expand this post", + "status.pin": "Pin on profile", + "status.pinned": "Pinned post", + "status.read_more": "Read more", + "status.reblog": "Repost", + "status.reblog_private": "Repost to original audience", + "status.reblogged_by": "{name} reposted", + "status.reblogs.empty": "No one has reposted this post yet. When someone does, they will show up here.", + "status.redraft": "Delete & re-draft", + "status.remove_account_from_group": "Remove account from group", + "status.remove_post_from_group": "Remove post from group", + "status.reply": "Reply", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_warning": "Sensitive content", + "status.share": "Share", + "status.show_less": "Show less", + "status.show_less_all": "Show less for all", + "status.show_more": "Show more", + "status.show_more_all": "Show more for all", + "status.show_thread": "Show thread", + "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", + "status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}", + "suggestions.dismiss": "Dismiss suggestion", + "tabs_bar.apps": "Apps", + "tabs_bar.home": "Home", + "tabs_bar.news": "News", + "tabs_bar.notifications": "Alerts", + "tabs_bar.post": "Post", + "tabs_bar.search": "Discover", + "time_remaining.days": "{number, plural, one {# day} other {# days}} left", + "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", + "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.moments": "Moments remaining", + "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", + "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", + "trends.title": "Trends", + "ui.beforeunload": "Your draft will be lost if you leave.", + "unauthorized_modal.footer": "Already have an account? {login}.", + "unauthorized_modal.text": "You need to be logged in to do that.", + "unauthorized_modal.title": "Sign up for {site_title}", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Add media attachment", + "upload_error.limit": "File upload limit exceeded.", + "upload_error.poll": "File upload not allowed with polls.", + "upload_form.description": "Describe for the visually impaired", + "upload_form.focus": "Change preview", + "upload_form.undo": "Delete", + "upload_progress.label": "Uploading...", + "video.close": "Close video", + "video.exit_fullscreen": "Exit full screen", + "video.expand": "Expand video", + "video.fullscreen": "Full screen", + "video.hide": "Hide video", + "video.mute": "Mute sound", + "video.pause": "Pause", + "video.play": "Play", + "video.unmute": "Unmute sound", + "who_to_follow.title": "Who To Follow" +} diff --git a/src/__fixtures__/lain.json b/src/__fixtures__/lain.json new file mode 100644 index 0000000..ab27af4 --- /dev/null +++ b/src/__fixtures__/lain.json @@ -0,0 +1,57 @@ +{ + "acct": "lain@lain.com", + "avatar": "https://lain.com/media/0b7eb9eee68845f94dd1c7bd10d9bae90a2420cf6704de5485179c441eb0e6e0.jpg", + "avatar_static": "https://lain.com/media/0b7eb9eee68845f94dd1c7bd10d9bae90a2420cf6704de5485179c441eb0e6e0.jpg", + "bot": false, + "created_at": "2020-01-10T17:30:10.000Z", + "display_name": "Avalokiteshvara", + "emojis": [], + "fields": [], + "followers_count": 807, + "following_count": 223, + "header": "https://lain.com/media/fb0768dfa331ad730de32189d2e89b99fe51eebe1782a16cf076d7693394e4f9.png", + "header_static": "https://lain.com/media/fb0768dfa331ad730de32189d2e89b99fe51eebe1782a16cf076d7693394e4f9.png", + "id": "9v5bqYwY2jfmvPNhTM", + "locked": false, + "note": "No more hiding", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bqYwY2jfmvPNhTM", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "No more hiding", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 21107, + "url": "https://lain.com/users/lain", + "username": "lain" +} diff --git a/src/__fixtures__/markers.json b/src/__fixtures__/markers.json new file mode 100644 index 0000000..ba3f5f8 --- /dev/null +++ b/src/__fixtures__/markers.json @@ -0,0 +1,18 @@ +{ + "notifications": { + "last_read_id": "35098814", + "version": 361, + "updated_at": "2019-11-26T22:37:25.239Z", + "pleroma": { + "unread_count": 3 + } + }, + "home": { + "last_read_id": "103206604258487607", + "version": 468, + "updated_at": "2019-11-26T22:37:25.235Z", + "pleroma": { + "unread_count": 32 + } + } +} diff --git a/src/__fixtures__/mastodon-3.0.0-instance.json b/src/__fixtures__/mastodon-3.0.0-instance.json new file mode 100644 index 0000000..f1d0a5e --- /dev/null +++ b/src/__fixtures__/mastodon-3.0.0-instance.json @@ -0,0 +1,43 @@ +{ + "uri": "animalliberation.social", + "title": "Animal Liberation Network", + "short_description": "", + "description": "Animal Liberation Network is a community for animal activists on the Fediverse. You can connect with other activists through the local timeline, as well as spread your activism to the outside world with the federated timeline.", + "email": "alex@alexgleason.me", + "version": "3.0.0", + "urls": { + "streaming_api": "wss://animalliberation.social" + }, + "stats": { + "user_count": 662, + "status_count": 2904, + "domain_count": 4003 + }, + "thumbnail": "https://animalliberation.social/packs/media/images/preview-9a17d32fc48369e8ccd910a75260e67d.jpg", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "contact_account": { + "id": "1", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": false, + "bot": false, + "created_at": "2016-11-30T22:19:42.956Z", + "note": "

Animal liberation free software Communist

", + "url": "https://animalliberation.social/@alex", + "avatar": "https://media.animalliberation.social/accounts/avatars/000/000/001/original/media.jpg", + "avatar_static": "https://media.animalliberation.social/accounts/avatars/000/000/001/original/media.jpg", + "header": "https://media.animalliberation.social/accounts/headers/000/000/001/original/09887023017e02c9.jpg", + "header_static": "https://media.animalliberation.social/accounts/headers/000/000/001/original/09887023017e02c9.jpg", + "followers_count": 236, + "following_count": 83, + "statuses_count": 357, + "last_status_at": "2021-02-20T19:28:24.353Z", + "emojis": [], + "fields": [] + } +} diff --git a/src/__fixtures__/mastodon-account.json b/src/__fixtures__/mastodon-account.json new file mode 100644 index 0000000..7a00340 --- /dev/null +++ b/src/__fixtures__/mastodon-account.json @@ -0,0 +1,23 @@ +{ + "id": "106801667066418367", + "username": "benis911", + "acct": "benis911", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2021-08-22T00:00:00.000Z", + "note": "", + "url": "https://mastodon.social/@benis911", + "avatar": "https://mastodon.social/avatars/original/missing.png", + "avatar_static": "https://mastodon.social/avatars/original/missing.png", + "header": "https://mastodon.social/headers/original/missing.png", + "header_static": "https://mastodon.social/headers/original/missing.png", + "followers_count": 1, + "following_count": 0, + "statuses_count": 5, + "last_status_at": "2022-02-23", + "emojis": [], + "fields": [] +} diff --git a/src/__fixtures__/mastodon-instance-rc.json b/src/__fixtures__/mastodon-instance-rc.json new file mode 100644 index 0000000..277839d --- /dev/null +++ b/src/__fixtures__/mastodon-instance-rc.json @@ -0,0 +1,123 @@ +{ + "uri": "mastodon.social", + "title": "Mastodon", + "short_description": "Server run by the main developers of the project \"🐘\" It is not focused on any particular niche interest - everyone is welcome as long as you follow our code of conduct!", + "description": "Server run by the main developers of the project \"🐘\" It is not focused on any particular niche interest - everyone is welcome as long as you follow our code of conduct!", + "email": "staff@mastodon.social", + "version": "3.5.0rc1", + "urls": { + "streaming_api": "wss://mastodon.social" + }, + "stats": { + "user_count": 635078, + "status_count": 34700866, + "domain_count": 21989 + }, + "thumbnail": "https://files.mastodon.social/site_uploads/files/000/000/001/original/vlcsnap-2018-08-27-16h43m11s127.png", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "invites_enabled": true, + "configuration": { + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "image_size_limit": 10485760, + "image_matrix_limit": 16777216, + "video_size_limit": 41943040, + "video_frame_rate_limit": 60, + "video_matrix_limit": 2304000 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 50, + "min_expiration": 300, + "max_expiration": 2629746 + } + }, + "contact_account": { + "id": "1", + "username": "Gargron", + "acct": "Gargron", + "display_name": "Eugen", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "

Founder, CEO and lead developer @Mastodon, Germany.

", + "url": "https://mastodon.social/@Gargron", + "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/ccb05a778962e171.png", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/ccb05a778962e171.png", + "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "followers_count": 99760, + "following_count": 274, + "statuses_count": 71657, + "last_status_at": "2022-03-17", + "emojis": [], + "fields": [ + { + "name": "Patreon", + "value": "https://www.patreon.com/mastodon", + "verified_at": null + } + ] + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive when posting" + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies" + }, + { + "id": "4", + "text": "No harassment, dogpiling or doxxing of other users" + }, + { + "id": "5", + "text": "No content illegal in Germany" + }, + { + "id": "7", + "text": "Do not share intentionally false or misleading information" + } + ] +} diff --git a/src/__fixtures__/mastodon-instance.json b/src/__fixtures__/mastodon-instance.json new file mode 100644 index 0000000..3c8a2f9 --- /dev/null +++ b/src/__fixtures__/mastodon-instance.json @@ -0,0 +1,128 @@ +{ + "uri": "mastodon.social", + "title": "Mastodon", + "short_description": "Server run by the main developers of the project \"🐘\" It is not focused on any particular niche interest - everyone is welcome as long as you follow our code of conduct!", + "description": "Server run by the main developers of the project \"🐘\" It is not focused on any particular niche interest - everyone is welcome as long as you follow our code of conduct!", + "email": "staff@mastodon.social", + "version": "3.4.3", + "urls": { + "streaming_api": "wss://mastodon.social" + }, + "stats": { + "user_count": 619022, + "status_count": 33914684, + "domain_count": 21524 + }, + "thumbnail": "https://files.mastodon.social/site_uploads/files/000/000/001/original/vlcsnap-2018-08-27-16h43m11s127.png", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "invites_enabled": true, + "configuration": { + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "image_size_limit": 10485760, + "image_matrix_limit": 16777216, + "video_size_limit": 41943040, + "video_frame_rate_limit": 60, + "video_matrix_limit": 2304000 + }, + "polls": { + "max_options": 4, + "max_characters_per_option": 50, + "min_expiration": 300, + "max_expiration": 2629746 + } + }, + "contact_account": { + "id": "1", + "username": "Gargron", + "acct": "Gargron", + "display_name": "Eugen 🎄", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "

Founder, CEO and lead developer @Mastodon, Germany.

", + "url": "https://mastodon.social/@Gargron", + "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/ccb05a778962e171.png", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/ccb05a778962e171.png", + "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "followers_count": 98343, + "following_count": 271, + "statuses_count": 71288, + "last_status_at": "2022-01-31", + "emojis": [], + "fields": [ + { + "name": "Patreon", + "value": "https://www.patreon.com/mastodon", + "verified_at": null + }, + { + "name": "Homepage", + "value": "https://zeonfederated.com", + "verified_at": "2019-07-15T18:29:57.191+00:00" + } + ] + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive when posting" + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies" + }, + { + "id": "4", + "text": "No harassment, dogpiling or doxxing of other users" + }, + { + "id": "5", + "text": "No content illegal in Germany" + }, + { + "id": "6", + "text": "No spam, advertising or bot accounts" + } + ] +} diff --git a/src/__fixtures__/mastodon-reply-to-self.json b/src/__fixtures__/mastodon-reply-to-self.json new file mode 100644 index 0000000..7cfc756 --- /dev/null +++ b/src/__fixtures__/mastodon-reply-to-self.json @@ -0,0 +1,51 @@ +{ + "id": "107828148293766288", + "created_at": "2022-02-20T03:16:09.812Z", + "in_reply_to_id": "107828147870368566", + "in_reply_to_account_id": "106801667066418367", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://mastodon.social/users/benis911/statuses/107828148293766288", + "url": "https://mastodon.social/@benis911/107828148293766288", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "content": "

test reply to self

", + "reblog": null, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "account": { + "id": "106801667066418367", + "username": "benis911", + "acct": "benis911", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2021-08-22T00:00:00.000Z", + "note": "

", + "url": "https://mastodon.social/@benis911", + "avatar": "https://mastodon.social/avatars/original/missing.png", + "avatar_static": "https://mastodon.social/avatars/original/missing.png", + "header": "https://mastodon.social/headers/original/missing.png", + "header_static": "https://mastodon.social/headers/original/missing.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 3, + "last_status_at": "2022-02-20", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null +} diff --git a/src/__fixtures__/mastodon_initial_state.json b/src/__fixtures__/mastodon_initial_state.json new file mode 100644 index 0000000..5212856 --- /dev/null +++ b/src/__fixtures__/mastodon_initial_state.json @@ -0,0 +1,228 @@ +{ + "meta": { + "streaming_api_base_url": "wss://mastodon.social", + "access_token": "Nh15V9JWyY5Fshf2OJ_feNvOIkTV7YGVfEJFr0Y0D6Q", + "locale": "en", + "domain": "mastodon.social", + "title": "Mastodon", + "admin": "1", + "search_enabled": true, + "repository": "mastodon/mastodon", + "source_url": "https://github.com/mastodon/mastodon", + "version": "3.4.1", + "invites_enabled": true, + "limited_federation_mode": false, + "mascot": null, + "profile_directory": true, + "trends": true, + "me": "106801667066418367", + "unfollow_modal": false, + "boost_modal": false, + "delete_modal": true, + "auto_play_gif": false, + "display_media": "default", + "expand_spoilers": false, + "reduce_motion": false, + "disable_swiping": false, + "advanced_layout": false, + "use_blurhash": true, + "use_pending_items": false, + "is_staff": false, + "crop_images": true + }, + "compose": { + "me": "106801667066418367", + "default_privacy": "public", + "default_sensitive": false, + "text": "" + }, + "accounts": { + "1": { + "id": "1", + "username": "Gargron", + "acct": "Gargron", + "display_name": "Eugen", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "\\u003cp\\u003eDeveloper of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.\\u003c/p\\u003e", + "url": "https://mastodon.social/@Gargron", + "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg", + "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png", + "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png", + "followers_count": 469426, + "following_count": 459, + "statuses_count": 70336, + "last_status_at": "2021-09-15", + "emojis": [], + "fields": [ + { + "name": "Patreon", + "value": "\\u003ca href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"\\u003e\\u003cspan class=\"invisible\"\\u003ehttps://www.\\u003c/span\\u003e\\u003cspan class=\"\"\\u003epatreon.com/mastodon\\u003c/span\\u003e\\u003cspan class=\"invisible\"\\u003e\\u003c/span\\u003e\\u003c/a\\u003e", + "verified_at": null + }, + { + "name": "Homepage", + "value": "\\u003ca href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"\\u003e\\u003cspan class=\"invisible\"\\u003ehttps://\\u003c/span\\u003e\\u003cspan class=\"\"\\u003ezeonfederated.com\\u003c/span\\u003e\\u003cspan class=\"invisible\"\\u003e\\u003c/span\\u003e\\u003c/a\\u003e", + "verified_at": "2019-07-15T18:29:57.191+00:00" + } + ] + }, + "106801667066418367": { + "id": "106801667066418367", + "username": "benis911", + "acct": "benis911", + "display_name": "", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2021-08-22T00:00:00.000Z", + "note": "\\u003cp\\u003e\\u003c/p\\u003e", + "url": "https://mastodon.social/@benis911", + "avatar": "https://mastodon.social/avatars/original/missing.png", + "avatar_static": "https://mastodon.social/avatars/original/missing.png", + "header": "https://mastodon.social/headers/original/missing.png", + "header_static": "https://mastodon.social/headers/original/missing.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + }, + "media_attachments": { + "accept_content_types": [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webm", + ".mp4", + ".m4v", + ".mov", + ".ogg", + ".oga", + ".mp3", + ".wav", + ".flac", + ".opus", + ".aac", + ".m4a", + ".3gp", + ".wma", + "image/jpeg", + "image/png", + "image/gif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/ogg", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ] + }, + "settings": { + "known_fediverse": false, + "notifications": { + "alerts": { + "follow": false, + "follow_request": false, + "favourite": false, + "reblog": false, + "mention": false, + "poll": false, + "status": false + }, + "quickFilter": { + "active": "all", + "show": true, + "advanced": false + }, + "dismissPermissionBanner": false, + "showUnread": true, + "shows": { + "follow": true, + "follow_request": false, + "favourite": true, + "reblog": true, + "mention": true, + "poll": true, + "status": true + }, + "sounds": { + "follow": true, + "follow_request": false, + "favourite": true, + "reblog": true, + "mention": true, + "poll": true, + "status": true + } + }, + "public": { + "regex": { + "body": "" + } + }, + "direct": { + "regex": { + "body": "" + } + }, + "community": { + "regex": { + "body": "" + } + }, + "skinTone": 1, + "trends": { + "show": true + }, + "columns": [ + { + "id": "COMPOSE", + "uuid": "b6dce3ed-c6cc-4446-8981-f08f8461ae8d", + "params": {} + }, + { + "id": "HOME", + "uuid": "e89b270b-6e79-4956-98fb-e8bf0aff098c", + "params": {} + }, + { + "id": "NOTIFICATIONS", + "uuid": "d359cdfa-e074-44ba-bde5-f46867a3bca6", + "params": {} + } + ], + "introductionVersion": 20181216044202, + "home": { + "shows": { + "reblog": true, + "reply": true + }, + "regex": { + "body": "" + } + } + }, + "push_subscription": null +} diff --git a/src/__fixtures__/mitra-context.json b/src/__fixtures__/mitra-context.json new file mode 100644 index 0000000..91b4842 --- /dev/null +++ b/src/__fixtures__/mitra-context.json @@ -0,0 +1,107 @@ +[ + { + "id": "017ed503-bc96-301a-e871-2c23b30ddd05", + "uri": "https://mitra.social/objects/017ed503-bc96-301a-e871-2c23b30ddd05", + "created_at": "2022-02-07T16:28:18.966874Z", + "account": { + "id": "017ed4f9-c121-2ae6-0805-15516cce02c3", + "username": "alex", + "acct": "alex", + "url": "https://mitra.social/users/alex", + "display_name": null, + "created_at": "2022-02-07T16:17:24.769229Z", + "note": null, + "avatar": null, + "header": null, + "fields": [], + "followers_count": 1, + "following_count": 1, + "statuses_count": 3, + "source": null, + "wallet_address": null + }, + "content": "@silverpill sup!", + "in_reply_to_id": null, + "reblog": null, + "visibility": "public", + "replies_count": 1, + "favourites_count": 0, + "reblogs_count": 0, + "media_attachments": [], + "mentions": [ + { + "id": "dd4ebc18-269d-4c7b-a310-03d29c6ab551", + "username": "silverpill", + "acct": "silverpill", + "url": "https://mitra.social/users/silverpill" + } + ], + "tags": [], + "favourited": false, + "reblogged": false, + "ipfs_cid": null, + "token_id": null, + "token_tx_id": null + }, + { + "id": "017ed505-5926-392f-256a-f86d5075df70", + "uri": "https://mitra.social/objects/017ed505-5926-392f-256a-f86d5075df70", + "created_at": "2022-02-07T16:30:04.582771Z", + "account": { + "id": "dd4ebc18-269d-4c7b-a310-03d29c6ab551", + "username": "silverpill", + "acct": "silverpill", + "url": "https://mitra.social/users/silverpill", + "display_name": "silverpill", + "created_at": "2021-11-06T21:08:57.441927Z", + "note": "Admin of mitra.social instance. It is running experimental ActivityPub server Mitra.", + "avatar": "https://mitra.social/media/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png", + "header": null, + "fields": [ + { + "name": "Matrix", + "value": "@silverpill:poa.st" + }, + { + "name": "Alt", + "value": "@silverpill@poa.st" + }, + { + "name": "Code", + "value": "https://codeberg.org/silverpill/" + }, + { + "name": "$XMR", + "value": "884y9LmsWY7PQNsyR7bJy1dvj91tuF5spVabyCnPk4KfQtSuzFbQobTFC7xSemJgVW1FWAwnJbjTZX5zZWbBrfkv62DB62d" + } + ], + "followers_count": 27, + "following_count": 15, + "statuses_count": 110, + "source": null, + "wallet_address": null + }, + "content": "@alex welcome", + "in_reply_to_id": "017ed503-bc96-301a-e871-2c23b30ddd05", + "reblog": null, + "visibility": "public", + "replies_count": 0, + "favourites_count": 1, + "reblogs_count": 0, + "media_attachments": [], + "mentions": [ + { + "id": "017ed4f9-c121-2ae6-0805-15516cce02c3", + "username": "alex", + "acct": "alex", + "url": "https://mitra.social/users/alex" + } + ], + "tags": [], + "favourited": true, + "reblogged": false, + "ipfs_cid": null, + "token_id": null, + "token_tx_id": null + } +] diff --git a/src/__fixtures__/mitra-instance.json b/src/__fixtures__/mitra-instance.json new file mode 100644 index 0000000..2c476ba --- /dev/null +++ b/src/__fixtures__/mitra-instance.json @@ -0,0 +1,13 @@ +{ + "uri": "mitra.social", + "title": "Mitra", + "short_description": "Federated social network with smart contracts", + "description": "This is an instance of [Mitra](https://codeberg.org/silverpill/mitra), federated social network built on [ActivityPub](https://activitypub.rocks/) protocol.\nRegistration is invitation-only.\nAdmin:\n - [@silverpill@mitra.social](https://mitra.social/profile/dd4ebc18-269d-4c7b-a310-03d29c6ab551)\n - Matrix: @silverpill:poa.st\n", + "version": "3.0.0 (compatible; Mitra 0.4.0)", + "registrations": false, + "login_message": "Sign this message to log in to https://mitra.social. Do not sign this message on other sites!", + "post_character_limit": 5000, + "blockchain_explorer_url": null, + "blockchain_contract_address": null, + "ipfs_gateway_url": "https://ipfs.mitra.social" +} diff --git a/src/__fixtures__/mitra-status-with-attachments.json b/src/__fixtures__/mitra-status-with-attachments.json new file mode 100644 index 0000000..689e4d3 --- /dev/null +++ b/src/__fixtures__/mitra-status-with-attachments.json @@ -0,0 +1,95 @@ +{ + "id": "017eeb0e-e5e7-98fe-6b2b-ad02349251fb", + "uri": "https://gleasonator.com/objects/aa5e66c9-0a10-4167-9c80-f40d9574aaec", + "created_at": "2022-02-11T23:11:59.891770Z", + "account": { + "id": "8fe4d6ed-3a99-43e1-a7d4-66b4e635f756", + "username": "alex", + "acct": "alex@gleasonator.com", + "url": "https://gleasonator.com/users/alex", + "display_name": "Alex Gleason", + "created_at": "2021-11-14T17:01:17.446307Z", + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "avatar": "https://mitra.social/media/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "header": "https://mitra.social/media/bdfb009adac0e31257e9fe527d3844a7234cc71f6e06dff2bec94354639555dd.png", + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2, + "following_count": 2, + "statuses_count": 970, + "source": null, + "wallet_address": null + }, + "content": "

Test

", + "in_reply_to_id": null, + "reblog": null, + "visibility": "public", + "replies_count": 0, + "favourites_count": 0, + "reblogs_count": 0, + "media_attachments": [ + { + "id": "017eeb0e-e5df-30a4-77a7-a929145cb836", + "type": "image", + "url": "https://mitra.social/media/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png" + }, + { + "id": "017eeb0e-e5e4-2a48-2889-afdebf368a54", + "type": "unknown", + "url": "https://mitra.social/media/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac" + }, + { + "id": "017eeb0e-e5e5-79fd-6054-8b6869b1db49", + "type": "unknown", + "url": "https://mitra.social/media/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.oga" + }, + { + "id": "017eeb0e-e5e6-c416-a444-21e560c47839", + "type": "unknown", + "url": "https://mitra.social/media/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0" + } + ], + "mentions": [], + "tags": [], + "favourited": false, + "reblogged": false, + "ipfs_cid": null, + "token_id": null, + "token_tx_id": null +} diff --git a/src/__fixtures__/mk.json b/src/__fixtures__/mk.json new file mode 100644 index 0000000..a7c841f --- /dev/null +++ b/src/__fixtures__/mk.json @@ -0,0 +1,123 @@ +{ + "acct": "mk", + "avatar": "https://media.spinster.xyz/4043b9fb3f9d468aa48a8d68294f338914d9d54b2816aa1c789f548efe6c6239.jpg", + "avatar_static": "https://media.spinster.xyz/4043b9fb3f9d468aa48a8d68294f338914d9d54b2816aa1c789f548efe6c6239.jpg", + "bot": false, + "created_at": "2019-08-01T22:06:30.000Z", + "display_name": "M. K. Fain", + "emojis": [ + { + "shortcode": "4w", + "static_url": "https://spinster.xyz/emoji/custom/4w.png", + "url": "https://spinster.xyz/emoji/custom/4w.png", + "visible_in_picker": false + }, + { + "shortcode": "spinster", + "static_url": "https://spinster.xyz/emoji/custom/spinster.png", + "url": "https://spinster.xyz/emoji/custom/spinster.png", + "visible_in_picker": false + } + ], + "fields": [ + { + "name": "Website", + "value": "https://marykatefain.com" + }, + { + "name": "Twitter", + "value": "https://twitter.com/mkay_fain" + }, + { + "name": "Patreon", + "value": "https://www.patreon.com/mkfain" + }, + { + "name": "Paypal", + "value": "https://www.paypal.com/donate?hosted_button_id=NYXHYFQ6CRWJJ" + }, + { + "name": "Facebook", + "value": "https://www.facebook.com/M-K-Fain-102559968375112" + }, + { + "name": "Dog Pics", + "value": "https://www.instagram.com/mmkaayyy92" + }, + { + "name": "$BTC", + "value": "bc1q7fp347muhnuxrtu0pft6eswn0e7pldhssdg8py" + } + ], + "followers_count": 5687, + "following_count": 18017, + "fqn": "mk@spinster.xyz", + "header": "https://media.spinster.xyz/3a5f9d5ef06940d0c319f8f0135b1153a8a42cefd10eace97378875c0347da71.png", + "header_static": "https://media.spinster.xyz/3a5f9d5ef06940d0c319f8f0135b1153a8a42cefd10eace97378875c0347da71.png", + "id": "9y4BZYXEDuQ6K1zW9g", + "last_status_at": "2022-02-27T01:58:21", + "locked": false, + "note": ":spinster: Admin of @spinster
:4w: Editor of @4WPub

Sorry I didn't reply to you.

Boost ≠ agree. All opinions my own.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://spinster.xyz/users/mk", + "background_image": null, + "favicon": "https://spinster.xyz/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [ + "verified" + ] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://marykatefain.com" + }, + { + "name": "Twitter", + "value": "https://twitter.com/mkay_fain" + }, + { + "name": "Patreon", + "value": "https://www.patreon.com/mkfain" + }, + { + "name": "Paypal", + "value": "https://www.paypal.com/donate?hosted_button_id=NYXHYFQ6CRWJJ" + }, + { + "name": "Facebook", + "value": "https://www.facebook.com/M-K-Fain-102559968375112" + }, + { + "name": "Dog Pics", + "value": "https://www.instagram.com/mmkaayyy92" + }, + { + "name": "$BTC", + "value": "bc1q7fp347muhnuxrtu0pft6eswn0e7pldhssdg8py" + } + ], + "note": ":spinster: Admin of @spinster\r\n:4w: Editor of @4WPub\r\n\r\nSorry I didn't reply to you.\r\n\r\nBoost ≠ agree. All opinions my own.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 9580, + "url": "https://spinster.xyz/users/mk", + "username": "mk" +} diff --git a/src/__fixtures__/notification-favourite.json b/src/__fixtures__/notification-favourite.json new file mode 100644 index 0000000..00da9c8 --- /dev/null +++ b/src/__fixtures__/notification-favourite.json @@ -0,0 +1,290 @@ +{ + "account": { + "acct": "Hollahollara@spinster.xyz", + "avatar": "https://gleasonator.com/proxy/LArKQiIrW265rGIJGwdgX7rRsao/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvYXZhdGFycy8wMDAvMTQxLzI5NC9vcmlnaW5hbC9lNjA1NjljMjBjNGY3ODNjLnBuZw/e60569c20c4f783c.png", + "avatar_static": "https://gleasonator.com/proxy/LArKQiIrW265rGIJGwdgX7rRsao/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvYXZhdGFycy8wMDAvMTQxLzI5NC9vcmlnaW5hbC9lNjA1NjljMjBjNGY3ODNjLnBuZw/e60569c20c4f783c.png", + "bot": false, + "created_at": "2020-05-29T03:15:59.000Z", + "display_name": "Hollahollara", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "fqn": "Hollahollara@spinster.xyz", + "header": "https://gleasonator.com/proxy/XSANC57uDBL3tM0LBLEer7yMyaA/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvaGVhZGVycy8wMDAvMTQxLzI5NC9vcmlnaW5hbC84NTMzMWEzMjJkMTIyN2Q0LnBuZw/85331a322d1227d4.png", + "header_static": "https://gleasonator.com/proxy/XSANC57uDBL3tM0LBLEer7yMyaA/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvaGVhZGVycy8wMDAvMTQxLzI5NC9vcmlnaW5hbC84NTMzMWEzMjJkMTIyN2Q0LnBuZw/85331a322d1227d4.png", + "id": "9vWfJdLwuJSyJXqCeG", + "last_status_at": "2022-04-16T20:33:32", + "locked": true, + "note": "Adult human female. Artist. Evil terv. Millennial, killing all the things. Public Universal Friend.

www.jenniferaldridge.com


", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://spinster.xyz/users/Hollahollara", + "background_image": null, + "deactivated": false, + "favicon": "https://gleasonator.com/proxy/owo6QgsHm_0ogz5enHyvD68wDUA/aHR0cHM6Ly9zcGluc3Rlci54eXovZmF2aWNvbi5wbmc/favicon.png", + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": false, + "hide_follows": true, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 7191, + "url": "https://spinster.xyz/users/Hollahollara", + "username": "Hollahollara" + }, + "created_at": "2022-04-14T20:36:52.000Z", + "id": "427825", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "follow_requests_count": 0, + "followers_count": 2602, + "following_count": 1603, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-04-16T19:23:50", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "accepts_email_list": true, + "allow_following_move": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "deactivated": false, + "email": "alex@alexgleason.me", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": "Texas", + "notification_settings": { + "block_from_strangers": false, + "hide_notification_contents": false + }, + "relationship": {}, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 392, + "unread_notifications_count": 2 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_birthday": true, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 24050, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "", + "created_at": "2022-04-12T01:31:00.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 11, + "id": "AIMEslRcKrcu02D3HU", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [ + { + "blurhash": "etMZzVWq%1%1o#_NayWCofae_Ns:R*kDjYS5a{jYoJj]V@a}WBbGof", + "description": "", + "id": "AIMEqtBeZtvpQvqfIG", + "meta": { + "original": { + "aspect": 0.9726443768996961, + "height": 658, + "width": 640 + } + }, + "pleroma": { + "mime_type": "image/jpeg" + }, + "preview_url": "https://media.gleasonator.com/6c0a1d878b7c9d1d737f415645cf34cdacdf6438c468348f4fa7534a15798023.jpg", + "remote_url": "https://media.gleasonator.com/6c0a1d878b7c9d1d737f415645cf34cdacdf6438c468348f4fa7534a15798023.jpg", + "text_url": "https://media.gleasonator.com/6c0a1d878b7c9d1d737f415645cf34cdacdf6438c468348f4fa7534a15798023.jpg", + "type": "image", + "url": "https://media.gleasonator.com/6c0a1d878b7c9d1d737f415645cf34cdacdf6438c468348f4fa7534a15798023.jpg" + } + ], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "" + }, + "content_type": null, + "conversation_id": "AIMEslPqRSCzuXNdWC", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 4, + "me": false, + "name": "😆" + }, + { + "count": 1, + "me": false, + "name": "🤢" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 4, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/7953f9fb-d3d7-4f50-b9d8-27e311ac1f5e", + "url": "https://gleasonator.com/notice/AIMEslRcKrcu02D3HU", + "visibility": "public" + }, + "type": "favourite" +} diff --git a/src/__fixtures__/notification-follow.json b/src/__fixtures__/notification-follow.json new file mode 100644 index 0000000..f563646 --- /dev/null +++ b/src/__fixtures__/notification-follow.json @@ -0,0 +1,61 @@ +{ + "account": { + "acct": "neko@rdrama.cc", + "avatar": "https://gleasonator.com/proxy/QJ3einzsXdobgWPsyZowxnor1zY/aHR0cHM6Ly9yZHJhbWEuY2MvbWVkaWEvODcyNDhjYjctZWYwNC00ZThjLWEwYzEtNTYxNWMyNWM0MTk1L2Jsb2I/blob", + "avatar_static": "https://gleasonator.com/proxy/QJ3einzsXdobgWPsyZowxnor1zY/aHR0cHM6Ly9yZHJhbWEuY2MvbWVkaWEvODcyNDhjYjctZWYwNC00ZThjLWEwYzEtNTYxNWMyNWM0MTk1L2Jsb2I/blob", + "bot": false, + "created_at": "2022-04-16T20:23:16.000Z", + "display_name": "Nekobit", + "emojis": [], + "fields": [], + "followers_count": 19, + "following_count": 357, + "fqn": "neko@rdrama.cc", + "header": "https://gleasonator.com/proxy/ojpBSVKfePvLnb7pwqepQspzIko/aHR0cHM6Ly9yZHJhbWEuY2MvbWVkaWEvNjBkMTJjOWYtOTNkNi00ODBmLThhMGUtMTE3M2ZkNjg5MzhmL3dhbGxwYXBlcmZsYXJlLmNvbV93YWxscGFwZXItd2ViLmpwZw/wallpaperflare.com_wallpaper-web.jpg", + "header_static": "https://gleasonator.com/proxy/ojpBSVKfePvLnb7pwqepQspzIko/aHR0cHM6Ly9yZHJhbWEuY2MvbWVkaWEvNjBkMTJjOWYtOTNkNi00ODBmLThhMGUtMTE3M2ZkNjg5MzhmL3dhbGxwYXBlcmZsYXJlLmNvbV93YWxscGFwZXItd2ViLmpwZw/wallpaperflare.com_wallpaper-web.jpg", + "id": "AIW9zGESDwdT27vk0W", + "last_status_at": "2022-04-16T21:49:29", + "locked": false, + "note": "New instance, hello!

Please follow if you followed my desuposter.club alt", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://rdrama.cc/users/neko", + "background_image": null, + "deactivated": false, + "favicon": "https://gleasonator.com/proxy/dbCdmChqVRi0vjYTCpRj5lDLtNM/aHR0cHM6Ly9yZHJhbWEuY2MvZmF2aWNvbi5wbmc/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 6, + "url": "https://rdrama.cc/users/neko", + "username": "neko" + }, + "created_at": "2022-04-16T20:24:03.000Z", + "id": "429280", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "type": "follow" +} diff --git a/src/__fixtures__/notification-follow_request.json b/src/__fixtures__/notification-follow_request.json new file mode 100644 index 0000000..391dfec --- /dev/null +++ b/src/__fixtures__/notification-follow_request.json @@ -0,0 +1,61 @@ +{ + "account": { + "acct": "alex@spinster.xyz", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2020-01-08T03:08:22.000Z", + "display_name": "**MOVED**", + "emojis": [], + "fields": [], + "followers_count": 1005, + "following_count": 724, + "fqn": "alex@spinster.xyz", + "header": "https://gleasonator.com/proxy/yxa7ucolLFAsmBHYJzksSh_zoao/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvaGVhZGVycy8wMDAvMDAwLzAwMS9vcmlnaW5hbC83ZmE4MWY5ZmZiYWVjZDk3LnBuZw/7fa81f9ffbaecd97.png", + "header_static": "https://gleasonator.com/proxy/yxa7ucolLFAsmBHYJzksSh_zoao/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvaGVhZGVycy8wMDAvMDAwLzAwMS9vcmlnaW5hbC83ZmE4MWY5ZmZiYWVjZDk3LnBuZw/7fa81f9ffbaecd97.png", + "id": "9v5bmXkCYkqU30gp9s", + "last_status_at": null, + "locked": true, + "note": "Moved to https://spinster.xyz/@alex@gleasonator.com", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://spinster.xyz/users/alex", + "background_image": null, + "deactivated": false, + "favicon": "https://gleasonator.com/proxy/owo6QgsHm_0ogz5enHyvD68wDUA/aHR0cHM6Ly9zcGluc3Rlci54eXovZmF2aWNvbi5wbmc/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": false, + "is_moderator": false, + "is_suggested": false, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 2687, + "url": "https://spinster.xyz/users/alex", + "username": "alex" + }, + "created_at": "2020-12-30T02:23:35.000Z", + "id": "87967", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "type": "follow_request" +} diff --git a/src/__fixtures__/notification-mention.json b/src/__fixtures__/notification-mention.json new file mode 100644 index 0000000..d2ad0a2 --- /dev/null +++ b/src/__fixtures__/notification-mention.json @@ -0,0 +1,226 @@ +{ + "account": { + "acct": "silverpill@mitra.social", + "avatar": "https://gleasonator.com/proxy/ZbLqy9s8Hxn9I5K23y2mffsL6iY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvbWVkaWEvNmE3ODViZjdkZDA1ZjYxYzM1OTBlODkzNWFhNDkxNTZhNDk5YWMzMGZkMWU0MDJmNzllN2UxNjRhZGIzNmUyYy5wbmc/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png", + "avatar_static": "https://gleasonator.com/proxy/ZbLqy9s8Hxn9I5K23y2mffsL6iY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvbWVkaWEvNmE3ODViZjdkZDA1ZjYxYzM1OTBlODkzNWFhNDkxNTZhNDk5YWMzMGZkMWU0MDJmNzllN2UxNjRhZGIzNmUyYy5wbmc/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png", + "bot": false, + "created_at": "2021-11-11T22:31:51.000Z", + "display_name": "silverpill", + "emojis": [], + "fields": [ + { + "name": "Matrix", + "value": "@silverpill:poa.st" + }, + { + "name": "Alt", + "value": "@silverpill@poa.st" + }, + { + "name": "Code", + "value": "https://codeberg.org/silverpill/" + }, + { + "name": "$XMR", + "value": "884y9LmsWY7PQNsyR7bJy1dvj91tuF5spVabyCnPk4KfQtSuzFbQobTFC7xSemJgVW1FWAwnJbjTZX5zZWbBrfkv62DB62d" + } + ], + "followers_count": 0, + "following_count": 0, + "fqn": "silverpill@mitra.social", + "header": "https://gleasonator.com/images/banner.png", + "header_static": "https://gleasonator.com/images/banner.png", + "id": "ADIzJ7q9gExPvDKBCS", + "last_status_at": "2022-04-15T11:27:33", + "locked": false, + "note": "", + "pleroma": { + "accepts_chat_messages": false, + "also_known_as": [], + "ap_id": "https://mitra.social/users/silverpill", + "background_image": null, + "deactivated": false, + "favicon": "https://gleasonator.com/proxy/XSE9_kQbQyYcSFWszWx2GgCbBuY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvZmF2aWNvbi5pY28/favicon.ico", + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": false, + "hide_follows": true, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 135, + "url": "https://mitra.social/users/silverpill", + "username": "silverpill" + }, + "created_at": "2022-04-15T11:27:33.000Z", + "id": "428172", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "status": { + "account": { + "acct": "silverpill@mitra.social", + "avatar": "https://gleasonator.com/proxy/ZbLqy9s8Hxn9I5K23y2mffsL6iY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvbWVkaWEvNmE3ODViZjdkZDA1ZjYxYzM1OTBlODkzNWFhNDkxNTZhNDk5YWMzMGZkMWU0MDJmNzllN2UxNjRhZGIzNmUyYy5wbmc/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png", + "avatar_static": "https://gleasonator.com/proxy/ZbLqy9s8Hxn9I5K23y2mffsL6iY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvbWVkaWEvNmE3ODViZjdkZDA1ZjYxYzM1OTBlODkzNWFhNDkxNTZhNDk5YWMzMGZkMWU0MDJmNzllN2UxNjRhZGIzNmUyYy5wbmc/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png", + "bot": false, + "created_at": "2021-11-11T22:31:51.000Z", + "display_name": "silverpill", + "emojis": [], + "fields": [ + { + "name": "Matrix", + "value": "@silverpill:poa.st" + }, + { + "name": "Alt", + "value": "@silverpill@poa.st" + }, + { + "name": "Code", + "value": "https://codeberg.org/silverpill/" + }, + { + "name": "$XMR", + "value": "884y9LmsWY7PQNsyR7bJy1dvj91tuF5spVabyCnPk4KfQtSuzFbQobTFC7xSemJgVW1FWAwnJbjTZX5zZWbBrfkv62DB62d" + } + ], + "followers_count": 0, + "following_count": 0, + "fqn": "silverpill@mitra.social", + "header": "https://gleasonator.com/images/banner.png", + "header_static": "https://gleasonator.com/images/banner.png", + "id": "ADIzJ7q9gExPvDKBCS", + "last_status_at": "2022-04-15T11:27:33", + "locked": false, + "note": "", + "pleroma": { + "accepts_chat_messages": false, + "also_known_as": [], + "ap_id": "https://mitra.social/users/silverpill", + "background_image": null, + "deactivated": false, + "favicon": "https://gleasonator.com/proxy/XSE9_kQbQyYcSFWszWx2GgCbBuY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvZmF2aWNvbi5pY28/favicon.ico", + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": false, + "hide_follows": true, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 135, + "url": "https://mitra.social/users/silverpill", + "username": "silverpill" + }, + "application": null, + "bookmarked": true, + "card": { + "author_name": "", + "author_url": "", + "blurhash": null, + "description": "The ActivityPub protocol is a decentralized social networking protocol\n based upon the [ActivityStreams] 2.0 data format.\n It provides a client to server API for creating, updating and deleting\n content, as well as a federated server to server API for delivering\n notifications and content.", + "embed_url": "", + "height": 0, + "html": "", + "image": null, + "provider_name": "www.w3.org", + "provider_url": "https://www.w3.org", + "title": "ActivityPub", + "type": "link", + "url": "https://www.w3.org/TR/activitypub/#retrieving-objects", + "width": 0 + }, + "content": "@alex @lain The second one is suggested by ActivityPub spec: https://www.w3.org/TR/activitypub/#retrieving-objects
\nThe first one is likely a legacy of earlier ActivityStreams standards, I'm not sure", + "created_at": "2022-04-15T11:27:28.000Z", + "emojis": [], + "favourited": true, + "favourites_count": 2, + "id": "AITJf9Wpr0msWChNBI", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "AISPFI5nnPaS7J94rI", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + { + "acct": "lain@lain.com", + "id": "9v5bqYwY2jfmvPNhTM", + "url": "https://lain.com/users/lain", + "username": "lain" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "@alex @lain The second one is suggested by ActivityPub spec: https://www.w3.org/TR/activitypub/#retrieving-objects\nThe first one is likely a legacy of earlier ActivityStreams standards, I'm not sure" + }, + "content_type": null, + "conversation_id": "AISPFI2bzH2DxPeWsy", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": false, + "parent_visible": true, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://mitra.social/objects/01802cfa-633c-1c2c-e9cf-e6e0ffef0afe", + "url": "https://mitra.social/objects/01802cfa-633c-1c2c-e9cf-e6e0ffef0afe", + "visibility": "public" + }, + "type": "mention" +} diff --git a/src/__fixtures__/notification-move.json b/src/__fixtures__/notification-move.json new file mode 100644 index 0000000..4bffd19 --- /dev/null +++ b/src/__fixtures__/notification-move.json @@ -0,0 +1,119 @@ +{ + "account": { + "acct": "alex@fedibird.com", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2022-01-24T21:25:37.000Z", + "display_name": "alex@fedibird.com", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 2, + "fqn": "alex@fedibird.com", + "header": "https://gleasonator.com/images/banner.png", + "header_static": "https://gleasonator.com/images/banner.png", + "id": "AFmHQ18XZ7Lco68MW8", + "last_status_at": "2022-03-16T22:07:53", + "locked": false, + "note": "

", + "pleroma": { + "accepts_chat_messages": null, + "also_known_as": [], + "ap_id": "https://fedibird.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "deactivated": false, + "favicon": "https://gleasonator.com/proxy/HzfsidHss3CuA7aM2zxXN-tAjF8/aHR0cHM6Ly9mZWRpYmlyZC5jb20vZmF2aWNvbi5pY28/favicon.ico", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": "Texas", + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 5, + "url": "https://fedibird.com/@alex", + "username": "alex" + }, + "created_at": "2022-03-17T00:08:48.000Z", + "id": "406814", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "target": { + "acct": "benis911", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2021-03-26T20:42:11.000Z", + "display_name": "benis911", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "fqn": "benis911@gleasonator.com", + "header": "https://media.gleasonator.com/fc595bbbcf5aabefecd1c2adfe5b7f5457db59847992881668653a0338ba25bd.jpg", + "header_static": "https://media.gleasonator.com/fc595bbbcf5aabefecd1c2adfe5b7f5457db59847992881668653a0338ba25bd.jpg", + "id": "A5c5LK7EJTFR0u26Pg", + "last_status_at": "2022-03-19T22:33:38", + "locked": false, + "note": "hello world 2", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://gleasonator.com/users/alex", + "https://poa.st/users/alex", + "https://fedibird.com/users/alex" + ], + "ap_id": "https://gleasonator.com/users/benis911", + "background_image": null, + "birthday": "2000-01-25", + "deactivated": false, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": true, + "hide_follows": true, + "hide_follows_count": true, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "hello world 2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 174, + "url": "https://gleasonator.com/users/benis911", + "username": "benis911" + }, + "type": "move" +} diff --git a/src/__fixtures__/notification-pleroma-chat_mention.json b/src/__fixtures__/notification-pleroma-chat_mention.json new file mode 100644 index 0000000..c90cc7b --- /dev/null +++ b/src/__fixtures__/notification-pleroma-chat_mention.json @@ -0,0 +1,73 @@ +{ + "account": { + "acct": "dave", + "avatar": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png", + "avatar_static": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png", + "bot": false, + "created_at": "2020-02-01T07:28:46.000Z", + "display_name": "Elden Beedle 🇺🇦 🇫🇷", + "emojis": [], + "fields": [], + "followers_count": 490, + "following_count": 367, + "fqn": "dave@gleasonator.com", + "header": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg", + "header_static": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg", + "id": "9v5c0Pkz3MT5KTfam8", + "last_status_at": "2022-04-16T19:57:10", + "locked": false, + "note": "Beedle is back, baby!

Mostly just crosspost memes and stuff I find on the internet", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/dave", + "background_image": null, + "birthday": "1990-01-01", + "deactivated": false, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Beedle is back, baby!\r\n\r\nMostly just crosspost memes and stuff I find on the internet", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 16758, + "url": "https://gleasonator.com/users/dave", + "username": "dave" + }, + "chat_message": { + "account_id": "9v5c0Pkz3MT5KTfam8", + "attachment": null, + "card": null, + "chat_id": "9yX4Q9DiC2te6lvk5g", + "content": "Cool, it works, I'll keep letting you know when I find broken stuff", + "created_at": "2022-04-16T19:22:54.000Z", + "emojis": [], + "id": "AIW4bHoICoZ9CsRTW4", + "unread": false + }, + "created_at": "2022-04-16T19:22:55.000Z", + "id": "429247", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "type": "pleroma:chat_mention" +} diff --git a/src/__fixtures__/notification-pleroma-emoji_reaction.json b/src/__fixtures__/notification-pleroma-emoji_reaction.json new file mode 100644 index 0000000..cc988d3 --- /dev/null +++ b/src/__fixtures__/notification-pleroma-emoji_reaction.json @@ -0,0 +1,301 @@ +{ + "account": { + "acct": "dave", + "avatar": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png", + "avatar_static": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png", + "bot": false, + "created_at": "2020-02-01T07:28:46.000Z", + "display_name": "Elden Beedle 🇺🇦 🇫🇷", + "emojis": [], + "fields": [], + "followers_count": 490, + "following_count": 367, + "fqn": "dave@gleasonator.com", + "header": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg", + "header_static": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg", + "id": "9v5c0Pkz3MT5KTfam8", + "last_status_at": "2022-04-16T19:57:10", + "locked": false, + "note": "Beedle is back, baby!

Mostly just crosspost memes and stuff I find on the internet", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/dave", + "background_image": null, + "birthday": "1990-01-01", + "deactivated": false, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Beedle is back, baby!\r\n\r\nMostly just crosspost memes and stuff I find on the internet", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 16758, + "url": "https://gleasonator.com/users/dave", + "username": "dave" + }, + "created_at": "2022-04-16T16:52:15.000Z", + "emoji": "😮", + "id": "429071", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "follow_requests_count": 0, + "followers_count": 2602, + "following_count": 1603, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-04-16T19:23:50", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "accepts_email_list": true, + "allow_following_move": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "deactivated": false, + "email": "alex@alexgleason.me", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": "Texas", + "notification_settings": { + "block_from_strangers": false, + "hide_notification_contents": false + }, + "relationship": {}, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 392, + "unread_notifications_count": 0 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_birthday": true, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 24050, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": { + "author_name": "Kaze Emanuar", + "author_url": "https://www.youtube.com/c/KazeEmanuar", + "blurhash": null, + "description": "", + "embed_url": null, + "height": 113, + "html": "", + "image": "https://gleasonator.com/proxy/mI004Vq00johZtAUmMp0fC_XAuM/aHR0cHM6Ly9pLnl0aW1nLmNvbS92aS90X3J6WW5YRVFsRS9ocWRlZmF1bHQuanBn/hqdefault.jpg", + "provider_name": "YouTube", + "provider_url": "https://www.youtube.com/", + "title": "FIXING the ENTIRE SM64 Source Code (INSANE N64 performance)", + "type": "video", + "url": "https://youtu.be/t_rzYnXEQlE", + "width": 200 + }, + "content": "

Bruh. This guy rewrote the reversed engineered Super Mario 64 code for 10x performance. Games need to be open source. https://youtu.be/t_rzYnXEQlE

", + "created_at": "2022-04-16T16:40:28.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 11, + "id": "AIVq6SrJg5yb8eGVsm", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Bruh. This guy rewrote the reversed engineered Super Mario 64 code for 10x performance. Games need to be open source. https://youtu.be/t_rzYnXEQlE" + }, + "content_type": null, + "conversation_id": "AIVq6SqFk37r5LlfE0", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 1, + "me": false, + "name": "❤️" + }, + { + "count": 2, + "me": false, + "name": "😮" + }, + { + "count": 1, + "me": false, + "name": "😆" + }, + { + "count": 1, + "me": false, + "name": "👍🏻" + }, + { + "count": 1, + "me": false, + "name": "🔥" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 7, + "replies_count": 2, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/160dcbb2-73bc-4cd2-971e-e7f6a38602a0", + "url": "https://gleasonator.com/notice/AIVq6SrJg5yb8eGVsm", + "visibility": "public" + }, + "type": "pleroma:emoji_reaction" +} diff --git a/src/__fixtures__/notification-poll.json b/src/__fixtures__/notification-poll.json new file mode 100644 index 0000000..fe582f2 --- /dev/null +++ b/src/__fixtures__/notification-poll.json @@ -0,0 +1,202 @@ +{ + "account": { + "acct": "dave", + "avatar": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png", + "avatar_static": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png", + "bot": false, + "created_at": "2020-02-01T07:28:46.000Z", + "display_name": "Elden Beedle 🇺🇦 🇫🇷", + "emojis": [], + "fields": [], + "followers_count": 490, + "following_count": 367, + "fqn": "dave@gleasonator.com", + "header": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg", + "header_static": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg", + "id": "9v5c0Pkz3MT5KTfam8", + "last_status_at": "2022-04-16T19:57:10", + "locked": false, + "note": "Beedle is back, baby!

Mostly just crosspost memes and stuff I find on the internet", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/dave", + "background_image": null, + "birthday": "1990-01-01", + "deactivated": false, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Beedle is back, baby!\r\n\r\nMostly just crosspost memes and stuff I find on the internet", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 16758, + "url": "https://gleasonator.com/users/dave", + "username": "dave" + }, + "created_at": "2022-04-14T01:12:27.000Z", + "id": "427339", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "status": { + "account": { + "acct": "dave", + "avatar": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png", + "avatar_static": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png", + "bot": false, + "created_at": "2020-02-01T07:28:46.000Z", + "display_name": "Elden Beedle 🇺🇦 🇫🇷", + "emojis": [], + "fields": [], + "followers_count": 490, + "following_count": 367, + "fqn": "dave@gleasonator.com", + "header": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg", + "header_static": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg", + "id": "9v5c0Pkz3MT5KTfam8", + "last_status_at": "2022-04-16T19:57:10", + "locked": false, + "note": "Beedle is back, baby!

Mostly just crosspost memes and stuff I find on the internet", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/dave", + "background_image": null, + "birthday": "1990-01-01", + "deactivated": false, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Beedle is back, baby!\r\n\r\nMostly just crosspost memes and stuff I find on the internet", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 16758, + "url": "https://gleasonator.com/users/dave", + "username": "dave" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

Focusing on just the look, what do you guys think?

", + "created_at": "2022-04-13T01:12:26.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 1, + "id": "AIOHjtGEaqUHoXGVf6", + "in_reply_to_account_id": "9v5c0Pkz3MT5KTfam8", + "in_reply_to_id": "AIOFTLqQrljhdNBNHE", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "dave", + "id": "9v5c0Pkz3MT5KTfam8", + "url": "https://gleasonator.com/users/dave", + "username": "dave" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Focusing on just the look, what do you guys think?" + }, + "content_type": null, + "conversation_id": "AIOFTLp0x2bNYyWF4C", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "dave", + "local": true, + "parent_visible": true, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": { + "emojis": [], + "expired": true, + "expires_at": "2022-04-14T01:12:26.000Z", + "id": "AIOHjtAuucEZY2mGNE", + "multiple": false, + "options": [ + { + "title": "Looks good, looking forward to wider deployment", + "votes_count": 10 + }, + { + "title": "Not a fan, l'll stick to the current UI thanks", + "votes_count": 1 + }, + { + "title": "Hard to say, need to actually try to decide honestly", + "votes_count": 1 + } + ], + "own_votes": [ + 0 + ], + "voted": true, + "voters_count": 12, + "votes_count": 12 + }, + "reblog": null, + "reblogged": false, + "reblogs_count": 1, + "replies_count": 1, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/a8465271-a48d-4c39-a0a9-d3eda3ab2735", + "url": "https://gleasonator.com/notice/AIOHjtGEaqUHoXGVf6", + "visibility": "public" + }, + "type": "poll" +} diff --git a/src/__fixtures__/notification-reblog.json b/src/__fixtures__/notification-reblog.json new file mode 100644 index 0000000..94638b8 --- /dev/null +++ b/src/__fixtures__/notification-reblog.json @@ -0,0 +1,284 @@ +{ + "account": { + "acct": "rob@nicecrew.digital", + "avatar": "https://gleasonator.com/proxy/RcEgR4-0InIpw_sCpDWV-XrAbmY/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL21lZGlhL2M0MTllMTk1Nzg0MmEzMTY5M2MzNDExNTZlMTBhNmQwMTY2ZTM5YzQzM2ExZTczMmVmYWNlYmJkYjAyMDYzZjEucG5n/c419e1957842a31693c341156e10a6d0166e39c433a1e732efacebbdb02063f1.png", + "avatar_static": "https://gleasonator.com/proxy/RcEgR4-0InIpw_sCpDWV-XrAbmY/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL21lZGlhL2M0MTllMTk1Nzg0MmEzMTY5M2MzNDExNTZlMTBhNmQwMTY2ZTM5YzQzM2ExZTczMmVmYWNlYmJkYjAyMDYzZjEucG5n/c419e1957842a31693c341156e10a6d0166e39c433a1e732efacebbdb02063f1.png", + "bot": false, + "created_at": "2022-03-10T12:30:41.000Z", + "display_name": "Rob Colbert", + "emojis": [], + "fields": [ + { + "name": "Shing.tv", + "value": "https://shing.tv" + }, + { + "name": "LibertyLinks", + "value": "https://libertylinks.io" + }, + { + "name": "GiveSendGo", + "value": "https://givesendgo.com/dtp" + } + ], + "followers_count": 0, + "following_count": 0, + "fqn": "rob@nicecrew.digital", + "header": "https://gleasonator.com/proxy/t4--aro68-XZlasaR2bYiuiZMcA/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL21lZGlhL2E5ODYzYWE4YjEzM2QwMzkxNmU1N2MzNDgzMzBhZmE5MTM5MDFlNGZiMDEwYjk1Y2FiZjlmYmZiZTA4N2QxODMucG5n/a9863aa8b133d03916e57c348330afa913901e4fb010b95cabf9fbfbe087d183.png", + "header_static": "https://gleasonator.com/proxy/t4--aro68-XZlasaR2bYiuiZMcA/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL21lZGlhL2E5ODYzYWE4YjEzM2QwMzkxNmU1N2MzNDgzMzBhZmE5MTM5MDFlNGZiMDEwYjk1Y2FiZjlmYmZiZTA4N2QxODMucG5n/a9863aa8b133d03916e57c348330afa913901e4fb010b95cabf9fbfbe087d183.png", + "id": "AHGmnebARD1aa1IiBc", + "last_status_at": "2022-04-16T21:08:35", + "locked": false, + "note": "Creator and CTO of the Digital Telepresence Platform and DTP Technologies, LLC.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://nicecrew.digital/users/rob", + "background_image": null, + "deactivated": false, + "favicon": "https://gleasonator.com/proxy/gb2NPo0Kv_svADN1_J9_9iSwlrY/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL2Zhdmljb24ucG5n/favicon.png", + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": false, + "hide_follows": true, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 761, + "url": "https://nicecrew.digital/users/rob", + "username": "rob" + }, + "created_at": "2022-04-16T03:43:24.000Z", + "id": "428608", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "follow_requests_count": 0, + "followers_count": 2602, + "following_count": 1603, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-04-16T19:23:50", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "accepts_email_list": true, + "allow_following_move": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "deactivated": false, + "email": "alex@alexgleason.me", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": "Texas", + "notification_settings": { + "block_from_strangers": false, + "hide_notification_contents": false + }, + "relationship": {}, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 392, + "unread_notifications_count": 0 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_birthday": true, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 24050, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

The @fsf needs to give out an award to every American who has never downloaded TikTok.

", + "created_at": "2022-04-16T03:42:50.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 15, + "id": "AIUihbqUEe5Uvv7P9s", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "fsf@status.fsf.org", + "id": "9v5boQSsaxVc3AU8u0", + "url": "https://status.fsf.org/fsf", + "username": "fsf" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "The @fsf needs to give out an award to every American who has never downloaded TikTok." + }, + "content_type": null, + "conversation_id": "AIUihbp4JuxArWSGwq", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 2, + "me": false, + "name": "🔥" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 8, + "replies_count": 4, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/6be95787-fb9c-41cd-96cf-9652b2680863", + "url": "https://gleasonator.com/notice/AIUihbqUEe5Uvv7P9s", + "visibility": "public" + }, + "type": "reblog" +} diff --git a/src/__fixtures__/notification.json b/src/__fixtures__/notification.json new file mode 100644 index 0000000..65f522b --- /dev/null +++ b/src/__fixtures__/notification.json @@ -0,0 +1,250 @@ +{ + "account": { + "acct": "crockwave", + "avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "bot": false, + "created_at": "2020-02-26T16:31:25.000Z", + "display_name": "Curtis Rock", + "emojis": [], + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "followers_count": 13, + "following_count": 11, + "header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "id": "9v5c6xSEgAi3Zu1Lv6", + "locked": false, + "note": "soapbox development team test test2", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c6xSEgAi3Zu1Lv6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "note": "soapbox development team test test2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 212, + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + }, + "created_at": "2020-06-10T02:51:05.000Z", + "id": "10743", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.

Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.

I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus.", + "created_at": "2020-06-10T01:29:20.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 4, + "id": "9vvNxoo5EFbbnfdXQu", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus." + }, + "conversation_id": 1168229, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/aa294f83-5a6c-4d2b-ba20-2b8bf69a82ba", + "url": "https://gleasonator.com/notice/9vvNxoo5EFbbnfdXQu", + "visibility": "public" + }, + "type": "favourite" +} diff --git a/src/__fixtures__/notifications.json b/src/__fixtures__/notifications.json new file mode 100644 index 0000000..fd99490 --- /dev/null +++ b/src/__fixtures__/notifications.json @@ -0,0 +1,4461 @@ +[ + { + "account": { + "acct": "seanking", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2020-05-24T01:46:12.000Z", + "display_name": "Sean King", + "emojis": [], + "fields": [], + "followers_count": 8, + "following_count": 4, + "header": "https://gleasonator.com/images/banner.png", + "header_static": "https://gleasonator.com/images/banner.png", + "id": "9vMAje101ngtjlMj7w", + "locked": false, + "note": "Hi, I'm Sean King, Founder and President of Sandia Mesa. I'll be here to try out new features and help test out some with Soapbox FE.", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9vMAje101ngtjlMj7w", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Hi, I'm Sean King, Founder and President of Sandia Mesa. I'll be here to try out new features and help test out some with Soapbox FE.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 15, + "url": "https://gleasonator.com/users/seanking", + "username": "seanking" + }, + "created_at": "2020-06-10T02:54:39.000Z", + "emoji": "😢", + "id": "10744", + "pleroma": { + "is_seen": false + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.

Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.

I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus.", + "created_at": "2020-06-10T01:29:20.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 4, + "id": "9vvNxoo5EFbbnfdXQu", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus." + }, + "conversation_id": 1168229, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/aa294f83-5a6c-4d2b-ba20-2b8bf69a82ba", + "url": "https://gleasonator.com/notice/9vvNxoo5EFbbnfdXQu", + "visibility": "public" + }, + "type": "pleroma:emoji_reaction" + }, + { + "account": { + "acct": "crockwave", + "avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "bot": false, + "created_at": "2020-02-26T16:31:25.000Z", + "display_name": "Curtis Rock", + "emojis": [], + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "followers_count": 13, + "following_count": 11, + "header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "id": "9v5c6xSEgAi3Zu1Lv6", + "locked": false, + "note": "soapbox development team test test2", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c6xSEgAi3Zu1Lv6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "note": "soapbox development team test test2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 212, + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + }, + "created_at": "2020-06-10T02:51:05.000Z", + "id": "10743", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.

Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.

I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus.", + "created_at": "2020-06-10T01:29:20.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 4, + "id": "9vvNxoo5EFbbnfdXQu", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus." + }, + "conversation_id": 1168229, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/aa294f83-5a6c-4d2b-ba20-2b8bf69a82ba", + "url": "https://gleasonator.com/notice/9vvNxoo5EFbbnfdXQu", + "visibility": "public" + }, + "type": "favourite" + }, + { + "account": { + "acct": "niggaflamebuttholeaids@husk.site", + "avatar": "https://husk.site/media/063a8f97e9b5d2f04e01e8ce98f71a201f82e86e53b78e66b121b774a3ca565d.png", + "avatar_static": "https://husk.site/media/063a8f97e9b5d2f04e01e8ce98f71a201f82e86e53b78e66b121b774a3ca565d.png", + "bot": false, + "created_at": "2020-05-03T01:39:47.000Z", + "display_name": ":brain3: Steven :alexjonesflexing:", + "emojis": [ + { + "shortcode": "alexjonesflexing", + "static_url": "https://husk.site/emoji/custom/alexjonesflexing.png", + "url": "https://husk.site/emoji/custom/alexjonesflexing.png", + "visible_in_picker": false + }, + { + "shortcode": "brain3", + "static_url": "https://husk.site/emoji/custom/brain3.png", + "url": "https://husk.site/emoji/custom/brain3.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 90, + "following_count": 67, + "header": "https://husk.site/media/8af4afad13e7940333df2680b1ade653bb6e63b76d58d583ed8cffe85292dc16.png", + "header_static": "https://husk.site/media/8af4afad13e7940333df2680b1ade653bb6e63b76d58d583ed8cffe85292dc16.png", + "id": "9v5cKMOPGqPcgfcWp6", + "locked": false, + "note": "Professional Liar", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5cKMOPGqPcgfcWp6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Professional Liar", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 5350, + "url": "https://husk.site/users/niggaflamebuttholeaids", + "username": "niggaflamebuttholeaids" + }, + "created_at": "2020-06-10T02:05:06.000Z", + "id": "10741", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.

Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.

I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus.", + "created_at": "2020-06-10T01:29:20.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 4, + "id": "9vvNxoo5EFbbnfdXQu", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus." + }, + "conversation_id": 1168229, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/aa294f83-5a6c-4d2b-ba20-2b8bf69a82ba", + "url": "https://gleasonator.com/notice/9vvNxoo5EFbbnfdXQu", + "visibility": "public" + }, + "type": "favourite" + }, + { + "account": { + "acct": "dielan@shitposter.club", + "avatar": "https://shitposter.club/media/99884dfe7d89658f00c7454cfd9d3cc932a650384a69963b8499fc89284464b5.gif?name=99884dfe7d89658f00c7454cfd9d3cc932a650384a69963b8499fc89284464b5.gif", + "avatar_static": "https://shitposter.club/media/99884dfe7d89658f00c7454cfd9d3cc932a650384a69963b8499fc89284464b5.gif?name=99884dfe7d89658f00c7454cfd9d3cc932a650384a69963b8499fc89284464b5.gif", + "bot": false, + "created_at": "2020-01-09T13:21:37.000Z", + "display_name": ":8b_d:‍:8b_i:‍:8b_e:‍:8b_l:‍:8b_a:‍:8b_n:", + "emojis": [ + { + "shortcode": "8b_a", + "static_url": "https://shitposter.club/emoji/stolen/8b_a.png", + "url": "https://shitposter.club/emoji/stolen/8b_a.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_d", + "static_url": "https://shitposter.club/emoji/stolen/8b_d.png", + "url": "https://shitposter.club/emoji/stolen/8b_d.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_e", + "static_url": "https://shitposter.club/emoji/stolen/8b_e.png", + "url": "https://shitposter.club/emoji/stolen/8b_e.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_i", + "static_url": "https://shitposter.club/emoji/stolen/8b_i.png", + "url": "https://shitposter.club/emoji/stolen/8b_i.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_l", + "static_url": "https://shitposter.club/emoji/stolen/8b_l.png", + "url": "https://shitposter.club/emoji/stolen/8b_l.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_n", + "static_url": "https://shitposter.club/emoji/stolen/8b_n.png", + "url": "https://shitposter.club/emoji/stolen/8b_n.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 855, + "following_count": 962, + "header": "https://shitposter.club/media/8c063a5c6625df7fca6b2c201b4473c524ac8dd29b227a86e1ad93ead4abc5f5.png?name=MQFDMQIO15X8.png", + "header_static": "https://shitposter.club/media/8c063a5c6625df7fca6b2c201b4473c524ac8dd29b227a86e1ad93ead4abc5f5.png?name=MQFDMQIO15X8.png", + "id": "9v5bpF8QCmRpCUJ1ay", + "locked": false, + "note": "The Botfather

















Podcast: @podcast@melonmancy.net
Waifu: @ironee212@shitposter.club", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bpF8QCmRpCUJ1ay", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "The Botfather\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nPodcast: @podcast@melonmancy.net \nWaifu: @ironee212@shitposter.club", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 5474, + "url": "https://shitposter.club/users/dielan", + "username": "dielan" + }, + "created_at": "2020-06-10T01:32:59.000Z", + "id": "10740", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "dielan@shitposter.club", + "avatar": "https://shitposter.club/media/99884dfe7d89658f00c7454cfd9d3cc932a650384a69963b8499fc89284464b5.gif?name=99884dfe7d89658f00c7454cfd9d3cc932a650384a69963b8499fc89284464b5.gif", + "avatar_static": "https://shitposter.club/media/99884dfe7d89658f00c7454cfd9d3cc932a650384a69963b8499fc89284464b5.gif?name=99884dfe7d89658f00c7454cfd9d3cc932a650384a69963b8499fc89284464b5.gif", + "bot": false, + "created_at": "2020-01-09T13:21:37.000Z", + "display_name": ":8b_d:‍:8b_i:‍:8b_e:‍:8b_l:‍:8b_a:‍:8b_n:", + "emojis": [ + { + "shortcode": "8b_a", + "static_url": "https://shitposter.club/emoji/stolen/8b_a.png", + "url": "https://shitposter.club/emoji/stolen/8b_a.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_d", + "static_url": "https://shitposter.club/emoji/stolen/8b_d.png", + "url": "https://shitposter.club/emoji/stolen/8b_d.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_e", + "static_url": "https://shitposter.club/emoji/stolen/8b_e.png", + "url": "https://shitposter.club/emoji/stolen/8b_e.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_i", + "static_url": "https://shitposter.club/emoji/stolen/8b_i.png", + "url": "https://shitposter.club/emoji/stolen/8b_i.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_l", + "static_url": "https://shitposter.club/emoji/stolen/8b_l.png", + "url": "https://shitposter.club/emoji/stolen/8b_l.png", + "visible_in_picker": false + }, + { + "shortcode": "8b_n", + "static_url": "https://shitposter.club/emoji/stolen/8b_n.png", + "url": "https://shitposter.club/emoji/stolen/8b_n.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 855, + "following_count": 962, + "header": "https://shitposter.club/media/8c063a5c6625df7fca6b2c201b4473c524ac8dd29b227a86e1ad93ead4abc5f5.png?name=MQFDMQIO15X8.png", + "header_static": "https://shitposter.club/media/8c063a5c6625df7fca6b2c201b4473c524ac8dd29b227a86e1ad93ead4abc5f5.png?name=MQFDMQIO15X8.png", + "id": "9v5bpF8QCmRpCUJ1ay", + "locked": false, + "note": "The Botfather

















Podcast: @podcast@melonmancy.net
Waifu: @ironee212@shitposter.club", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bpF8QCmRpCUJ1ay", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "The Botfather\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nPodcast: @podcast@melonmancy.net \nWaifu: @ironee212@shitposter.club", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 5474, + "url": "https://shitposter.club/users/dielan", + "username": "dielan" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@alex you dont need to write tests when your community builds a social framework that flags people not using your software as bad people. They have no choice but to eat it up bugs in all", + "created_at": "2020-06-10T01:32:58.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 1, + "id": "9vvOI4du3rkuhoX8wS", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vvNxoo5EFbbnfdXQu", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex you dont need to write tests when your community builds a social framework that flags people not using your software as bad people. They have no choice but to eat it up bugs in all" + }, + "conversation_id": 1168229, + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 1, + "me": true, + "name": "😆" + } + ], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": false, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://shitposter.club/objects/3e7565f6-13f2-432c-a316-7d4034f91d79", + "url": "https://shitposter.club/objects/3e7565f6-13f2-432c-a316-7d4034f91d79", + "visibility": "public" + }, + "type": "mention" + }, + { + "account": { + "acct": "tk@bbs.kawa-kun.com", + "avatar": "https://bbs.kawa-kun.com/media/66c8ef028f985b01c41f173f868a84a49f8ab60b4e919b4231366963953ad75e.png?name=noface_avatar.png", + "avatar_static": "https://bbs.kawa-kun.com/media/66c8ef028f985b01c41f173f868a84a49f8ab60b4e919b4231366963953ad75e.png?name=noface_avatar.png", + "bot": false, + "created_at": "2020-01-28T22:21:12.000Z", + "display_name": "竹下憲二✔️", + "emojis": [], + "fields": [], + "followers_count": 204, + "following_count": 205, + "header": "https://gleasonator.com/images/banner.png", + "header_static": "https://gleasonator.com/images/banner.png", + "id": "9v5bzD24sOZtZm5gVU", + "locked": false, + "note": "The monotony is bad enough on its own, but the scarcity of groceries makes the roll of this situation even worse.

The Great Wall of Mastodon is ever-expanding.

XMPP: tk@msg.kawa-kun.com
Telegram: Ask

Flickr: https://www.flickr.com/photos/105592384@N07/

#cycling #linux #pchardware", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bzD24sOZtZm5gVU", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "The monotony is bad enough on its own, but the scarcity of groceries makes the roll of this situation even worse.\n\nThe Great Wall of Mastodon is ever-expanding.\n\nXMPP: tk@msg.kawa-kun.com\nTelegram: Ask\n\nFlickr: https://www.flickr.com/photos/105592384@N07/\n\n#cycling #linux #pchardware", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 10393, + "url": "https://bbs.kawa-kun.com/users/tk", + "username": "tk" + }, + "created_at": "2020-06-10T01:30:44.000Z", + "id": "10739", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.

Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.

I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus.", + "created_at": "2020-06-10T01:29:20.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 4, + "id": "9vvNxoo5EFbbnfdXQu", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus." + }, + "conversation_id": 1168229, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/aa294f83-5a6c-4d2b-ba20-2b8bf69a82ba", + "url": "https://gleasonator.com/notice/9vvNxoo5EFbbnfdXQu", + "visibility": "public" + }, + "type": "favourite" + }, + { + "account": { + "acct": "cy@verge.info.tm", + "avatar": "https://verge.info.tm/media/906c5ecab609712291859dfd56c4c8df746d5f71737745fcb70e7e8d8a93fae0.jpg", + "avatar_static": "https://verge.info.tm/media/906c5ecab609712291859dfd56c4c8df746d5f71737745fcb70e7e8d8a93fae0.jpg", + "bot": false, + "created_at": "2020-05-31T02:52:45.000Z", + "display_name": "cy", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "header": "https://verge.info.tm/media/58d7a0358697dd90e76b55f068341d6d314c4954081b67a8a78355d2912f15bf.jpg", + "header_static": "https://verge.info.tm/media/58d7a0358697dd90e76b55f068341d6d314c4954081b67a8a78355d2912f15bf.jpg", + "id": "9vamGqGoKW9uQAWG7k", + "locked": false, + "note": "Just someone near #Portland, #Oregon, living in west #Hillsboro. The sidewalks are empty. I know not who lives down the street. I'm all alone. Plz send hugs
PGP: dsa3072/E4F606A10AC7DA56
Tox: 2F22EF2948934B65812827248FB6B2142F4AA9725C8599D02AFC7D3103B94B191A43C2E6DCF6", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9vamGqGoKW9uQAWG7k", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Just someone near #Portland, #Oregon, living in west #Hillsboro. The sidewalks are empty. I know not who lives down the street. I'm all alone. Plz send hugs\nPGP: dsa3072/E4F606A10AC7DA56\nTox: 2F22EF2948934B65812827248FB6B2142F4AA9725C8599D02AFC7D3103B94B191A43C2E6DCF6", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 77, + "url": "https://verge.info.tm/users/cy", + "username": "cy" + }, + "created_at": "2020-06-09T20:36:39.000Z", + "id": "10731", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "cy@verge.info.tm", + "avatar": "https://verge.info.tm/media/906c5ecab609712291859dfd56c4c8df746d5f71737745fcb70e7e8d8a93fae0.jpg", + "avatar_static": "https://verge.info.tm/media/906c5ecab609712291859dfd56c4c8df746d5f71737745fcb70e7e8d8a93fae0.jpg", + "bot": false, + "created_at": "2020-05-31T02:52:45.000Z", + "display_name": "cy", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "header": "https://verge.info.tm/media/58d7a0358697dd90e76b55f068341d6d314c4954081b67a8a78355d2912f15bf.jpg", + "header_static": "https://verge.info.tm/media/58d7a0358697dd90e76b55f068341d6d314c4954081b67a8a78355d2912f15bf.jpg", + "id": "9vamGqGoKW9uQAWG7k", + "locked": false, + "note": "Just someone near #Portland, #Oregon, living in west #Hillsboro. The sidewalks are empty. I know not who lives down the street. I'm all alone. Plz send hugs
PGP: dsa3072/E4F606A10AC7DA56
Tox: 2F22EF2948934B65812827248FB6B2142F4AA9725C8599D02AFC7D3103B94B191A43C2E6DCF6", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9vamGqGoKW9uQAWG7k", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Just someone near #Portland, #Oregon, living in west #Hillsboro. The sidewalks are empty. I know not who lives down the street. I'm all alone. Plz send hugs\nPGP: dsa3072/E4F606A10AC7DA56\nTox: 2F22EF2948934B65812827248FB6B2142F4AA9725C8599D02AFC7D3103B94B191A43C2E6DCF6", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 77, + "url": "https://verge.info.tm/users/cy", + "username": "cy" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "

@alex The first step to getting people to commit inhumane acts is to convince them that they are incapable of doing so.

", + "created_at": "2020-06-09T20:36:38.000Z", + "emojis": [], + "favourited": true, + "favourites_count": 2, + "id": "9vuxqLdmfiHiVXb2tU", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vtSW9UapzWfImEAPw", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex The first step to getting people to commit inhumane acts is to convince them that they are incapable of doing so." + }, + "conversation_id": 1117237, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": false, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://verge.info.tm/objects/3901aa21-5e89-447b-842e-1721e2e7309f", + "url": "https://verge.info.tm/objects/3901aa21-5e89-447b-842e-1721e2e7309f", + "visibility": "public" + }, + "type": "mention" + }, + { + "account": { + "acct": "seanking", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2020-05-24T01:46:12.000Z", + "display_name": "Sean King", + "emojis": [], + "fields": [], + "followers_count": 8, + "following_count": 4, + "header": "https://gleasonator.com/images/banner.png", + "header_static": "https://gleasonator.com/images/banner.png", + "id": "9vMAje101ngtjlMj7w", + "locked": false, + "note": "Hi, I'm Sean King, Founder and President of Sandia Mesa. I'll be here to try out new features and help test out some with Soapbox FE.", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9vMAje101ngtjlMj7w", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Hi, I'm Sean King, Founder and President of Sandia Mesa. I'll be here to try out new features and help test out some with Soapbox FE.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 15, + "url": "https://gleasonator.com/users/seanking", + "username": "seanking" + }, + "created_at": "2020-06-09T18:53:51.000Z", + "id": "10729", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881", + "created_at": "2020-06-09T18:08:42.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 3, + "id": "9vukdh0qbKoPch1GVM", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881" + }, + "conversation_id": 1149840, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 1, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/945e75ad-e6ce-4f07-be69-23027e153100", + "url": "https://gleasonator.com/notice/9vukdh0qbKoPch1GVM", + "visibility": "public" + }, + "type": "favourite" + }, + { + "account": { + "acct": "lain@lain.com", + "avatar": "https://lain.com/media/0b7eb9eee68845f94dd1c7bd10d9bae90a2420cf6704de5485179c441eb0e6e0.jpg", + "avatar_static": "https://lain.com/media/0b7eb9eee68845f94dd1c7bd10d9bae90a2420cf6704de5485179c441eb0e6e0.jpg", + "bot": false, + "created_at": "2020-01-10T17:30:10.000Z", + "display_name": "Avalokiteshvara", + "emojis": [], + "fields": [], + "followers_count": 807, + "following_count": 223, + "header": "https://lain.com/media/fb0768dfa331ad730de32189d2e89b99fe51eebe1782a16cf076d7693394e4f9.png", + "header_static": "https://lain.com/media/fb0768dfa331ad730de32189d2e89b99fe51eebe1782a16cf076d7693394e4f9.png", + "id": "9v5bqYwY2jfmvPNhTM", + "locked": false, + "note": "No more hiding", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bqYwY2jfmvPNhTM", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "No more hiding", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 20907, + "url": "https://lain.com/users/lain", + "username": "lain" + }, + "created_at": "2020-06-09T18:47:58.000Z", + "id": "10728", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "lain@lain.com", + "avatar": "https://lain.com/media/0b7eb9eee68845f94dd1c7bd10d9bae90a2420cf6704de5485179c441eb0e6e0.jpg", + "avatar_static": "https://lain.com/media/0b7eb9eee68845f94dd1c7bd10d9bae90a2420cf6704de5485179c441eb0e6e0.jpg", + "bot": false, + "created_at": "2020-01-10T17:30:10.000Z", + "display_name": "Avalokiteshvara", + "emojis": [], + "fields": [], + "followers_count": 807, + "following_count": 223, + "header": "https://lain.com/media/fb0768dfa331ad730de32189d2e89b99fe51eebe1782a16cf076d7693394e4f9.png", + "header_static": "https://lain.com/media/fb0768dfa331ad730de32189d2e89b99fe51eebe1782a16cf076d7693394e4f9.png", + "id": "9v5bqYwY2jfmvPNhTM", + "locked": false, + "note": "No more hiding", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bqYwY2jfmvPNhTM", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "No more hiding", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 20907, + "url": "https://lain.com/users/lain", + "username": "lain" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@alex @crockwave well, the preliminary state of the issue is not "no it's not happening", but rather " let's find a way to make this possible in the generic case", so we'll see what can be done. I think a wrapper is definitely possible but also probably expensive to maintain.", + "created_at": "2020-06-09T18:47:56.000Z", + "emojis": [], + "favourited": true, + "favourites_count": 3, + "id": "9vuo8zWu6ZHTtNd5qy", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vun9P6euZhNGLzDsG", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + { + "acct": "crockwave", + "id": "9v5c6xSEgAi3Zu1Lv6", + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex @crockwave well, the preliminary state of the issue is not \"no it's not happening\", but rather \" let's find a way to make this possible in the generic case\", so we'll see what can be done. I think a wrapper is definitely possible but also probably expensive to maintain." + }, + "conversation_id": 1149840, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": false, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://lain.com/objects/d4f12540-1988-4609-8830-923da784cbbc", + "url": "https://lain.com/objects/d4f12540-1988-4609-8830-923da784cbbc", + "visibility": "public" + }, + "type": "mention" + }, + { + "account": { + "acct": "crockwave", + "avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "bot": false, + "created_at": "2020-02-26T16:31:25.000Z", + "display_name": "Curtis Rock", + "emojis": [], + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "followers_count": 13, + "following_count": 11, + "header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "id": "9v5c6xSEgAi3Zu1Lv6", + "locked": false, + "note": "soapbox development team test test2", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c6xSEgAi3Zu1Lv6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "note": "soapbox development team test test2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 212, + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + }, + "created_at": "2020-06-09T18:38:07.000Z", + "id": "10724", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@crockwave I think we can achieve both those things (funding goal on homepage, Patron badge) by exposing API endpoints from fosspay.

I agree a wrapper service could provide many future benefits, but I've done that exact thing before (see: Mastodon Engine: https://gitlab.com/soapbox-pub/mastodon-engine ) and it was experimental, back-breaking, and required a DEEP understanding of the underlying tech. I just don't have the time or energy to dive that deep into Phoenix right now, especially not having a strong grasp of Elixir.", + "created_at": "2020-06-09T18:36:50.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 1, + "id": "9vun9P6euZhNGLzDsG", + "in_reply_to_account_id": "9v5c6xSEgAi3Zu1Lv6", + "in_reply_to_id": "9vumx07RzIPCI7RUqO", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "crockwave", + "id": "9v5c6xSEgAi3Zu1Lv6", + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@crockwave I think we can achieve both those things (funding goal on homepage, Patron badge) by exposing API endpoints from fosspay.I agree a wrapper service could provide many future benefits, but I've done that exact thing before (see: Mastodon Engine: https://gitlab.com/soapbox-pub/mastodon-engine ) and it was experimental, back-breaking, and required a DEEP understanding of the underlying tech. I just don't have the time or energy to dive that deep into Phoenix right now, especially not having a strong grasp of Elixir." + }, + "conversation_id": 1149840, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "crockwave", + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 2, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/0260c269-09ba-46b8-a59d-710c00bbb674", + "url": "https://gleasonator.com/notice/9vun9P6euZhNGLzDsG", + "visibility": "public" + }, + "type": "favourite" + }, + { + "account": { + "acct": "realcaseyrollins", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/023/720/original/1dbd625cbe9113c4.png", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/023/720/original/1dbd625cbe9113c4.png", + "bot": false, + "created_at": "2020-01-31T05:03:54.000Z", + "display_name": "Queso", + "emojis": [], + "fields": [], + "followers_count": 49, + "following_count": 81, + "header": "https://media.gleasonator.com/accounts/headers/000/023/720/original/9dfdc7a027af0e9e.jpg", + "header_static": "https://media.gleasonator.com/accounts/headers/000/023/720/original/9dfdc7a027af0e9e.jpg", + "id": "9v5bzlc4J5ZMpFBuKG", + "locked": false, + "note": "

Hey I'm just tryin' out Soapbox

", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bzlc4J5ZMpFBuKG", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Hey I'm just tryin' out Soapbox", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 72, + "url": "https://gleasonator.com/users/realcaseyrollins", + "username": "realcaseyrollins" + }, + "created_at": "2020-06-09T18:37:27.000Z", + "emoji": "😢", + "id": "10722", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "Been feeling like I'm losing my mind a bit the past few days. I feel like I'm pushing heavy weights up a steep hill and it's never-ending. Can't wait to get to the top so I can sail down.

Working on automated tests today.", + "created_at": "2020-06-09T18:34:26.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 1, + "id": "9vumw53EAK9vIW1oqe", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "Been feeling like I'm losing my mind a bit the past few days. I feel like I'm pushing heavy weights up a steep hill and it's never-ending. Can't wait to get to the top so I can sail down.Working on automated tests today." + }, + "conversation_id": 1150963, + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 1, + "me": false, + "name": "😢" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/30bde29e-c456-41ce-b767-bf562513285b", + "url": "https://gleasonator.com/notice/9vumw53EAK9vIW1oqe", + "visibility": "public" + }, + "type": "pleroma:emoji_reaction" + }, + { + "account": { + "acct": "crockwave", + "avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "bot": false, + "created_at": "2020-02-26T16:31:25.000Z", + "display_name": "Curtis Rock", + "emojis": [], + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "followers_count": 13, + "following_count": 11, + "header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "id": "9v5c6xSEgAi3Zu1Lv6", + "locked": false, + "note": "soapbox development team test test2", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c6xSEgAi3Zu1Lv6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "note": "soapbox development team test test2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 212, + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + }, + "created_at": "2020-06-09T18:37:26.000Z", + "id": "10721", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "crockwave", + "avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "bot": false, + "created_at": "2020-02-26T16:31:25.000Z", + "display_name": "Curtis Rock", + "emojis": [], + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "followers_count": 13, + "following_count": 11, + "header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "id": "9v5c6xSEgAi3Zu1Lv6", + "locked": false, + "note": "soapbox development team test test2", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c6xSEgAi3Zu1Lv6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "note": "soapbox development team test test2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 212, + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@alex Can you create some automated test templates, then outsource the drudgerous portion of the effort to your support team?", + "created_at": "2020-06-09T18:37:26.000Z", + "emojis": [], + "favourited": true, + "favourites_count": 1, + "id": "9vunChQZFapwxAs0EC", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vumw53EAK9vIW1oqe", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex Can you create some automated test templates, then outsource the drudgerous portion of the effort to your support team?" + }, + "conversation_id": 1150963, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 1, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/c3b656ca-616d-4241-9c30-bc1ac92848bd", + "url": "https://gleasonator.com/notice/9vunChQZFapwxAs0EC", + "visibility": "public" + }, + "type": "mention" + }, + { + "account": { + "acct": "PFreak@panthermodern.net", + "avatar": "https://pool.jortage.com/panthermodernnet/accounts/avatars/000/129/877/original/8250b48cca8b4980.jpg", + "avatar_static": "https://pool.jortage.com/panthermodernnet/accounts/avatars/000/129/877/original/8250b48cca8b4980.jpg", + "bot": false, + "created_at": "2020-02-27T17:25:04.000Z", + "display_name": "PFreak@panthermodern.net", + "emojis": [], + "fields": [ + { + "name": "pgp", + "value": "just ask :)" + }, + { + "name": "pronouns", + "value": "she/her || they/them" + }, + { + "name": "groks?", + "value": "groks" + } + ], + "followers_count": 2, + "following_count": 15, + "header": "https://pool.jortage.com/panthermodernnet/accounts/headers/000/129/877/original/f509a4a560064193.png", + "header_static": "https://pool.jortage.com/panthermodernnet/accounts/headers/000/129/877/original/f509a4a560064193.png", + "id": "9v5c7DOvOFZH2Gavyq", + "locked": true, + "note": "

Some creature with an internet connection...
Computers were my first friends in life.

#NoBot

", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c7DOvOFZH2Gavyq", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Some creature with an internet connection...\nComputers were my first friends in life.#NoBot", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 253, + "url": "https://social.panthermodern.net/@PFreak", + "username": "PFreak" + }, + "created_at": "2020-06-09T18:36:20.000Z", + "id": "10718", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "PFreak@panthermodern.net", + "avatar": "https://pool.jortage.com/panthermodernnet/accounts/avatars/000/129/877/original/8250b48cca8b4980.jpg", + "avatar_static": "https://pool.jortage.com/panthermodernnet/accounts/avatars/000/129/877/original/8250b48cca8b4980.jpg", + "bot": false, + "created_at": "2020-02-27T17:25:04.000Z", + "display_name": "PFreak@panthermodern.net", + "emojis": [], + "fields": [ + { + "name": "pgp", + "value": "just ask :)" + }, + { + "name": "pronouns", + "value": "she/her || they/them" + }, + { + "name": "groks?", + "value": "groks" + } + ], + "followers_count": 2, + "following_count": 15, + "header": "https://pool.jortage.com/panthermodernnet/accounts/headers/000/129/877/original/f509a4a560064193.png", + "header_static": "https://pool.jortage.com/panthermodernnet/accounts/headers/000/129/877/original/f509a4a560064193.png", + "id": "9v5c7DOvOFZH2Gavyq", + "locked": true, + "note": "

Some creature with an internet connection...
Computers were my first friends in life.

#NoBot

", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c7DOvOFZH2Gavyq", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Some creature with an internet connection...\nComputers were my first friends in life.#NoBot", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 253, + "url": "https://social.panthermodern.net/@PFreak", + "username": "PFreak" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "

@alex it's good to be thorough, and is useful if that code ever changes down the line.

", + "created_at": "2020-06-09T18:36:19.000Z", + "emojis": [], + "favourited": true, + "favourites_count": 1, + "id": "9vun6Z45Qh45HPQgQi", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vufIbN3slCeH1X984", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex it's good to be thorough, and is useful if that code ever changes down the line." + }, + "conversation_id": 1147390, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": false, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://social.panthermodern.net/users/PFreak/statuses/104315471735910922", + "url": "https://social.panthermodern.net/@PFreak/104315471735910922", + "visibility": "public" + }, + "type": "mention" + }, + { + "account": { + "acct": "hackerjunkie@quey.org", + "avatar": "https://media.quey.org/quey-media/accounts/avatars/000/215/311/original/5339ce3b70ea8c04.png", + "avatar_static": "https://media.quey.org/quey-media/accounts/avatars/000/215/311/original/5339ce3b70ea8c04.png", + "bot": false, + "created_at": "2020-02-11T19:13:34.000Z", + "display_name": "Thaha Jemni", + "emojis": [], + "fields": [ + { + "name": "Country", + "value": "The Netherlands" + }, + { + "name": "Fav. OS", + "value": "Linux" + } + ], + "followers_count": 38, + "following_count": 48, + "header": "https://media.quey.org/quey-media/accounts/headers/000/215/311/original/f9dcebd451487a6c.png", + "header_static": "https://media.quey.org/quey-media/accounts/headers/000/215/311/original/f9dcebd451487a6c.png", + "id": "9v5c3Y0cpOFdnATp5c", + "locked": false, + "note": "

Lover of tech. Supporter of freedom.

Be nice, I will be nice back.

Boosts ≠ endorsement.

", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c3Y0cpOFdnATp5c", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Lover of tech. Supporter of freedom.Be nice, I will be nice back.Boosts ≠ endorsement.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 642, + "url": "https://quey.org/@hackerjunkie", + "username": "hackerjunkie" + }, + "created_at": "2020-06-09T18:36:14.000Z", + "id": "10717", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "hackerjunkie@quey.org", + "avatar": "https://media.quey.org/quey-media/accounts/avatars/000/215/311/original/5339ce3b70ea8c04.png", + "avatar_static": "https://media.quey.org/quey-media/accounts/avatars/000/215/311/original/5339ce3b70ea8c04.png", + "bot": false, + "created_at": "2020-02-11T19:13:34.000Z", + "display_name": "Thaha Jemni", + "emojis": [], + "fields": [ + { + "name": "Country", + "value": "The Netherlands" + }, + { + "name": "Fav. OS", + "value": "Linux" + } + ], + "followers_count": 38, + "following_count": 48, + "header": "https://media.quey.org/quey-media/accounts/headers/000/215/311/original/f9dcebd451487a6c.png", + "header_static": "https://media.quey.org/quey-media/accounts/headers/000/215/311/original/f9dcebd451487a6c.png", + "id": "9v5c3Y0cpOFdnATp5c", + "locked": false, + "note": "

Lover of tech. Supporter of freedom.

Be nice, I will be nice back.

Boosts ≠ endorsement.

", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c3Y0cpOFdnATp5c", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Lover of tech. Supporter of freedom.Be nice, I will be nice back.Boosts ≠ endorsement.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 642, + "url": "https://quey.org/@hackerjunkie", + "username": "hackerjunkie" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "

@alex whatever you do: KEEP GOING ALMOST THERE YOU CAN DO IT!

", + "created_at": "2020-06-09T18:36:14.000Z", + "emojis": [], + "favourited": true, + "favourites_count": 1, + "id": "9vun65zpVRQd7oDNGi", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vumw53EAK9vIW1oqe", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex whatever you do: KEEP GOING ALMOST THERE YOU CAN DO IT!" + }, + "conversation_id": 1150963, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": false, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 3, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://quey.org/users/hackerjunkie/statuses/104315471432969831", + "url": "https://quey.org/@hackerjunkie/104315471432969831", + "visibility": "public" + }, + "type": "mention" + }, + { + "account": { + "acct": "niggaflamebuttholeaids@husk.site", + "avatar": "https://husk.site/media/063a8f97e9b5d2f04e01e8ce98f71a201f82e86e53b78e66b121b774a3ca565d.png", + "avatar_static": "https://husk.site/media/063a8f97e9b5d2f04e01e8ce98f71a201f82e86e53b78e66b121b774a3ca565d.png", + "bot": false, + "created_at": "2020-05-03T01:39:47.000Z", + "display_name": ":brain3: Steven :alexjonesflexing:", + "emojis": [ + { + "shortcode": "alexjonesflexing", + "static_url": "https://husk.site/emoji/custom/alexjonesflexing.png", + "url": "https://husk.site/emoji/custom/alexjonesflexing.png", + "visible_in_picker": false + }, + { + "shortcode": "brain3", + "static_url": "https://husk.site/emoji/custom/brain3.png", + "url": "https://husk.site/emoji/custom/brain3.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 90, + "following_count": 67, + "header": "https://husk.site/media/8af4afad13e7940333df2680b1ade653bb6e63b76d58d583ed8cffe85292dc16.png", + "header_static": "https://husk.site/media/8af4afad13e7940333df2680b1ade653bb6e63b76d58d583ed8cffe85292dc16.png", + "id": "9v5cKMOPGqPcgfcWp6", + "locked": false, + "note": "Professional Liar", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5cKMOPGqPcgfcWp6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Professional Liar", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 5350, + "url": "https://husk.site/users/niggaflamebuttholeaids", + "username": "niggaflamebuttholeaids" + }, + "created_at": "2020-06-09T18:35:23.000Z", + "id": "10716", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "Been feeling like I'm losing my mind a bit the past few days. I feel like I'm pushing heavy weights up a steep hill and it's never-ending. Can't wait to get to the top so I can sail down.

Working on automated tests today.", + "created_at": "2020-06-09T18:34:26.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 1, + "id": "9vumw53EAK9vIW1oqe", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "Been feeling like I'm losing my mind a bit the past few days. I feel like I'm pushing heavy weights up a steep hill and it's never-ending. Can't wait to get to the top so I can sail down.Working on automated tests today." + }, + "conversation_id": 1150963, + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 1, + "me": false, + "name": "😢" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/30bde29e-c456-41ce-b767-bf562513285b", + "url": "https://gleasonator.com/notice/9vumw53EAK9vIW1oqe", + "visibility": "public" + }, + "type": "favourite" + }, + { + "account": { + "acct": "niggaflamebuttholeaids@husk.site", + "avatar": "https://husk.site/media/063a8f97e9b5d2f04e01e8ce98f71a201f82e86e53b78e66b121b774a3ca565d.png", + "avatar_static": "https://husk.site/media/063a8f97e9b5d2f04e01e8ce98f71a201f82e86e53b78e66b121b774a3ca565d.png", + "bot": false, + "created_at": "2020-05-03T01:39:47.000Z", + "display_name": ":brain3: Steven :alexjonesflexing:", + "emojis": [ + { + "shortcode": "alexjonesflexing", + "static_url": "https://husk.site/emoji/custom/alexjonesflexing.png", + "url": "https://husk.site/emoji/custom/alexjonesflexing.png", + "visible_in_picker": false + }, + { + "shortcode": "brain3", + "static_url": "https://husk.site/emoji/custom/brain3.png", + "url": "https://husk.site/emoji/custom/brain3.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 90, + "following_count": 67, + "header": "https://husk.site/media/8af4afad13e7940333df2680b1ade653bb6e63b76d58d583ed8cffe85292dc16.png", + "header_static": "https://husk.site/media/8af4afad13e7940333df2680b1ade653bb6e63b76d58d583ed8cffe85292dc16.png", + "id": "9v5cKMOPGqPcgfcWp6", + "locked": false, + "note": "Professional Liar", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5cKMOPGqPcgfcWp6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Professional Liar", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 5350, + "url": "https://husk.site/users/niggaflamebuttholeaids", + "username": "niggaflamebuttholeaids" + }, + "created_at": "2020-06-09T18:35:09.000Z", + "id": "10715", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "niggaflamebuttholeaids@husk.site", + "avatar": "https://husk.site/media/063a8f97e9b5d2f04e01e8ce98f71a201f82e86e53b78e66b121b774a3ca565d.png", + "avatar_static": "https://husk.site/media/063a8f97e9b5d2f04e01e8ce98f71a201f82e86e53b78e66b121b774a3ca565d.png", + "bot": false, + "created_at": "2020-05-03T01:39:47.000Z", + "display_name": ":brain3: Steven :alexjonesflexing:", + "emojis": [ + { + "shortcode": "alexjonesflexing", + "static_url": "https://husk.site/emoji/custom/alexjonesflexing.png", + "url": "https://husk.site/emoji/custom/alexjonesflexing.png", + "visible_in_picker": false + }, + { + "shortcode": "brain3", + "static_url": "https://husk.site/emoji/custom/brain3.png", + "url": "https://husk.site/emoji/custom/brain3.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 90, + "following_count": 67, + "header": "https://husk.site/media/8af4afad13e7940333df2680b1ade653bb6e63b76d58d583ed8cffe85292dc16.png", + "header_static": "https://husk.site/media/8af4afad13e7940333df2680b1ade653bb6e63b76d58d583ed8cffe85292dc16.png", + "id": "9v5cKMOPGqPcgfcWp6", + "locked": false, + "note": "Professional Liar", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5cKMOPGqPcgfcWp6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "Professional Liar", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 5350, + "url": "https://husk.site/users/niggaflamebuttholeaids", + "username": "niggaflamebuttholeaids" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@alex You're close to a finish line 🌈", + "created_at": "2020-06-09T18:35:08.000Z", + "emojis": [], + "favourited": true, + "favourites_count": 1, + "id": "9vun03Vlpd9xgHH5UW", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vumw53EAK9vIW1oqe", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex You're close to a finish line 🌈" + }, + "conversation_id": 1150963, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": false, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://husk.site/objects/9d3eb87b-978c-4ff2-8fb7-22fe22adcbd7", + "url": "https://husk.site/objects/9d3eb87b-978c-4ff2-8fb7-22fe22adcbd7", + "visibility": "unlisted" + }, + "type": "mention" + }, + { + "account": { + "acct": "crockwave", + "avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "bot": false, + "created_at": "2020-02-26T16:31:25.000Z", + "display_name": "Curtis Rock", + "emojis": [], + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "followers_count": 13, + "following_count": 11, + "header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "id": "9v5c6xSEgAi3Zu1Lv6", + "locked": false, + "note": "soapbox development team test test2", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c6xSEgAi3Zu1Lv6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "note": "soapbox development team test test2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 212, + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + }, + "created_at": "2020-06-09T18:34:36.000Z", + "id": "10713", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "crockwave", + "avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "bot": false, + "created_at": "2020-02-26T16:31:25.000Z", + "display_name": "Curtis Rock", + "emojis": [], + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "followers_count": 13, + "following_count": 11, + "header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "id": "9v5c6xSEgAi3Zu1Lv6", + "locked": false, + "note": "soapbox development team test test2", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c6xSEgAi3Zu1Lv6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "note": "soapbox development team test test2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 212, + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@alex I think a wrapper service can provide many future benefits, if the complications are manageable. It would allow you to stay well ahead of the features development curve. There is also great value in displaying funding goals status on the home page. The Patron tag is also highly valuable from a social community standpoint.", + "created_at": "2020-06-09T18:34:36.000Z", + "emojis": [], + "favourited": true, + "favourites_count": 2, + "id": "9vumx07RzIPCI7RUqO", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vukdh0qbKoPch1GVM", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex I think a wrapper service can provide many future benefits, if the complications are manageable. It would allow you to stay well ahead of the features development curve. There is also great value in displaying funding goals status on the home page. The Patron tag is also highly valuable from a social community standpoint." + }, + "conversation_id": 1149840, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/33384000-c19e-4df3-a5ea-b1d5a8dc4c3c", + "url": "https://gleasonator.com/notice/9vumx07RzIPCI7RUqO", + "visibility": "public" + }, + "type": "mention" + }, + { + "account": { + "acct": "crockwave", + "avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png", + "bot": false, + "created_at": "2020-02-26T16:31:25.000Z", + "display_name": "Curtis Rock", + "emojis": [], + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "followers_count": 13, + "following_count": 11, + "header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png", + "id": "9v5c6xSEgAi3Zu1Lv6", + "locked": false, + "note": "soapbox development team test test2", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c6xSEgAi3Zu1Lv6", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Web Site/Book", + "value": "https://teci.world/a-users-guide-to-the-great-awakening" + }, + { + "name": "Gab", + "value": "https://gab.com/crockwave" + }, + { + "name": "Twitter", + "value": "https://twitter.com/GAP_Great" + }, + { + "name": "MeWe", + "value": "https://mewe.com/i/curtisrock" + } + ], + "note": "soapbox development team test test2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 212, + "url": "https://gleasonator.com/users/crockwave", + "username": "crockwave" + }, + "created_at": "2020-06-09T18:31:42.000Z", + "id": "10712", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881", + "created_at": "2020-06-09T18:08:42.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 3, + "id": "9vukdh0qbKoPch1GVM", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881" + }, + "conversation_id": 1149840, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 1, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/945e75ad-e6ce-4f07-be69-23027e153100", + "url": "https://gleasonator.com/notice/9vukdh0qbKoPch1GVM", + "visibility": "public" + }, + "type": "favourite" + }, + { + "account": { + "acct": "judgedread@freespeechextremist.com", + "avatar": "https://freespeechextremist.com/media/208ebb008b0c4e1ba3eb1ac42523883cd0eaf00b37d329051e5ed36ab709ffbc.png?name=dreddavi2019.png", + "avatar_static": "https://freespeechextremist.com/media/208ebb008b0c4e1ba3eb1ac42523883cd0eaf00b37d329051e5ed36ab709ffbc.png?name=dreddavi2019.png", + "bot": false, + "created_at": "2020-01-08T09:47:11.000Z", + "display_name": "Dread :verified:", + "emojis": [ + { + "shortcode": "verified", + "static_url": "https://freespeechextremist.com/emoji/custom/verified.png", + "url": "https://freespeechextremist.com/emoji/custom/verified.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 463, + "following_count": 52, + "header": "https://freespeechextremist.com/media/4f58807e934a28d718052cb47c38c4af955dbd4d0e1c15b26a47bb7e32f5efba.png?name=magasanta.png", + "header_static": "https://freespeechextremist.com/media/4f58807e934a28d718052cb47c38c4af955dbd4d0e1c15b26a47bb7e32f5efba.png?name=magasanta.png", + "id": "9v5bn9xIUVtqYd6oqW", + "locked": false, + "note": "IN MAGA CITY ONE I AM THE LAW", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": false, + "hide_follows": true, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bn9xIUVtqYd6oqW", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "IN MAGA CITY ONE I AM THE LAW", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 2967, + "url": "https://freespeechextremist.com/users/judgedread", + "username": "judgedread" + }, + "created_at": "2020-06-09T18:25:58.000Z", + "id": "10711", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@lain Yeah I have no clue what the reasoning was for that one. 😆 It's crazy how "words" are such an ongoing issue in FOSS.

A seasoned programmer once told me she's had the deepest programming debates about "naming things." It's true that names are important in programming, but at the end of the day it's just code.", + "created_at": "2020-06-09T06:12:44.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 2, + "id": "9vtik6wOifKqi1uXFA", + "in_reply_to_account_id": "9v5bqYwY2jfmvPNhTM", + "in_reply_to_id": "9vtiLkS2gRq20c5yHw", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "lain@lain.com", + "id": "9v5bqYwY2jfmvPNhTM", + "url": "https://lain.com/users/lain", + "username": "lain" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@lain Yeah I have no clue what the reasoning was for that one. 😆 It's crazy how \"words\" are such an ongoing issue in FOSS.A seasoned programmer once told me she's had the deepest programming debates about \"naming things.\" It's true that names are important in programming, but at the end of the day it's just code." + }, + "conversation_id": 1122858, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "lain@lain.com", + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/e26ed039-136a-4d48-a599-7e4dc4960147", + "url": "https://gleasonator.com/notice/9vtik6wOifKqi1uXFA", + "visibility": "public" + }, + "type": "favourite" + }, + { + "account": { + "acct": "judgedread@freespeechextremist.com", + "avatar": "https://freespeechextremist.com/media/208ebb008b0c4e1ba3eb1ac42523883cd0eaf00b37d329051e5ed36ab709ffbc.png?name=dreddavi2019.png", + "avatar_static": "https://freespeechextremist.com/media/208ebb008b0c4e1ba3eb1ac42523883cd0eaf00b37d329051e5ed36ab709ffbc.png?name=dreddavi2019.png", + "bot": false, + "created_at": "2020-01-08T09:47:11.000Z", + "display_name": "Dread :verified:", + "emojis": [ + { + "shortcode": "verified", + "static_url": "https://freespeechextremist.com/emoji/custom/verified.png", + "url": "https://freespeechextremist.com/emoji/custom/verified.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 463, + "following_count": 52, + "header": "https://freespeechextremist.com/media/4f58807e934a28d718052cb47c38c4af955dbd4d0e1c15b26a47bb7e32f5efba.png?name=magasanta.png", + "header_static": "https://freespeechextremist.com/media/4f58807e934a28d718052cb47c38c4af955dbd4d0e1c15b26a47bb7e32f5efba.png?name=magasanta.png", + "id": "9v5bn9xIUVtqYd6oqW", + "locked": false, + "note": "IN MAGA CITY ONE I AM THE LAW", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": false, + "hide_follows": true, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bn9xIUVtqYd6oqW", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "IN MAGA CITY ONE I AM THE LAW", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 2967, + "url": "https://freespeechextremist.com/users/judgedread", + "username": "judgedread" + }, + "created_at": "2020-06-09T18:25:51.000Z", + "id": "10710", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "judgedread@freespeechextremist.com", + "avatar": "https://freespeechextremist.com/media/208ebb008b0c4e1ba3eb1ac42523883cd0eaf00b37d329051e5ed36ab709ffbc.png?name=dreddavi2019.png", + "avatar_static": "https://freespeechextremist.com/media/208ebb008b0c4e1ba3eb1ac42523883cd0eaf00b37d329051e5ed36ab709ffbc.png?name=dreddavi2019.png", + "bot": false, + "created_at": "2020-01-08T09:47:11.000Z", + "display_name": "Dread :verified:", + "emojis": [ + { + "shortcode": "verified", + "static_url": "https://freespeechextremist.com/emoji/custom/verified.png", + "url": "https://freespeechextremist.com/emoji/custom/verified.png", + "visible_in_picker": false + } + ], + "fields": [], + "followers_count": 463, + "following_count": 52, + "header": "https://freespeechextremist.com/media/4f58807e934a28d718052cb47c38c4af955dbd4d0e1c15b26a47bb7e32f5efba.png?name=magasanta.png", + "header_static": "https://freespeechextremist.com/media/4f58807e934a28d718052cb47c38c4af955dbd4d0e1c15b26a47bb7e32f5efba.png?name=magasanta.png", + "id": "9v5bn9xIUVtqYd6oqW", + "locked": false, + "note": "IN MAGA CITY ONE I AM THE LAW", + "pleroma": { + "background_image": null, + "confirmation_pending": true, + "deactivated": false, + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": false, + "hide_follows": true, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5bn9xIUVtqYd6oqW", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "IN MAGA CITY ONE I AM THE LAW", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 2967, + "url": "https://freespeechextremist.com/users/judgedread", + "username": "judgedread" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@alex @lain The Peter Principle has a chapter on this.

The less significant the decision the more people feel qualified to have an opinion on it.", + "created_at": "2020-06-09T18:25:50.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 0, + "id": "9vumAbQUUZUho8GpNY", + "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", + "in_reply_to_id": "9vtik6wOifKqi1uXFA", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + { + "acct": "lain@lain.com", + "id": "9v5bqYwY2jfmvPNhTM", + "url": "https://lain.com/users/lain", + "username": "lain" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "@alex @lain The Peter Principle has a chapter on this.The less significant the decision the more people feel qualified to have an opinion on it." + }, + "conversation_id": 1122858, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "alex", + "local": false, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://freespeechextremist.com/objects/f66b52ad-733d-4f39-b1e0-5894620da68f", + "url": "https://freespeechextremist.com/objects/f66b52ad-733d-4f39-b1e0-5894620da68f", + "visibility": "public" + }, + "type": "mention" + }, + { + "account": { + "acct": "realcaseyrollins@gameliberty.club", + "avatar": "https://gameliberty.club/system/accounts/avatars/000/152/185/original/5ebd1445396f1cfc.png?1587331139", + "avatar_static": "https://gameliberty.club/system/accounts/avatars/000/152/185/original/5ebd1445396f1cfc.png?1587331139", + "bot": false, + "created_at": "2020-06-09T05:19:42.000Z", + "display_name": "DON'T RIOT 🔵 :verified:", + "emojis": [ + { + "shortcode": "verified", + "static_url": "https://gameliberty.club/system/custom_emojis/images/000/013/521/original/759328de266b47aa.png?1552490515", + "url": "https://gameliberty.club/system/custom_emojis/images/000/013/521/original/759328de266b47aa.png?1552490515", + "visible_in_picker": false + } + ], + "fields": [ + { + "name": "Gender Identity", + "value": "Cisgender Heterosexual Male Human" + }, + { + "name": "Counter Points Media", + "value": "https://www.bitchute.com/channel/M4vBArMe72x0/" + }, + { + "name": "SFW Account", + "value": "@realcaseyrollins" + }, + { + "name": "My clone", + "value": "@notcaseyrollins" + } + ], + "followers_count": 0, + "following_count": 0, + "header": "https://gameliberty.club/system/accounts/headers/000/152/185/original/41ff3762439e7b4a.png?1582765040", + "header_static": "https://gameliberty.club/system/accounts/headers/000/152/185/original/41ff3762439e7b4a.png?1582765040", + "id": "9v5c73UwD2R6K1Kaxs", + "locked": false, + "note": "

"Ugly homophobe!" - @HernanHenry
"Morally defunct" - @freemo
"Racist! I look down on you." - @pennyzhangsan
"Confirmed Nazi." - @p

This used to be way longer. TL;DR, I'm Christian, have a grudge against Gab but like some of the users, am on the #ADHD spectrum, don't like racism, hate groupthink and tribalism, and oh yeah, I have a podcast right here:

https://bitbucket.org/caseyrollins/countercast/raw/master/rss2.xml

Two favorite Bible verses:

Jesus replied: “‘Love the Lord your God with all your heart and with all your soul and with all your mind.’ This is the first and greatest commandment. And the second is like it: ‘Love your neighbor as yourself.’ (Matthew 22:37-39)

Rejoice in the Lord always. I will say it again: Rejoice! (Phillipians 4:4)

Headspace indicators (inspired by pizza@goblin.camp):
🔵 = I'm in a good headspace.
⚪ = Not doing great, but still ok.
🔴 = Terrible headspace, feel free to ask me how I'm doing. May react angrier than usual to insults.

", + "pleroma": { + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": true, + "following": true, + "id": "9v5c73UwD2R6K1Kaxs", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": ""Ugly homophobe!" - @HernanHenry\n"Morally defunct" - @freemo\n"Racist! I look down on you." - @pennyzhangsan\n"Confirmed Nazi." - @pThis used to be way longer. TL;DR, I'm Christian, have a grudge against Gab but like some of the users, am on the #ADHD spectrum, don't like racism, hate groupthink and tribalism, and oh yeah, I have a podcast right here:https://bitbucket.org/caseyrollins/countercast/raw/master/rss2.xmlTwo favorite Bible verses:Jesus replied: “‘Love the Lord your God with all your heart and with all your soul and with all your mind.’ This is the first and greatest commandment. And the second is like it: ‘Love your neighbor as yourself.’ (Matthew 22:37-39)Rejoice in the Lord always. I will say it again: Rejoice! (Phillipians 4:4)Headspace indicators (inspired by pizza@goblin.camp):\n🔵 = I'm in a good headspace.\n⚪ = Not doing great, but still ok.\n🔴 = Terrible headspace, feel free to ask me how I'm doing. May react angrier than usual to insults.", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 49, + "url": "https://gameliberty.club/@realcaseyrollins", + "username": "realcaseyrollins" + }, + "created_at": "2020-06-09T18:09:39.000Z", + "id": "10709", + "pleroma": { + "is_seen": true + }, + "status": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "follow_requests_count": 0, + "followers_count": 474, + "following_count": 1083, + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "locked": false, + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "allow_following_move": true, + "background_image": null, + "confirmation_pending": false, + "deactivated": false, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_moderator": false, + "notification_settings": { + "followers": true, + "follows": true, + "non_followers": true, + "non_follows": true, + "privacy_option": false + }, + "relationship": { + "blocked_by": false, + "blocking": false, + "domain_blocking": false, + "endorsed": false, + "followed_by": false, + "following": false, + "id": "9v5bmRalQvjOy0ECcC", + "muting": false, + "muting_notifications": false, + "requested": false, + "showing_reblogs": true, + "subscribing": false + }, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 25 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + } + ], + "note": "Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 4857, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881", + "created_at": "2020-06-09T18:08:42.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 3, + "id": "9vukdh0qbKoPch1GVM", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text_plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881" + }, + "conversation_id": 1149840, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text_plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 1, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://gleasonator.com/objects/945e75ad-e6ce-4f07-be69-23027e153100", + "url": "https://gleasonator.com/notice/9vukdh0qbKoPch1GVM", + "visibility": "public" + }, + "type": "reblog" + } +] diff --git a/src/__fixtures__/patron-instance.json b/src/__fixtures__/patron-instance.json new file mode 100644 index 0000000..e8b8219 --- /dev/null +++ b/src/__fixtures__/patron-instance.json @@ -0,0 +1,17 @@ +{ + "funding": { + "amount": 3500, + "patrons": 3, + "currency": "usd", + "interval": "monthly" + }, + "goals": [ + { + "amount": 20000, + "currency": "usd", + "interval": "monthly", + "text": "I'll be able to afford an avocado." + } + ], + "url": "https://patron.gleasonator.com" +} diff --git a/src/__fixtures__/patron-user.json b/src/__fixtures__/patron-user.json new file mode 100644 index 0000000..95d3667 --- /dev/null +++ b/src/__fixtures__/patron-user.json @@ -0,0 +1,4 @@ +{ + "is_patron": true, + "url": "https://gleasonator.com/users/dave" +} diff --git a/src/__fixtures__/pixelfed-instance.json b/src/__fixtures__/pixelfed-instance.json new file mode 100644 index 0000000..41830e0 --- /dev/null +++ b/src/__fixtures__/pixelfed-instance.json @@ -0,0 +1,66 @@ +{ + "uri": "pixelfed.social", + "title": "pixelfed", + "short_description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms", + "description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms", + "email": "hello@pixelfed.org", + "version": "2.7.2 (compatible; Pixelfed 0.11.2)", + "urls": { + "streaming_api": "wss://pixelfed.social" + }, + "stats": { + "user_count": 45061, + "status_count": 301357, + "domain_count": 5028 + }, + "thumbnail": "https://pixelfed.social/img/pixelfed-icon-color.png", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "contact_account": { + "id": "1", + "username": "admin", + "acct": "admin", + "display_name": "Admin", + "discoverable": true, + "locked": false, + "followers_count": 419, + "following_count": 2, + "statuses_count": 6, + "note": "pixelfed.social Admin. Managed by @dansup", + "url": "https://pixelfed.social/admin", + "avatar": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4", + "created_at": "2018-06-01T03:54:08.000000Z", + "avatar_static": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4", + "bot": false, + "emojis": [], + "fields": [], + "header": "https://pixelfed.social/storage/headers/missing.png", + "header_static": "https://pixelfed.social/storage/headers/missing.png", + "last_status_at": null + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive when posting" + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies" + }, + { + "id": "4", + "text": "No harassment, dogpiling or doxxing of other users" + }, + { + "id": "5", + "text": "No content illegal in United States" + } + ] +} diff --git a/src/__fixtures__/pleroma-2.2.2-account.json b/src/__fixtures__/pleroma-2.2.2-account.json new file mode 100644 index 0000000..7df005d --- /dev/null +++ b/src/__fixtures__/pleroma-2.2.2-account.json @@ -0,0 +1,46 @@ +{ + "acct": "alex", + "avatar": "https://freespeechextremist.com/images/avi.png", + "avatar_static": "https://freespeechextremist.com/images/avi.png", + "bot": false, + "created_at": "2022-02-28T01:55:05.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "header": "https://freespeechextremist.com/images/banner.png", + "header_static": "https://freespeechextremist.com/images/banner.png", + "id": "AGv8wCadU7DqWgMqNk", + "locked": false, + "note": "I'm testing out compatibility with an older Pleroma version", + "pleroma": { + "accepts_chat_messages": true, + "ap_id": "https://freespeechextremist.com/users/alex", + "background_image": null, + "confirmation_pending": false, + "favicon": null, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "I'm testing out compatibility with an older Pleroma version", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 0, + "url": "https://freespeechextremist.com/users/alex", + "username": "alex" +} diff --git a/src/__fixtures__/pleroma-account.json b/src/__fixtures__/pleroma-account.json new file mode 100644 index 0000000..022978f --- /dev/null +++ b/src/__fixtures__/pleroma-account.json @@ -0,0 +1,127 @@ +{ + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2378, + "following_count": 1571, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-02-20T04:14:49", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23477, + "url": "https://gleasonator.com/users/alex", + "username": "alex" +} diff --git a/src/__fixtures__/pleroma-admin-config.json b/src/__fixtures__/pleroma-admin-config.json new file mode 100644 index 0000000..8e17d1e --- /dev/null +++ b/src/__fixtures__/pleroma-admin-config.json @@ -0,0 +1,3120 @@ +{ + "configs": [ + { + "group": ":pleroma", + "key": ":ecto_repos", + "value": [ + "Pleroma.Repo" + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Captcha", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":seconds_valid", + 300 + ] + }, + { + "tuple": [ + ":method", + "Pleroma.Captcha.Native" + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Captcha.Kocaptcha", + "value": [ + { + "tuple": [ + ":endpoint", + "https://captcha.kotobank.ch" + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Uploaders.S3", + "value": [ + { + "tuple": [ + ":bucket", + null + ] + }, + { + "tuple": [ + ":bucket_namespace", + null + ] + }, + { + "tuple": [ + ":truncated_namespace", + null + ] + }, + { + "tuple": [ + ":streaming_enabled", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":emoji", + "value": [ + { + "tuple": [ + ":shortcode_globs", + [ + "/emoji/custom/**/*.png" + ] + ] + }, + { + "tuple": [ + ":pack_extensions", + [ + ".png", + ".gif" + ] + ] + }, + { + "tuple": [ + ":groups", + [ + { + "tuple": [ + ":Custom", + [ + "/emoji/*.png", + "/emoji/**/*.png" + ] + ] + } + ] + ] + }, + { + "tuple": [ + ":default_manifest", + "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json" + ] + }, + { + "tuple": [ + ":shared_pack_cache_seconds_per_file", + 60 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":uri_schemes", + "value": [ + { + "tuple": [ + ":valid_schemes", + [ + "https", + "http", + "dat", + "dweb", + "gopher", + "hyper", + "ipfs", + "ipns", + "irc", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + "xmpp" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":http", + "value": [ + { + "tuple": [ + ":proxy_url", + null + ] + }, + { + "tuple": [ + ":send_user_agent", + true + ] + }, + { + "tuple": [ + ":user_agent", + ":default" + ] + }, + { + "tuple": [ + ":adapter", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":welcome", + "value": [ + { + "tuple": [ + ":direct_message", + [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":sender_nickname", + null + ] + }, + { + "tuple": [ + ":message", + null + ] + } + ] + ] + }, + { + "tuple": [ + ":chat_message", + [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":sender_nickname", + null + ] + }, + { + "tuple": [ + ":message", + null + ] + } + ] + ] + }, + { + "tuple": [ + ":email", + [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":sender", + null + ] + }, + { + "tuple": [ + ":subject", + "Welcome to <%= instance_name %>" + ] + }, + { + "tuple": [ + ":html", + "Welcome to <%= instance_name %>" + ] + }, + { + "tuple": [ + ":text", + "Welcome to <%= instance_name %>" + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":feed", + "value": [ + { + "tuple": [ + ":post_title", + { + ":max_length": 100, + ":omission": "..." + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":markup", + "value": [ + { + "tuple": [ + ":allow_inline_images", + true + ] + }, + { + "tuple": [ + ":allow_headings", + false + ] + }, + { + "tuple": [ + ":allow_tables", + false + ] + }, + { + "tuple": [ + ":allow_fonts", + false + ] + }, + { + "tuple": [ + ":scrub_policy", + [ + "Pleroma.HTML.Scrubber.Default", + "Pleroma.HTML.Transform.MediaProxy" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":frontend_configurations", + "value": [ + { + "tuple": [ + ":pleroma_fe", + { + ":alwaysShowSubjectInput": true, + ":background": "/images/city.jpg", + ":collapseMessageWithSubject": false, + ":disableChat": false, + ":greentext": false, + ":hideFilteredStatuses": false, + ":hideMutedPosts": false, + ":hidePostStats": false, + ":hideSitename": false, + ":hideUserStats": false, + ":loginMethod": "password", + ":logo": "/static/logo.svg", + ":logoMargin": ".1em", + ":logoMask": true, + ":minimalScopesMode": false, + ":noAttachmentLinks": false, + ":nsfwCensorImage": "", + ":postContentType": "text/plain", + ":redirectRootLogin": "/main/friends", + ":redirectRootNoLogin": "/main/all", + ":scopeCopy": true, + ":showFeaturesPanel": true, + ":showInstanceSpecificPanel": false, + ":sidebarRight": false, + ":subjectLineBehavior": "email", + ":theme": "pleroma-dark", + ":webPushNotifications": false + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":assets", + "value": [ + { + "tuple": [ + ":mascots", + [ + { + "tuple": [ + ":pleroma_fox_tan", + { + ":mime_type": "image/png", + ":url": "/images/pleroma-fox-tan-smol.png" + } + ] + }, + { + "tuple": [ + ":pleroma_fox_tan_shy", + { + ":mime_type": "image/png", + ":url": "/images/pleroma-fox-tan-shy.png" + } + ] + } + ] + ] + }, + { + "tuple": [ + ":default_mascot", + ":pleroma_fox_tan" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":manifest", + "value": [ + { + "tuple": [ + ":icons", + [ + { + ":src": "/static/logo.svg", + ":type": "image/svg+xml" + } + ] + ] + }, + { + "tuple": [ + ":theme_color", + "#282c37" + ] + }, + { + "tuple": [ + ":background_color", + "#191b22" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":streamer", + "value": [ + { + "tuple": [ + ":workers", + 3 + ] + }, + { + "tuple": [ + ":overflow_workers", + 2 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":user", + "value": [ + { + "tuple": [ + ":deny_follow_blocked", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_normalize_markup", + "value": [ + { + "tuple": [ + ":scrub_policy", + "Pleroma.HTML.Scrubber.Default" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_rejectnonpublic", + "value": [ + { + "tuple": [ + ":allow_followersonly", + false + ] + }, + { + "tuple": [ + ":allow_direct", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_simple", + "value": [ + { + "tuple": [ + ":media_removal", + [] + ] + }, + { + "tuple": [ + ":media_nsfw", + [] + ] + }, + { + "tuple": [ + ":federated_timeline_removal", + [] + ] + }, + { + "tuple": [ + ":report_removal", + [] + ] + }, + { + "tuple": [ + ":reject", + [] + ] + }, + { + "tuple": [ + ":followers_only", + [] + ] + }, + { + "tuple": [ + ":accept", + [] + ] + }, + { + "tuple": [ + ":avatar_removal", + [] + ] + }, + { + "tuple": [ + ":banner_removal", + [] + ] + }, + { + "tuple": [ + ":reject_deletes", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_keyword", + "value": [ + { + "tuple": [ + ":reject", + [] + ] + }, + { + "tuple": [ + ":federated_timeline_removal", + [] + ] + }, + { + "tuple": [ + ":replace", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_hashtag", + "value": [ + { + "tuple": [ + ":sensitive", + [ + "nsfw" + ] + ] + }, + { + "tuple": [ + ":reject", + [] + ] + }, + { + "tuple": [ + ":federated_timeline_removal", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_subchain", + "value": [ + { + "tuple": [ + ":match_actor", + {} + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_activity_expiration", + "value": [ + { + "tuple": [ + ":days", + 365 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_vocabulary", + "value": [ + { + "tuple": [ + ":accept", + [] + ] + }, + { + "tuple": [ + ":reject", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_object_age", + "value": [ + { + "tuple": [ + ":threshold", + 604800 + ] + }, + { + "tuple": [ + ":actions", + [ + ":delist", + ":strip_followers" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_nsfw_api", + "value": [ + { + "tuple": [ + ":url", + "http://127.0.0.1:5000/" + ] + }, + { + "tuple": [ + ":threshold", + 0.7 + ] + }, + { + "tuple": [ + ":mark_sensitive", + true + ] + }, + { + "tuple": [ + ":unlist", + false + ] + }, + { + "tuple": [ + ":reject", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_follow_bot", + "value": [ + { + "tuple": [ + ":follower_nickname", + null + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_inline_quote", + "value": [ + { + "tuple": [ + ":prefix", + "RT" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":rich_media", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":ignore_hosts", + [] + ] + }, + { + "tuple": [ + ":ignore_tld", + [ + "local", + "localdomain", + "lan" + ] + ] + }, + { + "tuple": [ + ":parsers", + [ + "Pleroma.Web.RichMedia.Parsers.OEmbed", + "Pleroma.Web.RichMedia.Parsers.TwitterCard" + ] + ] + }, + { + "tuple": [ + ":oembed_providers_enabled", + true + ] + }, + { + "tuple": [ + ":failure_backoff", + 60000 + ] + }, + { + "tuple": [ + ":ttl_setters", + [ + "Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.MediaProxy.Invalidation.Http", + "value": [ + { + "tuple": [ + ":method", + ":purge" + ] + }, + { + "tuple": [ + ":headers", + [] + ] + }, + { + "tuple": [ + ":options", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.MediaProxy.Invalidation.Script", + "value": [ + { + "tuple": [ + ":script_path", + null + ] + }, + { + "tuple": [ + ":url_format", + null + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":media_preview_proxy", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":thumbnail_max_width", + 600 + ] + }, + { + "tuple": [ + ":thumbnail_max_height", + 600 + ] + }, + { + "tuple": [ + ":image_quality", + 85 + ] + }, + { + "tuple": [ + ":min_content_length", + 102400 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":shout", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":limit", + 5000 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":gopher", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":ip", + { + "tuple": [ + 0, + 0, + 0, + 0 + ] + } + ] + }, + { + "tuple": [ + ":port", + 9999 + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.Metadata", + "value": [ + { + "tuple": [ + ":providers", + [ + "Pleroma.Web.Metadata.Providers.OpenGraph", + "Pleroma.Web.Metadata.Providers.TwitterCard" + ] + ] + }, + { + "tuple": [ + ":unfurl_nsfw", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.Preload", + "value": [ + { + "tuple": [ + ":providers", + [ + "Pleroma.Web.Preload.Providers.Instance" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":http_security", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":sts", + false + ] + }, + { + "tuple": [ + ":sts_max_age", + 31536000 + ] + }, + { + "tuple": [ + ":ct_max_age", + 2592000 + ] + }, + { + "tuple": [ + ":referrer_policy", + "same-origin" + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.User", + "value": [ + { + "tuple": [ + ":restricted_nicknames", + [ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "check_password", + "dev", + "friend-requests", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "ostatus_subscribe", + "pleroma", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "user-search", + "user_exists", + "users", + "web", + "verify_credentials", + "update_credentials", + "relationships", + "search", + "confirmation_resend", + "mfa" + ] + ] + }, + { + "tuple": [ + ":email_blacklist", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Oban", + "value": [ + { + "tuple": [ + ":repo", + "Pleroma.Repo" + ] + }, + { + "tuple": [ + ":log", + false + ] + }, + { + "tuple": [ + ":queues", + [ + { + "tuple": [ + ":activity_expiration", + 10 + ] + }, + { + "tuple": [ + ":token_expiration", + 5 + ] + }, + { + "tuple": [ + ":filter_expiration", + 1 + ] + }, + { + "tuple": [ + ":backup", + 1 + ] + }, + { + "tuple": [ + ":federator_incoming", + 50 + ] + }, + { + "tuple": [ + ":federator_outgoing", + 50 + ] + }, + { + "tuple": [ + ":ingestion_queue", + 50 + ] + }, + { + "tuple": [ + ":web_push", + 50 + ] + }, + { + "tuple": [ + ":mailer", + 10 + ] + }, + { + "tuple": [ + ":transmogrifier", + 20 + ] + }, + { + "tuple": [ + ":scheduled_activities", + 10 + ] + }, + { + "tuple": [ + ":poll_notifications", + 10 + ] + }, + { + "tuple": [ + ":background", + 5 + ] + }, + { + "tuple": [ + ":remote_fetcher", + 2 + ] + }, + { + "tuple": [ + ":attachments_cleanup", + 1 + ] + }, + { + "tuple": [ + ":new_users_digest", + 1 + ] + }, + { + "tuple": [ + ":mute_expire", + 5 + ] + } + ] + ] + }, + { + "tuple": [ + ":plugins", + [ + "Oban.Plugins.Pruner" + ] + ] + }, + { + "tuple": [ + ":crontab", + [ + { + "tuple": [ + "0 0 * * 0", + "Pleroma.Workers.Cron.DigestEmailsWorker" + ] + }, + { + "tuple": [ + "0 0 * * *", + "Pleroma.Workers.Cron.NewUsersDigestWorker" + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":workers", + "value": [ + { + "tuple": [ + ":retries", + [ + { + "tuple": [ + ":federator_incoming", + 5 + ] + }, + { + "tuple": [ + ":federator_outgoing", + 5 + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Formatter", + "value": [ + { + "tuple": [ + ":class", + false + ] + }, + { + "tuple": [ + ":rel", + "ugc" + ] + }, + { + "tuple": [ + ":new_window", + false + ] + }, + { + "tuple": [ + ":truncate", + false + ] + }, + { + "tuple": [ + ":strip_prefix", + false + ] + }, + { + "tuple": [ + ":extra", + true + ] + }, + { + "tuple": [ + ":validate_tld", + ":no_scheme" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":ldap", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":host", + "localhost" + ] + }, + { + "tuple": [ + ":port", + 389 + ] + }, + { + "tuple": [ + ":ssl", + false + ] + }, + { + "tuple": [ + ":sslopts", + [] + ] + }, + { + "tuple": [ + ":tls", + false + ] + }, + { + "tuple": [ + ":tlsopts", + [] + ] + }, + { + "tuple": [ + ":base", + "dc=example,dc=com" + ] + }, + { + "tuple": [ + ":uid", + "cn" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":auth", + "value": [ + { + "tuple": [ + ":oauth_consumer_strategies", + [] + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Emails.UserEmail", + "value": [ + { + "tuple": [ + ":logo", + null + ] + }, + { + "tuple": [ + ":styling", + { + ":background_color": "#2C3645", + ":content_background_color": "#1B2635", + ":header_color": "#d8a070", + ":link_color": "#d8a070", + ":text_color": "#b9b9ba", + ":text_muted_color": "#b9b9ba" + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Emails.NewUsersDigestEmail", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.ScheduledActivity", + "value": [ + { + "tuple": [ + ":daily_user_limit", + 25 + ] + }, + { + "tuple": [ + ":total_user_limit", + 300 + ] + }, + { + "tuple": [ + ":enabled", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":email_notifications", + "value": [ + { + "tuple": [ + ":digest", + { + ":active": false, + ":inactivity_threshold": 7, + ":interval": 7 + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":oauth2", + "value": [ + { + "tuple": [ + ":token_expires_in", + 3153600000 + ] + }, + { + "tuple": [ + ":issue_new_refresh_token", + true + ] + }, + { + "tuple": [ + ":clean_expired_tokens", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":features", + "value": [ + { + "tuple": [ + ":improved_hashtag_timeline", + ":auto" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":populate_hashtags_table", + "value": [ + { + "tuple": [ + ":fault_rate_allowance", + 0.01 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":rate_limit", + "value": [ + { + "tuple": [ + ":authentication", + { + "tuple": [ + 60000, + 15 + ] + } + ] + }, + { + "tuple": [ + ":timeline", + { + "tuple": [ + 500, + 3 + ] + } + ] + }, + { + "tuple": [ + ":search", + [ + { + "tuple": [ + 1000, + 10 + ] + }, + { + "tuple": [ + 1000, + 30 + ] + } + ] + ] + }, + { + "tuple": [ + ":app_account_creation", + { + "tuple": [ + 1800000, + 25 + ] + } + ] + }, + { + "tuple": [ + ":relations_actions", + { + "tuple": [ + 10000, + 10 + ] + } + ] + }, + { + "tuple": [ + ":relation_id_action", + { + "tuple": [ + 60000, + 2 + ] + } + ] + }, + { + "tuple": [ + ":statuses_actions", + { + "tuple": [ + 10000, + 15 + ] + } + ] + }, + { + "tuple": [ + ":status_id_action", + { + "tuple": [ + 60000, + 3 + ] + } + ] + }, + { + "tuple": [ + ":password_reset", + { + "tuple": [ + 1800000, + 5 + ] + } + ] + }, + { + "tuple": [ + ":account_confirmation_resend", + { + "tuple": [ + 8640000, + 5 + ] + } + ] + }, + { + "tuple": [ + ":ap_routes", + { + "tuple": [ + 60000, + 15 + ] + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Workers.PurgeExpiredActivity", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":min_lifetime", + 600 + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.Plugs.RemoteIp", + "value": [ + { + "tuple": [ + ":enabled", + true + ] + }, + { + "tuple": [ + ":headers", + [ + "x-forwarded-for" + ] + ] + }, + { + "tuple": [ + ":proxies", + [] + ] + }, + { + "tuple": [ + ":reserved", + [ + "127.0.0.0/8", + "::1/128", + "fc00::/7", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":static_fe", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":web_cache_ttl", + "value": [ + { + "tuple": [ + ":activity_pub", + null + ] + }, + { + "tuple": [ + ":activity_pub_question", + 30000 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":modules", + "value": [ + { + "tuple": [ + ":runtime_dir", + "instance/modules" + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":connections_pool", + "value": [ + { + "tuple": [ + ":reclaim_multiplier", + 0.1 + ] + }, + { + "tuple": [ + ":connection_acquisition_wait", + 250 + ] + }, + { + "tuple": [ + ":connection_acquisition_retries", + 5 + ] + }, + { + "tuple": [ + ":max_connections", + 250 + ] + }, + { + "tuple": [ + ":max_idle_time", + 30000 + ] + }, + { + "tuple": [ + ":retry", + 0 + ] + }, + { + "tuple": [ + ":connect_timeout", + 5000 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":pools", + "value": [ + { + "tuple": [ + ":federation", + [ + { + "tuple": [ + ":size", + 50 + ] + }, + { + "tuple": [ + ":max_waiting", + 10 + ] + }, + { + "tuple": [ + ":recv_timeout", + 10000 + ] + } + ] + ] + }, + { + "tuple": [ + ":media", + [ + { + "tuple": [ + ":size", + 50 + ] + }, + { + "tuple": [ + ":max_waiting", + 20 + ] + }, + { + "tuple": [ + ":recv_timeout", + 15000 + ] + } + ] + ] + }, + { + "tuple": [ + ":upload", + [ + { + "tuple": [ + ":size", + 25 + ] + }, + { + "tuple": [ + ":max_waiting", + 5 + ] + }, + { + "tuple": [ + ":recv_timeout", + 15000 + ] + } + ] + ] + }, + { + "tuple": [ + ":default", + [ + { + "tuple": [ + ":size", + 10 + ] + }, + { + "tuple": [ + ":max_waiting", + 2 + ] + }, + { + "tuple": [ + ":recv_timeout", + 5000 + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":hackney_pools", + "value": [ + { + "tuple": [ + ":federation", + [ + { + "tuple": [ + ":max_connections", + 50 + ] + }, + { + "tuple": [ + ":timeout", + 150000 + ] + } + ] + ] + }, + { + "tuple": [ + ":media", + [ + { + "tuple": [ + ":max_connections", + 50 + ] + }, + { + "tuple": [ + ":timeout", + 150000 + ] + } + ] + ] + }, + { + "tuple": [ + ":upload", + [ + { + "tuple": [ + ":max_connections", + 25 + ] + }, + { + "tuple": [ + ":timeout", + 300000 + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":majic_pool", + "value": [ + { + "tuple": [ + ":size", + 2 + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":restrict_unauthenticated", + "value": [ + { + "tuple": [ + ":timelines", + { + ":federated": ":if_instance_is_private", + ":local": ":if_instance_is_private" + } + ] + }, + { + "tuple": [ + ":profiles", + { + ":local": ":if_instance_is_private", + ":remote": ":if_instance_is_private" + } + ] + }, + { + "tuple": [ + ":activities", + { + ":local": ":if_instance_is_private", + ":remote": ":if_instance_is_private" + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":instances_favicons", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.Auth.Authenticator", + "value": "Pleroma.Web.Auth.PleromaAuthenticator" + }, + { + "group": ":pleroma", + "key": "Pleroma.User.Backup", + "value": [ + { + "tuple": [ + ":purge_after_days", + 30 + ] + }, + { + "tuple": [ + ":limit_days", + 7 + ] + }, + { + "tuple": [ + ":dir", + null + ] + } + ] + }, + { + "group": ":pleroma", + "key": "ConcurrentLimiter", + "value": [ + { + "tuple": [ + "Pleroma.Web.RichMedia.Helpers", + [ + { + "tuple": [ + ":max_running", + 5 + ] + }, + { + "tuple": [ + ":max_waiting", + 5 + ] + } + ] + ] + }, + { + "tuple": [ + "Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy", + [ + { + "tuple": [ + ":max_running", + 5 + ] + }, + { + "tuple": [ + ":max_waiting", + 5 + ] + } + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":activitypub", + "value": [ + { + "tuple": [ + ":unfollow_blocked", + true + ] + }, + { + "tuple": [ + ":outgoing_blocks", + true + ] + }, + { + "tuple": [ + ":follow_handshake_timeout", + 500 + ] + }, + { + "tuple": [ + ":note_replies_output_limit", + 5 + ] + }, + { + "tuple": [ + ":sign_object_fetches", + true + ] + }, + { + "tuple": [ + ":authorized_fetch_mode", + false + ] + }, + { + "tuple": [ + ":blockers_visible", + false + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":frontends", + "value": [ + { + "tuple": [ + ":available", + { + "admin-fe": { + "build_url": "https://git.pleroma.social/pleroma/admin-fe/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/pleroma/admin-fe", + "name": "admin-fe", + "ref": "develop" + }, + "fedi-fe": { + "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build", + "custom-http-headers": [ + { + "tuple": [ + "service-worker-allowed", + "/" + ] + } + ], + "git": "https://git.pleroma.social/pleroma/fedi-fe", + "name": "fedi-fe", + "ref": "master" + }, + "kenoma": { + "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/lambadalambda/kenoma", + "name": "kenoma", + "ref": "master" + }, + "pleroma-fe": { + "build_url": "https://git.pleroma.social/pleroma/pleroma-fe/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/pleroma/pleroma-fe", + "name": "pleroma-fe", + "ref": "develop" + }, + "soapbox-fe": { + "build_dir": "static", + "build_url": "https://gitlab.com/soapbox-pub/soapbox-fe/-/jobs/artifacts/${ref}/download?job=build-production", + "git": "https://gitlab.com/soapbox-pub/soapbox-fe", + "name": "soapbox-fe", + "ref": "develop" + } + } + ] + }, + { + "tuple": [ + ":primary", + { + "name": "landing-fe", + "ref": "vendor" + } + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Upload", + "value": [ + { + "tuple": [ + ":uploader", + "Pleroma.Uploaders.Local" + ] + }, + { + "tuple": [ + ":link_name", + false + ] + }, + { + "tuple": [ + ":proxy_remote", + false + ] + }, + { + "tuple": [ + ":filename_display_max_length", + 30 + ] + }, + { + "tuple": [ + ":default_description", + null + ] + }, + { + "tuple": [ + ":base_url", + null + ] + }, + { + "tuple": [ + ":filters", + [ + "Pleroma.Upload.Filter.AnalyzeMetadata", + "Pleroma.Upload.Filter.Dedupe", + "Pleroma.Upload.Filter.Exiftool" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf", + "value": [ + { + "tuple": [ + ":transparency", + true + ] + }, + { + "tuple": [ + ":transparency_exclusions", + [] + ] + }, + { + "tuple": [ + ":policies", + [ + "Pleroma.Web.ActivityPub.MRF.SimplePolicy", + "Pleroma.Web.ActivityPub.MRF.HellthreadPolicy", + "Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy", + "Pleroma.Web.ActivityPub.MRF.TagPolicy", + "Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy" + ] + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":dangerzone", + "value": [ + { + "tuple": [ + ":override_repo_pool_size", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":mrf_hellthread", + "value": [ + { + "tuple": [ + ":delist_threshold", + 15 + ] + }, + { + "tuple": [ + ":reject_threshold", + 100 + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Emails.Mailer", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":adapter", + "Swoosh.Adapters.Local" + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Web.ApiSpec.CastAndValidate", + "value": [ + { + "tuple": [ + ":strict", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":media_proxy", + "value": [ + { + "tuple": [ + ":invalidation", + [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":provider", + "Pleroma.Web.MediaProxy.Invalidation.Script" + ] + } + ] + ] + }, + { + "tuple": [ + ":proxy_opts", + [ + { + "tuple": [ + ":redirect_on_failure", + false + ] + }, + { + "tuple": [ + ":max_body_length", + 26214400 + ] + }, + { + "tuple": [ + ":max_read_duration", + 30000 + ] + }, + { + "tuple": [ + ":http", + [ + { + "tuple": [ + ":follow_redirect", + true + ] + }, + { + "tuple": [ + ":pool", + ":media" + ] + } + ] + ] + } + ] + ] + }, + { + "tuple": [ + ":whitelist", + [] + ] + }, + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":redirect_on_failure", + true + ] + } + ] + }, + { + "group": ":pleroma", + "key": ":instance", + "value": [ + { + "tuple": [ + ":background_image", + "/images/city.jpg" + ] + }, + { + "tuple": [ + ":description_limit", + 5000 + ] + }, + { + "tuple": [ + ":remote_limit", + 100000 + ] + }, + { + "tuple": [ + ":upload_limit", + 16000000 + ] + }, + { + "tuple": [ + ":avatar_upload_limit", + 2000000 + ] + }, + { + "tuple": [ + ":background_upload_limit", + 4000000 + ] + }, + { + "tuple": [ + ":banner_upload_limit", + 4000000 + ] + }, + { + "tuple": [ + ":poll_limits", + { + ":max_expiration": 31536000, + ":max_option_chars": 200, + ":max_options": 20, + ":min_expiration": 0 + } + ] + }, + { + "tuple": [ + ":invites_enabled", + false + ] + }, + { + "tuple": [ + ":account_activation_required", + false + ] + }, + { + "tuple": [ + ":account_approval_required", + false + ] + }, + { + "tuple": [ + ":federating", + true + ] + }, + { + "tuple": [ + ":federation_incoming_replies_max_depth", + 100 + ] + }, + { + "tuple": [ + ":federation_reachability_timeout_days", + 7 + ] + }, + { + "tuple": [ + ":federation_publisher_modules", + [ + "Pleroma.Web.ActivityPub.Publisher" + ] + ] + }, + { + "tuple": [ + ":allow_relay", + true + ] + }, + { + "tuple": [ + ":public", + true + ] + }, + { + "tuple": [ + ":quarantined_instances", + [] + ] + }, + { + "tuple": [ + ":allowed_post_formats", + [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" + ] + ] + }, + { + "tuple": [ + ":autofollowed_nicknames", + [] + ] + }, + { + "tuple": [ + ":autofollowing_nicknames", + [] + ] + }, + { + "tuple": [ + ":max_pinned_statuses", + 1 + ] + }, + { + "tuple": [ + ":attachment_links", + false + ] + }, + { + "tuple": [ + ":max_report_comment_size", + 1000 + ] + }, + { + "tuple": [ + ":safe_dm_mentions", + false + ] + }, + { + "tuple": [ + ":healthcheck", + false + ] + }, + { + "tuple": [ + ":remote_post_retention_days", + 90 + ] + }, + { + "tuple": [ + ":skip_thread_containment", + true + ] + }, + { + "tuple": [ + ":limit_to_local_content", + ":unauthenticated" + ] + }, + { + "tuple": [ + ":user_bio_length", + 5000 + ] + }, + { + "tuple": [ + ":user_name_length", + 100 + ] + }, + { + "tuple": [ + ":user_location_length", + 50 + ] + }, + { + "tuple": [ + ":max_account_fields", + 10 + ] + }, + { + "tuple": [ + ":max_remote_account_fields", + 20 + ] + }, + { + "tuple": [ + ":account_field_name_length", + 512 + ] + }, + { + "tuple": [ + ":account_field_value_length", + 2048 + ] + }, + { + "tuple": [ + ":registration_reason_length", + 500 + ] + }, + { + "tuple": [ + ":external_user_synchronization", + true + ] + }, + { + "tuple": [ + ":extended_nickname_format", + true + ] + }, + { + "tuple": [ + ":cleanup_attachments", + false + ] + }, + { + "tuple": [ + ":multi_factor_authentication", + [ + { + "tuple": [ + ":totp", + [ + { + "tuple": [ + ":digits", + 6 + ] + }, + { + "tuple": [ + ":period", + 30 + ] + } + ] + ] + }, + { + "tuple": [ + ":backup_codes", + [ + { + "tuple": [ + ":number", + 5 + ] + }, + { + "tuple": [ + ":length", + 16 + ] + } + ] + ] + } + ] + ] + }, + { + "tuple": [ + ":show_reactions", + true + ] + }, + { + "tuple": [ + ":password_reset_token_validity", + 86400 + ] + }, + { + "tuple": [ + ":profile_directory", + true + ] + }, + { + "tuple": [ + ":max_endorsed_users", + 20 + ] + }, + { + "tuple": [ + ":birthday_required", + false + ] + }, + { + "tuple": [ + ":birthday_min_age", + 0 + ] + }, + { + "tuple": [ + ":privileged_staff", + true + ] + }, + { + "tuple": [ + ":max_media_attachments", + 20 + ] + }, + { + "tuple": [ + ":description", + "Social media owned by you" + ] + }, + { + "tuple": [ + ":instance_thumbnail", + "/instance/thumbnail.png" + ] + }, + { + "tuple": [ + ":name", + "localhost" + ] + }, + { + "tuple": [ + ":email", + "alex@alexgleason.me" + ] + }, + { + "tuple": [ + ":notify_email", + "alex@alexgleason.me" + ] + }, + { + "tuple": [ + ":limit", + 5000 + ] + }, + { + "tuple": [ + ":registrations_open", + true + ] + }, + { + "tuple": [ + ":static_dir", + "/home/alex/Projects/soapbox-be/instance/static" + ] + } + ] + }, + { + "group": ":pleroma", + "key": "Pleroma.Uploaders.Local", + "value": [ + { + "tuple": [ + ":uploads", + "/home/alex/Projects/soapbox-be/uploads" + ] + } + ] + }, + { + "group": ":joken", + "key": ":default_signer", + "value": "KaehAYXaKzxCdcqSD35I6R9KRUMvgqb0DBMV6PIiqjoHbg0eppqH6nSpNp4fbbLT" + }, + { + "group": ":web_push_encryption", + "key": ":http_client", + "value": "Pleroma.HTTP.WebPush" + }, + { + "group": ":web_push_encryption", + "key": ":vapid_details", + "value": [ + { + "tuple": [ + ":subject", + "mailto:alex@alexgleason.me" + ] + }, + { + "tuple": [ + ":public_key", + "BCUFu4_-77t6dQ2XfZIyEE7k8H4r11s-a5doq7hZHDv9RqTUek-8yrE9nUN-rZYTxkjxuXF7IMcDdRgZ1fOCUy8" + ] + }, + { + "tuple": [ + ":private_key", + "KN3JU4Ug0e7_lsxhqOW_jQuQNeQTrr7QtOmdNIpUUbY" + ] + } + ] + }, + { + "group": ":phoenix", + "key": ":format_encoders", + "value": [ + { + "tuple": [ + ":json", + "Jason" + ] + }, + { + "tuple": [ + ":\"activity+json\"", + "Jason" + ] + } + ] + }, + { + "group": ":phoenix", + "key": ":json_library", + "value": "Jason" + }, + { + "group": ":phoenix", + "key": ":filter_parameters", + "value": [ + "password", + "confirm" + ] + }, + { + "group": ":phoenix", + "key": ":stacktrace_depth", + "value": 20 + }, + { + "group": ":phoenix", + "key": ":plug_init_mode", + "value": ":runtime" + }, + { + "group": ":logger", + "key": ":ex_syslogger", + "value": [ + { + "tuple": [ + ":level", + ":debug" + ] + }, + { + "tuple": [ + ":ident", + "pleroma" + ] + }, + { + "tuple": [ + ":format", + "$metadata[$level] $message" + ] + }, + { + "tuple": [ + ":metadata", + [ + ":request_id" + ] + ] + } + ] + }, + { + "group": ":logger", + "key": ":console", + "value": [ + { + "tuple": [ + ":level", + ":debug" + ] + }, + { + "tuple": [ + ":metadata", + [ + ":request_id" + ] + ] + }, + { + "tuple": [ + ":format", + "[$level] $message\n" + ] + } + ] + }, + { + "group": ":floki", + "key": ":html_parser", + "value": "Floki.HTMLParser.FastHtml" + }, + { + "group": ":ex_aws", + "key": ":s3", + "value": [ + { + "tuple": [ + ":access_key_id", + null + ] + }, + { + "tuple": [ + ":secret_access_key", + null + ] + }, + { + "tuple": [ + ":scheme", + "https://" + ] + } + ] + }, + { + "group": ":ex_aws", + "key": ":http_client", + "value": "Pleroma.HTTP.ExAws" + }, + { + "group": ":tzdata", + "key": ":http_client", + "value": "Pleroma.HTTP.Tzdata" + }, + { + "group": ":http_signatures", + "key": ":adapter", + "value": "Pleroma.Signature" + }, + { + "group": ":prometheus", + "key": "Pleroma.Web.Endpoint.MetricsExporter", + "value": [ + { + "tuple": [ + ":enabled", + false + ] + }, + { + "tuple": [ + ":auth", + false + ] + }, + { + "tuple": [ + ":ip_whitelist", + [] + ] + }, + { + "tuple": [ + ":path", + "/api/pleroma/app_metrics" + ] + }, + { + "tuple": [ + ":format", + ":text" + ] + } + ] + }, + { + "group": ":ueberauth", + "key": "Ueberauth", + "value": [ + { + "tuple": [ + ":base_path", + "/oauth" + ] + }, + { + "tuple": [ + ":providers", + [] + ] + } + ] + }, + { + "group": ":esshd", + "key": ":enabled", + "value": false + }, + { + "group": ":cors_plug", + "key": ":max_age", + "value": 86400 + }, + { + "group": ":cors_plug", + "key": ":methods", + "value": [ + "POST", + "PUT", + "DELETE", + "GET", + "PATCH", + "OPTIONS" + ] + }, + { + "group": ":cors_plug", + "key": ":expose", + "value": [ + "Link", + "X-RateLimit-Reset", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-Request-Id", + "Idempotency-Key" + ] + }, + { + "group": ":cors_plug", + "key": ":credentials", + "value": true + }, + { + "group": ":cors_plug", + "key": ":headers", + "value": [ + "Authorization", + "Content-Type", + "Idempotency-Key" + ] + }, + { + "group": ":mime", + "key": ":types", + "value": { + "application/activity+json": [ + "activity+json" + ], + "application/jrd+json": [ + "jrd+json" + ], + "application/ld+json": [ + "activity+json" + ], + "application/xml": [ + "xml" + ], + "application/xrd+xml": [ + "xrd+xml" + ] + } + }, + { + "group": ":quack", + "key": ":level", + "value": ":warn" + }, + { + "group": ":quack", + "key": ":meta", + "value": [ + ":all" + ] + }, + { + "group": ":quack", + "key": ":webhook_url", + "value": "https://hooks.slack.com/services/YOUR-KEY-HERE" + } + ], + "need_reboot": false +} diff --git a/src/__fixtures__/pleroma-instance.json b/src/__fixtures__/pleroma-instance.json new file mode 100644 index 0000000..b913763 --- /dev/null +++ b/src/__fixtures__/pleroma-instance.json @@ -0,0 +1,131 @@ +{ + "approval_required": true, + "avatar_upload_limit": 2000000, + "background_image": "https://gleasonator.com/images/city.jpg", + "background_upload_limit": 4000000, + "banner_upload_limit": 4000000, + "description": "Building the next generation of the Fediverse. Speak freely.", + "description_limit": 5000, + "email": "alex@alexgleason.me", + "languages": [ + "en" + ], + "max_toot_chars": 5000, + "pleroma": { + "metadata": { + "account_activation_required": false, + "birthday_min_age": 0, + "birthday_required": false, + "features": [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "v2_suggestions", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + "quote_posting", + "media_proxy", + "relay", + "pleroma_emoji_reactions", + "pleroma_chat_messages", + "email_list", + "profile_directory" + ], + "federation": { + "enabled": true, + "exclusions": false, + "mrf_hashtag": { + "federated_timeline_removal": [], + "reject": [], + "sensitive": [ + "nsfw" + ] + }, + "mrf_policies": [ + "TagPolicy", + "SimplePolicy", + "InlineQuotePolicy", + "HashtagPolicy" + ], + "mrf_simple": { + "accept": [], + "avatar_removal": [ + "pawoo.net", + "sinblr.com", + "dajiaweibo.com", + "baraag.net" + ], + "banner_removal": [ + "pawoo.net", + "sinblr.com", + "dajiaweibo.com", + "baraag.net" + ], + "federated_timeline_removal": [], + "followers_only": [], + "media_nsfw": [], + "media_removal": [ + "pawoo.net", + "sinblr.com", + "dajiaweibo.com", + "baraag.net" + ], + "reject": [ + "solagg.com" + ], + "reject_deletes": [], + "report_removal": [] + }, + "mrf_simple_info": {}, + "quarantined_instances": [], + "quarantined_instances_info": { + "quarantined_instances": {} + } + }, + "fields_limits": { + "max_fields": 15, + "max_remote_fields": 20, + "name_length": 512, + "value_length": 2048 + }, + "post_formats": [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" + ], + "privileged_staff": true + }, + "stats": { + "mau": 71 + }, + "vapid_public_key": "BLElLQVJVmY_e4F5JoYxI5jXiVOYNsJ9p-amkykc9NcI-jwa9T1Y2GIbDqbY-HqC6ayPkfW4K4o9vgBFKYmkuS4" + }, + "poll_limits": { + "max_expiration": 31536000, + "max_option_chars": 200, + "max_options": 20, + "min_expiration": 0 + }, + "registrations": true, + "shout_limit": 5000, + "soapbox": { + "version": "1.1.1" + }, + "stats": { + "domain_count": 8140, + "status_count": 101956, + "user_count": 421 + }, + "thumbnail": "https://media.gleasonator.com/c0d38bde6ef0b3baa483f574797662ebd83ef9e1a1162e8e4fcd930bb4b3c068.png", + "title": "Gleasonator", + "upload_limit": 100000000, + "uri": "https://gleasonator.com", + "urls": { + "streaming_api": "wss://gleasonator.com" + }, + "version": "2.7.2 (compatible; Pleroma 2.4.51-1129-gf2cfef09-gleasonator)" +} diff --git a/src/__fixtures__/pleroma-notification-move.json b/src/__fixtures__/pleroma-notification-move.json new file mode 100644 index 0000000..d7a7634 --- /dev/null +++ b/src/__fixtures__/pleroma-notification-move.json @@ -0,0 +1,119 @@ +{ + "account": { + "acct": "alex@fedibird.com", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2022-01-24T21:25:37.000Z", + "display_name": "alex@fedibird.com", + "emojis": [], + "fields": [], + "followers_count": 2, + "following_count": 1, + "fqn": "alex@fedibird.com", + "header": "https://gleasonator.com/images/banner.png", + "header_static": "https://gleasonator.com/images/banner.png", + "id": "AFmHQ18XZ7Lco68MW8", + "last_status_at": "2022-03-16T22:07:53", + "locked": false, + "note": "

", + "pleroma": { + "accepts_chat_messages": null, + "also_known_as": [], + "ap_id": "https://fedibird.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "deactivated": false, + "favicon": "https://gleasonator.com/proxy/HzfsidHss3CuA7aM2zxXN-tAjF8/aHR0cHM6Ly9mZWRpYmlyZC5jb20vZmF2aWNvbi5pY28/favicon.ico", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": "Texas, USA", + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 5, + "url": "https://fedibird.com/@alex", + "username": "alex" + }, + "created_at": "2022-03-17T00:08:48.000Z", + "id": "406814", + "pleroma": { + "is_muted": false, + "is_seen": true + }, + "target": { + "acct": "benis911", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2021-03-26T20:42:11.000Z", + "display_name": "benis911", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "fqn": "benis911@gleasonator.com", + "header": "https://media.gleasonator.com/fc595bbbcf5aabefecd1c2adfe5b7f5457db59847992881668653a0338ba25bd.jpg", + "header_static": "https://media.gleasonator.com/fc595bbbcf5aabefecd1c2adfe5b7f5457db59847992881668653a0338ba25bd.jpg", + "id": "A5c5LK7EJTFR0u26Pg", + "last_status_at": "2022-03-16T22:01:57", + "locked": false, + "note": "hello world 2", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://gleasonator.com/users/alex", + "https://poa.st/users/alex", + "https://fedibird.com/users/alex" + ], + "ap_id": "https://gleasonator.com/users/benis911", + "background_image": null, + "birthday": "2000-01-25", + "deactivated": false, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": true, + "hide_follows": true, + "hide_follows_count": true, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "hello world 2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 172, + "url": "https://gleasonator.com/users/benis911", + "username": "benis911" + }, + "type": "move" +} diff --git a/src/__fixtures__/pleroma-quote-of-quote-post.json b/src/__fixtures__/pleroma-quote-of-quote-post.json new file mode 100644 index 0000000..1156cdb --- /dev/null +++ b/src/__fixtures__/pleroma-quote-of-quote-post.json @@ -0,0 +1,371 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2220, + "following_count": 1544, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-01-24T21:02:44", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23004, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

Quote of quote post

", + "created_at": "2022-01-24T21:02:43.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 0, + "id": "AFmFNKmfrR9CxtV01g", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Quote of quote post" + }, + "conversation_id": "AFmFNKkXzLRirIVIi8", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2220, + "following_count": 1544, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-01-24T21:02:44", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23004, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

Quote post

", + "created_at": "2022-01-24T21:02:34.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 0, + "id": "AFmFMSpITT9xcOJKcK", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Quote post" + }, + "conversation_id": "AFmFMSnWa3k3WtTur2", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 1, + "me": false, + "name": "👍" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": "https://gleasonator.com/objects/4f35159c-3794-4037-9269-a7c84f7137c7", + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/54d93075-7d04-4016-a128-81f3843bca79", + "url": "https://gleasonator.com/notice/AFmFMSpITT9xcOJKcK", + "visibility": "public" + }, + "quote_url": "https://gleasonator.com/objects/54d93075-7d04-4016-a128-81f3843bca79", + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 1, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/1e2cfb5a-ece5-42df-9ec1-13e5de6d9f5b", + "url": "https://gleasonator.com/notice/AFmFNKmfrR9CxtV01g", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma-quote-post.json b/src/__fixtures__/pleroma-quote-post.json new file mode 100644 index 0000000..994671c --- /dev/null +++ b/src/__fixtures__/pleroma-quote-post.json @@ -0,0 +1,364 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2220, + "following_count": 1544, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-01-24T21:02:44", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23004, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

Quote post

", + "created_at": "2022-01-24T21:02:34.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 0, + "id": "AFmFMSpITT9xcOJKcK", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Quote post" + }, + "conversation_id": "AFmFMSnWa3k3WtTur2", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 1, + "me": false, + "name": "👍" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": { + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2220, + "following_count": 1544, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-01-24T21:02:44", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23004, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

Test post

", + "created_at": "2022-01-24T21:02:25.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 0, + "id": "AFmFLcd6XYVdjWCrOS", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Test post" + }, + "conversation_id": "AFmFLcaGi6EzaisayO", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": null, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/4f35159c-3794-4037-9269-a7c84f7137c7", + "url": "https://gleasonator.com/notice/AFmFLcd6XYVdjWCrOS", + "visibility": "public" + }, + "quote_url": "https://gleasonator.com/objects/4f35159c-3794-4037-9269-a7c84f7137c7", + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/54d93075-7d04-4016-a128-81f3843bca79", + "url": "https://gleasonator.com/notice/AFmFMSpITT9xcOJKcK", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma-status-deleted.json b/src/__fixtures__/pleroma-status-deleted.json new file mode 100644 index 0000000..2d37af2 --- /dev/null +++ b/src/__fixtures__/pleroma-status-deleted.json @@ -0,0 +1,229 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "follow_requests_count": 0, + "followers_count": 2489, + "following_count": 1586, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-03-16T21:57:17", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "accepts_email_list": true, + "allow_following_move": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "deactivated": false, + "email": "alex@alexgleason.me", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": null, + "notification_settings": { + "block_from_strangers": false, + "hide_notification_contents": false + }, + "relationship": {}, + "skip_thread_containment": false, + "tags": [], + "unread_conversation_count": 392, + "unread_notifications_count": 2 + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false, + "no_rich_text": false, + "show_birthday": true, + "show_role": true + }, + "privacy": "public", + "sensitive": false + }, + "statuses_count": 23695, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

I am going to delete this post for testing purposes

", + "created_at": "2022-03-16T21:57:16.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 3, + "id": "AHU2RrX0wdcwzCYjFQ", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [ + { + "blurhash": "eWGlL@?b~q%MRj4nt7IUof%M%MIURjRjIUM{IUM{Rjayxut7j[j[xu", + "description": "", + "id": "508107650", + "meta": { + "original": { + "aspect": 1, + "height": 1024, + "width": 1024 + } + }, + "pleroma": { + "mime_type": "image/png" + }, + "preview_url": "https://media.gleasonator.com/2b9ddcd8b27cad786fd34bc2cfe02c1b63aa1b8e7b8d72379b5c9375fb61f199.png", + "remote_url": "https://media.gleasonator.com/2b9ddcd8b27cad786fd34bc2cfe02c1b63aa1b8e7b8d72379b5c9375fb61f199.png", + "text_url": "https://media.gleasonator.com/2b9ddcd8b27cad786fd34bc2cfe02c1b63aa1b8e7b8d72379b5c9375fb61f199.png", + "type": "image", + "url": "https://media.gleasonator.com/2b9ddcd8b27cad786fd34bc2cfe02c1b63aa1b8e7b8d72379b5c9375fb61f199.png" + } + ], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "I am going to delete this post for testing purposes" + }, + "content_type": "text/markdown", + "conversation_id": "AHU2RrUB7BMIqPESpM", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 1, + "me": false, + "name": "😭" + }, + { + "count": 1, + "me": false, + "name": "❔" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 1, + "replies_count": 2, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": "I am going to delete this post for testing purposes", + "uri": "https://gleasonator.com/objects/205ec868-d28d-4668-a56a-33321f7e285e", + "url": "https://gleasonator.com/notice/AHU2RrX0wdcwzCYjFQ", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma-status-reply-with-mentions.json b/src/__fixtures__/pleroma-status-reply-with-mentions.json new file mode 100644 index 0000000..21caf17 --- /dev/null +++ b/src/__fixtures__/pleroma-status-reply-with-mentions.json @@ -0,0 +1,207 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2536, + "following_count": 1587, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-03-26T15:13:42", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "location": null, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23825, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

DMs are definitely only federated to the servers of the recipients tho. So if I DM a kfcc user, the kfcc admins can see it, but no other instance admins can.

", + "created_at": "2022-03-21T05:04:45.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 5, + "id": "AHcweewcCh0iPUtMdk", + "in_reply_to_account_id": "9v5bo8xPghEnkedGzo", + "in_reply_to_id": "AHcwFrnbH1Xb2RqxhQ", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "crunklord420@kiwifarms.cc", + "id": "9v5bo8xPghEnkedGzo", + "url": "https://kiwifarms.cc/users/crunklord420", + "username": "crunklord420" + }, + { + "acct": "becassine@kiwifarms.cc", + "id": "A6W9i7UOhgpBBOkcEK", + "url": "https://kiwifarms.cc/users/becassine", + "username": "becassine" + }, + { + "acct": "King_Porgi@poa.st", + "id": "A5U7Z228z3h3dBdg6C", + "url": "https://poa.st/users/King_Porgi", + "username": "King_Porgi" + }, + { + "acct": "ademan@thebag.social", + "id": "A79wLvOahABnWFnwWm", + "url": "https://thebag.social/users/ademan", + "username": "ademan" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "DMs are definitely only federated to the servers of the recipients tho. So if I DM a kfcc user, the kfcc admins can see it, but no other instance admins can." + }, + "content_type": null, + "conversation_id": "AHcrQt04eG9qB87g3s", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "crunklord420@kiwifarms.cc", + "local": true, + "parent_visible": true, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 2, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/0981bab7-a086-4ed9-b938-adc3ed9d5f51", + "url": "https://gleasonator.com/notice/AHcweewcCh0iPUtMdk", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma-status-vertical-video-without-metadata.json b/src/__fixtures__/pleroma-status-vertical-video-without-metadata.json new file mode 100644 index 0000000..edb24b9 --- /dev/null +++ b/src/__fixtures__/pleroma-status-vertical-video-without-metadata.json @@ -0,0 +1,108 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://freespeechextremist.com/images/avi.png", + "avatar_static": "https://freespeechextremist.com/images/avi.png", + "bot": false, + "created_at": "2022-02-28T01:55:05.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [], + "followers_count": 1, + "following_count": 0, + "header": "https://freespeechextremist.com/images/banner.png", + "header_static": "https://freespeechextremist.com/images/banner.png", + "id": "AGv8wCadU7DqWgMqNk", + "locked": false, + "note": "I'm testing out compatibility with an older Pleroma version", + "pleroma": { + "accepts_chat_messages": true, + "ap_id": "https://freespeechextremist.com/users/alex", + "background_image": null, + "confirmation_pending": false, + "favicon": null, + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_moderator": false, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "I'm testing out compatibility with an older Pleroma version", + "pleroma": { + "actor_type": "Person", + "discoverable": true + }, + "sensitive": false + }, + "statuses_count": 1, + "url": "https://freespeechextremist.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "
0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", + "created_at": "2022-04-14T19:42:48.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 0, + "id": "AIRxLeIzncpCtsr2hs", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [ + { + "description": "0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", + "id": "1142674091", + "pleroma": { + "mime_type": "video/webm" + }, + "preview_url": "https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", + "remote_url": "https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", + "text_url": "https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm", + "type": "video", + "url": "https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm" + } + ], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm" + }, + "conversation_id": 97191096, + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://freespeechextremist.com/objects/419b2cad-656a-4dbc-b2b5-94bb75e0afc8", + "url": "https://freespeechextremist.com/notice/AIRxLeIzncpCtsr2hs", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma-status-with-attachments.json b/src/__fixtures__/pleroma-status-with-attachments.json new file mode 100644 index 0000000..75db829 --- /dev/null +++ b/src/__fixtures__/pleroma-status-with-attachments.json @@ -0,0 +1,238 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2344, + "following_count": 1564, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-02-11T23:12:00", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Pleroma+Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23357, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

Test

", + "created_at": "2022-02-11T23:11:59.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 1, + "id": "AGNkA21auFR5lnEAHw", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [ + { + "blurhash": "emLqe9t7~q%M%M-;WBt7ofRj%Moft7ofoft7ayWBj[of-;j[ayofM{", + "description": "", + "id": "974611173", + "meta": { + "original": { + "aspect": 0.9944598337950139, + "height": 1444, + "width": 1436 + } + }, + "pleroma": { + "mime_type": "image/png" + }, + "preview_url": "https://media.gleasonator.com/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png", + "remote_url": "https://media.gleasonator.com/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png", + "text_url": "https://media.gleasonator.com/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png", + "type": "image", + "url": "https://media.gleasonator.com/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png" + }, + { + "blurhash": null, + "description": "", + "id": "-1764036199", + "pleroma": { + "mime_type": "application/x-nes-rom" + }, + "preview_url": "https://media.gleasonator.com/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac.nes", + "remote_url": "https://media.gleasonator.com/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac.nes", + "text_url": "https://media.gleasonator.com/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac.nes", + "type": "unknown", + "url": "https://media.gleasonator.com/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac.nes" + }, + { + "blurhash": null, + "description": "", + "id": "-636167741", + "pleroma": { + "mime_type": "audio/ogg" + }, + "preview_url": "https://media.gleasonator.com/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.ogg", + "remote_url": "https://media.gleasonator.com/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.ogg", + "text_url": "https://media.gleasonator.com/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.ogg", + "type": "audio", + "url": "https://media.gleasonator.com/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.ogg" + }, + { + "blurhash": null, + "description": "", + "id": "517941208", + "pleroma": { + "mime_type": "text/plain" + }, + "preview_url": "https://media.gleasonator.com/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0.LICENSE", + "remote_url": "https://media.gleasonator.com/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0.LICENSE", + "text_url": "https://media.gleasonator.com/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0.LICENSE", + "type": "unknown", + "url": "https://media.gleasonator.com/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0.LICENSE" + } + ], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Test" + }, + "conversation_id": "AGNkA1yP66srbtjcJc", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/aa5e66c9-0a10-4167-9c80-f40d9574aaec", + "url": "https://gleasonator.com/notice/AGNkA21auFR5lnEAHw", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma-status-with-poll-with-emojis.json b/src/__fixtures__/pleroma-status-with-poll-with-emojis.json new file mode 100644 index 0000000..76c722a --- /dev/null +++ b/src/__fixtures__/pleroma-status-with-poll-with-emojis.json @@ -0,0 +1,236 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2467, + "following_count": 1581, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-03-11T01:33:19", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23651, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "

Test poll

", + "created_at": "2022-03-11T01:33:18.000Z", + "emojis": [ + { + "shortcode": "gleason_excited", + "static_url": "https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png", + "url": "https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png", + "visible_in_picker": false + }, + { + "shortcode": "soapbox", + "static_url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", + "url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", + "visible_in_picker": false + } + ], + "favourited": false, + "favourites_count": 1, + "id": "AHHue68kB59xtUv7MO", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Test poll" + }, + "conversation_id": "AHHue65YMwbjjbQZO4", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": { + "emojis": [ + { + "shortcode": "gleason_excited", + "static_url": "https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png", + "url": "https://gleasonator.com/emoji/gleason_emojis/gleason_excited.png", + "visible_in_picker": false + }, + { + "shortcode": "soapbox", + "static_url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", + "url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", + "visible_in_picker": false + } + ], + "expired": false, + "expires_at": "2022-03-12T01:33:18.000Z", + "id": "AHHue67gF2JDqCQGhc", + "multiple": false, + "options": [ + { + "title": "Regular emoji 😍 ", + "votes_count": 0 + }, + { + "title": "Custom emoji :gleason_excited: ", + "votes_count": 1 + }, + { + "title": "No emoji", + "votes_count": 0 + }, + { + "title": "🤔 😮 😠 ", + "votes_count": 1 + }, + { + "title": ":soapbox:", + "votes_count": 1 + } + ], + "voters_count": 3, + "votes_count": 3 + }, + "reblog": null, + "reblogged": false, + "reblogs_count": 1, + "replies_count": 1, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/46d2ab26-3497-442b-999f-612fe717b0a3", + "url": "https://gleasonator.com/notice/AHHue68kB59xtUv7MO", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma-status-with-poll.json b/src/__fixtures__/pleroma-status-with-poll.json new file mode 100644 index 0000000..452a5ac --- /dev/null +++ b/src/__fixtures__/pleroma-status-with-poll.json @@ -0,0 +1,201 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2465, + "following_count": 1581, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-03-10T18:19:50", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23648, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": null, + "bookmarked": false, + "card": null, + "content": "

What is tolerance?

", + "created_at": "2020-03-23T19:33:06.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 49, + "id": "103874034847713213", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": true, + "pleroma": { + "content": { + "text/plain": "What is tolerance?" + }, + "conversation_id": "3023268", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 3, + "me": false, + "name": "❤️" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": "2021-11-23T01:38:44.000Z", + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": { + "emojis": [], + "expired": true, + "expires_at": "2020-03-24T19:33:06.000Z", + "id": "4930", + "multiple": false, + "options": [ + { + "title": "Banning, censoring, and deplatforming anyone you disagree with", + "votes_count": 2 + }, + { + "title": "Promoting free speech, even for people and ideas you dislike", + "votes_count": 36 + } + ], + "voters_count": 2, + "votes_count": 38 + }, + "reblog": null, + "reblogged": false, + "reblogs_count": 27, + "replies_count": 15, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/users/alex/statuses/103874034847713213", + "url": "https://gleasonator.com/notice/103874034847713213", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma-status.json b/src/__fixtures__/pleroma-status.json new file mode 100644 index 0000000..69f84af --- /dev/null +++ b/src/__fixtures__/pleroma-status.json @@ -0,0 +1,183 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2465, + "following_count": 1581, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-03-10T18:19:50", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23648, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": null, + "bookmarked": false, + "card": null, + "content": "

Good morning! Hope you have a wonderful day.

", + "created_at": "2020-03-23T19:33:06.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 49, + "id": "103874034845713213", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": true, + "pleroma": { + "content": { + "text/plain": "What is tolerance?" + }, + "conversation_id": "3023268", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 3, + "me": false, + "name": "❤️" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": "2021-11-23T01:38:44.000Z", + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 27, + "replies_count": 15, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/users/alex/statuses/103874034847713213", + "url": "https://gleasonator.com/notice/103874034847713213", + "visibility": "public" +} diff --git a/src/__fixtures__/pleroma_initial_results.json b/src/__fixtures__/pleroma_initial_results.json new file mode 100644 index 0000000..027b646 --- /dev/null +++ b/src/__fixtures__/pleroma_initial_results.json @@ -0,0 +1,6 @@ +{ + "/api/pleroma/frontend_configurations": "eyJtYXN0b19mZSI6eyJzaG93SW5zdGFuY2VTcGVjaWZpY1BhbmVsIjp0cnVlfSwicGxlcm9tYV9mZSI6eyJhbHdheXNTaG93U3ViamVjdElucHV0Ijp0cnVlLCJiYWNrZ3JvdW5kIjoiL2ltYWdlcy9jaXR5LmpwZyIsImNvbGxhcHNlTWVzc2FnZVdpdGhTdWJqZWN0IjpmYWxzZSwiZGlzYWJsZUNoYXQiOmZhbHNlLCJncmVlbnRleHQiOmZhbHNlLCJoaWRlRmlsdGVyZWRTdGF0dXNlcyI6ZmFsc2UsImhpZGVNdXRlZFBvc3RzIjpmYWxzZSwiaGlkZVBvc3RTdGF0cyI6ZmFsc2UsImhpZGVTaXRlbmFtZSI6ZmFsc2UsImhpZGVVc2VyU3RhdHMiOmZhbHNlLCJsb2dpbk1ldGhvZCI6InBhc3N3b3JkIiwibG9nbyI6Ii9zdGF0aWMvbG9nby5zdmciLCJsb2dvTWFyZ2luIjoiLjFlbSIsImxvZ29NYXNrIjp0cnVlLCJtaW5pbWFsU2NvcGVzTW9kZSI6ZmFsc2UsIm5vQXR0YWNobWVudExpbmtzIjpmYWxzZSwibnNmd0NlbnNvckltYWdlIjoiIiwicG9zdENvbnRlbnRUeXBlIjoidGV4dC9wbGFpbiIsInJlZGlyZWN0Um9vdExvZ2luIjoiL21haW4vZnJpZW5kcyIsInJlZGlyZWN0Um9vdE5vTG9naW4iOiIvbWFpbi9hbGwiLCJzY29wZUNvcHkiOnRydWUsInNob3dGZWF0dXJlc1BhbmVsIjp0cnVlLCJzaG93SW5zdGFuY2VTcGVjaWZpY1BhbmVsIjpmYWxzZSwic2lkZWJhclJpZ2h0IjpmYWxzZSwic3ViamVjdExpbmVCZWhhdmlvciI6ImVtYWlsIiwidGhlbWUiOiJwbGVyb21hLWRhcmsiLCJ3ZWJQdXNoTm90aWZpY2F0aW9ucyI6ZmFsc2V9LCJzb2FwYm94X2ZlIjp7ImJyYW5kQ29sb3IiOiIjMWNhODJiIiwiY3J5cHRvQWRkcmVzc2VzIjpbeyJhZGRyZXNzIjoiYmMxcTljeDM1YWRwbTczYXEyZnc0MHllNnRzOGhmeHF6anI1dW53ZzBuIiwibm90ZSI6IiIsInRpY2tlciI6ImJ0YyJ9LHsiYWRkcmVzcyI6IjB4QWM5YUI1RmMwNERjMWNCMTc4OUFmNzViNTIzQmQyM0M3MEIyRDcxNyIsInRpY2tlciI6ImV0aCJ9LHsiYWRkcmVzcyI6IkQ1elZaczZqclJha2FQVkdpRXJrUWlIdDlzYXl6bTZWNUQiLCJ0aWNrZXIiOiJkb2dlIn0seyJhZGRyZXNzIjoiMHg1NDFhNDVjYjIxMmI1N2Y0MTM5MzQyN2ZiMTUzMzVmYzg5YzM1ODUxIiwidGlja2VyIjoidWJxIn0seyJhZGRyZXNzIjoiNDVKRENMcmpKNGJnVlVTYmJzMnlqeTltNU1mNFZMUFc4Zkc3anc5c3E1dTY5clhaWm9wUW9nWk5leVlrTUJuWHBrYWlwNHA0UXdhYUpOaGRUb3RQYTlnNDREQkN6ZEsiLCJub3RlIjoiIiwidGlja2VyIjoieG1yIn0seyJhZGRyZXNzIjoibHRjMXFkYTY0NWpkZjRqc3p3eGN2c24zMnlrZGhlbXZseDd5bDluNWd6OSIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJsdGMifSx7ImFkZHJlc3MiOiJiaXRjb2luY2FzaDpxcGNmbm05dzh1ZW1heDM4eXFoeWc1OHpuMnB0cGY2c3p2a3IwbjQ4YTciLCJub3RlIjoiIiwidGlja2VyIjoiYmNoIn0seyJhZGRyZXNzIjoiWG5CNXA0SnZMM1NvOTFBMWMxTUVSb3paRWplTVNzQUQ3SiIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJkYXNoIn0seyJhZGRyZXNzIjoidDFQSFpYNVpqWTd5NjFpQzE5QTk1OFc5aGR5SDNTaUxKdUYiLCJub3RlIjoiIiwidGlja2VyIjoiemVjIn0seyJhZGRyZXNzIjoiMHhCODFCQUVFMTBkMTYzNDA0YTFjNjAwNDVhODcyYTBkYTlFMjU4NDY1Iiwibm90ZSI6IiIsInRpY2tlciI6ImV0YyJ9LHsiYWRkcmVzcyI6IkFHVExSWGFwUFlweHQzUExkaVhFczh5NGtMdzZReTNDNHQiLCJub3RlIjoiIiwidGlja2VyIjoiYnRnIn0seyJhZGRyZXNzIjoiU2JRY0ZVRGk3a0t5eGttc2t6VzN3NzR4NjhINWVVcmc3NiIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJkZ2IifSx7ImFkZHJlc3MiOiJON25vbXBVVnh6NUFUcnpSVlR6dzdDYUFKb1NpVnRFY1F4Iiwibm90ZSI6IiIsInRpY2tlciI6Im5tYyJ9LHsiYWRkcmVzcyI6IjNBUWNVZ0NiRjZ5bWlSNEhHQ1U4QU54OVNxYnpMNm54OHIiLCJub3RlIjoiIiwidGlja2VyIjoidnRjIn1dLCJjcnlwdG9Eb25hdGVQYW5lbCI6eyJsaW1pdCI6MX0sImRlZmF1bHRTZXR0aW5ncyI6eyJ0aGVtZU1vZGUiOiJsaWdodCJ9LCJleHRlbnNpb25zIjp7InBhdHJvbiI6eyJlbmFibGVkIjp0cnVlfX0sImdyZWVudGV4dCI6dHJ1ZSwibG9nbyI6Imh0dHBzOi8vbWVkaWEuZ2xlYXNvbmF0b3IuY29tLzBjNzYwYjNlY2RiYzk5M2JhNDdiNzg1ZDBhZGVjZjBlYzcxZmQ5YzU5ODA4ZTI3ZDA2NjViOWY3N2EzMmQ4ZGUucG5nIiwibmF2bGlua3MiOnsiaG9tZUZvb3RlciI6W3sidGl0bGUiOiJBYm91dCIsInVybCI6Ii9hYm91dCJ9LHsidGl0bGUiOiJUZXJtcyBvZiBTZXJ2aWNlIiwidXJsIjoiL2Fib3V0L3RvcyJ9LHsidGl0bGUiOiJQcml2YWN5IFBvbGljeSIsInVybCI6Ii9hYm91dC9wcml2YWN5In0seyJ0aXRsZSI6IkRNQ0EiLCJ1cmwiOiIvYWJvdXQvZG1jYSJ9LHsidGl0bGUiOiJTb3VyY2UgQ29kZSIsInVybCI6Ii9hYm91dCNvcGVuc291cmNlIn1dfSwicHJvbW9QYW5lbCI6eyJpdGVtcyI6W3siaWNvbiI6ImNvbW1lbnQtbyIsInRleHQiOiJHbGVhc29uYXRvciB0aGVtZSBzb25nIiwidXJsIjoiaHR0cHM6Ly9tZWRpYS5nbGVhc29uYXRvci5jb20vY3VzdG9tLzI2MTkwNV9nbGVhc29uYXRvcl9zb25nLm1wMyJ9XX0sInZlcmlmaWVkQ2FuRWRpdE5hbWUiOnRydWV9fQ==", + "/api/v1/instance": "eyJhcHByb3ZhbF9yZXF1aXJlZCI6dHJ1ZSwiYXZhdGFyX3VwbG9hZF9saW1pdCI6MjAwMDAwMCwiYmFja2dyb3VuZF9pbWFnZSI6Imh0dHBzOi8vZ2xlYXNvbmF0b3IuY29tL2ltYWdlcy9jaXR5LmpwZyIsImJhY2tncm91bmRfdXBsb2FkX2xpbWl0Ijo0MDAwMDAwLCJiYW5uZXJfdXBsb2FkX2xpbWl0Ijo0MDAwMDAwLCJjaGF0X2xpbWl0Ijo1MDAwLCJkZXNjcmlwdGlvbiI6IkJ1aWxkaW5nIHRoZSBuZXh0IGdlbmVyYXRpb24gb2YgdGhlIEZlZGl2ZXJzZS4gU3BlYWsgZnJlZWx5LiIsImRlc2NyaXB0aW9uX2xpbWl0Ijo1MDAwLCJlbWFpbCI6ImFsZXhAYWxleGdsZWFzb24ubWUiLCJsYW5ndWFnZXMiOlsiZW4iXSwibWF4X3Rvb3RfY2hhcnMiOjUwMDAsInBsZXJvbWEiOnsibWV0YWRhdGEiOnsiYWNjb3VudF9hY3RpdmF0aW9uX3JlcXVpcmVkIjpmYWxzZSwiZmVhdHVyZXMiOlsicGxlcm9tYV9hcGkiLCJtYXN0b2Rvbl9hcGkiLCJtYXN0b2Rvbl9hcGlfc3RyZWFtaW5nIiwicG9sbHMiLCJwbGVyb21hX2V4cGxpY2l0X2FkZHJlc3NpbmciLCJzaGFyZWFibGVfZW1vamlfcGFja3MiLCJtdWx0aWZldGNoIiwicGxlcm9tYTphcGkvdjEvbm90aWZpY2F0aW9uczppbmNsdWRlX3R5cGVzX2ZpbHRlciIsIm1lZGlhX3Byb3h5IiwicmVsYXkiLCJwbGVyb21hX2Vtb2ppX3JlYWN0aW9ucyIsInBsZXJvbWFfY2hhdF9tZXNzYWdlcyIsImVtYWlsX2xpc3QiXSwiZmVkZXJhdGlvbiI6eyJlbmFibGVkIjp0cnVlLCJleGNsdXNpb25zIjpmYWxzZSwibXJmX3BvbGljaWVzIjpbIlRhZ1BvbGljeSIsIlNpbXBsZVBvbGljeSJdLCJtcmZfc2ltcGxlIjp7ImFjY2VwdCI6W10sImF2YXRhcl9yZW1vdmFsIjpbInBhd29vLm5ldCIsInNpbmJsci5jb20iLCJkYWppYXdlaWJvLmNvbSJdLCJiYW5uZXJfcmVtb3ZhbCI6WyJwYXdvby5uZXQiLCJzaW5ibHIuY29tIiwiZGFqaWF3ZWliby5jb20iXSwiZmVkZXJhdGVkX3RpbWVsaW5lX3JlbW92YWwiOltdLCJmb2xsb3dlcnNfb25seSI6W10sIm1lZGlhX25zZnciOltdLCJtZWRpYV9yZW1vdmFsIjpbInBhd29vLm5ldCIsInNpbmJsci5jb20iLCJkYWppYXdlaWJvLmNvbSJdLCJyZWplY3QiOltdLCJyZWplY3RfZGVsZXRlcyI6W10sInJlcG9ydF9yZW1vdmFsIjpbXX0sInF1YXJhbnRpbmVkX2luc3RhbmNlcyI6W119LCJmaWVsZHNfbGltaXRzIjp7Im1heF9maWVsZHMiOjE1LCJtYXhfcmVtb3RlX2ZpZWxkcyI6MjAsIm5hbWVfbGVuZ3RoIjo1MTIsInZhbHVlX2xlbmd0aCI6MjA0OH0sInBvc3RfZm9ybWF0cyI6WyJ0ZXh0L3BsYWluIiwidGV4dC9odG1sIiwidGV4dC9tYXJrZG93biIsInRleHQvYmJjb2RlIl19LCJzdGF0cyI6eyJtYXUiOjU0fSwidmFwaWRfcHVibGljX2tleSI6IkJMRWxMUVZKVm1ZX2U0RjVKb1l4STVqWGlWT1lOc0o5cC1hbWt5a2M5TmNJLWp3YTlUMVkyR0liRHFiWS1IcUM2YXlQa2ZXNEs0bzl2Z0JGS1lta3VTNCJ9LCJwb2xsX2xpbWl0cyI6eyJtYXhfZXhwaXJhdGlvbiI6MzE1MzYwMDAsIm1heF9vcHRpb25fY2hhcnMiOjIwMCwibWF4X29wdGlvbnMiOjIwLCJtaW5fZXhwaXJhdGlvbiI6MH0sInJlZ2lzdHJhdGlvbnMiOnRydWUsInNvYXBib3giOnsidmVyc2lvbiI6IjEuMS4xIn0sInN0YXRzIjp7ImRvbWFpbl9jb3VudCI6NzIwMCwic3RhdHVzX2NvdW50Ijo3ODkwNiwidXNlcl9jb3VudCI6MzU3fSwidGh1bWJuYWlsIjoiaHR0cHM6Ly9nbGVhc29uYXRvci5jb21odHRwczovL21lZGlhLmdsZWFzb25hdG9yLmNvbS9jMGQzOGJkZTZlZjBiM2JhYTQ4M2Y1NzQ3OTc2NjJlYmQ4M2VmOWUxYTExNjJlOGU0ZmNkOTMwYmI0YjNjMDY4LnBuZyIsInRpdGxlIjoiR2xlYXNvbmF0b3IiLCJ1cGxvYWRfbGltaXQiOjEwMDAwMDAwMCwidXJpIjoiaHR0cHM6Ly9nbGVhc29uYXRvci5jb20iLCJ1cmxzIjp7InN0cmVhbWluZ19hcGkiOiJ3c3M6Ly9nbGVhc29uYXRvci5jb20ifSwidmVyc2lvbiI6IjIuNy4yIChjb21wYXRpYmxlOyBQbGVyb21hIDIuMy4wLTExMS1nYjQ3OGE4N2UtZGV2ZWxvcCkifQ==", + "/instance/panel.html": "IjxkaXYgc3R5bGU9XCJtYXJnaW4tbGVmdDoxMnB4OyBtYXJnaW4tcmlnaHQ6MTJweFwiPlxuPHA+V2VsY29tZSB0byA8YSBocmVmPVwiaHR0cHM6Ly9wbGVyb21hLnNvY2lhbFwiIHRhcmdldD1cIl9ibGFua1wiPlBsZXJvbWEhPC9hPjwvcD4gICAgXG48cD48YSBocmVmPVwiL21haW4vYWxsXCI+UGxlcm9tYSBGRTwvYT4gfCA8YSBocmVmPVwiL3dlYlwiPk1hc3RvZG9uIEZFPC9hPjwvcD5cbjwvZGl2PlxuXG4i", + "/nodeinfo/2.0.json": "eyJtZXRhZGF0YSI6eyJhY2NvdW50QWN0aXZhdGlvblJlcXVpcmVkIjpmYWxzZSwiZmVhdHVyZXMiOlsicGxlcm9tYV9hcGkiLCJtYXN0b2Rvbl9hcGkiLCJtYXN0b2Rvbl9hcGlfc3RyZWFtaW5nIiwicG9sbHMiLCJwbGVyb21hX2V4cGxpY2l0X2FkZHJlc3NpbmciLCJzaGFyZWFibGVfZW1vamlfcGFja3MiLCJtdWx0aWZldGNoIiwicGxlcm9tYTphcGkvdjEvbm90aWZpY2F0aW9uczppbmNsdWRlX3R5cGVzX2ZpbHRlciIsIm1lZGlhX3Byb3h5IiwicmVsYXkiLCJwbGVyb21hX2Vtb2ppX3JlYWN0aW9ucyIsInBsZXJvbWFfY2hhdF9tZXNzYWdlcyIsImVtYWlsX2xpc3QiXSwiZmVkZXJhdGlvbiI6eyJlbmFibGVkIjp0cnVlLCJleGNsdXNpb25zIjpmYWxzZSwibXJmX3BvbGljaWVzIjpbIlRhZ1BvbGljeSIsIlNpbXBsZVBvbGljeSJdLCJtcmZfc2ltcGxlIjp7ImFjY2VwdCI6W10sImF2YXRhcl9yZW1vdmFsIjpbInBhd29vLm5ldCIsInNpbmJsci5jb20iLCJkYWppYXdlaWJvLmNvbSJdLCJiYW5uZXJfcmVtb3ZhbCI6WyJwYXdvby5uZXQiLCJzaW5ibHIuY29tIiwiZGFqaWF3ZWliby5jb20iXSwiZmVkZXJhdGVkX3RpbWVsaW5lX3JlbW92YWwiOltdLCJmb2xsb3dlcnNfb25seSI6W10sIm1lZGlhX25zZnciOltdLCJtZWRpYV9yZW1vdmFsIjpbInBhd29vLm5ldCIsInNpbmJsci5jb20iLCJkYWppYXdlaWJvLmNvbSJdLCJyZWplY3QiOltdLCJyZWplY3RfZGVsZXRlcyI6W10sInJlcG9ydF9yZW1vdmFsIjpbXX0sInF1YXJhbnRpbmVkX2luc3RhbmNlcyI6W119LCJmaWVsZHNMaW1pdHMiOnsibWF4RmllbGRzIjoxNSwibWF4UmVtb3RlRmllbGRzIjoyMCwibmFtZUxlbmd0aCI6NTEyLCJ2YWx1ZUxlbmd0aCI6MjA0OH0sImludml0ZXNFbmFibGVkIjpmYWxzZSwibWFpbGVyRW5hYmxlZCI6dHJ1ZSwibm9kZURlc2NyaXB0aW9uIjoiQnVpbGRpbmcgdGhlIG5leHQgZ2VuZXJhdGlvbiBvZiB0aGUgRmVkaXZlcnNlLiBTcGVhayBmcmVlbHkuIiwibm9kZU5hbWUiOiJHbGVhc29uYXRvciIsInBvbGxMaW1pdHMiOnsibWF4X2V4cGlyYXRpb24iOjMxNTM2MDAwLCJtYXhfb3B0aW9uX2NoYXJzIjoyMDAsIm1heF9vcHRpb25zIjoyMCwibWluX2V4cGlyYXRpb24iOjB9LCJwb3N0Rm9ybWF0cyI6WyJ0ZXh0L3BsYWluIiwidGV4dC9odG1sIiwidGV4dC9tYXJrZG93biIsInRleHQvYmJjb2RlIl0sInByaXZhdGUiOmZhbHNlLCJyZXN0cmljdGVkTmlja25hbWVzIjpbIi53ZWxsLWtub3duIiwifiIsImFib3V0IiwiYWN0aXZpdGllcyIsImFwaSIsImF1dGgiLCJjaGVja19wYXNzd29yZCIsImRldiIsImZyaWVuZC1yZXF1ZXN0cyIsImluYm94IiwiaW50ZXJuYWwiLCJtYWluIiwibWVkaWEiLCJub2RlaW5mbyIsIm5vdGljZSIsIm9hdXRoIiwib2JqZWN0cyIsIm9zdGF0dXNfc3Vic2NyaWJlIiwicGxlcm9tYSIsInByb3h5IiwicHVzaCIsInJlZ2lzdHJhdGlvbiIsInJlbGF5Iiwic2V0dGluZ3MiLCJzdGF0dXMiLCJ0YWciLCJ1c2VyLXNlYXJjaCIsInVzZXJfZXhpc3RzIiwidXNlcnMiLCJ3ZWIiLCJ2ZXJpZnlfY3JlZGVudGlhbHMiLCJ1cGRhdGVfY3JlZGVudGlhbHMiLCJyZWxhdGlvbnNoaXBzIiwic2VhcmNoIiwiY29uZmlybWF0aW9uX3Jlc2VuZCIsIm1mYSJdLCJza2lwVGhyZWFkQ29udGFpbm1lbnQiOnRydWUsInN0YWZmQWNjb3VudHMiOlsiaHR0cHM6Ly9nbGVhc29uYXRvci5jb20vdXNlcnMvYWxleCJdLCJzdWdnZXN0aW9ucyI6eyJlbmFibGVkIjpmYWxzZX0sInVwbG9hZExpbWl0cyI6eyJhdmF0YXIiOjIwMDAwMDAsImJhY2tncm91bmQiOjQwMDAwMDAsImJhbm5lciI6NDAwMDAwMCwiZ2VuZXJhbCI6MTAwMDAwMDAwfX0sIm9wZW5SZWdpc3RyYXRpb25zIjp0cnVlLCJwcm90b2NvbHMiOlsiYWN0aXZpdHlwdWIiXSwic2VydmljZXMiOnsiaW5ib3VuZCI6W10sIm91dGJvdW5kIjpbXX0sInNvZnR3YXJlIjp7Im5hbWUiOiJwbGVyb21hIiwidmVyc2lvbiI6IjIuMy4wLTExMS1nYjQ3OGE4N2UtZGV2ZWxvcCJ9LCJ1c2FnZSI6eyJsb2NhbFBvc3RzIjo3ODkwNiwidXNlcnMiOnsidG90YWwiOjM1N319LCJ2ZXJzaW9uIjoiMi4wIn0=" +} diff --git a/src/__fixtures__/realDonaldTrump.json b/src/__fixtures__/realDonaldTrump.json new file mode 100644 index 0000000..c9cf200 --- /dev/null +++ b/src/__fixtures__/realDonaldTrump.json @@ -0,0 +1,26 @@ +{ + "id": "107780257626128497", + "username": "realDonaldTrump", + "acct": "realDonaldTrump", + "display_name": "Donald J. Trump", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2022-02-11T00:00:00.000Z", + "note": "

45th President of the United States of America

", + "url": "https://truthsocial.com/@realDonaldTrump", + "avatar": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/780/257/626/128/497/original/573cf5cc8281e7e9.jpeg", + "avatar_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/780/257/626/128/497/original/573cf5cc8281e7e9.jpeg", + "header": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/780/257/626/128/497/original/3c1acf607b065ded.jpeg", + "header_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/780/257/626/128/497/original/3c1acf607b065ded.jpeg", + "followers_count": 51507, + "following_count": 1, + "statuses_count": 1, + "last_status_at": "2022-02-14", + "verified": true, + "location": "", + "website": "", + "emojis": [], + "fields": [] +} diff --git a/src/__fixtures__/relationship.json b/src/__fixtures__/relationship.json new file mode 100644 index 0000000..179b833 --- /dev/null +++ b/src/__fixtures__/relationship.json @@ -0,0 +1,14 @@ +{ + "showing_reblogs": true, + "followed_by": false, + "subscribing": false, + "blocked_by": false, + "requested": false, + "domain_blocking": false, + "following": false, + "endorsed": false, + "blocking": true, + "muting": false, + "id": "9vMAje101ngtjlMj7w", + "muting_notifications": true +} diff --git a/src/__fixtures__/rules.json b/src/__fixtures__/rules.json new file mode 100644 index 0000000..c1c7a1f --- /dev/null +++ b/src/__fixtures__/rules.json @@ -0,0 +1,14 @@ +[ + { + "id": "1", + "text": "Illegal activity and behavior", + "subtext": "Content that depicts illegal or criminal acts, threats of violence.", + "rule_type": "content" + }, + { + "id": "2", + "text": "Intellectual property infringement", + "subtext": "Impersonating another account or business, infringing on intellectual property rights.", + "rule_type": "content" + } +] diff --git a/src/__fixtures__/soapbox.json b/src/__fixtures__/soapbox.json new file mode 100644 index 0000000..6208b85 --- /dev/null +++ b/src/__fixtures__/soapbox.json @@ -0,0 +1,40 @@ +{ + "logo": "blob:http://localhost:3036/0cdfa863-6889-4199-b870-4942cedd364f", + "banner": "blob:http://localhost:3036/a835afed-6078-45bd-92b4-7ffd858c3eca", + "brandColor": "#254f92", + "customCss": [ + "/instance/static/custom.css" + ], + "promoPanel": { + "items": [ + { + "icon": "globe", + "text": "blog", + "url": "https://teci.world/blog" + }, + { + "icon": "globe", + "text": "book", + "url": "https://teci.world/book" + } + ] + }, + "extensions": { + "patron": false + }, + "defaultSettings": { + "autoPlayGif": false + }, + "navlinks": { + "homeFooter": [ + { + "title": "about", + "url": "/instance/about/index.html" + }, + { + "title": "tos", + "url": "/instance/about/tos.html" + } + ] + } +} diff --git a/src/__fixtures__/spinster-soapbox.json b/src/__fixtures__/spinster-soapbox.json new file mode 100644 index 0000000..8f3ed63 --- /dev/null +++ b/src/__fixtures__/spinster-soapbox.json @@ -0,0 +1,119 @@ +{ + "allowedEmoji": [ + "👍", + "❤️", + "😆", + "😮", + "😢", + "😡", + "😩" + ], + "brandColor": "#990099", + "copyright": "♡2021. Copying is an act of love. Please copy and share.", + "cryptoAddresses": [ + { + "address": "bc1qv7lk3algpfg4zpyuhvxfm0uza9ck4parz3y3l5", + "note": "", + "ticker": "btc" + }, + { + "address": "0xadc66B63bFee7677CD27CFb81b16a8860f1A1226", + "note": "", + "ticker": "eth" + }, + { + "address": "DSf7UmRf7DGGsjh4QYhzQaqtjJMTXZ8k79", + "note": "", + "ticker": "doge" + }, + { + "address": "ltc1q642pnkuvw0gpuuvddw6vafvl9hhp3efyl9mnqz", + "note": "", + "ticker": "ltc" + }, + { + "address": "t1faHDsoa4bd3pGaLjaU7DiuUtBPzbnEEse", + "note": "", + "ticker": "zec" + }, + { + "address": "XchTLkcSMsDoZGESwr4tqtxSU5dideAZVQ", + "note": "", + "ticker": "dash" + }, + { + "address": "bitcoincash:qp8f80z27294phmhdk55yf05p3f0tkxl4v9r2aavw5", + "note": "", + "ticker": "bch" + } + ], + "cryptoDonatePanel": { + "limit": 1 + }, + "customCss": [ + "/instance/spinster.css" + ], + "defaultSettings": { + "autoPlayGif": false, + "themeMode": "light" + }, + "extensions": { + "patron": { + "enabled": true + } + }, + "logo": "https://spinster.xyz/instance/images/spinster-logo.svg", + "navlinks": { + "homeFooter": [ + { + "title": "About", + "url": "/about" + }, + { + "title": "Terms of Service", + "url": "/about/tos" + }, + { + "title": "Privacy Policy", + "url": "/about/privacy" + }, + { + "title": "DMCA", + "url": "/about/dmca" + }, + { + "title": "Source Code", + "url": "/about#opensource" + } + ] + }, + "promoPanel": { + "items": [ + { + "icon": "shopping-basket", + "text": "Buy Spinster Merch", + "url": "https://shop.4w.pub/collections/spinster" + }, + { + "icon": "eye-slash", + "text": "Privacy Guide", + "url": "https://4w.pub/your-guide-to-spinster-privacy-options/" + }, + { + "icon": "question-circle", + "text": "Spinster FAQs", + "url": "https://spinster.xyz/about#faqs" + }, + { + "icon": "bug", + "text": "Report a Bug", + "url": "https://gitlab.com/soapbox-pub/soapbox-fe/-/issues/" + }, + { + "icon": "fediverse", + "text": "About the Fediverse", + "url": "https://jointhefedi.com/" + } + ] + } +} diff --git a/src/__fixtures__/status-custom-emoji.json b/src/__fixtures__/status-custom-emoji.json new file mode 100644 index 0000000..ac3f184 --- /dev/null +++ b/src/__fixtures__/status-custom-emoji.json @@ -0,0 +1,126 @@ +{ + "account": { + "acct": "benis911", + "avatar": "https://gleasonator.com/images/avi.png", + "avatar_static": "https://gleasonator.com/images/avi.png", + "bot": false, + "created_at": "2021-03-26T20:42:11.000Z", + "display_name": "benis911", + "emojis": [], + "fields": [], + "followers_count": 0, + "following_count": 0, + "fqn": "benis911@gleasonator.com", + "header": "https://media.gleasonator.com/fc595bbbcf5aabefecd1c2adfe5b7f5457db59847992881668653a0338ba25bd.jpg", + "header_static": "https://media.gleasonator.com/fc595bbbcf5aabefecd1c2adfe5b7f5457db59847992881668653a0338ba25bd.jpg", + "id": "A5c5LK7EJTFR0u26Pg", + "last_status_at": "2022-02-23T17:31:08", + "locked": true, + "note": "hello world 2", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://gleasonator.com/users/alex", + "https://poa.st/users/alex" + ], + "ap_id": "https://gleasonator.com/users/benis911", + "background_image": null, + "birthday": "2000-01-25", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": true, + "hide_followers_count": true, + "hide_follows": true, + "hide_follows_count": true, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "hello world 2", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 152, + "url": "https://gleasonator.com/users/benis911", + "username": "benis911" + }, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "bookmarked": false, + "card": null, + "content": "Hello :ablobcathyper: :ageblobcat: 😂 world 😋 test :blobcatphoto:", + "created_at": "2022-02-23T17:31:07.000Z", + "emojis": [ + { + "shortcode": "ablobcathyper", + "static_url": "https://gleasonator.com/emoji/blobcat/ablobcathyper.png", + "url": "https://gleasonator.com/emoji/blobcat/ablobcathyper.png", + "visible_in_picker": false + }, + { + "shortcode": "ageblobcat", + "static_url": "https://gleasonator.com/emoji/blobcat/ageblobcat.png", + "url": "https://gleasonator.com/emoji/blobcat/ageblobcat.png", + "visible_in_picker": false + }, + { + "shortcode": "blobcatphoto", + "static_url": "https://gleasonator.com/emoji/blobcat/blobcatphoto.png", + "url": "https://gleasonator.com/emoji/blobcat/blobcatphoto.png", + "visible_in_picker": false + } + ], + "favourited": false, + "favourites_count": 0, + "id": "AGm7uC9DaAIGUa4KYK", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "Hello :ablobcathyper: :ageblobcat: 😂 world 😋 test :blobcatphoto:" + }, + "conversation_id": "AGm7uC3BwZTOBtFW9w", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": null, + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/2dc79219-aed6-40c0-8818-0c2d26ed3436", + "url": "https://gleasonator.com/notice/AGm7uC9DaAIGUa4KYK", + "visibility": "public" +} diff --git a/src/__fixtures__/status-cw.json b/src/__fixtures__/status-cw.json new file mode 100644 index 0000000..af9978c --- /dev/null +++ b/src/__fixtures__/status-cw.json @@ -0,0 +1,63 @@ +{ + "id": "107831528995252317", + "created_at": "2022-02-20T17:35:55.224Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": true, + "spoiler_text": "testing", + "visibility": "public", + "language": "en", + "uri": "https://fedibird.com/users/alex/statuses/107831528995252317", + "url": "https://fedibird.com/@alex/107831528995252317", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "emoji_reactions_count": 0, + "emoji_reactions": [], + "content": "

hello world

", + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "66768", + "username": "alex", + "acct": "alex", + "display_name": "", + "locked": false, + "bot": false, + "cat": false, + "discoverable": null, + "group": false, + "created_at": "2020-01-27T00:00:00.000Z", + "note": "

", + "url": "https://fedibird.com/@alex", + "avatar": "https://fedibird.com/avatars/original/missing.png", + "avatar_static": "https://fedibird.com/avatars/original/missing.png", + "header": "https://fedibird.com/headers/original/missing.png", + "header_static": "https://fedibird.com/headers/original/missing.png", + "followers_count": 1, + "following_count": 1, + "subscribing_count": 0, + "statuses_count": 5, + "last_status_at": "2022-02-20", + "emojis": [], + "fields": [], + "other_settings": { + "noindex": false, + "hide_network": false, + "hide_statuses_count": false, + "hide_following_count": false, + "hide_followers_count": false, + "enable_reaction": true + } + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null, + "quote": null +} diff --git a/src/__fixtures__/status-quotes.json b/src/__fixtures__/status-quotes.json new file mode 100644 index 0000000..d74a149 --- /dev/null +++ b/src/__fixtures__/status-quotes.json @@ -0,0 +1,15 @@ +[ + { + "account": { + "id": "ABDSjI3Q0R8aDaz1U0" + }, + "content": "quoast", + "id": "AJsajx9hY4Q7IKQXEe", + "pleroma": { + "quote": { + "content": "

10

", + "id": "AJmoVikzI3SkyITyim" + } + } + } +] diff --git a/src/__fixtures__/status-unordered-mentions.json b/src/__fixtures__/status-unordered-mentions.json new file mode 100644 index 0000000..40bdbe8 --- /dev/null +++ b/src/__fixtures__/status-unordered-mentions.json @@ -0,0 +1,122 @@ +{ + "account": { + "acct": "apropos@freespeechextremist.com", + "avatar": "https://gleasonator.com/proxy/WVdkCbG7AOZ_eqMzskzXQoyjq8o/aHR0cHM6Ly9mcmVlc3BlZWNoZXh0cmVtaXN0LmNvbS9tZWRpYS8zN2I4MDMzZC03OGQ1LTQ0YmMtYmY5NC0xYTI2NzY5NTQwM2YvYmxvYi5wbmc_bmFtZT1ibG9iLnBuZw/blob.png", + "avatar_static": "https://gleasonator.com/proxy/WVdkCbG7AOZ_eqMzskzXQoyjq8o/aHR0cHM6Ly9mcmVlc3BlZWNoZXh0cmVtaXN0LmNvbS9tZWRpYS8zN2I4MDMzZC03OGQ1LTQ0YmMtYmY5NC0xYTI2NzY5NTQwM2YvYmxvYi5wbmc_bmFtZT1ibG9iLnBuZw/blob.png", + "bot": false, + "created_at": "2020-05-21T07:20:46.000Z", + "display_name": "of nothing", + "emojis": [], + "fields": [], + "followers_count": 87, + "following_count": 85, + "fqn": "apropos@freespeechextremist.com", + "header": "https://gleasonator.com/proxy/pIracLGWm_skCfOOgdwcCNqES5s/aHR0cHM6Ly9mcmVlc3BlZWNoZXh0cmVtaXN0LmNvbS9tZWRpYS8yZDEwYmRjZC01NDUwLTRjZjYtYWFhZS1hNTJjMzYwYjk2YjYvdHJhY2tzb25tYXJzLmpwZz9uYW1lPXRyYWNrc29ubWFycy5qcGc/tracksonmars.jpg", + "header_static": "https://gleasonator.com/proxy/pIracLGWm_skCfOOgdwcCNqES5s/aHR0cHM6Ly9mcmVlc3BlZWNoZXh0cmVtaXN0LmNvbS9tZWRpYS8yZDEwYmRjZC01NDUwLTRjZjYtYWFhZS1hNTJjMzYwYjk2YjYvdHJhY2tzb25tYXJzLmpwZz9uYW1lPXRyYWNrc29ubWFycy5qcGc/tracksonmars.jpg", + "id": "9vGR3IWmWVYRkKUZ4i", + "last_status_at": "2022-01-07T21:47:39", + "locked": false, + "note": "If you wait by the river long enough, the bodies of your enemies will float by.

Deo Vindice", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [], + "ap_id": "https://freespeechextremist.com/users/apropos", + "background_image": null, + "favicon": "https://gleasonator.com/proxy/EN7BSaEEYTRpmRj4lITIjgWp2sg/aHR0cHM6Ly9mcmVlc3BlZWNoZXh0cmVtaXN0LmNvbS9mYXZpY29uLnBuZw/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": false, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": false, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [], + "note": "", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 7087, + "url": "https://freespeechextremist.com/users/apropos", + "username": "apropos" + }, + "application": null, + "bookmarked": false, + "card": null, + "content": "@NEETzsche @alex @Lumeinshin @sneeden
>seething
'posting', just like you.", + "created_at": "2022-01-07T17:29:58.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 1, + "id": "AFChectaqZjmOVkXZ2", + "in_reply_to_account_id": "9v5bw7hEGBPc9nrpzc", + "in_reply_to_id": "AFChbnWqrAZ2VIlPJw", + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "alex", + "id": "9v5bmRalQvjOy0ECcC", + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + { + "acct": "NEETzsche@iddqd.social", + "id": "9v5bw7hEGBPc9nrpzc", + "url": "https://iddqd.social/users/NEETzsche", + "username": "NEETzsche" + }, + { + "acct": "Lumeinshin@pleroma.skyshanty.xyz", + "id": "A3dFSwTkwgRfd998iG", + "url": "https://pleroma.skyshanty.xyz/users/Lumeinshin", + "username": "Lumeinshin" + }, + { + "acct": "sneeden@social.silkky.cloud", + "id": "ACrsPAbAOPh3GbKZhQ", + "url": "https://social.silkky.cloud/users/sneeden", + "username": "sneeden" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "@NEETzsche @alex @Lumeinshin @sneeden >seething'posting', just like you." + }, + "conversation_id": "AFCYCBFN9SgOwoIWTg", + "direct_conversation_id": null, + "emoji_reactions": [], + "expires_at": null, + "in_reply_to_account_acct": "NEETzsche@iddqd.social", + "local": false, + "parent_visible": true, + "pinned_at": null, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://freespeechextremist.com/objects/714b0e04-bec4-4a2a-9514-312814380064", + "url": "https://freespeechextremist.com/objects/714b0e04-bec4-4a2a-9514-312814380064", + "visibility": "public" +} diff --git a/src/__fixtures__/status-with-card.json b/src/__fixtures__/status-with-card.json new file mode 100644 index 0000000..da2f83b --- /dev/null +++ b/src/__fixtures__/status-with-card.json @@ -0,0 +1,210 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [ + { + "shortcode": "soapbox", + "static_url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", + "url": "https://gleasonator.com/emoji/Gleasonator/soapbox.png", + "visible_in_picker": false + } + ], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox :soapbox:", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2476, + "following_count": 1584, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-03-12T16:35:10", + "locked": false, + "note": "I create Fediverse software that empowers people online. :soapbox:

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox :soapbox:", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online. :soapbox:\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23674, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": null, + "bookmarked": false, + "card": { + "author_name": "Alex Gleason", + "author_url": "https://soapbox.pub/author/alex/", + "blurhash": null, + "description": "On cryptocurrency I’ve always believed integrated donations would be a necessary part of the Fediverse. Admins do all the heavy lifting; it’s a thankless job. Meanwhile users want to help secure their new online home, but feel powerless to do so. I have been running an experimental payment platform based on Stripe alongside my Soapbox […]", + "embed_url": null, + "height": 338, + "html": "", + "image": "https://gleasonator.com/proxy/L2kUi5uxMdoC6LYYrnAdlJviPGQ/aHR0cHM6Ly9tZWRpYS5zb2FwYm94LnB1Yi91cGxvYWRzLzIwMjEvMDcvdi0xLTMtdGh1bWIucG5n/v-1-3-thumb.png", + "provider_name": "Soapbox", + "provider_url": "https://soapbox.pub", + "title": "Soapbox FE v1.3: The Crypto Release - Soapbox", + "type": "link", + "url": "https://soapbox.pub/blog/soapbox-fe-v1.3-cryptocurrency-release/", + "width": 600 + }, + "content": "

Soapbox FE v1.3 released. Read about it here: https://soapbox.pub/blog/soapbox-fe-v1.3-cryptocurrency-release/

Enjoy!

", + "created_at": "2021-07-02T20:49:39.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 29, + "id": "A8tEMYF2GNnfPcL4dc", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": true, + "pleroma": { + "content": { + "text/plain": "Soapbox FE v1.3 released. Read about it here: https://soapbox.pub/blog/soapbox-fe-v1.3-cryptocurrency-release/Enjoy!" + }, + "conversation_id": "16496668", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 5, + "me": false, + "name": "❤️" + }, + { + "count": 1, + "me": false, + "name": "👍" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": "2021-11-23T01:38:44.000Z", + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 16, + "replies_count": 7, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/objects/3eabaf63-47f4-4314-9ddb-ce7dbf46b393", + "url": "https://gleasonator.com/notice/A8tEMYF2GNnfPcL4dc", + "visibility": "public" +} diff --git a/src/__fixtures__/status-with-poll.json b/src/__fixtures__/status-with-poll.json new file mode 100644 index 0000000..9dfd90d --- /dev/null +++ b/src/__fixtures__/status-with-poll.json @@ -0,0 +1,201 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2390, + "following_count": 1574, + "fqn": "alex@gleasonator.com", + "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", + "id": "9v5bmRalQvjOy0ECcC", + "last_status_at": "2022-02-23T17:54:41", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "favicon": "https://gleasonator.com/favicon.png", + "hide_favorites": true, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "is_admin": true, + "is_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23502, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": null, + "bookmarked": false, + "card": null, + "content": "

What is tolerance?

", + "created_at": "2020-03-23T19:33:06.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 47, + "id": "103874034847713213", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": true, + "pleroma": { + "content": { + "text/plain": "What is tolerance?" + }, + "conversation_id": "3023268", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 3, + "me": false, + "name": "❤️" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": "2021-11-23T01:38:44.000Z", + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": { + "emojis": [], + "expired": true, + "expires_at": "2020-03-24T19:33:06.000Z", + "id": "4930", + "multiple": false, + "options": [ + { + "title": "Banning, censoring, and deplatforming anyone you disagree with", + "votes_count": 2 + }, + { + "title": "Promoting free speech, even for people and ideas you dislike", + "votes_count": 36 + } + ], + "voters_count": 2, + "votes_count": 38 + }, + "reblog": null, + "reblogged": false, + "reblogs_count": 26, + "replies_count": 14, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/users/alex/statuses/103874034847713213", + "url": "https://gleasonator.com/notice/103874034847713213", + "visibility": "public" +} diff --git a/src/__fixtures__/truthsocial-account.json b/src/__fixtures__/truthsocial-account.json new file mode 100644 index 0000000..6b451ca --- /dev/null +++ b/src/__fixtures__/truthsocial-account.json @@ -0,0 +1,26 @@ +{ + "id": "107759994408336377", + "username": "alex", + "acct": "alex", + "display_name": "Alex G.", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2022-02-08T00:00:00.000Z", + "note": "

Launching Truth Social

", + "url": "https://truthsocial.com/@alex", + "avatar": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png", + "avatar_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png", + "header": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png", + "header_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png", + "followers_count": 966, + "following_count": 39, + "statuses_count": 4, + "last_status_at": "2022-02-20", + "verified": true, + "location": "Texas", + "website": "https://soapbox.pub", + "emojis": [], + "fields": [] +} diff --git a/src/__fixtures__/truthsocial-status-in-moderation.json b/src/__fixtures__/truthsocial-status-in-moderation.json new file mode 100644 index 0000000..7613ebd --- /dev/null +++ b/src/__fixtures__/truthsocial-status-in-moderation.json @@ -0,0 +1,85 @@ +{ + "id": "108046224464672537", + "created_at": "2022-03-30T15:40:53.287Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "self", + "language": null, + "uri": "https://truthsocial.com/users/alex/statuses/108046244464677537", + "url": "https://truthsocial.com/@alex/108046244464677537", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "content": "

A federal agent inspects a 'lumber' truck after smelling alcohol during the prohibition period. Los Angeles, 1926 (during the Prohibition era).

", + "reblog": null, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "account": { + "id": "107759994408336377", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2022-02-08T00:00:00.000Z", + "note": "

Launching Truth Social

", + "url": "https://truthsocial.com/@alex", + "avatar": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png", + "avatar_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png", + "header": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png", + "header_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png", + "followers_count": 4713, + "following_count": 43, + "statuses_count": 7, + "last_status_at": "2022-03-30", + "verified": true, + "location": "Texas", + "website": "https://soapbox.pub/", + "emojis": [], + "fields": [] + }, + "media_attachments": [ + { + "id": "108635651287436632", + "type": "image", + "url": "https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/media_attachments/files/108/635/651/487/436/632/original/7873bda5a7ab45d3.jpeg", + "preview_url": "https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/media_attachments/files/108/635/651/487/436/632/small/7873bda5a7ab45d3.jpeg", + "external_video_id": null, + "remote_url": null, + "preview_remote_url": null, + "text_url": "https://truthsocial.com/media/_Kc-2w2Pe7knhYJV-CM", + "meta": { + "original": { + "width": 1080, + "height": 841, + "size": "1080x841", + "aspect": 1.2841854934601664 + }, + "small": { + "width": 907, + "height": 706, + "size": "907x706", + "aspect": 1.2847025495750708 + } + }, + "description": null, + "blurhash": "UIIY5?4n~q9FIUIUD%WB?bt7M{t7of%MofIU" + } + ], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null +} \ No newline at end of file diff --git a/src/__fixtures__/user.json b/src/__fixtures__/user.json new file mode 100644 index 0000000..24617cc --- /dev/null +++ b/src/__fixtures__/user.json @@ -0,0 +1,8 @@ +{ + "access_token": "UVBP2e17b4pTpb_h8fImIm3F5a66IBVb-JkyZHs4gLE", + "expires_in": 600, + "me": "https://social.teci.world/users/curtis", + "refresh_token": "c2DpbVxYZBJDogNn-VBNFES72yXPNUYQCv0CrXGOplY", + "scope": "read write follow push admin", + "token_type": "Bearer" +} diff --git a/src/actions/about.ts b/src/actions/about.ts new file mode 100644 index 0000000..0735856 --- /dev/null +++ b/src/actions/about.ts @@ -0,0 +1,31 @@ +import api from '../api/index.ts'; + +import type { AnyAction } from 'redux'; +import type { RootState } from 'soapbox/store.ts'; + +const FETCH_ABOUT_PAGE_REQUEST = 'FETCH_ABOUT_PAGE_REQUEST'; +const FETCH_ABOUT_PAGE_SUCCESS = 'FETCH_ABOUT_PAGE_SUCCESS'; +const FETCH_ABOUT_PAGE_FAIL = 'FETCH_ABOUT_PAGE_FAIL'; + +const fetchAboutPage = (slug = 'index', locale?: string) => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: FETCH_ABOUT_PAGE_REQUEST, slug, locale }); + + const filename = `${slug}${locale ? `.${locale}` : ''}.html`; + return api(getState).get(`/instance/about/${filename}`) + .then((response) => response.text()) + .then((html) => { + dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html }); + return html; + }) + .catch(error => { + dispatch({ type: FETCH_ABOUT_PAGE_FAIL, slug, locale, error }); + throw error; + }); +}; + +export { + fetchAboutPage, + FETCH_ABOUT_PAGE_REQUEST, + FETCH_ABOUT_PAGE_SUCCESS, + FETCH_ABOUT_PAGE_FAIL, +}; diff --git a/src/actions/account-notes.ts b/src/actions/account-notes.ts new file mode 100644 index 0000000..4a989b8 --- /dev/null +++ b/src/actions/account-notes.ts @@ -0,0 +1,44 @@ +import api from '../api/index.ts'; + +import type { AnyAction } from 'redux'; +import type { RootState } from 'soapbox/store.ts'; + +const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; +const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; +const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; + +const submitAccountNote = (id: string, value: string) => + (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch(submitAccountNoteRequest()); + + return api(getState) + .post(`/api/v1/accounts/${id}/note`, { + comment: value, + }) + .then((response) => response.json()) + .then((data) => { + dispatch(submitAccountNoteSuccess(data)); + }) + .catch(error => dispatch(submitAccountNoteFail(error))); + }; + +const submitAccountNoteRequest = () => ({ + type: ACCOUNT_NOTE_SUBMIT_REQUEST, +}); + +const submitAccountNoteSuccess = (relationship: any) => ({ + type: ACCOUNT_NOTE_SUBMIT_SUCCESS, + relationship, +}); + +const submitAccountNoteFail = (error: unknown) => ({ + type: ACCOUNT_NOTE_SUBMIT_FAIL, + error, +}); + +export { + submitAccountNote, + ACCOUNT_NOTE_SUBMIT_REQUEST, + ACCOUNT_NOTE_SUBMIT_SUCCESS, + ACCOUNT_NOTE_SUBMIT_FAIL, +}; diff --git a/src/actions/accounts.ts b/src/actions/accounts.ts new file mode 100644 index 0000000..94de466 --- /dev/null +++ b/src/actions/accounts.ts @@ -0,0 +1,1102 @@ +import { HTTPError } from 'soapbox/api/HTTPError.ts'; +import { importEntities } from 'soapbox/entity-store/actions.ts'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { relationshipSchema } from 'soapbox/schemas/relationship.ts'; +import { selectAccount } from 'soapbox/selectors/index.ts'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; +import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +import { + importFetchedAccount, + importFetchedAccounts, + importErrorWhileFetchingAccountByUsername, +} from './importer/index.ts'; + +import type { Map as ImmutableMap } from 'immutable'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity, Relationship, Status } from 'soapbox/types/entities.ts'; +import type { History } from 'soapbox/types/history.ts'; + +const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST'; +const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS'; +const ACCOUNT_CREATE_FAIL = 'ACCOUNT_CREATE_FAIL'; + +const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; +const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; +const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; + +const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; +const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; +const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; + +const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; +const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; +const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; + +const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + +const ACCOUNT_SUBSCRIBE_REQUEST = 'ACCOUNT_SUBSCRIBE_REQUEST'; +const ACCOUNT_SUBSCRIBE_SUCCESS = 'ACCOUNT_SUBSCRIBE_SUCCESS'; +const ACCOUNT_SUBSCRIBE_FAIL = 'ACCOUNT_SUBSCRIBE_FAIL'; + +const ACCOUNT_UNSUBSCRIBE_REQUEST = 'ACCOUNT_UNSUBSCRIBE_REQUEST'; +const ACCOUNT_UNSUBSCRIBE_SUCCESS = 'ACCOUNT_UNSUBSCRIBE_SUCCESS'; +const ACCOUNT_UNSUBSCRIBE_FAIL = 'ACCOUNT_UNSUBSCRIBE_FAIL'; + +const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; +const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS'; +const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; + +const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; +const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; +const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; + +const ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST'; +const ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS'; +const ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL'; + +const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; +const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; +const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; + +const ACCOUNT_SEARCH_REQUEST = 'ACCOUNT_SEARCH_REQUEST'; +const ACCOUNT_SEARCH_SUCCESS = 'ACCOUNT_SEARCH_SUCCESS'; +const ACCOUNT_SEARCH_FAIL = 'ACCOUNT_SEARCH_FAIL'; + +const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST'; +const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS'; +const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL'; + +const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; +const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; +const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; + +const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; +const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; +const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; + +const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; +const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; +const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; + +const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; +const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; +const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; + +const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; +const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; +const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; + +const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; +const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; +const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; + +const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; +const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; +const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; + +const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; +const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; +const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; + +const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; +const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; +const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; + +const NOTIFICATION_SETTINGS_REQUEST = 'NOTIFICATION_SETTINGS_REQUEST'; +const NOTIFICATION_SETTINGS_SUCCESS = 'NOTIFICATION_SETTINGS_SUCCESS'; +const NOTIFICATION_SETTINGS_FAIL = 'NOTIFICATION_SETTINGS_FAIL'; + +const BIRTHDAY_REMINDERS_FETCH_REQUEST = 'BIRTHDAY_REMINDERS_FETCH_REQUEST'; +const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS'; +const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL'; + +const maybeRedirectLogin = (error: HTTPError, history?: History) => { + // The client is unauthorized - redirect to login. + if (history && error?.response?.status === 401) { + history.push('/login'); + } +}; + +const noOp = () => new Promise(f => f(undefined)); + +const createAccount = (params: Record) => + async (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); + return api(getState, 'app').post('/api/v1/accounts', params).then((response) => response.json()).then((token) => { + return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token }); + }).catch(error => { + dispatch({ type: ACCOUNT_CREATE_FAIL, error, params }); + throw error; + }); + }; + +const fetchAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchRelationships([id])); + + const account = selectAccount(getState(), id); + + if (account) { + return Promise.resolve(null); + } + + dispatch(fetchAccountRequest(id)); + + return api(getState) + .get(`/api/v1/accounts/${id}`) + .then((response) => response.json()) + .then((data) => { + dispatch(importFetchedAccount(data)); + dispatch(fetchAccountSuccess(data)); + }) + .catch(error => { + dispatch(fetchAccountFail(id, error)); + }); + }; + +const fetchAccountByUsername = (username: string, history?: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance, me } = getState(); + const features = getFeatures(instance); + + if (features.accountByUsername && (me || !features.accountLookup)) { + return api(getState).get(`/api/v1/accounts/${username}`).then((response) => response.json()).then((data) => { + dispatch(fetchRelationships([data.id])); + dispatch(importFetchedAccount(data)); + dispatch(fetchAccountSuccess(data)); + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + }); + } else if (features.accountLookup) { + return dispatch(accountLookup(username)).then(account => { + dispatch(fetchRelationships([account.id])); + dispatch(fetchAccountSuccess(account)); + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + maybeRedirectLogin(error, history); + }); + } else { + return dispatch(accountSearch({ + q: username, + limit: 5, + resolve: true, + })).then(accounts => { + const found = accounts.find((a: APIEntity) => a.acct === username); + + if (found) { + dispatch(fetchRelationships([found.id])); + dispatch(fetchAccountSuccess(found)); + } else { + throw accounts; + } + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + }); + } + }; + +const fetchAccountRequest = (id: string) => ({ + type: ACCOUNT_FETCH_REQUEST, + id, +}); + +const fetchAccountSuccess = (account: APIEntity) => ({ + type: ACCOUNT_FETCH_SUCCESS, + account, +}); + +const fetchAccountFail = (id: string | null, error: unknown) => ({ + type: ACCOUNT_FETCH_FAIL, + id, + error, + skipAlert: true, +}); + +const blockAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(blockAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/block`) + .then((response) => response.json()).then((data) => { + dispatch(importEntities([data], Entities.RELATIONSHIPS)); + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + return dispatch(blockAccountSuccess(data, getState().statuses)); + }).catch(error => dispatch(blockAccountFail(error))); + }; + +const unblockAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unblockAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/unblock`) + .then((response) => response.json()).then((data) => { + dispatch(importEntities([data], Entities.RELATIONSHIPS)); + return dispatch(unblockAccountSuccess(data)); + }) + .catch(error => dispatch(unblockAccountFail(error))); + }; + +const blockAccountRequest = (id: string) => ({ + type: ACCOUNT_BLOCK_REQUEST, + id, +}); + +const blockAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap) => ({ + type: ACCOUNT_BLOCK_SUCCESS, + relationship, + statuses, +}); + +const blockAccountFail = (error: unknown) => ({ + type: ACCOUNT_BLOCK_FAIL, + error, +}); + +const unblockAccountRequest = (id: string) => ({ + type: ACCOUNT_UNBLOCK_REQUEST, + id, +}); + +const unblockAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNBLOCK_SUCCESS, + relationship, +}); + +const unblockAccountFail = (error: unknown) => ({ + type: ACCOUNT_UNBLOCK_FAIL, + error, +}); + +const muteAccount = (id: string, notifications?: boolean, duration = 0) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(muteAccountRequest(id)); + + const params: Record = { + notifications, + }; + + if (duration) { + const state = getState(); + const instance = state.instance; + const v = parseVersion(instance.version); + + if (v.software === PLEROMA) { + params.expires_in = duration; + } else { + params.duration = duration; + } + } + + return api(getState) + .post(`/api/v1/accounts/${id}/mute`, params) + .then((response) => response.json()).then((data) => { + dispatch(importEntities([data], Entities.RELATIONSHIPS)); + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + return dispatch(muteAccountSuccess(data, getState().statuses)); + }) + .catch(error => dispatch(muteAccountFail(error))); + }; + +const unmuteAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unmuteAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/unmute`) + .then((response) => response.json()).then((data) => { + dispatch(importEntities([data], Entities.RELATIONSHIPS)); + return dispatch(unmuteAccountSuccess(data)); + }) + .catch(error => dispatch(unmuteAccountFail(error))); + }; + +const muteAccountRequest = (id: string) => ({ + type: ACCOUNT_MUTE_REQUEST, + id, +}); + +const muteAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap) => ({ + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses, +}); + +const muteAccountFail = (error: unknown) => ({ + type: ACCOUNT_MUTE_FAIL, + error, +}); + +const unmuteAccountRequest = (id: string) => ({ + type: ACCOUNT_UNMUTE_REQUEST, + id, +}); + +const unmuteAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNMUTE_SUCCESS, + relationship, +}); + +const unmuteAccountFail = (error: unknown) => ({ + type: ACCOUNT_UNMUTE_FAIL, + error, +}); + +const subscribeAccount = (id: string, notifications?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(subscribeAccountRequest(id)); + + return api(getState) + .post(`/api/v1/pleroma/accounts/${id}/subscribe`, { notifications }) + .then((response) => response.json()).then((data) => dispatch(subscribeAccountSuccess(data))) + .catch(error => dispatch(subscribeAccountFail(error))); + }; + +const unsubscribeAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unsubscribeAccountRequest(id)); + + return api(getState) + .post(`/api/v1/pleroma/accounts/${id}/unsubscribe`) + .then((response) => response.json()).then((data) => dispatch(unsubscribeAccountSuccess(data))) + .catch(error => dispatch(unsubscribeAccountFail(error))); + }; + +const subscribeAccountRequest = (id: string) => ({ + type: ACCOUNT_SUBSCRIBE_REQUEST, + id, +}); + +const subscribeAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_SUBSCRIBE_SUCCESS, + relationship, +}); + +const subscribeAccountFail = (error: unknown) => ({ + type: ACCOUNT_SUBSCRIBE_FAIL, + error, +}); + +const unsubscribeAccountRequest = (id: string) => ({ + type: ACCOUNT_UNSUBSCRIBE_REQUEST, + id, +}); + +const unsubscribeAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNSUBSCRIBE_SUCCESS, + relationship, +}); + +const unsubscribeAccountFail = (error: unknown) => ({ + type: ACCOUNT_UNSUBSCRIBE_FAIL, + error, +}); + +const removeFromFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(removeFromFollowersRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/remove_from_followers`) + .then((response) => response.json()).then((data) => dispatch(removeFromFollowersSuccess(data))) + .catch(error => dispatch(removeFromFollowersFail(id, error))); + }; + +const removeFromFollowersRequest = (id: string) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST, + id, +}); + +const removeFromFollowersSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, + relationship, +}); + +const removeFromFollowersFail = (id: string, error: unknown) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL, + id, + error, +}); + +const fetchFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowersRequest(id)); + + return api(getState) + .get(`/api/v1/accounts/${id}/followers`) + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data)); + dispatch(fetchFollowersSuccess(id, data, next)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + }) + .catch(error => { + dispatch(fetchFollowersFail(id, error)); + }); + }; + +const fetchFollowersRequest = (id: string) => ({ + type: FOLLOWERS_FETCH_REQUEST, + id, +}); + +const fetchFollowersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWERS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchFollowersFail = (id: string, error: unknown) => ({ + type: FOLLOWERS_FETCH_FAIL, + id, + error, +}); + +const expandFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const url = getState().user_lists.followers.get(id)?.next as string; + + if (url === null) { + return null; + } + + dispatch(expandFollowersRequest(id)); + + return api(getState) + .get(url) + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data)); + dispatch(expandFollowersSuccess(id, data, next)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + }) + .catch(error => { + dispatch(expandFollowersFail(id, error)); + }); + }; + +const expandFollowersRequest = (id: string) => ({ + type: FOLLOWERS_EXPAND_REQUEST, + id, +}); + +const expandFollowersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWERS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandFollowersFail = (id: string, error: unknown) => ({ + type: FOLLOWERS_EXPAND_FAIL, + id, + error, +}); + +const fetchFollowing = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowingRequest(id)); + + return api(getState) + .get(`/api/v1/accounts/${id}/following`) + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data)); + dispatch(fetchFollowingSuccess(id, data, next)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + }) + .catch(error => { + dispatch(fetchFollowingFail(id, error)); + }); + }; + +const fetchFollowingRequest = (id: string) => ({ + type: FOLLOWING_FETCH_REQUEST, + id, +}); + +const fetchFollowingSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWING_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchFollowingFail = (id: string, error: unknown) => ({ + type: FOLLOWING_FETCH_FAIL, + id, + error, +}); + +const expandFollowing = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const url = getState().user_lists.following.get(id)!.next; + + if (url === null) { + return null; + } + + dispatch(expandFollowingRequest(id)); + + return api(getState) + .get(url) + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data)); + dispatch(expandFollowingSuccess(id, data, next)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + }) + .catch(error => { + dispatch(expandFollowingFail(id, error)); + }); + }; + +const expandFollowingRequest = (id: string) => ({ + type: FOLLOWING_EXPAND_REQUEST, + id, +}); + +const expandFollowingSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWING_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandFollowingFail = (id: string, error: unknown) => ({ + type: FOLLOWING_EXPAND_FAIL, + id, + error, +}); + +const fetchRelationships = (accountIds: string[]) => + async (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const loadedRelationships = getState().relationships; + const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newAccountIds.length === 0) { + return null; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + const results: Relationship[] = []; + + try { + for (const ids of chunkArray(newAccountIds, 20)) { + const response = await api(getState).get('/api/v1/accounts/relationships', { searchParams: { id: ids } }); + const json = await response.json(); + const data = relationshipSchema.array().parse(json); + + results.push(...data); + } + + dispatch(importEntities(results, Entities.RELATIONSHIPS)); + dispatch(fetchRelationshipsSuccess(results)); + } catch (error) { + dispatch(fetchRelationshipsFail(error)); + } + }; + +function* chunkArray(array: T[], chunkSize: number): Iterable { + if (chunkSize <= 0) throw new Error('Chunk size must be greater than zero.'); + + for (let i = 0; i < array.length; i += chunkSize) { + yield array.slice(i, i + chunkSize); + } +} + +const fetchRelationshipsRequest = (ids: string[]) => ({ + type: RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, +}); + +const fetchRelationshipsSuccess = (relationships: APIEntity[]) => ({ + type: RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, +}); + +const fetchRelationshipsFail = (error: unknown) => ({ + type: RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, +}); + +const fetchFollowRequests = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(fetchFollowRequestsRequest()); + + return api(getState) + .get('/api/v1/follow_requests') + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + dispatch(fetchFollowRequestsSuccess(data, next)); + }) + .catch(error => dispatch(fetchFollowRequestsFail(error))); + }; + +const fetchFollowRequestsRequest = () => ({ + type: FOLLOW_REQUESTS_FETCH_REQUEST, +}); + +const fetchFollowRequestsSuccess = (accounts: APIEntity[], next: string | null) => ({ + type: FOLLOW_REQUESTS_FETCH_SUCCESS, + accounts, + next, +}); + +const fetchFollowRequestsFail = (error: unknown) => ({ + type: FOLLOW_REQUESTS_FETCH_FAIL, + error, +}); + +const expandFollowRequests = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const url = getState().user_lists.follow_requests.next; + + if (url === null) { + return null; + } + + dispatch(expandFollowRequestsRequest()); + + return api(getState) + .get(url) + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + dispatch(expandFollowRequestsSuccess(data, next)); + }) + .catch(error => dispatch(expandFollowRequestsFail(error))); + }; + +const expandFollowRequestsRequest = () => ({ + type: FOLLOW_REQUESTS_EXPAND_REQUEST, +}); + +const expandFollowRequestsSuccess = (accounts: APIEntity[], next: string | null) => ({ + type: FOLLOW_REQUESTS_EXPAND_SUCCESS, + accounts, + next, +}); + +const expandFollowRequestsFail = (error: unknown) => ({ + type: FOLLOW_REQUESTS_EXPAND_FAIL, + error, +}); + +const authorizeFollowRequest = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(authorizeFollowRequestRequest(id)); + + return api(getState) + .post(`/api/v1/follow_requests/${id}/authorize`) + .then(() => dispatch(authorizeFollowRequestSuccess(id))) + .catch(error => dispatch(authorizeFollowRequestFail(id, error))); + }; + +const authorizeFollowRequestRequest = (id: string) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, + id, +}); + +const authorizeFollowRequestSuccess = (id: string) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + id, +}); + +const authorizeFollowRequestFail = (id: string, error: unknown) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_FAIL, + id, + error, +}); + +const rejectFollowRequest = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(rejectFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/reject`) + .then(() => dispatch(rejectFollowRequestSuccess(id))) + .catch(error => dispatch(rejectFollowRequestFail(id, error))); + }; + +const rejectFollowRequestRequest = (id: string) => ({ + type: FOLLOW_REQUEST_REJECT_REQUEST, + id, +}); + +const rejectFollowRequestSuccess = (id: string) => ({ + type: FOLLOW_REQUEST_REJECT_SUCCESS, + id, +}); + +const rejectFollowRequestFail = (id: string, error: unknown) => ({ + type: FOLLOW_REQUEST_REJECT_FAIL, + id, + error, +}); + +const pinAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp); + + dispatch(pinAccountRequest(id)); + + return api(getState).post(`/api/v1/accounts/${id}/pin`).then((response) => response.json()).then((data) => { + dispatch(pinAccountSuccess(data)); + }).catch(error => { + dispatch(pinAccountFail(error)); + }); + }; + +const unpinAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp); + + dispatch(unpinAccountRequest(id)); + + return api(getState).post(`/api/v1/accounts/${id}/unpin`).then((response) => response.json()).then((data) => { + dispatch(unpinAccountSuccess(data)); + }).catch(error => { + dispatch(unpinAccountFail(error)); + }); + }; + +const updateNotificationSettings = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: NOTIFICATION_SETTINGS_REQUEST, params }); + return api(getState).put('/api/pleroma/notification_settings', params).then((response) => response.json()).then((data) => { + dispatch({ type: NOTIFICATION_SETTINGS_SUCCESS, params, data }); + }).catch(error => { + dispatch({ type: NOTIFICATION_SETTINGS_FAIL, params, error }); + throw error; + }); + }; + +const pinAccountRequest = (id: string) => ({ + type: ACCOUNT_PIN_REQUEST, + id, +}); + +const pinAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_PIN_SUCCESS, + relationship, +}); + +const pinAccountFail = (error: unknown) => ({ + type: ACCOUNT_PIN_FAIL, + error, +}); + +const unpinAccountRequest = (id: string) => ({ + type: ACCOUNT_UNPIN_REQUEST, + id, +}); + +const unpinAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNPIN_SUCCESS, + relationship, +}); + +const unpinAccountFail = (error: unknown) => ({ + type: ACCOUNT_UNPIN_FAIL, + error, +}); + +const fetchPinnedAccounts = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchPinnedAccountsRequest(id)); + + api(getState).get(`/api/v1/pleroma/accounts/${id}/endorsements`).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchPinnedAccountsSuccess(id, data, null)); + }).catch(error => { + dispatch(fetchPinnedAccountsFail(id, error)); + }); + }; + +const fetchPinnedAccountsRequest = (id: string) => ({ + type: PINNED_ACCOUNTS_FETCH_REQUEST, + id, +}); + +const fetchPinnedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: PINNED_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchPinnedAccountsFail = (id: string, error: unknown) => ({ + type: PINNED_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +const accountSearch = (params: Record, signal?: AbortSignal) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_SEARCH_REQUEST, params }); + return api(getState).get('/api/v1/accounts/search', { searchParams: params, signal }).then((response) => response.json()).then((accounts) => { + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: ACCOUNT_SEARCH_SUCCESS, accounts }); + return accounts; + }).catch(error => { + dispatch({ type: ACCOUNT_SEARCH_FAIL, skipAlert: true }); + throw error; + }); + }; + +const accountLookup = (acct: string, signal?: AbortSignal) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_LOOKUP_REQUEST, acct }); + return api(getState).get('/api/v1/accounts/lookup', { searchParams: { acct }, signal }).then((response) => response.json()).then((account) => { + if (account && account.id) dispatch(importFetchedAccount(account)); + dispatch({ type: ACCOUNT_LOOKUP_SUCCESS, account }); + return account; + }).catch(error => { + dispatch({ type: ACCOUNT_LOOKUP_FAIL }); + throw error; + }); + }; + +const fetchBirthdayReminders = (month: number, day: number) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const me = getState().me; + + dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me }); + + return api(getState).get('/api/v1/pleroma/birthdays', { searchParams: { day, month } }).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch({ + type: BIRTHDAY_REMINDERS_FETCH_SUCCESS, + accounts: data, + day, + month, + id: me, + }); + }).catch(error => { + dispatch({ type: BIRTHDAY_REMINDERS_FETCH_FAIL, day, month, id: me }); + }); + }; + +export { + ACCOUNT_CREATE_REQUEST, + ACCOUNT_CREATE_SUCCESS, + ACCOUNT_CREATE_FAIL, + ACCOUNT_FETCH_REQUEST, + ACCOUNT_FETCH_SUCCESS, + ACCOUNT_FETCH_FAIL, + ACCOUNT_BLOCK_REQUEST, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_BLOCK_FAIL, + ACCOUNT_UNBLOCK_REQUEST, + ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_UNBLOCK_FAIL, + ACCOUNT_MUTE_REQUEST, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_MUTE_FAIL, + ACCOUNT_UNMUTE_REQUEST, + ACCOUNT_UNMUTE_SUCCESS, + ACCOUNT_UNMUTE_FAIL, + ACCOUNT_SUBSCRIBE_REQUEST, + ACCOUNT_SUBSCRIBE_SUCCESS, + ACCOUNT_SUBSCRIBE_FAIL, + ACCOUNT_UNSUBSCRIBE_REQUEST, + ACCOUNT_UNSUBSCRIBE_SUCCESS, + ACCOUNT_UNSUBSCRIBE_FAIL, + ACCOUNT_PIN_REQUEST, + ACCOUNT_PIN_SUCCESS, + ACCOUNT_PIN_FAIL, + ACCOUNT_UNPIN_REQUEST, + ACCOUNT_UNPIN_SUCCESS, + ACCOUNT_UNPIN_FAIL, + ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST, + ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, + ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL, + PINNED_ACCOUNTS_FETCH_REQUEST, + PINNED_ACCOUNTS_FETCH_SUCCESS, + PINNED_ACCOUNTS_FETCH_FAIL, + ACCOUNT_SEARCH_REQUEST, + ACCOUNT_SEARCH_SUCCESS, + ACCOUNT_SEARCH_FAIL, + ACCOUNT_LOOKUP_REQUEST, + ACCOUNT_LOOKUP_SUCCESS, + ACCOUNT_LOOKUP_FAIL, + FOLLOWERS_FETCH_REQUEST, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_FETCH_FAIL, + FOLLOWERS_EXPAND_REQUEST, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWERS_EXPAND_FAIL, + FOLLOWING_FETCH_REQUEST, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_FETCH_FAIL, + FOLLOWING_EXPAND_REQUEST, + FOLLOWING_EXPAND_SUCCESS, + FOLLOWING_EXPAND_FAIL, + RELATIONSHIPS_FETCH_REQUEST, + RELATIONSHIPS_FETCH_SUCCESS, + RELATIONSHIPS_FETCH_FAIL, + FOLLOW_REQUESTS_FETCH_REQUEST, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_FETCH_FAIL, + FOLLOW_REQUESTS_EXPAND_REQUEST, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUESTS_EXPAND_FAIL, + FOLLOW_REQUEST_AUTHORIZE_REQUEST, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_FAIL, + FOLLOW_REQUEST_REJECT_REQUEST, + FOLLOW_REQUEST_REJECT_SUCCESS, + FOLLOW_REQUEST_REJECT_FAIL, + NOTIFICATION_SETTINGS_REQUEST, + NOTIFICATION_SETTINGS_SUCCESS, + NOTIFICATION_SETTINGS_FAIL, + BIRTHDAY_REMINDERS_FETCH_REQUEST, + BIRTHDAY_REMINDERS_FETCH_SUCCESS, + BIRTHDAY_REMINDERS_FETCH_FAIL, + createAccount, + fetchAccount, + fetchAccountByUsername, + fetchAccountRequest, + fetchAccountSuccess, + fetchAccountFail, + blockAccount, + unblockAccount, + blockAccountRequest, + blockAccountSuccess, + blockAccountFail, + unblockAccountRequest, + unblockAccountSuccess, + unblockAccountFail, + muteAccount, + unmuteAccount, + muteAccountRequest, + muteAccountSuccess, + muteAccountFail, + unmuteAccountRequest, + unmuteAccountSuccess, + unmuteAccountFail, + subscribeAccount, + unsubscribeAccount, + subscribeAccountRequest, + subscribeAccountSuccess, + subscribeAccountFail, + unsubscribeAccountRequest, + unsubscribeAccountSuccess, + unsubscribeAccountFail, + removeFromFollowers, + removeFromFollowersRequest, + removeFromFollowersSuccess, + removeFromFollowersFail, + fetchFollowers, + fetchFollowersRequest, + fetchFollowersSuccess, + fetchFollowersFail, + expandFollowers, + expandFollowersRequest, + expandFollowersSuccess, + expandFollowersFail, + fetchFollowing, + fetchFollowingRequest, + fetchFollowingSuccess, + fetchFollowingFail, + expandFollowing, + expandFollowingRequest, + expandFollowingSuccess, + expandFollowingFail, + fetchRelationships, + fetchRelationshipsRequest, + fetchRelationshipsSuccess, + fetchRelationshipsFail, + fetchFollowRequests, + fetchFollowRequestsRequest, + fetchFollowRequestsSuccess, + fetchFollowRequestsFail, + expandFollowRequests, + expandFollowRequestsRequest, + expandFollowRequestsSuccess, + expandFollowRequestsFail, + authorizeFollowRequest, + authorizeFollowRequestRequest, + authorizeFollowRequestSuccess, + authorizeFollowRequestFail, + rejectFollowRequest, + rejectFollowRequestRequest, + rejectFollowRequestSuccess, + rejectFollowRequestFail, + pinAccount, + unpinAccount, + updateNotificationSettings, + pinAccountRequest, + pinAccountSuccess, + pinAccountFail, + unpinAccountRequest, + unpinAccountSuccess, + unpinAccountFail, + fetchPinnedAccounts, + fetchPinnedAccountsRequest, + fetchPinnedAccountsSuccess, + fetchPinnedAccountsFail, + accountSearch, + accountLookup, + fetchBirthdayReminders, +}; diff --git a/src/actions/admin.ts b/src/actions/admin.ts new file mode 100644 index 0000000..3b46525 --- /dev/null +++ b/src/actions/admin.ts @@ -0,0 +1,449 @@ +import { fetchRelationships } from 'soapbox/actions/accounts.ts'; +import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer/index.ts'; +import { accountIdsToAccts } from 'soapbox/selectors/index.ts'; +import { filterBadges, getTagDiff } from 'soapbox/utils/badges.ts'; + +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST'; +const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS'; +const ADMIN_CONFIG_FETCH_FAIL = 'ADMIN_CONFIG_FETCH_FAIL'; + +const ADMIN_CONFIG_UPDATE_REQUEST = 'ADMIN_CONFIG_UPDATE_REQUEST'; +const ADMIN_CONFIG_UPDATE_SUCCESS = 'ADMIN_CONFIG_UPDATE_SUCCESS'; +const ADMIN_CONFIG_UPDATE_FAIL = 'ADMIN_CONFIG_UPDATE_FAIL'; + +const ADMIN_REPORTS_FETCH_REQUEST = 'ADMIN_REPORTS_FETCH_REQUEST'; +const ADMIN_REPORTS_FETCH_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS'; +const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL'; + +const ADMIN_REPORTS_PATCH_REQUEST = 'ADMIN_REPORTS_PATCH_REQUEST'; +const ADMIN_REPORTS_PATCH_SUCCESS = 'ADMIN_REPORTS_PATCH_SUCCESS'; +const ADMIN_REPORTS_PATCH_FAIL = 'ADMIN_REPORTS_PATCH_FAIL'; + +const ADMIN_USERS_FETCH_REQUEST = 'ADMIN_USERS_FETCH_REQUEST'; +const ADMIN_USERS_FETCH_SUCCESS = 'ADMIN_USERS_FETCH_SUCCESS'; +const ADMIN_USERS_FETCH_FAIL = 'ADMIN_USERS_FETCH_FAIL'; + +const ADMIN_USERS_DELETE_REQUEST = 'ADMIN_USERS_DELETE_REQUEST'; +const ADMIN_USERS_DELETE_SUCCESS = 'ADMIN_USERS_DELETE_SUCCESS'; +const ADMIN_USERS_DELETE_FAIL = 'ADMIN_USERS_DELETE_FAIL'; + +const ADMIN_USERS_APPROVE_REQUEST = 'ADMIN_USERS_APPROVE_REQUEST'; +const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS'; +const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL'; + +const ADMIN_USERS_REJECT_REQUEST = 'ADMIN_USERS_REJECT_REQUEST'; +const ADMIN_USERS_REJECT_SUCCESS = 'ADMIN_USERS_REJECT_SUCCESS'; +const ADMIN_USERS_REJECT_FAIL = 'ADMIN_USERS_REJECT_FAIL'; + +const ADMIN_USERS_DEACTIVATE_REQUEST = 'ADMIN_USERS_DEACTIVATE_REQUEST'; +const ADMIN_USERS_DEACTIVATE_SUCCESS = 'ADMIN_USERS_DEACTIVATE_SUCCESS'; +const ADMIN_USERS_DEACTIVATE_FAIL = 'ADMIN_USERS_DEACTIVATE_FAIL'; + +const ADMIN_STATUS_DELETE_REQUEST = 'ADMIN_STATUS_DELETE_REQUEST'; +const ADMIN_STATUS_DELETE_SUCCESS = 'ADMIN_STATUS_DELETE_SUCCESS'; +const ADMIN_STATUS_DELETE_FAIL = 'ADMIN_STATUS_DELETE_FAIL'; + +const ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST'; +const ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS'; +const ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL'; + +const ADMIN_USERS_TAG_REQUEST = 'ADMIN_USERS_TAG_REQUEST'; +const ADMIN_USERS_TAG_SUCCESS = 'ADMIN_USERS_TAG_SUCCESS'; +const ADMIN_USERS_TAG_FAIL = 'ADMIN_USERS_TAG_FAIL'; + +const ADMIN_USERS_UNTAG_REQUEST = 'ADMIN_USERS_UNTAG_REQUEST'; +const ADMIN_USERS_UNTAG_SUCCESS = 'ADMIN_USERS_UNTAG_SUCCESS'; +const ADMIN_USERS_UNTAG_FAIL = 'ADMIN_USERS_UNTAG_FAIL'; + +const ADMIN_ADD_PERMISSION_GROUP_REQUEST = 'ADMIN_ADD_PERMISSION_GROUP_REQUEST'; +const ADMIN_ADD_PERMISSION_GROUP_SUCCESS = 'ADMIN_ADD_PERMISSION_GROUP_SUCCESS'; +const ADMIN_ADD_PERMISSION_GROUP_FAIL = 'ADMIN_ADD_PERMISSION_GROUP_FAIL'; + +const ADMIN_REMOVE_PERMISSION_GROUP_REQUEST = 'ADMIN_REMOVE_PERMISSION_GROUP_REQUEST'; +const ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS = 'ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS'; +const ADMIN_REMOVE_PERMISSION_GROUP_FAIL = 'ADMIN_REMOVE_PERMISSION_GROUP_FAIL'; + +const fetchConfig = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST }); + return api(getState) + .get('/api/v1/pleroma/admin/config') + .then((response) => response.json()).then((data) => { + dispatch({ type: ADMIN_CONFIG_FETCH_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); + }).catch(error => { + dispatch({ type: ADMIN_CONFIG_FETCH_FAIL, error }); + }); + }; + +const updateConfig = (configs: Record[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST, configs }); + return api(getState) + .post('/api/v1/pleroma/admin/config', { configs }) + .then((response) => response.json()).then((data) => { + dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); + }).catch(error => { + dispatch({ type: ADMIN_CONFIG_UPDATE_FAIL, error, configs }); + }); + }; + +const updateSoapboxConfig = (data: Record) => + (dispatch: AppDispatch, _getState: () => RootState) => { + const params = [{ + group: ':pleroma', + key: ':frontend_configurations', + value: [{ + tuple: [':soapbox_fe', data], + }], + }]; + + return dispatch(updateConfig(params)); + }; + +function fetchReports(params: Record = {}) { + return async (dispatch: AppDispatch, getState: () => RootState): Promise => { + dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params }); + + try { + const response = await api(getState).get('/api/v1/admin/reports', { searchParams: params }); + const reports = await response.json(); + reports.forEach((report: APIEntity) => { + dispatch(importFetchedAccount(report.account?.account)); + dispatch(importFetchedAccount(report.target_account?.account)); + dispatch(importFetchedStatuses(report.statuses)); + }); + dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, reports, params }); + } catch (error) { + dispatch({ type: ADMIN_REPORTS_FETCH_FAIL, error, params }); + } + }; +} + +function patchReports(ids: string[], reportState: string) { + return (dispatch: AppDispatch, getState: () => RootState) => { + const reports = ids.map(id => ({ id, state: reportState })); + + dispatch({ type: ADMIN_REPORTS_PATCH_REQUEST, reports }); + + return Promise.all( + reports.map(async ({ id, state }) => { + try { + await api(getState).post(`/api/v1/admin/reports/${id}/${state === 'resolved' ? 'reopen' : 'resolve'}`); + dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports }); + } catch (error) { + dispatch({ type: ADMIN_REPORTS_PATCH_FAIL, error, reports }); + } + }, + ), + ); + }; +} + +function closeReports(ids: string[]) { + return patchReports(ids, 'closed'); +} + +function fetchUsers(filters: Record, page = 1, query?: string | null, pageSize = 50, url?: string | null) { + return async (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize }); + + const params: Record = { + ...filters, + username: query, + }; + + try { + const response = await api(getState).get(url || '/api/v1/admin/accounts', { searchParams: params }); + const accounts = await response.json(); + const next = response.next(); + + dispatch(importFetchedAccounts(accounts.map(({ account }: APIEntity) => account))); + dispatch(fetchRelationships(accounts.map((account_1: APIEntity) => account_1.id))); + dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, accounts, pageSize, filters, page, next }); + return { accounts, next }; + } catch (error) { + return dispatch({ type: ADMIN_USERS_FETCH_FAIL, error, filters, page, pageSize }); + } + }; +} + +function revokeName(accountId: string, reportId?: string) { + return (_dispatch: AppDispatch, getState: () => RootState) => { + const params = { + type: 'revoke_name', + report_id: reportId, + }; + + return api(getState).post(`/api/v1/admin/accounts/${accountId}/action`, params); + }; +} + +function deactivateUsers(accountIds: string[], reportId?: string) { + return (dispatch: AppDispatch, getState: () => RootState) => { + return Promise.all( + accountIds.map(async (accountId) => { + const params = { + type: 'disable', + report_id: reportId, + }; + try { + await api(getState).post(`/api/v1/admin/accounts/${accountId}/action`, params); + dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, accountIds: [accountId] }); + } catch (error) { + dispatch({ type: ADMIN_USERS_DEACTIVATE_FAIL, error, accountIds: [accountId] }); + } + }), + ); + }; +} + +const deleteUser = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = accountIdsToAccts(getState(), [accountId]); + dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountId }); + return api(getState) + .request('DELETE', '/api/v1/pleroma/admin/users', { nicknames }) + .then((response) => response.json()) + .then(({ nicknames }) => { + dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountId }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountId }); + }); + }; + +function approveUser(accountId: string) { + return async (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountId }); + try { + const { user } = await api(getState) + .post(`/api/v1/admin/accounts/${accountId}/approve`) + .then((response) => response.json()); + dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user, accountId }); + } catch (error) { + dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId }); + } + }; +} + +function rejectUser(accountId: string) { + return async (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_USERS_REJECT_REQUEST, accountId }); + try { + const { user } = await api(getState) + .post(`/api/v1/admin/accounts/${accountId}/reject`) + .then((response) => response.json()); + dispatch({ type: ADMIN_USERS_REJECT_SUCCESS, user, accountId }); + } catch (error) { + dispatch({ type: ADMIN_USERS_REJECT_FAIL, error, accountId }); + } + }; +} + +const deleteStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_STATUS_DELETE_REQUEST, id }); + return api(getState) + .delete(`/api/v1/pleroma/admin/statuses/${id}`) + .then(() => { + dispatch({ type: ADMIN_STATUS_DELETE_SUCCESS, id }); + }).catch(error => { + dispatch({ type: ADMIN_STATUS_DELETE_FAIL, error, id }); + }); + }; + +const toggleStatusSensitivity = (id: string, sensitive: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, id }); + return api(getState) + .put(`/api/v1/pleroma/admin/statuses/${id}`, { sensitive: !sensitive }) + .then(() => { + dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, id }); + }).catch(error => { + dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL, error, id }); + }); + }; + +const tagUsers = (accountIds: string[], tags: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = accountIdsToAccts(getState(), accountIds); + dispatch({ type: ADMIN_USERS_TAG_REQUEST, accountIds, tags }); + return api(getState) + .put('/api/v1/pleroma/admin/users/tag', { nicknames, tags }) + .then(() => { + dispatch({ type: ADMIN_USERS_TAG_SUCCESS, accountIds, tags }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_TAG_FAIL, error, accountIds, tags }); + }); + }; + +const untagUsers = (accountIds: string[], tags: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = accountIdsToAccts(getState(), accountIds); + + // Legacy: allow removing legacy 'donor' tags. + if (tags.includes('badge:donor')) { + tags = [...tags, 'donor']; + } + + dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags }); + return api(getState) + .request('DELETE', '/api/v1/pleroma/admin/users/tag', { nicknames, tags }) + .then(() => { + dispatch({ type: ADMIN_USERS_UNTAG_SUCCESS, accountIds, tags }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_UNTAG_FAIL, error, accountIds, tags }); + }); + }; + +/** Synchronizes user tags to the backend. */ +const setTags = (accountId: string, oldTags: string[], newTags: string[]) => + async(dispatch: AppDispatch) => { + const diff = getTagDiff(oldTags, newTags); + + await dispatch(tagUsers([accountId], diff.added)); + await dispatch(untagUsers([accountId], diff.removed)); + }; + +/** Synchronizes badges to the backend. */ +const setBadges = (accountId: string, oldTags: string[], newTags: string[]) => + (dispatch: AppDispatch) => { + const oldBadges = filterBadges(oldTags); + const newBadges = filterBadges(newTags); + + return dispatch(setTags(accountId, oldBadges, newBadges)); + }; + +const addPermission = (accountIds: string[], permissionGroup: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = accountIdsToAccts(getState(), accountIds); + dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup }); + return api(getState) + .post(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { nicknames }) + .then((response) => response.json()).then((data) => { + dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data }); + }).catch(error => { + dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup }); + }); + }; + +const removePermission = (accountIds: string[], permissionGroup: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = accountIdsToAccts(getState(), accountIds); + dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup }); + return api(getState) + .request('DELETE', `/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { nicknames }) + .then((response) => response.json()).then((data) => { + dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data }); + }).catch(error => { + dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup }); + }); + }; + +const promoteToAdmin = (accountId: string) => + (dispatch: AppDispatch) => + Promise.all([ + dispatch(addPermission([accountId], 'admin')), + dispatch(removePermission([accountId], 'moderator')), + ]); + +const promoteToModerator = (accountId: string) => + (dispatch: AppDispatch) => + Promise.all([ + dispatch(removePermission([accountId], 'admin')), + dispatch(addPermission([accountId], 'moderator')), + ]); + +const demoteToUser = (accountId: string) => + (dispatch: AppDispatch) => + Promise.all([ + dispatch(removePermission([accountId], 'admin')), + dispatch(removePermission([accountId], 'moderator')), + ]); + +const setRole = (accountId: string, role: 'user' | 'moderator' | 'admin') => + (dispatch: AppDispatch) => { + switch (role) { + case 'user': + return dispatch(demoteToUser(accountId)); + case 'moderator': + return dispatch(promoteToModerator(accountId)); + case 'admin': + return dispatch(promoteToAdmin(accountId)); + } + }; + +export { + ADMIN_CONFIG_FETCH_REQUEST, + ADMIN_CONFIG_FETCH_SUCCESS, + ADMIN_CONFIG_FETCH_FAIL, + ADMIN_CONFIG_UPDATE_REQUEST, + ADMIN_CONFIG_UPDATE_SUCCESS, + ADMIN_CONFIG_UPDATE_FAIL, + ADMIN_REPORTS_FETCH_REQUEST, + ADMIN_REPORTS_FETCH_SUCCESS, + ADMIN_REPORTS_FETCH_FAIL, + ADMIN_REPORTS_PATCH_REQUEST, + ADMIN_REPORTS_PATCH_SUCCESS, + ADMIN_REPORTS_PATCH_FAIL, + ADMIN_USERS_FETCH_REQUEST, + ADMIN_USERS_FETCH_SUCCESS, + ADMIN_USERS_FETCH_FAIL, + ADMIN_USERS_DELETE_REQUEST, + ADMIN_USERS_DELETE_SUCCESS, + ADMIN_USERS_DELETE_FAIL, + ADMIN_USERS_APPROVE_REQUEST, + ADMIN_USERS_APPROVE_SUCCESS, + ADMIN_USERS_APPROVE_FAIL, + ADMIN_USERS_REJECT_REQUEST, + ADMIN_USERS_REJECT_SUCCESS, + ADMIN_USERS_REJECT_FAIL, + ADMIN_USERS_DEACTIVATE_REQUEST, + ADMIN_USERS_DEACTIVATE_SUCCESS, + ADMIN_USERS_DEACTIVATE_FAIL, + ADMIN_STATUS_DELETE_REQUEST, + ADMIN_STATUS_DELETE_SUCCESS, + ADMIN_STATUS_DELETE_FAIL, + ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, + ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, + ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL, + ADMIN_USERS_TAG_REQUEST, + ADMIN_USERS_TAG_SUCCESS, + ADMIN_USERS_TAG_FAIL, + ADMIN_USERS_UNTAG_REQUEST, + ADMIN_USERS_UNTAG_SUCCESS, + ADMIN_USERS_UNTAG_FAIL, + ADMIN_ADD_PERMISSION_GROUP_REQUEST, + ADMIN_ADD_PERMISSION_GROUP_SUCCESS, + ADMIN_ADD_PERMISSION_GROUP_FAIL, + ADMIN_REMOVE_PERMISSION_GROUP_REQUEST, + ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS, + ADMIN_REMOVE_PERMISSION_GROUP_FAIL, + fetchConfig, + updateConfig, + updateSoapboxConfig, + fetchReports, + closeReports, + fetchUsers, + deactivateUsers, + deleteUser, + approveUser, + rejectUser, + revokeName, + deleteStatus, + toggleStatusSensitivity, + tagUsers, + untagUsers, + setTags, + setBadges, + addPermission, + removePermission, + promoteToAdmin, + promoteToModerator, + demoteToUser, + setRole, +}; diff --git a/src/actions/aliases.ts b/src/actions/aliases.ts new file mode 100644 index 0000000..b4df15e --- /dev/null +++ b/src/actions/aliases.ts @@ -0,0 +1,231 @@ +import { defineMessages } from 'react-intl'; + +import toast from 'soapbox/toast.tsx'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +import { importFetchedAccounts } from './importer/index.ts'; +import { patchMeSuccess } from './me.ts'; + +import type { Account } from 'soapbox/schemas/index.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const ALIASES_FETCH_REQUEST = 'ALIASES_FETCH_REQUEST'; +const ALIASES_FETCH_SUCCESS = 'ALIASES_FETCH_SUCCESS'; +const ALIASES_FETCH_FAIL = 'ALIASES_FETCH_FAIL'; + +const ALIASES_SUGGESTIONS_CHANGE = 'ALIASES_SUGGESTIONS_CHANGE'; +const ALIASES_SUGGESTIONS_READY = 'ALIASES_SUGGESTIONS_READY'; +const ALIASES_SUGGESTIONS_CLEAR = 'ALIASES_SUGGESTIONS_CLEAR'; + +const ALIASES_ADD_REQUEST = 'ALIASES_ADD_REQUEST'; +const ALIASES_ADD_SUCCESS = 'ALIASES_ADD_SUCCESS'; +const ALIASES_ADD_FAIL = 'ALIASES_ADD_FAIL'; + +const ALIASES_REMOVE_REQUEST = 'ALIASES_REMOVE_REQUEST'; +const ALIASES_REMOVE_SUCCESS = 'ALIASES_REMOVE_SUCCESS'; +const ALIASES_REMOVE_FAIL = 'ALIASES_REMOVE_FAIL'; + +const messages = defineMessages({ + createSuccess: { id: 'aliases.success.add', defaultMessage: 'Account alias created successfully' }, + removeSuccess: { id: 'aliases.success.remove', defaultMessage: 'Account alias removed successfully' }, +}); + +const fetchAliases = (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.accountMoving) return; + + dispatch(fetchAliasesRequest()); + + api(getState).get('/api/pleroma/aliases') + .then((response) => response.json()).then((data) => { + dispatch(fetchAliasesSuccess(data.aliases)); + }) + .catch(err => dispatch(fetchAliasesFail(err))); +}; + +const fetchAliasesRequest = () => ({ + type: ALIASES_FETCH_REQUEST, +}); + +const fetchAliasesSuccess = (aliases: unknown[]) => ({ + type: ALIASES_FETCH_SUCCESS, + value: aliases, +}); + +const fetchAliasesFail = (error: unknown) => ({ + type: ALIASES_FETCH_FAIL, + error, +}); + +const fetchAliasesSuggestions = (q: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const params = { + q, + resolve: true, + limit: 4, + }; + + api(getState).get('/api/v1/accounts/search', { searchParams: params }).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchAliasesSuggestionsReady(q, data)); + }).catch(error => toast.showAlertForError(error)); + }; + +const fetchAliasesSuggestionsReady = (query: string, accounts: unknown[]) => ({ + type: ALIASES_SUGGESTIONS_READY, + query, + accounts, +}); + +const clearAliasesSuggestions = () => ({ + type: ALIASES_SUGGESTIONS_CLEAR, +}); + +const changeAliasesSuggestions = (value: string) => ({ + type: ALIASES_SUGGESTIONS_CHANGE, + value, +}); + +const addToAliases = (account: Account) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.accountMoving) { + const me = state.me as string; + const alsoKnownAs = state.accounts_meta[me]?.pleroma?.also_known_as ?? []; + + dispatch(addToAliasesRequest()); + + api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma?.ap_id] }) + .then((response) => response.json()) + .then((data) => { + toast.success(messages.createSuccess); + dispatch(addToAliasesSuccess); + dispatch(patchMeSuccess(data)); + }) + .catch(err => dispatch(addToAliasesFail(err))); + + return; + } + + dispatch(addToAliasesRequest()); + + api(getState).put('/api/pleroma/aliases', { + alias: account.acct, + }) + .then(() => { + toast.success(messages.createSuccess); + dispatch(addToAliasesSuccess); + dispatch(fetchAliases); + }) + .catch(err => dispatch(fetchAliasesFail(err))); + }; + +const addToAliasesRequest = () => ({ + type: ALIASES_ADD_REQUEST, +}); + +const addToAliasesSuccess = () => ({ + type: ALIASES_ADD_SUCCESS, +}); + +const addToAliasesFail = (error: unknown) => ({ + type: ALIASES_ADD_FAIL, + error, +}); + +const removeFromAliases = (account: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.accountMoving) { + const me = state.me as string; + const alsoKnownAs = state.accounts_meta[me]?.pleroma?.also_known_as ?? []; + + dispatch(removeFromAliasesRequest()); + + api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) }) + .then((response) => response.json()).then((data) => { + toast.success(messages.removeSuccess); + dispatch(removeFromAliasesSuccess); + dispatch(patchMeSuccess(data)); + }) + .catch(err => dispatch(removeFromAliasesFail(err))); + + return; + } + + dispatch(addToAliasesRequest()); + + api(getState).request('DELETE', '/api/pleroma/aliases', { + alias: account, + }) + .then(() => { + toast.success(messages.removeSuccess); + dispatch(removeFromAliasesSuccess); + dispatch(fetchAliases); + }) + .catch(err => dispatch(fetchAliasesFail(err))); + }; + +const removeFromAliasesRequest = () => ({ + type: ALIASES_REMOVE_REQUEST, +}); + +const removeFromAliasesSuccess = () => ({ + type: ALIASES_REMOVE_SUCCESS, +}); + +const removeFromAliasesFail = (error: unknown) => ({ + type: ALIASES_REMOVE_FAIL, + error, +}); + +export { + ALIASES_FETCH_REQUEST, + ALIASES_FETCH_SUCCESS, + ALIASES_FETCH_FAIL, + ALIASES_SUGGESTIONS_CHANGE, + ALIASES_SUGGESTIONS_READY, + ALIASES_SUGGESTIONS_CLEAR, + ALIASES_ADD_REQUEST, + ALIASES_ADD_SUCCESS, + ALIASES_ADD_FAIL, + ALIASES_REMOVE_REQUEST, + ALIASES_REMOVE_SUCCESS, + ALIASES_REMOVE_FAIL, + fetchAliases, + fetchAliasesRequest, + fetchAliasesSuccess, + fetchAliasesFail, + fetchAliasesSuggestions, + fetchAliasesSuggestionsReady, + clearAliasesSuggestions, + changeAliasesSuggestions, + addToAliases, + addToAliasesRequest, + addToAliasesSuccess, + addToAliasesFail, + removeFromAliases, + removeFromAliasesRequest, + removeFromAliasesSuccess, + removeFromAliasesFail, +}; diff --git a/src/actions/apps.ts b/src/actions/apps.ts new file mode 100644 index 0000000..39d1664 --- /dev/null +++ b/src/actions/apps.ts @@ -0,0 +1,45 @@ +/** + * Apps: manage OAuth applications. + * Particularly useful for auth. + * https://docs.joinmastodon.org/methods/apps/ + * @module soapbox/actions/apps + * @see module:soapbox/actions/auth + */ + +import { baseClient } from '../api/index.ts'; + +import type { AnyAction } from 'redux'; + +export const APP_CREATE_REQUEST = 'APP_CREATE_REQUEST'; +export const APP_CREATE_SUCCESS = 'APP_CREATE_SUCCESS'; +export const APP_CREATE_FAIL = 'APP_CREATE_FAIL'; + +export const APP_VERIFY_CREDENTIALS_REQUEST = 'APP_VERIFY_CREDENTIALS_REQUEST'; +export const APP_VERIFY_CREDENTIALS_SUCCESS = 'APP_VERIFY_CREDENTIALS_SUCCESS'; +export const APP_VERIFY_CREDENTIALS_FAIL = 'APP_VERIFY_CREDENTIALS_FAIL'; + +export function createApp(params?: Record, baseURL?: string) { + return (dispatch: React.Dispatch) => { + dispatch({ type: APP_CREATE_REQUEST, params }); + return baseClient(null, baseURL).post('/api/v1/apps', params).then((response) => response.json()).then((app) => { + dispatch({ type: APP_CREATE_SUCCESS, params, app }); + return app as Record; + }).catch(error => { + dispatch({ type: APP_CREATE_FAIL, params, error }); + throw error; + }); + }; +} + +export function verifyAppCredentials(token: string) { + return (dispatch: React.Dispatch) => { + dispatch({ type: APP_VERIFY_CREDENTIALS_REQUEST, token }); + return baseClient(token).get('/api/v1/apps/verify_credentials').then((response) => response.json()).then((app) => { + dispatch({ type: APP_VERIFY_CREDENTIALS_SUCCESS, token, app }); + return app; + }).catch(error => { + dispatch({ type: APP_VERIFY_CREDENTIALS_FAIL, token, error }); + throw error; + }); + }; +} diff --git a/src/actions/auth.ts b/src/actions/auth.ts new file mode 100644 index 0000000..079024b --- /dev/null +++ b/src/actions/auth.ts @@ -0,0 +1,274 @@ +/** + * Auth: login & registration workflow. + * This file contains abstractions over auth concepts. + * @module soapbox/actions/auth + * @see module:soapbox/actions/apps + * @see module:soapbox/actions/oauth + * @see module:soapbox/actions/security + */ + +import { defineMessages } from 'react-intl'; + +import { createAccount } from 'soapbox/actions/accounts.ts'; +import { createApp } from 'soapbox/actions/apps.ts'; +import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me.ts'; +import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth.ts'; +import { startOnboarding } from 'soapbox/actions/onboarding.ts'; +import { HTTPError } from 'soapbox/api/HTTPError.ts'; +import { custom } from 'soapbox/custom.ts'; +import { queryClient } from 'soapbox/queries/client.ts'; +import { selectAccount } from 'soapbox/selectors/index.ts'; +import { unsetSentryAccount } from 'soapbox/sentry.ts'; +import toast from 'soapbox/toast.tsx'; +import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth.ts'; +import sourceCode from 'soapbox/utils/code.ts'; +import { normalizeUsername } from 'soapbox/utils/input.ts'; +import { getScopes } from 'soapbox/utils/scopes.ts'; + +import api, { baseClient } from '../api/index.ts'; + +import { importFetchedAccount } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; + +export const AUTH_APP_CREATED = 'AUTH_APP_CREATED'; +export const AUTH_APP_AUTHORIZED = 'AUTH_APP_AUTHORIZED'; +export const AUTH_LOGGED_IN = 'AUTH_LOGGED_IN'; +export const AUTH_LOGGED_OUT = 'AUTH_LOGGED_OUT'; + +export const VERIFY_CREDENTIALS_REQUEST = 'VERIFY_CREDENTIALS_REQUEST'; +export const VERIFY_CREDENTIALS_SUCCESS = 'VERIFY_CREDENTIALS_SUCCESS'; +export const VERIFY_CREDENTIALS_FAIL = 'VERIFY_CREDENTIALS_FAIL'; + +export const AUTH_ACCOUNT_REMEMBER_REQUEST = 'AUTH_ACCOUNT_REMEMBER_REQUEST'; +export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS'; +export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL'; + +const customApp = custom('app'); + +export const messages = defineMessages({ + loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, + awaitingApproval: { id: 'auth.awaiting_approval', defaultMessage: 'Your account is awaiting approval' }, + invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, +}); + +const noOp = () => new Promise(f => f(undefined)); + +const createAppAndToken = () => + (dispatch: AppDispatch) => + dispatch(getAuthApp()).then(() => + dispatch(createAppToken()), + ); + +/** Create an auth app, or use it from build config */ +const getAuthApp = () => + (dispatch: AppDispatch) => { + if (customApp?.client_secret) { + return noOp().then(() => dispatch({ type: AUTH_APP_CREATED, app: customApp })); + } else { + return dispatch(createAuthApp()); + } + }; + +const createAuthApp = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const params = { + client_name: sourceCode.displayName, + redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', + scopes: getScopes(getState()), + website: sourceCode.homepage, + }; + + return dispatch(createApp(params)).then((app: Record) => + dispatch({ type: AUTH_APP_CREATED, app }), + ); + }; + +const createAppToken = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const app = getState().auth.app; + + const params = { + client_id: app?.client_id, + client_secret: app?.client_secret, + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + grant_type: 'client_credentials', + scope: getScopes(getState()), + }; + + return dispatch(obtainOAuthToken(params)).then((token: Record) => + dispatch({ type: AUTH_APP_AUTHORIZED, app, token }), + ); + }; + +const createUserToken = (username: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const app = getState().auth.app; + + const params = { + client_id: app?.client_id, + client_secret: app?.client_secret, + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + grant_type: 'password', + username: username, + password: password, + scope: getScopes(getState()), + }; + + return dispatch(obtainOAuthToken(params)) + .then((token: Record) => dispatch(authLoggedIn(token))); + }; + +export const otpVerify = (code: string, mfa_token: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const app = getState().auth.app; + return api(getState, 'app').post('/oauth/mfa/challenge', { + client_id: app?.client_id, + client_secret: app?.client_secret, + mfa_token: mfa_token, + code: code, + challenge_type: 'totp', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + scope: getScopes(getState()), + }).then((response) => response.json()).then((token) => dispatch(authLoggedIn(token))); + }; + +export const verifyCredentials = (token: string, accountUrl?: string) => { + const baseURL = parseBaseURL(accountUrl); + + return (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: VERIFY_CREDENTIALS_REQUEST, token }); + + return baseClient(token, baseURL).get('/api/v1/accounts/verify_credentials').then((response) => response.json()).then((account) => { + dispatch(importFetchedAccount(account)); + dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); + if (account.id === getState().me) dispatch(fetchMeSuccess(account)); + return account; + }).catch(error => { + if (error?.response?.status === 403 && error?.response?.data?.id) { + // The user is waitlisted + const account = error.data; + dispatch(importFetchedAccount(account)); + dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); + if (account.id === getState().me) dispatch(fetchMeSuccess(account)); + return account; + } else { + if (getState().me === null) dispatch(fetchMeFail(error)); + dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error }); + throw error; + } + }); + }; +}; + +export class MfaRequiredError extends Error { + + constructor(public token: string) { + super('MFA is required'); + } + +} + +export const logIn = (username: string, password: string) => + (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { + return dispatch(createUserToken(normalizeUsername(username), password)); + }).catch(async (error) => { + if (error instanceof HTTPError) { + const data = await error.response.error(); + if (data) { + if (data.error === 'mfa_required' && 'mfa_token' in data && typeof data.mfa_token === 'string') { + // If MFA is required, throw the error and handle it in the component. + throw new MfaRequiredError(data.mfa_token); + } else if (data.error === 'awaiting_approval') { + toast.error(messages.awaitingApproval); + } else { + // Return "wrong password" message. + toast.error(messages.invalidCredentials); + } + } + } + throw error; + }); + +export const deleteSession = () => + (dispatch: AppDispatch, getState: () => RootState) => api(getState).delete('/api/sign_out'); + +export const logOut = (refresh = true) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const account = getLoggedInAccount(state); + + if (!account) return dispatch(noOp); + + const params = { + client_id: state.auth.app?.client_id, + client_secret: state.auth.app?.client_secret, + token: state.auth.users[account.url]?.access_token, + }; + + return dispatch(revokeOAuthToken(params as Record)) + .finally(() => { + // Clear all stored cache from React Query + queryClient.invalidateQueries(); + queryClient.clear(); + + // Clear the account from Sentry. + unsetSentryAccount(); + + // Remove external auth entries. + localStorage.removeItem('soapbox:external:app'); + localStorage.removeItem('soapbox:external:baseurl'); + localStorage.removeItem('soapbox:external:scopes'); + + dispatch({ type: AUTH_LOGGED_OUT, account, refresh }); + + toast.success(messages.loggedOut); + }); + }; + +export const switchAccount = (accountId: string, background = false) => + (dispatch: AppDispatch, getState: () => RootState) => { + const account = selectAccount(getState(), accountId); + // Clear all stored cache from React Query + queryClient.invalidateQueries(); + queryClient.clear(); + + return dispatch({ type: SWITCH_ACCOUNT, account, background }); + }; + +export const fetchOwnAccounts = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + return Object.values(state.auth.users).forEach((user) => { + const account = selectAccount(state, user.id); + if (!account) { + dispatch(verifyCredentials(user.access_token, user.url)) + .catch(() => console.warn(`Failed to load account: ${user.url}`)); + } + }); + }; + +export const register = (params: Record) => + (dispatch: AppDispatch) => { + params.fullname = params.username; + + return dispatch(createAppAndToken()) + .then(() => dispatch(createAccount(params))) + .then(({ token }: { token: Record }) => { + dispatch(startOnboarding()); + return dispatch(authLoggedIn(token)); + }); + }; + +export const fetchCaptcha = () => + (_dispatch: AppDispatch, getState: () => RootState) => { + return api(getState).get('/api/pleroma/captcha'); + }; + +export const authLoggedIn = (token: Record) => + (dispatch: AppDispatch) => { + dispatch({ type: AUTH_LOGGED_IN, token }); + return token; + }; diff --git a/src/actions/backups.ts b/src/actions/backups.ts new file mode 100644 index 0000000..0693e81 --- /dev/null +++ b/src/actions/backups.ts @@ -0,0 +1,31 @@ +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +export const BACKUPS_FETCH_REQUEST = 'BACKUPS_FETCH_REQUEST'; +export const BACKUPS_FETCH_SUCCESS = 'BACKUPS_FETCH_SUCCESS'; +export const BACKUPS_FETCH_FAIL = 'BACKUPS_FETCH_FAIL'; + +export const BACKUPS_CREATE_REQUEST = 'BACKUPS_CREATE_REQUEST'; +export const BACKUPS_CREATE_SUCCESS = 'BACKUPS_CREATE_SUCCESS'; +export const BACKUPS_CREATE_FAIL = 'BACKUPS_CREATE_FAIL'; + +export const fetchBackups = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: BACKUPS_FETCH_REQUEST }); + return api(getState).get('/api/v1/pleroma/backups').then((response) => response.json()).then((backups) => + dispatch({ type: BACKUPS_FETCH_SUCCESS, backups }), + ).catch(error => { + dispatch({ type: BACKUPS_FETCH_FAIL, error }); + }); + }; + +export const createBackup = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: BACKUPS_CREATE_REQUEST }); + return api(getState).post('/api/v1/pleroma/backups').then((response) => response.json()).then((backups) => + dispatch({ type: BACKUPS_CREATE_SUCCESS, backups }), + ).catch(error => { + dispatch({ type: BACKUPS_CREATE_FAIL, error }); + }); + }; diff --git a/src/actions/blocks.ts b/src/actions/blocks.ts new file mode 100644 index 0000000..f58d199 --- /dev/null +++ b/src/actions/blocks.ts @@ -0,0 +1,107 @@ +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import { fetchRelationships } from './accounts.ts'; +import { importFetchedAccounts } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; +const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; +const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL'; + +const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; +const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; +const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; + +const fetchBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(fetchBlocksRequest()); + + return api(getState) + .get('/api/v1/blocks') + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + dispatch(fetchBlocksSuccess(data, next)); + dispatch(fetchRelationships(data.map((item: any) => item.id)) as any); + }) + .catch(error => dispatch(fetchBlocksFail(error))); +}; + +function fetchBlocksRequest() { + return { type: BLOCKS_FETCH_REQUEST }; +} + +function fetchBlocksSuccess(accounts: any, next: any) { + return { + type: BLOCKS_FETCH_SUCCESS, + accounts, + next, + }; +} + +function fetchBlocksFail(error: unknown) { + return { + type: BLOCKS_FETCH_FAIL, + error, + }; +} + +const expandBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const url = getState().user_lists.blocks.next; + + if (url === null) { + return null; + } + + dispatch(expandBlocksRequest()); + + return api(getState) + .get(url) + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + dispatch(expandBlocksSuccess(data, next)); + dispatch(fetchRelationships(data.map((item: any) => item.id)) as any); + }) + .catch(error => dispatch(expandBlocksFail(error))); +}; + +function expandBlocksRequest() { + return { + type: BLOCKS_EXPAND_REQUEST, + }; +} + +function expandBlocksSuccess(accounts: any, next: any) { + return { + type: BLOCKS_EXPAND_SUCCESS, + accounts, + next, + }; +} + +function expandBlocksFail(error: unknown) { + return { + type: BLOCKS_EXPAND_FAIL, + error, + }; +} + +export { + fetchBlocks, + expandBlocks, + BLOCKS_FETCH_REQUEST, + BLOCKS_FETCH_SUCCESS, + BLOCKS_FETCH_FAIL, + BLOCKS_EXPAND_REQUEST, + BLOCKS_EXPAND_SUCCESS, + BLOCKS_EXPAND_FAIL, +}; diff --git a/src/actions/chats.ts b/src/actions/chats.ts new file mode 100644 index 0000000..3143e07 --- /dev/null +++ b/src/actions/chats.ts @@ -0,0 +1,263 @@ +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; + +import { getSettings, changeSetting } from 'soapbox/actions/settings.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { History } from 'soapbox/types/history.ts'; + +const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST'; +const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS'; +const CHATS_FETCH_FAIL = 'CHATS_FETCH_FAIL'; + +const CHATS_EXPAND_REQUEST = 'CHATS_EXPAND_REQUEST'; +const CHATS_EXPAND_SUCCESS = 'CHATS_EXPAND_SUCCESS'; +const CHATS_EXPAND_FAIL = 'CHATS_EXPAND_FAIL'; + +const CHAT_MESSAGES_FETCH_REQUEST = 'CHAT_MESSAGES_FETCH_REQUEST'; +const CHAT_MESSAGES_FETCH_SUCCESS = 'CHAT_MESSAGES_FETCH_SUCCESS'; +const CHAT_MESSAGES_FETCH_FAIL = 'CHAT_MESSAGES_FETCH_FAIL'; + +const CHAT_MESSAGE_SEND_REQUEST = 'CHAT_MESSAGE_SEND_REQUEST'; +const CHAT_MESSAGE_SEND_SUCCESS = 'CHAT_MESSAGE_SEND_SUCCESS'; +const CHAT_MESSAGE_SEND_FAIL = 'CHAT_MESSAGE_SEND_FAIL'; + +const CHAT_FETCH_REQUEST = 'CHAT_FETCH_REQUEST'; +const CHAT_FETCH_SUCCESS = 'CHAT_FETCH_SUCCESS'; +const CHAT_FETCH_FAIL = 'CHAT_FETCH_FAIL'; + +const CHAT_READ_REQUEST = 'CHAT_READ_REQUEST'; +const CHAT_READ_SUCCESS = 'CHAT_READ_SUCCESS'; +const CHAT_READ_FAIL = 'CHAT_READ_FAIL'; + +const CHAT_MESSAGE_DELETE_REQUEST = 'CHAT_MESSAGE_DELETE_REQUEST'; +const CHAT_MESSAGE_DELETE_SUCCESS = 'CHAT_MESSAGE_DELETE_SUCCESS'; +const CHAT_MESSAGE_DELETE_FAIL = 'CHAT_MESSAGE_DELETE_FAIL'; + +const fetchChatsV1 = () => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState).get('/api/v1/pleroma/chats').then((response) => response.json()).then((data) => { + dispatch({ type: CHATS_FETCH_SUCCESS, chats: data }); + }).catch(error => { + dispatch({ type: CHATS_FETCH_FAIL, error }); + }); + +const fetchChatsV2 = () => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState).get('/api/v2/pleroma/chats').then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch({ type: CHATS_FETCH_SUCCESS, chats: data, next }); + }).catch(error => { + dispatch({ type: CHATS_FETCH_FAIL, error }); + }); + +const fetchChats = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const { instance } = state; + const features = getFeatures(instance); + + dispatch({ type: CHATS_FETCH_REQUEST }); + if (features.chatsV2) { + return dispatch(fetchChatsV2()); + } else { + return dispatch(fetchChatsV1()); + } + }; + +const expandChats = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().chats.next; + + if (url === null) { + return; + } + + dispatch({ type: CHATS_EXPAND_REQUEST }); + api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch({ type: CHATS_EXPAND_SUCCESS, chats: data, next }); + }).catch(error => { + dispatch({ type: CHATS_EXPAND_FAIL, error }); + }); + }; + +const fetchChatMessages = (chatId: string, maxId: string | null = null) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CHAT_MESSAGES_FETCH_REQUEST, chatId, maxId }); + const searchParams = maxId ? { max_id: maxId } : undefined; + return api(getState).get(`/api/v1/pleroma/chats/${chatId}/messages`, { searchParams }).then((response) => response.json()).then((data) => { + dispatch({ type: CHAT_MESSAGES_FETCH_SUCCESS, chatId, maxId, chatMessages: data }); + }).catch(error => { + dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, maxId, error }); + }); + }; + +const sendChatMessage = (chatId: string, params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + const uuid = `末_${Date.now()}_${crypto.randomUUID()}`; + const me = getState().me; + dispatch({ type: CHAT_MESSAGE_SEND_REQUEST, chatId, params, uuid, me }); + return api(getState).post(`/api/v1/pleroma/chats/${chatId}/messages`, params).then((response) => response.json()).then((data) => { + dispatch({ type: CHAT_MESSAGE_SEND_SUCCESS, chatId, chatMessage: data, uuid }); + }).catch(error => { + dispatch({ type: CHAT_MESSAGE_SEND_FAIL, chatId, error, uuid }); + }); + }; + +const openChat = (chatId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const panes = getSettings(state).getIn(['chats', 'panes']) as ImmutableList>; + const idx = panes.findIndex(pane => pane.get('chat_id') === chatId); + + dispatch(markChatRead(chatId)); + + if (idx > -1) { + return dispatch(changeSetting(['chats', 'panes', idx as any, 'state'], 'open')); + } else { + const newPane = ImmutableMap({ chat_id: chatId, state: 'open' }); + return dispatch(changeSetting(['chats', 'panes'], panes.push(newPane))); + } + }; + +const closeChat = (chatId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const panes = getSettings(getState()).getIn(['chats', 'panes']) as ImmutableList>; + const idx = panes.findIndex(pane => pane.get('chat_id') === chatId); + + if (idx > -1) { + return dispatch(changeSetting(['chats', 'panes'], panes.delete(idx))); + } else { + return false; + } + }; + +const toggleChat = (chatId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const panes = getSettings(getState()).getIn(['chats', 'panes']) as ImmutableList>; + const [idx, pane] = panes.findEntry(pane => pane.get('chat_id') === chatId)!; + + if (idx > -1) { + const state = pane.get('state') === 'minimized' ? 'open' : 'minimized'; + if (state === 'open') dispatch(markChatRead(chatId)); + return dispatch(changeSetting(['chats', 'panes', idx as any, 'state'], state)); + } else { + return false; + } + }; + +const toggleMainWindow = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const main = getSettings(getState()).getIn(['chats', 'mainWindow']) as 'minimized' | 'open'; + const state = main === 'minimized' ? 'open' : 'minimized'; + return dispatch(changeSetting(['chats', 'mainWindow'], state)); + }; + +const fetchChat = (chatId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CHAT_FETCH_REQUEST, chatId }); + return api(getState).get(`/api/v1/pleroma/chats/${chatId}`).then((response) => response.json()).then((data) => { + dispatch({ type: CHAT_FETCH_SUCCESS, chat: data }); + }).catch(error => { + dispatch({ type: CHAT_FETCH_FAIL, chatId, error }); + }); + }; + +const startChat = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CHAT_FETCH_REQUEST, accountId }); + return api(getState).post(`/api/v1/pleroma/chats/by-account-id/${accountId}`).then((response) => response.json()).then((data) => { + dispatch({ type: CHAT_FETCH_SUCCESS, chat: data }); + return data; + }).catch(error => { + dispatch({ type: CHAT_FETCH_FAIL, accountId, error }); + }); + }; + +const markChatRead = (chatId: string, lastReadId?: string | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + const chat = getState().chats.items.get(chatId)!; + if (!lastReadId) lastReadId = chat.last_message; + + if (chat.get('unread') < 1) return; + if (!lastReadId) return; + + dispatch({ type: CHAT_READ_REQUEST, chatId, lastReadId }); + api(getState).post(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }).then((response) => response.json()).then((data) => { + dispatch({ type: CHAT_READ_SUCCESS, chat: data, lastReadId }); + }).catch(error => { + dispatch({ type: CHAT_READ_FAIL, chatId, error, lastReadId }); + }); + }; + +const deleteChatMessage = (chatId: string, messageId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CHAT_MESSAGE_DELETE_REQUEST, chatId, messageId }); + api(getState).delete(`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`).then((response) => response.json()).then((data) => { + dispatch({ type: CHAT_MESSAGE_DELETE_SUCCESS, chatId, messageId, chatMessage: data }); + }).catch(error => { + dispatch({ type: CHAT_MESSAGE_DELETE_FAIL, chatId, messageId, error }); + }); + }; + +/** Start a chat and launch it in the UI */ +const launchChat = (accountId: string, router: History, forceNavigate = false) => { + const isMobile = (width: number) => width <= 1190; + + return (dispatch: AppDispatch) => { + // TODO: make this faster + return dispatch(startChat(accountId)).then(chat => { + if (forceNavigate || isMobile(window.innerWidth)) { + router.push(`/chats/${chat.id}`); + } else { + dispatch(openChat(chat.id)); + } + }); + }; +}; + +export { + CHATS_FETCH_REQUEST, + CHATS_FETCH_SUCCESS, + CHATS_FETCH_FAIL, + CHATS_EXPAND_REQUEST, + CHATS_EXPAND_SUCCESS, + CHATS_EXPAND_FAIL, + CHAT_MESSAGES_FETCH_REQUEST, + CHAT_MESSAGES_FETCH_SUCCESS, + CHAT_MESSAGES_FETCH_FAIL, + CHAT_MESSAGE_SEND_REQUEST, + CHAT_MESSAGE_SEND_SUCCESS, + CHAT_MESSAGE_SEND_FAIL, + CHAT_FETCH_REQUEST, + CHAT_FETCH_SUCCESS, + CHAT_FETCH_FAIL, + CHAT_READ_REQUEST, + CHAT_READ_SUCCESS, + CHAT_READ_FAIL, + CHAT_MESSAGE_DELETE_REQUEST, + CHAT_MESSAGE_DELETE_SUCCESS, + CHAT_MESSAGE_DELETE_FAIL, + fetchChatsV1, + fetchChatsV2, + fetchChats, + expandChats, + fetchChatMessages, + sendChatMessage, + openChat, + closeChat, + toggleChat, + toggleMainWindow, + fetchChat, + startChat, + markChatRead, + deleteChatMessage, + launchChat, +}; diff --git a/src/actions/compose-status.ts b/src/actions/compose-status.ts new file mode 100644 index 0000000..fd1379f --- /dev/null +++ b/src/actions/compose-status.ts @@ -0,0 +1,38 @@ +import { AppDispatch, RootState } from 'soapbox/store.ts'; +import { getFeatures, parseVersion } from 'soapbox/utils/features.ts'; + +import type { Status } from 'soapbox/types/entities.ts'; + +export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' as const; + +export interface ComposeSetStatusAction { + type: typeof COMPOSE_SET_STATUS; + id: string; + status: Status; + rawText: string; + explicitAddressing: boolean; + spoilerText?: string; + contentType?: string | false; + v: ReturnType; + withRedraft?: boolean; +} + +export const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance } = getState(); + const { explicitAddressing } = getFeatures(instance); + + const action: ComposeSetStatusAction = { + type: COMPOSE_SET_STATUS, + id: 'compose-modal', + status, + rawText, + explicitAddressing, + spoilerText, + contentType, + v: parseVersion(instance.version), + withRedraft, + }; + + dispatch(action); + }; \ No newline at end of file diff --git a/src/actions/compose.test.ts b/src/actions/compose.test.ts new file mode 100644 index 0000000..62c0b2d --- /dev/null +++ b/src/actions/compose.test.ts @@ -0,0 +1,134 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildInstance } from 'soapbox/jest/factory.ts'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers.tsx'; +import { ReducerCompose } from 'soapbox/reducers/compose.ts'; + +import { uploadCompose, submitCompose } from './compose.ts'; +import { STATUS_CREATE_REQUEST } from './statuses.ts'; + +import type { IntlShape } from 'react-intl'; + +describe('uploadCompose()', () => { + describe('with images', () => { + let files: FileList, store: ReturnType; + + beforeEach(() => { + const instance = buildInstance({ + configuration: { + statuses: { + max_media_attachments: 4, + }, + media_attachments: { + image_size_limit: 10, + }, + }, + }); + + const state = { + ...rootState, + me: '1234', + instance, + compose: rootState.compose.set('home', ReducerCompose()), + }; + + store = mockStore(state); + files = [{ + uri: 'image.png', + name: 'Image', + size: 15, + type: 'image/png', + }] as unknown as FileList; + }); + + it('creates an alert if exceeds max size', async() => { + const mockIntl = { + formatMessage: vi.fn().mockReturnValue('Image exceeds the current file size limit (10 Bytes)'), + } as unknown as IntlShape; + + const expectedActions = [ + { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, + { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, + ]; + + await store.dispatch(uploadCompose('home', files, mockIntl)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with videos', () => { + let files: FileList, store: ReturnType; + + beforeEach(() => { + const instance = buildInstance({ + configuration: { + statuses: { + max_media_attachments: 4, + }, + media_attachments: { + video_size_limit: 10, + }, + }, + }); + + const state = { + ...rootState, + me: '1234', + instance, + compose: rootState.compose.set('home', ReducerCompose()), + }; + + store = mockStore(state); + files = [{ + uri: 'video.mp4', + name: 'Video', + size: 15, + type: 'video/mp4', + }] as unknown as FileList; + }); + + it('creates an alert if exceeds max size', async() => { + const mockIntl = { + formatMessage: vi.fn().mockReturnValue('Video exceeds the current file size limit (10 Bytes)'), + } as unknown as IntlShape; + + const expectedActions = [ + { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, + { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, + ]; + + await store.dispatch(uploadCompose('home', files, mockIntl)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('submitCompose()', () => { + it('inserts mentions from text', async() => { + const state = { + ...rootState, + me: '1234', + compose: rootState.compose.set('home', ReducerCompose({ text: '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me' })), + }; + + const store = mockStore(state); + await store.dispatch(submitCompose('home')); + const actions = store.getActions(); + + const statusCreateRequest = actions.find(action => action.type === STATUS_CREATE_REQUEST); + const to = statusCreateRequest!.params.to as ImmutableOrderedSet; + + const expected = [ + 'alex', + 'mkljczk@pl.fediverse.pl', + 'gg@汉语/漢語.com', + ]; + + expect(to.toJS()).toEqual(expected); + }); +}); diff --git a/src/actions/compose.ts b/src/actions/compose.ts new file mode 100644 index 0000000..0c3bd2a --- /dev/null +++ b/src/actions/compose.ts @@ -0,0 +1,970 @@ +import { throttle } from 'es-toolkit'; +import { List as ImmutableList } from 'immutable'; +import { defineMessages, IntlShape } from 'react-intl'; + +import { HTTPError } from 'soapbox/api/HTTPError.ts'; +import api from 'soapbox/api/index.ts'; +import { isNativeEmoji } from 'soapbox/features/emoji/index.ts'; +import emojiSearch from 'soapbox/features/emoji/search.ts'; +import { normalizeTag } from 'soapbox/normalizers/index.ts'; +import { selectAccount, selectOwnAccount } from 'soapbox/selectors/index.ts'; +import { tagHistory } from 'soapbox/settings.ts'; +import toast from 'soapbox/toast.tsx'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; + +import { ComposeSetStatusAction } from './compose-status.ts'; +import { chooseEmoji } from './emojis.ts'; +import { importFetchedAccounts } from './importer/index.ts'; +import { uploadFile, updateMedia } from './media.ts'; +import { openModal, closeModal } from './modals.ts'; +import { getSettings } from './settings.ts'; +import { createStatus } from './statuses.ts'; + +import type { EditorState } from 'lexical'; +import type { AutoSuggestion } from 'soapbox/components/autosuggest-input.tsx'; +import type { Emoji } from 'soapbox/features/emoji/index.ts'; +import type { Account, CustomEmoji, Group } from 'soapbox/schemas/index.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity, Status, Tag } from 'soapbox/types/entities.ts'; +import type { History } from 'soapbox/types/history.ts'; + +let cancelFetchComposeSuggestions: AbortController | undefined; + +const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const; +const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const; +const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const; +const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL' as const; +const COMPOSE_REPLY = 'COMPOSE_REPLY' as const; +const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY' as const; +const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL' as const; +const COMPOSE_QUOTE = 'COMPOSE_QUOTE' as const; +const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL' as const; +const COMPOSE_DIRECT = 'COMPOSE_DIRECT' as const; +const COMPOSE_MENTION = 'COMPOSE_MENTION' as const; +const COMPOSE_RESET = 'COMPOSE_RESET' as const; +const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST' as const; +const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS' as const; +const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL' as const; +const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS' as const; +const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO' as const; +const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST' as const; +const COMPOSE_SET_GROUP_TIMELINE_VISIBLE = 'COMPOSE_SET_GROUP_TIMELINE_VISIBLE' as const; + +const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR' as const; +const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY' as const; +const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT' as const; +const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE' as const; + +const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE' as const; + +const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE' as const; +const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE' as const; +const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE' as const; +const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE' as const; +const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE' as const; + +const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const; + +const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST' as const; +const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS' as const; +const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL' as const; + +const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD' as const; +const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE' as const; +const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD' as const; +const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE' as const; +const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE' as const; +const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE' as const; + +const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD' as const; +const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET' as const; +const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE' as const; + +const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS' as const; +const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS' as const; + +const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const; + +const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER' as const; + +const messages = defineMessages({ + scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, + success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent!' }, + editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, + uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, + view: { id: 'toast.view', defaultMessage: 'View' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, +}); + +const changeCompose = (composeId: string, text: string) => ({ + type: COMPOSE_CHANGE, + id: composeId, + text: text, +}); + +interface ComposeReplyAction { + type: typeof COMPOSE_REPLY; + id: string; + status: Status; + account: Account; + explicitAddressing: boolean; + preserveSpoilers: boolean; +} + +const replyCompose = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const { explicitAddressing } = getFeatures(instance); + const preserveSpoilers = !!getSettings(state).get('preserveSpoilers'); + const account = selectOwnAccount(state); + + if (!account) return; + + const action: ComposeReplyAction = { + type: COMPOSE_REPLY, + id: 'compose-modal', + status: status, + account, + explicitAddressing, + preserveSpoilers, + }; + + dispatch(action); + dispatch(openModal('COMPOSE')); + }; + +const cancelReplyCompose = () => ({ + type: COMPOSE_REPLY_CANCEL, + id: 'compose-modal', +}); + +interface ComposeQuoteAction { + type: typeof COMPOSE_QUOTE; + id: string; + status: Status; + account: Account | undefined; + explicitAddressing: boolean; +} + +const quoteCompose = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const { explicitAddressing } = getFeatures(instance); + + const action: ComposeQuoteAction = { + type: COMPOSE_QUOTE, + id: 'compose-modal', + status: status, + account: selectOwnAccount(state), + explicitAddressing, + }; + + dispatch(action); + dispatch(openModal('COMPOSE')); + }; + +const cancelQuoteCompose = () => ({ + type: COMPOSE_QUOTE_CANCEL, + id: 'compose-modal', +}); + +const groupComposeModal = (group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + const composeId = `group:${group.id}`; + + dispatch(groupCompose(composeId, group.id)); + dispatch(openModal('COMPOSE', { composeId })); + }; + +const resetCompose = (composeId = 'compose-modal') => ({ + type: COMPOSE_RESET, + id: composeId, +}); + +interface ComposeMentionAction { + type: typeof COMPOSE_MENTION; + id: string; + account: Account; +} + +const mentionCompose = (account: Account) => + (dispatch: AppDispatch) => { + const action: ComposeMentionAction = { + type: COMPOSE_MENTION, + id: 'compose-modal', + account: account, + }; + + dispatch(action); + dispatch(openModal('COMPOSE')); + }; + +interface ComposeDirectAction { + type: typeof COMPOSE_DIRECT; + id: string; + account: Account; +} + +const directCompose = (account: Account) => + (dispatch: AppDispatch) => { + const action: ComposeDirectAction = { + type: COMPOSE_DIRECT, + id: 'compose-modal', + account, + }; + + dispatch(action); + dispatch(openModal('COMPOSE')); + }; + +const directComposeById = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const account = selectAccount(getState(), accountId); + if (!account) return; + + const action: ComposeDirectAction = { + type: COMPOSE_DIRECT, + id: 'compose-modal', + account, + }; + + dispatch(action); + dispatch(openModal('COMPOSE')); + }; + +const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: APIEntity, status: string, edit?: boolean) => { + if (!dispatch || !getState) return; + + dispatch(insertIntoTagHistory(composeId, data.tags || [], status)); + dispatch(submitComposeSuccess(composeId, { ...data })); + toast.success(edit ? messages.editSuccess : messages.success, { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/posts/${data.id}`, + }); +}; + +const needsDescriptions = (state: RootState, composeId: string) => { + const media = state.compose.get(composeId)!.media_attachments; + const missingDescriptionModal = getSettings(state).get('missingDescriptionModal'); + + const hasMissing = media.filter(item => !item.description).size > 0; + + return missingDescriptionModal && hasMissing; +}; + +const validateSchedule = (state: RootState, composeId: string) => { + const schedule = state.compose.get(composeId)?.schedule; + if (!schedule) return true; + + const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); + + return schedule.getTime() > fiveMinutesFromNow.getTime(); +}; + +interface SubmitComposeOpts { + history?: History; + force?: boolean; +} + +const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => + async (dispatch: AppDispatch, getState: () => RootState) => { + const { history, force = false } = opts; + + if (!isLoggedIn(getState)) return; + const state = getState(); + + const compose = state.compose.get(composeId)!; + + const status = compose.text; + const media = compose.media_attachments; + const statusId = compose.id; + let to = compose.to; + + if (!validateSchedule(state, composeId)) { + toast.error(messages.scheduleError); + return; + } + + if ((!status || !status.length) && media.size === 0) { + return; + } + + if (!force && needsDescriptions(state, composeId)) { + dispatch(openModal('MISSING_DESCRIPTION', { + onContinue: () => { + dispatch(closeModal('MISSING_DESCRIPTION')); + dispatch(submitCompose(composeId, { history, force: true })); + }, + })); + return; + } + + const mentions: string[] | null = status.match(/(?:^|\s)@([^@\s]+(?:@[^@\s]+)?)/gi); + + if (mentions) { + to = to.union(mentions.map(mention => mention.trim().slice(1))); + } + + dispatch(submitComposeRequest(composeId)); + dispatch(closeModal()); + + const idempotencyKey = compose.idempotencyKey; + + const params: Record = { + status, + in_reply_to_id: compose.in_reply_to, + quote_id: compose.quote, + media_ids: media.map(item => item.id), + sensitive: compose.sensitive, + spoiler_text: compose.spoiler_text, + visibility: compose.privacy, + content_type: compose.content_type, + poll: compose.poll, + scheduled_at: compose.schedule, + to, + }; + + if (compose.privacy === 'group') { + params.group_id = compose.group_id; + params.group_timeline_visible = compose.group_timeline_visible; // Truth Social + } + + return dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) { + if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && history) { + history.push('/messages'); + } + handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId); + }).catch(function(error) { + dispatch(submitComposeFail(composeId, error)); + }); + }; + +const submitComposeRequest = (composeId: string) => ({ + type: COMPOSE_SUBMIT_REQUEST, + id: composeId, +}); + +const submitComposeSuccess = (composeId: string, status: APIEntity) => ({ + type: COMPOSE_SUBMIT_SUCCESS, + id: composeId, + status: status, +}); + +const submitComposeFail = (composeId: string, error: unknown) => ({ + type: COMPOSE_SUBMIT_FAIL, + id: composeId, + error: error, +}); + +const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const attachmentLimit = getState().instance.configuration.statuses.max_media_attachments; + + const media = getState().compose.get(composeId)?.media_attachments; + const progress: number[] = new Array(files.length).fill(0); + const mediaCount = media ? media.size : 0; + + if (files.length + mediaCount > attachmentLimit) { + toast.error(messages.uploadErrorLimit); + return; + } + + dispatch(uploadComposeRequest(composeId)); + + Array.from(files).forEach(async(f, i) => { + if (mediaCount + i > attachmentLimit - 1) return; + + dispatch(uploadFile( + f, + intl, + (data) => dispatch(uploadComposeSuccess(composeId, data, f)), + (error) => { + console.error(error); + dispatch(uploadComposeFail(composeId, error)); + }, + (e: ProgressEvent) => { + progress[i] = e.loaded; + dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), e.total)); + }, + )); + + }); + }; + +const uploadComposeRequest = (composeId: string) => ({ + type: COMPOSE_UPLOAD_REQUEST, + id: composeId, + skipLoading: true, +}); + +const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({ + type: COMPOSE_UPLOAD_PROGRESS, + id: composeId, + loaded: loaded, + total: total, +}); + +const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({ + type: COMPOSE_UPLOAD_SUCCESS, + id: composeId, + media: media, + file, + skipLoading: true, +}); + +const uploadComposeFail = (composeId: string, error: unknown) => ({ + type: COMPOSE_UPLOAD_FAIL, + id: composeId, + error: error, + skipLoading: true, +}); + +const changeUploadCompose = (composeId: string, id: string, params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(changeUploadComposeRequest(composeId)); + + dispatch(updateMedia(id, params)).then((response) => response.json()).then((data) => { + dispatch(changeUploadComposeSuccess(composeId, data)); + }).catch(error => { + dispatch(changeUploadComposeFail(composeId, id, error)); + }); + }; + +const changeUploadComposeRequest = (composeId: string) => ({ + type: COMPOSE_UPLOAD_CHANGE_REQUEST, + id: composeId, + skipLoading: true, +}); + +const changeUploadComposeSuccess = (composeId: string, media: APIEntity) => ({ + type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + id: composeId, + media: media, + skipLoading: true, +}); + +const changeUploadComposeFail = (composeId: string, id: string, error: unknown) => ({ + type: COMPOSE_UPLOAD_CHANGE_FAIL, + composeId, + id, + error: error, + skipLoading: true, +}); + +const undoUploadCompose = (composeId: string, media_id: string) => ({ + type: COMPOSE_UPLOAD_UNDO, + id: composeId, + media_id: media_id, +}); + +const groupCompose = (composeId: string, groupId: string) => ({ + type: COMPOSE_GROUP_POST, + id: composeId, + group_id: groupId, +}); + +const setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolean) => ({ + type: COMPOSE_SET_GROUP_TIMELINE_VISIBLE, + id: composeId, + groupTimelineVisible, +}); + +const clearComposeSuggestions = (composeId: string) => { + cancelFetchComposeSuggestions?.abort(); + + return { + type: COMPOSE_SUGGESTIONS_CLEAR, + id: composeId, + }; +}; + +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => { + cancelFetchComposeSuggestions?.abort(); + + api(getState).get('/api/v1/accounts/search', { + signal: cancelFetchComposeSuggestions?.signal, + searchParams: { + q: token.slice(1), + resolve: false, + limit: 10, + }, + }).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(readyComposeSuggestionsAccounts(composeId, token, data)); + }).catch(error => { + if (error instanceof HTTPError) { + toast.showAlertForError(error); + } + }); +}, 200, { edges: ['leading', 'trailing'] }); + +const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, composeId: string, token: string, customEmojis: CustomEmoji[]) => { + const results = emojiSearch(token.replace(':', ''), { maxResults: 10 }, customEmojis); + + dispatch(readyComposeSuggestionsEmojis(composeId, token, results)); +}; + +const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { + cancelFetchComposeSuggestions?.abort(); + + const state = getState(); + + const instance = state.instance; + const { trends } = getFeatures(instance); + + if (trends) { + const currentTrends = state.trends.items; + + return dispatch(updateSuggestionTags(composeId, token, currentTrends)); + } + + api(getState).get('/api/v2/search', { + signal: cancelFetchComposeSuggestions?.signal, + searchParams: { + q: token.slice(1), + limit: 10, + type: 'hashtags', + }, + }).then((response) => response.json()).then((data) => { + dispatch(updateSuggestionTags(composeId, token, data?.hashtags.map(normalizeTag))); + }).catch(error => { + if (error instanceof HTTPError) { + toast.showAlertForError(error); + } + }); +}; + +const fetchComposeSuggestions = (composeId: string, token: string, customEmojis: CustomEmoji[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + switch (token[0]) { + case ':': + fetchComposeSuggestionsEmojis(dispatch, composeId, token, customEmojis); + break; + case '#': + fetchComposeSuggestionsTags(dispatch, getState, composeId, token); + break; + default: + fetchComposeSuggestionsAccounts(dispatch, getState, composeId, token); + break; + } + }; + +interface ComposeSuggestionsReadyAction { + type: typeof COMPOSE_SUGGESTIONS_READY; + id: string; + token: string; + emojis?: Emoji[]; + accounts?: APIEntity[]; +} + +const readyComposeSuggestionsEmojis = (composeId: string, token: string, emojis: Emoji[]) => ({ + type: COMPOSE_SUGGESTIONS_READY, + id: composeId, + token, + emojis, +}); + +const readyComposeSuggestionsAccounts = (composeId: string, token: string, accounts: APIEntity[]) => ({ + type: COMPOSE_SUGGESTIONS_READY, + id: composeId, + token, + accounts, +}); + +interface ComposeSuggestionSelectAction { + type: typeof COMPOSE_SUGGESTION_SELECT; + id: string; + position: number; + token: string | null; + completion: string; + path: Array; +} + +const selectComposeSuggestion = (composeId: string, position: number, token: string | null, suggestion: AutoSuggestion, path: Array) => + (dispatch: AppDispatch, getState: () => RootState) => { + let completion = '', startPosition = position; + + if (typeof suggestion === 'object' && suggestion.id) { + completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons; + startPosition = position - 1; + + dispatch(chooseEmoji(suggestion)); + } else if (typeof suggestion === 'string' && suggestion[0] === '#') { + completion = suggestion; + startPosition = position - 1; + } else if (typeof suggestion === 'string') { + completion = selectAccount(getState(), suggestion)!.acct; + startPosition = position; + } + + const action: ComposeSuggestionSelectAction = { + type: COMPOSE_SUGGESTION_SELECT, + id: composeId, + position: startPosition, + token, + completion, + path, + }; + + dispatch(action); + }; + +const updateSuggestionTags = (composeId: string, token: string, tags: ImmutableList) => ({ + type: COMPOSE_SUGGESTION_TAGS_UPDATE, + id: composeId, + token, + tags, +}); + +const updateTagHistory = (composeId: string, tags: string[]) => ({ + type: COMPOSE_TAG_HISTORY_UPDATE, + id: composeId, + tags, +}); + +const insertIntoTagHistory = (composeId: string, recognizedTags: APIEntity[], text: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const oldHistory = state.compose.get(composeId)!.tagHistory; + const me = state.me; + const names = recognizedTags + .filter(tag => text.match(new RegExp(`#${tag.name}`, 'i'))) + .map(tag => tag.name); + const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1); + + names.push(...intersectedOldHistory.toJS()); + + const newHistory = names.slice(0, 1000); + + tagHistory.set(me as string, newHistory); + dispatch(updateTagHistory(composeId, newHistory)); + }; + +const changeComposeSpoilerness = (composeId: string) => ({ + type: COMPOSE_SPOILERNESS_CHANGE, + id: composeId, +}); + +const changeComposeContentType = (composeId: string, value: string) => ({ + type: COMPOSE_TYPE_CHANGE, + id: composeId, + value, +}); + +const changeComposeSpoilerText = (composeId: string, text: string) => ({ + type: COMPOSE_SPOILER_TEXT_CHANGE, + id: composeId, + text, +}); + +const changeComposeVisibility = (composeId: string, value: string) => ({ + type: COMPOSE_VISIBILITY_CHANGE, + id: composeId, + value, +}); + +const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({ + type: COMPOSE_EMOJI_INSERT, + id: composeId, + position, + emoji, + needsSpace, +}); + +const addPoll = (composeId: string) => ({ + type: COMPOSE_POLL_ADD, + id: composeId, +}); + +const removePoll = (composeId: string) => ({ + type: COMPOSE_POLL_REMOVE, + id: composeId, +}); + +const addSchedule = (composeId: string) => ({ + type: COMPOSE_SCHEDULE_ADD, + id: composeId, +}); + +const setSchedule = (composeId: string, date: Date) => ({ + type: COMPOSE_SCHEDULE_SET, + id: composeId, + date: date, +}); + +const removeSchedule = (composeId: string) => ({ + type: COMPOSE_SCHEDULE_REMOVE, + id: composeId, +}); + +const addPollOption = (composeId: string, title: string) => ({ + type: COMPOSE_POLL_OPTION_ADD, + id: composeId, + title, +}); + +const changePollOption = (composeId: string, index: number, title: string) => ({ + type: COMPOSE_POLL_OPTION_CHANGE, + id: composeId, + index, + title, +}); + +const removePollOption = (composeId: string, index: number) => ({ + type: COMPOSE_POLL_OPTION_REMOVE, + id: composeId, + index, +}); + +const changePollSettings = (composeId: string, expiresIn?: number, isMultiple?: boolean) => ({ + type: COMPOSE_POLL_SETTINGS_CHANGE, + id: composeId, + expiresIn, + isMultiple, +}); + +const openComposeWithText = (composeId: string, text = '') => + (dispatch: AppDispatch) => { + dispatch(resetCompose(composeId)); + dispatch(openModal('COMPOSE')); + dispatch(changeCompose(composeId, text)); + }; + +interface ComposeAddToMentionsAction { + type: typeof COMPOSE_ADD_TO_MENTIONS; + id: string; + account: string; +} + +const addToMentions = (composeId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const account = selectAccount(state, accountId); + if (!account) return; + + const action: ComposeAddToMentionsAction = { + type: COMPOSE_ADD_TO_MENTIONS, + id: composeId, + account: account.acct, + }; + + return dispatch(action); + }; + +interface ComposeRemoveFromMentionsAction { + type: typeof COMPOSE_REMOVE_FROM_MENTIONS; + id: string; + account: string; +} + +const removeFromMentions = (composeId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const account = selectAccount(state, accountId); + if (!account) return; + + const action: ComposeRemoveFromMentionsAction = { + type: COMPOSE_REMOVE_FROM_MENTIONS, + id: composeId, + account: account.acct, + }; + + return dispatch(action); + }; + +interface ComposeEventReplyAction { + type: typeof COMPOSE_EVENT_REPLY; + id: string; + status: Status; + account: Account; + explicitAddressing: boolean; +} + +const eventDiscussionCompose = (composeId: string, status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const { explicitAddressing } = getFeatures(instance); + + return dispatch({ + type: COMPOSE_EVENT_REPLY, + id: composeId, + status: status, + account: selectOwnAccount(state), + explicitAddressing, + }); + }; + +const setEditorState = (composeId: string, editorState: EditorState | string | null) => ({ + type: COMPOSE_EDITOR_STATE_SET, + id: composeId, + editorState: editorState, +}); + +const changeMediaOrder = (composeId: string, a: string, b: string) => ({ + type: COMPOSE_CHANGE_MEDIA_ORDER, + id: composeId, + a, + b, +}); + +type ComposeAction = + ComposeSetStatusAction + | ReturnType + | ComposeReplyAction + | ReturnType + | ComposeQuoteAction + | ReturnType + | ReturnType + | ComposeMentionAction + | ComposeDirectAction + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ComposeSuggestionsReadyAction + | ComposeSuggestionSelectAction + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ComposeAddToMentionsAction + | ComposeRemoveFromMentionsAction + | ComposeEventReplyAction + | ReturnType + | ReturnType + +export { + COMPOSE_CHANGE, + COMPOSE_SUBMIT_REQUEST, + COMPOSE_SUBMIT_SUCCESS, + COMPOSE_SUBMIT_FAIL, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_EVENT_REPLY, + COMPOSE_QUOTE, + COMPOSE_QUOTE_CANCEL, + COMPOSE_DIRECT, + COMPOSE_MENTION, + COMPOSE_RESET, + COMPOSE_UPLOAD_REQUEST, + COMPOSE_UPLOAD_SUCCESS, + COMPOSE_UPLOAD_FAIL, + COMPOSE_UPLOAD_PROGRESS, + COMPOSE_UPLOAD_UNDO, + COMPOSE_GROUP_POST, + COMPOSE_SUGGESTIONS_CLEAR, + COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_TAGS_UPDATE, + COMPOSE_TAG_HISTORY_UPDATE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_TYPE_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_LISTABILITY_CHANGE, + COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_POLL_ADD, + COMPOSE_POLL_REMOVE, + COMPOSE_POLL_OPTION_ADD, + COMPOSE_POLL_OPTION_CHANGE, + COMPOSE_POLL_OPTION_REMOVE, + COMPOSE_POLL_SETTINGS_CHANGE, + COMPOSE_SCHEDULE_ADD, + COMPOSE_SCHEDULE_SET, + COMPOSE_SCHEDULE_REMOVE, + COMPOSE_ADD_TO_MENTIONS, + COMPOSE_REMOVE_FROM_MENTIONS, + COMPOSE_EDITOR_STATE_SET, + COMPOSE_SET_GROUP_TIMELINE_VISIBLE, + COMPOSE_CHANGE_MEDIA_ORDER, + changeCompose, + replyCompose, + cancelReplyCompose, + quoteCompose, + cancelQuoteCompose, + resetCompose, + mentionCompose, + directCompose, + directComposeById, + handleComposeSubmit, + submitCompose, + submitComposeRequest, + submitComposeSuccess, + submitComposeFail, + uploadFile, + uploadCompose, + changeUploadCompose, + changeUploadComposeRequest, + changeUploadComposeSuccess, + changeUploadComposeFail, + uploadComposeRequest, + uploadComposeProgress, + uploadComposeSuccess, + uploadComposeFail, + undoUploadCompose, + groupCompose, + groupComposeModal, + setGroupTimelineVisible, + clearComposeSuggestions, + fetchComposeSuggestions, + readyComposeSuggestionsEmojis, + readyComposeSuggestionsAccounts, + selectComposeSuggestion, + updateSuggestionTags, + updateTagHistory, + changeComposeSpoilerness, + changeComposeContentType, + changeComposeSpoilerText, + changeComposeVisibility, + insertEmojiCompose, + addPoll, + removePoll, + addSchedule, + setSchedule, + removeSchedule, + addPollOption, + changePollOption, + removePollOption, + changePollSettings, + openComposeWithText, + addToMentions, + removeFromMentions, + eventDiscussionCompose, + setEditorState, + changeMediaOrder, + type ComposeAction, +}; diff --git a/src/actions/consumer-auth.ts b/src/actions/consumer-auth.ts new file mode 100644 index 0000000..1b88677 --- /dev/null +++ b/src/actions/consumer-auth.ts @@ -0,0 +1,46 @@ +import * as BuildConfig from 'soapbox/build-config.ts'; +import { isURL } from 'soapbox/utils/auth.ts'; +import sourceCode from 'soapbox/utils/code.ts'; +import { getScopes } from 'soapbox/utils/scopes.ts'; + +import { createApp } from './apps.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const createProviderApp = () => { + return async(dispatch: AppDispatch, getState: () => RootState) => { + const scopes = getScopes(getState()); + + const params = { + client_name: sourceCode.displayName, + redirect_uris: `${window.location.origin}/login/external`, + website: sourceCode.homepage, + scopes, + }; + + return dispatch(createApp(params)); + }; +}; + +export const prepareRequest = (provider: string) => { + return async(dispatch: AppDispatch, getState: () => RootState) => { + const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : ''; + + const scopes = getScopes(getState()); + const app = await dispatch(createProviderApp()); + const { client_id, redirect_uri } = app; + + localStorage.setItem('soapbox:external:app', JSON.stringify(app)); + localStorage.setItem('soapbox:external:baseurl', baseURL); + localStorage.setItem('soapbox:external:scopes', scopes); + + const query = new URLSearchParams({ provider }); + + // FIXME: I don't know if this is the correct way to encode the query params. + query.append('authorization.client_id', client_id); + query.append('authorization.redirect_uri', redirect_uri); + query.append('authorization.scope', scopes); + + location.href = `${baseURL}/oauth/prepare_request?${query.toString()}`; + }; +}; diff --git a/src/actions/conversations.ts b/src/actions/conversations.ts new file mode 100644 index 0000000..650d1bf --- /dev/null +++ b/src/actions/conversations.ts @@ -0,0 +1,113 @@ +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import { + importFetchedAccounts, + importFetchedStatuses, + importFetchedStatus, +} from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT'; +const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT'; + +const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST'; +const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; +const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'; +const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; + +const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; + +const mountConversations = () => ({ + type: CONVERSATIONS_MOUNT, +}); + +const unmountConversations = () => ({ + type: CONVERSATIONS_UNMOUNT, +}); + +const markConversationRead = (conversationId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ + type: CONVERSATIONS_READ, + id: conversationId, + }); + + api(getState).post(`/api/v1/conversations/${conversationId}/read`); +}; + +const expandConversations = ({ maxId }: Record = {}) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(expandConversationsRequest()); + + const params: Record = { max_id: maxId }; + + if (!maxId) { + params.since_id = getState().conversations.items.getIn([0, 'id']); + } + + const isLoadingRecent = !!params.since_id; + + api(getState).get('/api/v1/conversations', { searchParams: params }) + .then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data.reduce((aggr: Array, item: APIEntity) => aggr.concat(item.accounts), []))); + dispatch(importFetchedStatuses(data.map((item: Record) => item.last_status).filter((x?: APIEntity) => !!x))); + dispatch(expandConversationsSuccess(data, next, isLoadingRecent)); + }) + .catch(err => dispatch(expandConversationsFail(err))); +}; + +const expandConversationsRequest = () => ({ + type: CONVERSATIONS_FETCH_REQUEST, +}); + +const expandConversationsSuccess = (conversations: APIEntity[], next: string | null, isLoadingRecent: boolean) => ({ + type: CONVERSATIONS_FETCH_SUCCESS, + conversations, + next, + isLoadingRecent, +}); + +const expandConversationsFail = (error: unknown) => ({ + type: CONVERSATIONS_FETCH_FAIL, + error, +}); + +const updateConversations = (conversation: APIEntity) => (dispatch: AppDispatch) => { + dispatch(importFetchedAccounts(conversation.accounts)); + + if (conversation.last_status) { + dispatch(importFetchedStatus(conversation.last_status)); + } + + return dispatch({ + type: CONVERSATIONS_UPDATE, + conversation, + }); +}; + +export { + CONVERSATIONS_MOUNT, + CONVERSATIONS_UNMOUNT, + CONVERSATIONS_FETCH_REQUEST, + CONVERSATIONS_FETCH_SUCCESS, + CONVERSATIONS_FETCH_FAIL, + CONVERSATIONS_UPDATE, + CONVERSATIONS_READ, + mountConversations, + unmountConversations, + markConversationRead, + expandConversations, + expandConversationsRequest, + expandConversationsSuccess, + expandConversationsFail, + updateConversations, +}; \ No newline at end of file diff --git a/src/actions/directory.ts b/src/actions/directory.ts new file mode 100644 index 0000000..ac29b88 --- /dev/null +++ b/src/actions/directory.ts @@ -0,0 +1,84 @@ +import api from '../api/index.ts'; + +import { fetchRelationships } from './accounts.ts'; +import { importFetchedAccounts } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +const fetchDirectory = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { searchParams: { ...params, limit: 20 } }).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map((x: APIEntity) => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); + }; + +const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +const fetchDirectorySuccess = (accounts: APIEntity[]) => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +const fetchDirectoryFail = (error: unknown) => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +const expandDirectory = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().user_lists.directory.items.size; + + api(getState).get('/api/v1/directory', { searchParams: { ...params, offset: loadedItems, limit: 20 } }).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map((x: APIEntity) => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); + }; + +const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +const expandDirectorySuccess = (accounts: APIEntity[]) => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +const expandDirectoryFail = (error: unknown) => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); + +export { + DIRECTORY_FETCH_REQUEST, + DIRECTORY_FETCH_SUCCESS, + DIRECTORY_FETCH_FAIL, + DIRECTORY_EXPAND_REQUEST, + DIRECTORY_EXPAND_SUCCESS, + DIRECTORY_EXPAND_FAIL, + fetchDirectory, + fetchDirectoryRequest, + fetchDirectorySuccess, + fetchDirectoryFail, + expandDirectory, + expandDirectoryRequest, + expandDirectorySuccess, + expandDirectoryFail, +}; \ No newline at end of file diff --git a/src/actions/domain-blocks.ts b/src/actions/domain-blocks.ts new file mode 100644 index 0000000..62dea37 --- /dev/null +++ b/src/actions/domain-blocks.ts @@ -0,0 +1,197 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import type { EntityStore } from 'soapbox/entity-store/types.ts'; +import type { Account } from 'soapbox/schemas/index.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; +const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; +const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; + +const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; +const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS'; +const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; + +const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; +const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; +const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; + +const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST'; +const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS'; +const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL'; + +const blockDomain = (domain: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(blockDomainRequest(domain)); + + api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { + const accounts = selectAccountsByDomain(getState(), domain); + if (!accounts) return; + dispatch(blockDomainSuccess(domain, accounts)); + }).catch(err => { + dispatch(blockDomainFail(domain, err)); + }); + }; + +const blockDomainRequest = (domain: string) => ({ + type: DOMAIN_BLOCK_REQUEST, + domain, +}); + +const blockDomainSuccess = (domain: string, accounts: string[]) => ({ + type: DOMAIN_BLOCK_SUCCESS, + domain, + accounts, +}); + +const blockDomainFail = (domain: string, error: unknown) => ({ + type: DOMAIN_BLOCK_FAIL, + domain, + error, +}); + +const unblockDomain = (domain: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unblockDomainRequest(domain)); + + const data = new FormData(); + data.append('domain', domain); + + api(getState).request('DELETE', '/api/v1/domain_blocks', data).then(() => { + const accounts = selectAccountsByDomain(getState(), domain); + if (!accounts) return; + dispatch(unblockDomainSuccess(domain, accounts)); + }).catch(err => { + dispatch(unblockDomainFail(domain, err)); + }); + }; + +const unblockDomainRequest = (domain: string) => ({ + type: DOMAIN_UNBLOCK_REQUEST, + domain, +}); + +const unblockDomainSuccess = (domain: string, accounts: string[]) => ({ + type: DOMAIN_UNBLOCK_SUCCESS, + domain, + accounts, +}); + +const unblockDomainFail = (domain: string, error: unknown) => ({ + type: DOMAIN_UNBLOCK_FAIL, + domain, + error, +}); + +const fetchDomainBlocks = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchDomainBlocksRequest()); + + api(getState).get('/api/v1/domain_blocks').then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(fetchDomainBlocksSuccess(data, next)); + }).catch(err => { + dispatch(fetchDomainBlocksFail(err)); + }); + }; + +const fetchDomainBlocksRequest = () => ({ + type: DOMAIN_BLOCKS_FETCH_REQUEST, +}); + +const fetchDomainBlocksSuccess = (domains: string[], next: string | null) => ({ + type: DOMAIN_BLOCKS_FETCH_SUCCESS, + domains, + next, +}); + +const fetchDomainBlocksFail = (error: unknown) => ({ + type: DOMAIN_BLOCKS_FETCH_FAIL, + error, +}); + +const expandDomainBlocks = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().domain_lists.blocks.next; + + if (!url) { + return; + } + + dispatch(expandDomainBlocksRequest()); + + api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(expandDomainBlocksSuccess(data, next)); + }).catch(err => { + dispatch(expandDomainBlocksFail(err)); + }); + }; + +function selectAccountsByDomain(state: RootState, domain: string): string[] { + const store = state.entities[Entities.ACCOUNTS]?.store as EntityStore | undefined; + const entries = store ? Object.entries(store) : undefined; + const accounts = entries + ?.filter(([_, item]) => item && item.acct.endsWith(`@${domain}`)) + .map(([_, item]) => item!.id); + return accounts || []; +} + +const expandDomainBlocksRequest = () => ({ + type: DOMAIN_BLOCKS_EXPAND_REQUEST, +}); + +const expandDomainBlocksSuccess = (domains: string[], next: string | null) => ({ + type: DOMAIN_BLOCKS_EXPAND_SUCCESS, + domains, + next, +}); + +const expandDomainBlocksFail = (error: unknown) => ({ + type: DOMAIN_BLOCKS_EXPAND_FAIL, + error, +}); + +export { + DOMAIN_BLOCK_REQUEST, + DOMAIN_BLOCK_SUCCESS, + DOMAIN_BLOCK_FAIL, + DOMAIN_UNBLOCK_REQUEST, + DOMAIN_UNBLOCK_SUCCESS, + DOMAIN_UNBLOCK_FAIL, + DOMAIN_BLOCKS_FETCH_REQUEST, + DOMAIN_BLOCKS_FETCH_SUCCESS, + DOMAIN_BLOCKS_FETCH_FAIL, + DOMAIN_BLOCKS_EXPAND_REQUEST, + DOMAIN_BLOCKS_EXPAND_SUCCESS, + DOMAIN_BLOCKS_EXPAND_FAIL, + blockDomain, + blockDomainRequest, + blockDomainSuccess, + blockDomainFail, + unblockDomain, + unblockDomainRequest, + unblockDomainSuccess, + unblockDomainFail, + fetchDomainBlocks, + fetchDomainBlocksRequest, + fetchDomainBlocksSuccess, + fetchDomainBlocksFail, + expandDomainBlocks, + expandDomainBlocksRequest, + expandDomainBlocksSuccess, + expandDomainBlocksFail, +}; diff --git a/src/actions/dropdown-menu.ts b/src/actions/dropdown-menu.ts new file mode 100644 index 0000000..cad73f3 --- /dev/null +++ b/src/actions/dropdown-menu.ts @@ -0,0 +1,12 @@ +const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; +const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; + +const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN }); +const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE }); + +export { + DROPDOWN_MENU_OPEN, + DROPDOWN_MENU_CLOSE, + openDropdownMenu, + closeDropdownMenu, +}; diff --git a/src/actions/email-list.ts b/src/actions/email-list.ts new file mode 100644 index 0000000..8f80f96 --- /dev/null +++ b/src/actions/email-list.ts @@ -0,0 +1,21 @@ +import api from '../api/index.ts'; + +import type { RootState } from 'soapbox/store.ts'; + +const getSubscribersCsv = () => + (dispatch: any, getState: () => RootState) => + api(getState).get('/api/v1/pleroma/admin/email_list/subscribers.csv'); + +const getUnsubscribersCsv = () => + (dispatch: any, getState: () => RootState) => + api(getState).get('/api/v1/pleroma/admin/email_list/unsubscribers.csv'); + +const getCombinedCsv = () => + (dispatch: any, getState: () => RootState) => + api(getState).get('/api/v1/pleroma/admin/email_list/combined.csv'); + +export { + getSubscribersCsv, + getUnsubscribersCsv, + getCombinedCsv, +}; diff --git a/src/actions/emoji-reacts.ts b/src/actions/emoji-reacts.ts new file mode 100644 index 0000000..91f726f --- /dev/null +++ b/src/actions/emoji-reacts.ts @@ -0,0 +1,190 @@ +import { List as ImmutableList } from 'immutable'; + +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import { importFetchedAccounts, importFetchedStatus } from './importer/index.ts'; +import { favourite, unfavourite } from './interactions.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity, EmojiReaction, Status } from 'soapbox/types/entities.ts'; + +const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST'; +const EMOJI_REACT_SUCCESS = 'EMOJI_REACT_SUCCESS'; +const EMOJI_REACT_FAIL = 'EMOJI_REACT_FAIL'; + +const UNEMOJI_REACT_REQUEST = 'UNEMOJI_REACT_REQUEST'; +const UNEMOJI_REACT_SUCCESS = 'UNEMOJI_REACT_SUCCESS'; +const UNEMOJI_REACT_FAIL = 'UNEMOJI_REACT_FAIL'; + +const EMOJI_REACTS_FETCH_REQUEST = 'EMOJI_REACTS_FETCH_REQUEST'; +const EMOJI_REACTS_FETCH_SUCCESS = 'EMOJI_REACTS_FETCH_SUCCESS'; +const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL'; + +const noOp = () => () => new Promise(f => f(undefined)); + +const simpleEmojiReact = (status: Status, emoji: string, custom?: string) => + (dispatch: AppDispatch) => { + const emojiReacts: ImmutableList = status.reactions || ImmutableList(); + + if (emoji === '👍' && status.favourited) return dispatch(unfavourite(status)); + + const undo = emojiReacts.filter(e => e.me === true && e.name === emoji).count() > 0; + if (undo) return dispatch(unEmojiReact(status, emoji)); + + return Promise.all([ + ...emojiReacts + .filter((emojiReact) => emojiReact.me === true) + .map(emojiReact => dispatch(unEmojiReact(status, emojiReact.name))).toArray(), + status.favourited && dispatch(unfavourite(status)), + ]).then(() => { + if (emoji === '👍') { + dispatch(favourite(status)); + } else { + dispatch(emojiReact(status, emoji, custom)); + } + }).catch(err => { + console.error(err); + }); + }; + +const fetchEmojiReacts = (id: string, emoji: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp()); + + dispatch(fetchEmojiReactsRequest(id, emoji)); + + const url = emoji + ? `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` + : `/api/v1/pleroma/statuses/${id}/reactions`; + + return api(getState).get(url).then((response) => response.json()).then((data) => { + data.forEach((emojiReact: APIEntity) => { + dispatch(importFetchedAccounts(emojiReact.accounts)); + }); + dispatch(fetchEmojiReactsSuccess(id, data)); + }).catch(error => { + dispatch(fetchEmojiReactsFail(id, error)); + }); + }; + +const emojiReact = (status: Status, emoji: string, custom?: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp()); + + dispatch(emojiReactRequest(status, emoji, custom)); + + return api(getState) + .put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`) + .then((response) => response.json()).then((data) => { + dispatch(importFetchedStatus(data)); + dispatch(emojiReactSuccess(status, emoji)); + }).catch((error) => { + dispatch(emojiReactFail(status, emoji, error)); + }); + }; + +const unEmojiReact = (status: Status, emoji: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp()); + + dispatch(unEmojiReactRequest(status, emoji)); + + return api(getState) + .delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`) + .then((response) => response.json()).then((data) => { + dispatch(importFetchedStatus(data)); + dispatch(unEmojiReactSuccess(status, emoji)); + }).catch(error => { + dispatch(unEmojiReactFail(status, emoji, error)); + }); + }; + +const fetchEmojiReactsRequest = (id: string, emoji: string) => ({ + type: EMOJI_REACTS_FETCH_REQUEST, + id, + emoji, +}); + +const fetchEmojiReactsSuccess = (id: string, emojiReacts: APIEntity[]) => ({ + type: EMOJI_REACTS_FETCH_SUCCESS, + id, + emojiReacts, +}); + +const fetchEmojiReactsFail = (id: string, error: unknown) => ({ + type: EMOJI_REACTS_FETCH_FAIL, + id, + error, +}); + +const emojiReactRequest = (status: Status, emoji: string, custom?: string) => ({ + type: EMOJI_REACT_REQUEST, + status, + emoji, + custom, + skipLoading: true, +}); + +const emojiReactSuccess = (status: Status, emoji: string) => ({ + type: EMOJI_REACT_SUCCESS, + status, + emoji, + skipLoading: true, +}); + +const emojiReactFail = (status: Status, emoji: string, error: unknown) => ({ + type: EMOJI_REACT_FAIL, + status, + emoji, + error, + skipLoading: true, +}); + +const unEmojiReactRequest = (status: Status, emoji: string) => ({ + type: UNEMOJI_REACT_REQUEST, + status, + emoji, + skipLoading: true, +}); + +const unEmojiReactSuccess = (status: Status, emoji: string) => ({ + type: UNEMOJI_REACT_SUCCESS, + status, + emoji, + skipLoading: true, +}); + +const unEmojiReactFail = (status: Status, emoji: string, error: unknown) => ({ + type: UNEMOJI_REACT_FAIL, + status, + emoji, + error, + skipLoading: true, +}); + +export { + EMOJI_REACT_REQUEST, + EMOJI_REACT_SUCCESS, + EMOJI_REACT_FAIL, + UNEMOJI_REACT_REQUEST, + UNEMOJI_REACT_SUCCESS, + UNEMOJI_REACT_FAIL, + EMOJI_REACTS_FETCH_REQUEST, + EMOJI_REACTS_FETCH_SUCCESS, + EMOJI_REACTS_FETCH_FAIL, + simpleEmojiReact, + fetchEmojiReacts, + emojiReact, + unEmojiReact, + fetchEmojiReactsRequest, + fetchEmojiReactsSuccess, + fetchEmojiReactsFail, + emojiReactRequest, + emojiReactSuccess, + emojiReactFail, + unEmojiReactRequest, + unEmojiReactSuccess, + unEmojiReactFail, +}; diff --git a/src/actions/emojis.ts b/src/actions/emojis.ts new file mode 100644 index 0000000..88e73a8 --- /dev/null +++ b/src/actions/emojis.ts @@ -0,0 +1,21 @@ +import { saveSettings } from './settings.ts'; + +import type { Emoji } from 'soapbox/features/emoji/index.ts'; +import type { AppDispatch } from 'soapbox/store.ts'; + +const EMOJI_CHOOSE = 'EMOJI_CHOOSE'; + +const chooseEmoji = (emoji: Emoji) => + (dispatch: AppDispatch) => { + dispatch({ + type: EMOJI_CHOOSE, + emoji, + }); + + dispatch(saveSettings()); + }; + +export { + EMOJI_CHOOSE, + chooseEmoji, +}; diff --git a/src/actions/events.ts b/src/actions/events.ts new file mode 100644 index 0000000..0e147c1 --- /dev/null +++ b/src/actions/events.ts @@ -0,0 +1,732 @@ +import { defineMessages, IntlShape } from 'react-intl'; + +import api from 'soapbox/api/index.ts'; +import toast from 'soapbox/toast.tsx'; + +import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer/index.ts'; +import { uploadFile } from './media.ts'; +import { closeModal, openModal } from './modals.ts'; +import { + STATUS_FETCH_SOURCE_FAIL, + STATUS_FETCH_SOURCE_REQUEST, + STATUS_FETCH_SOURCE_SUCCESS, +} from './statuses.ts'; + +import type { ReducerStatus } from 'soapbox/reducers/statuses.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities.ts'; + +const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST' as const; +const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS' as const; +const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL' as const; + +const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE' as const; +const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE' as const; +const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE' as const; +const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE' as const; +const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE' as const; +const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE' as const; +const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE' as const; + +const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST' as const; +const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS' as const; +const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS' as const; +const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL' as const; +const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO' as const; + +const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST' as const; +const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS' as const; +const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL' as const; + +const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST' as const; +const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS' as const; +const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL' as const; + +const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST' as const; +const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS' as const; +const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL' as const; + +const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST' as const; +const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS' as const; +const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL' as const; + +const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST' as const; +const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS' as const; +const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL' as const; + +const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST' as const; +const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS' as const; +const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL' as const; + +const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST' as const; +const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS' as const; +const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL' as const; + +const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST' as const; +const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS' as const; +const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL' as const; + +const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST' as const; +const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS' as const; +const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL' as const; + +const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL' as const; + +const EVENT_FORM_SET = 'EVENT_FORM_SET' as const; + +const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST' as const; +const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS' as const; +const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL' as const; +const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST' as const; +const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS' as const; +const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL' as const; + +const noOp = () => new Promise(f => f(undefined)); + +const messages = defineMessages({ + exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, + success: { id: 'compose_event.submit_success', defaultMessage: 'Your event was created' }, + editSuccess: { id: 'compose_event.edit_success', defaultMessage: 'Your event was edited' }, + joinSuccess: { id: 'join_event.success', defaultMessage: 'Joined the event' }, + joinRequestSuccess: { id: 'join_event.request_success', defaultMessage: 'Requested to join the event' }, + view: { id: 'toast.view', defaultMessage: 'View' }, + authorized: { id: 'compose_event.participation_requests.authorize_success', defaultMessage: 'User accepted' }, + rejected: { id: 'compose_event.participation_requests.reject_success', defaultMessage: 'User rejected' }, +}); + +const locationSearch = (query: string, signal?: AbortSignal) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: LOCATION_SEARCH_REQUEST, query }); + return api(getState).get('/api/v1/pleroma/search/location', { searchParams: { q: query }, signal }).then((response) => response.json()).then((locations) => { + dispatch({ type: LOCATION_SEARCH_SUCCESS, locations }); + return locations; + }).catch(error => { + dispatch({ type: LOCATION_SEARCH_FAIL }); + throw error; + }); + }; + +const changeEditEventName = (value: string) => ({ + type: EDIT_EVENT_NAME_CHANGE, + value, +}); + +const changeEditEventDescription = (value: string) => ({ + type: EDIT_EVENT_DESCRIPTION_CHANGE, + value, +}); + +const changeEditEventStartTime = (value: Date) => ({ + type: EDIT_EVENT_START_TIME_CHANGE, + value, +}); + +const changeEditEventEndTime = (value: Date) => ({ + type: EDIT_EVENT_END_TIME_CHANGE, + value, +}); + +const changeEditEventHasEndTime = (value: boolean) => ({ + type: EDIT_EVENT_HAS_END_TIME_CHANGE, + value, +}); + +const changeEditEventApprovalRequired = (value: boolean) => ({ + type: EDIT_EVENT_APPROVAL_REQUIRED_CHANGE, + value, +}); + +const changeEditEventLocation = (value: string | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + let location = null; + + if (value) { + location = getState().locations.get(value); + } + + dispatch({ + type: EDIT_EVENT_LOCATION_CHANGE, + value: location, + }); + }; + +const uploadEventBanner = (file: File, intl: IntlShape) => + (dispatch: AppDispatch) => { + let progress = 0; + + dispatch(uploadEventBannerRequest()); + + dispatch(uploadFile( + file, + intl, + (data) => dispatch(uploadEventBannerSuccess(data, file)), + (error) => dispatch(uploadEventBannerFail(error)), + ({ loaded }: ProgressEvent) => { + progress = loaded; + dispatch(uploadEventBannerProgress(progress)); + }, + )); + }; + +const uploadEventBannerRequest = () => ({ + type: EVENT_BANNER_UPLOAD_REQUEST, +}); + +const uploadEventBannerProgress = (loaded: number) => ({ + type: EVENT_BANNER_UPLOAD_PROGRESS, + loaded, +}); + +const uploadEventBannerSuccess = (media: APIEntity, file: File) => ({ + type: EVENT_BANNER_UPLOAD_SUCCESS, + media, + file, +}); + +const uploadEventBannerFail = (error: unknown) => ({ + type: EVENT_BANNER_UPLOAD_FAIL, + error, +}); + +const undoUploadEventBanner = () => ({ + type: EVENT_BANNER_UPLOAD_UNDO, +}); + +const submitEvent = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const id = state.compose_event.id; + const name = state.compose_event.name; + const status = state.compose_event.status; + const banner = state.compose_event.banner; + const startTime = state.compose_event.start_time; + const endTime = state.compose_event.end_time; + const joinMode = state.compose_event.approval_required ? 'restricted' : 'free'; + const location = state.compose_event.location; + + if (!name || !name.length) { + return; + } + + dispatch(submitEventRequest()); + + const params: Record = { + name, + status, + start_time: startTime, + join_mode: joinMode, + content_type: 'text/markdown', + }; + + if (endTime) params.end_time = endTime; + if (banner) params.banner_id = banner.id; + if (location) params.location_id = location.origin_id; + + const method = id === null ? 'POST' : 'PUT'; + const path = id === null ? '/api/v1/pleroma/events' : `/api/v1/pleroma/events/${id}`; + + return api(getState).request(method, path, params).then((response) => response.json()).then((data) => { + dispatch(closeModal('COMPOSE_EVENT')); + dispatch(importFetchedStatus(data)); + dispatch(submitEventSuccess(data)); + toast.success( + id ? messages.editSuccess : messages.success, + { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/events/${data.id}`, + }, + ); + }).catch(function(error) { + dispatch(submitEventFail(error)); + }); + }; + +const submitEventRequest = () => ({ + type: EVENT_SUBMIT_REQUEST, +}); + +const submitEventSuccess = (status: APIEntity) => ({ + type: EVENT_SUBMIT_SUCCESS, + status, +}); + +const submitEventFail = (error: unknown) => ({ + type: EVENT_SUBMIT_FAIL, + error, +}); + +const joinEvent = (id: string, participationMessage?: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const status = getState().statuses.get(id); + + if (!status || !status.event || status.event.join_state) { + return dispatch(noOp); + } + + dispatch(joinEventRequest(status)); + + return api(getState).post(`/api/v1/pleroma/events/${id}/join`, { + participation_message: participationMessage, + }).then((response) => response.json()).then((data) => { + dispatch(importFetchedStatus(data)); + dispatch(joinEventSuccess(data)); + toast.success( + data.pleroma.event?.join_state === 'pending' ? messages.joinRequestSuccess : messages.joinSuccess, + { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/events/${data.id}`, + }, + ); + }).catch(function(error) { + dispatch(joinEventFail(error, status, status?.event?.join_state || null)); + }); + }; + +const joinEventRequest = (status: StatusEntity) => ({ + type: EVENT_JOIN_REQUEST, + id: status.id, +}); + +const joinEventSuccess = (status: APIEntity) => ({ + type: EVENT_JOIN_SUCCESS, + id: status.id, +}); + +const joinEventFail = (error: unknown, status: StatusEntity, previousState: string | null) => ({ + type: EVENT_JOIN_FAIL, + error, + id: status.id, + previousState, +}); + +const leaveEvent = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const status = getState().statuses.get(id); + + if (!status || !status.event || !status.event.join_state) { + return dispatch(noOp); + } + + dispatch(leaveEventRequest(status)); + + return api(getState).post(`/api/v1/pleroma/events/${id}/leave`).then((response) => response.json()).then((data) => { + dispatch(importFetchedStatus(data)); + dispatch(leaveEventSuccess(data)); + }).catch(function(error) { + dispatch(leaveEventFail(error, status)); + }); + }; + +const leaveEventRequest = (status: StatusEntity) => ({ + type: EVENT_LEAVE_REQUEST, + id: status.id, +}); + +const leaveEventSuccess = (status: APIEntity) => ({ + type: EVENT_LEAVE_SUCCESS, + id: status.id, +}); + +const leaveEventFail = (error: unknown, status: StatusEntity) => ({ + type: EVENT_LEAVE_FAIL, + id: status.id, + error, +}); + +const fetchEventParticipations = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchEventParticipationsRequest(id)); + + return api(getState).get(`/api/v1/pleroma/events/${id}/participations`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + return dispatch(fetchEventParticipationsSuccess(id, data, next)); + }).catch(error => { + dispatch(fetchEventParticipationsFail(id, error)); + }); + }; + +const fetchEventParticipationsRequest = (id: string) => ({ + type: EVENT_PARTICIPATIONS_FETCH_REQUEST, + id, +}); + +const fetchEventParticipationsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: EVENT_PARTICIPATIONS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchEventParticipationsFail = (id: string, error: unknown) => ({ + type: EVENT_PARTICIPATIONS_FETCH_FAIL, + id, + error, +}); + +const expandEventParticipations = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.event_participations.get(id)?.next || null; + + if (url === null) { + return dispatch(noOp); + } + + dispatch(expandEventParticipationsRequest(id)); + + return api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + return dispatch(expandEventParticipationsSuccess(id, data, next)); + }).catch(error => { + dispatch(expandEventParticipationsFail(id, error)); + }); + }; + +const expandEventParticipationsRequest = (id: string) => ({ + type: EVENT_PARTICIPATIONS_EXPAND_REQUEST, + id, +}); + +const expandEventParticipationsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: EVENT_PARTICIPATIONS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandEventParticipationsFail = (id: string, error: unknown) => ({ + type: EVENT_PARTICIPATIONS_EXPAND_FAIL, + id, + error, +}); + +const fetchEventParticipationRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchEventParticipationRequestsRequest(id)); + + return api(getState).get(`/api/v1/pleroma/events/${id}/participation_requests`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data.map(({ account }: APIEntity) => account))); + return dispatch(fetchEventParticipationRequestsSuccess(id, data, next)); + }).catch(error => { + dispatch(fetchEventParticipationRequestsFail(id, error)); + }); + }; + +const fetchEventParticipationRequestsRequest = (id: string) => ({ + type: EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST, + id, +}); + +const fetchEventParticipationRequestsSuccess = (id: string, participations: APIEntity[], next: string | null) => ({ + type: EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS, + id, + participations, + next, +}); + +const fetchEventParticipationRequestsFail = (id: string, error: unknown) => ({ + type: EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL, + id, + error, +}); + +const expandEventParticipationRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.event_participations.get(id)?.next || null; + + if (url === null) { + return dispatch(noOp); + } + + dispatch(expandEventParticipationRequestsRequest(id)); + + return api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data.map(({ account }: APIEntity) => account))); + return dispatch(expandEventParticipationRequestsSuccess(id, data, next)); + }).catch(error => { + dispatch(expandEventParticipationRequestsFail(id, error)); + }); + }; + +const expandEventParticipationRequestsRequest = (id: string) => ({ + type: EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST, + id, +}); + +const expandEventParticipationRequestsSuccess = (id: string, participations: APIEntity[], next: string | null) => ({ + type: EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS, + id, + participations, + next, +}); + +const expandEventParticipationRequestsFail = (id: string, error: unknown) => ({ + type: EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL, + id, + error, +}); + +const authorizeEventParticipationRequest = (id: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(authorizeEventParticipationRequestRequest(id, accountId)); + + return api(getState) + .post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/authorize`) + .then(() => { + dispatch(authorizeEventParticipationRequestSuccess(id, accountId)); + toast.success(messages.authorized); + }) + .catch(error => dispatch(authorizeEventParticipationRequestFail(id, accountId, error))); + }; + +const authorizeEventParticipationRequestRequest = (id: string, accountId: string) => ({ + type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST, + id, + accountId, +}); + +const authorizeEventParticipationRequestSuccess = (id: string, accountId: string) => ({ + type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS, + id, + accountId, +}); + +const authorizeEventParticipationRequestFail = (id: string, accountId: string, error: unknown) => ({ + type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL, + id, + accountId, + error, +}); + +const rejectEventParticipationRequest = (id: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(rejectEventParticipationRequestRequest(id, accountId)); + + return api(getState) + .post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/reject`) + .then(() => { + dispatch(rejectEventParticipationRequestSuccess(id, accountId)); + toast.success(messages.rejected); + }) + .catch(error => dispatch(rejectEventParticipationRequestFail(id, accountId, error))); + }; + +const rejectEventParticipationRequestRequest = (id: string, accountId: string) => ({ + type: EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST, + id, + accountId, +}); + +const rejectEventParticipationRequestSuccess = (id: string, accountId: string) => ({ + type: EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS, + id, + accountId, +}); + +const rejectEventParticipationRequestFail = (id: string, accountId: string, error: unknown) => ({ + type: EVENT_PARTICIPATION_REQUEST_REJECT_FAIL, + id, + accountId, + error, +}); + +const fetchEventIcs = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState).get(`/api/v1/pleroma/events/${id}/ics`); + +const cancelEventCompose = () => ({ + type: EVENT_COMPOSE_CANCEL, +}); + +interface EventFormSetAction { + type: typeof EVENT_FORM_SET; + status: ReducerStatus; + text: string; + location: Record; +} + +const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + const status = getState().statuses.get(id)!; + + dispatch({ type: STATUS_FETCH_SOURCE_REQUEST }); + + api(getState).get(`/api/v1/statuses/${id}/source`).then((response) => response.json()).then((data) => { + dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); + dispatch({ + type: EVENT_FORM_SET, + status, + text: data.text, + location: data.location, + }); + dispatch(openModal('COMPOSE_EVENT')); + }).catch(error => { + dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); + }); +}; + +const fetchRecentEvents = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.get('recent_events')?.isLoading) { + return; + } + + dispatch({ type: RECENT_EVENTS_FETCH_REQUEST }); + + api(getState).get('/api/v1/timelines/public?only_events=true').then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedStatuses(data)); + dispatch({ + type: RECENT_EVENTS_FETCH_SUCCESS, + statuses: data, + next, + }); + }).catch(error => { + dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error }); + }); + }; + +const fetchJoinedEvents = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.get('joined_events')?.isLoading) { + return; + } + + dispatch({ type: JOINED_EVENTS_FETCH_REQUEST }); + + api(getState).get('/api/v1/pleroma/events/joined_events').then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedStatuses(data)); + dispatch({ + type: JOINED_EVENTS_FETCH_SUCCESS, + statuses: data, + next, + }); + }).catch(error => { + dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error }); + }); + }; + +type EventsAction = + | ReturnType + | EventFormSetAction; + +export { + LOCATION_SEARCH_REQUEST, + LOCATION_SEARCH_SUCCESS, + LOCATION_SEARCH_FAIL, + EDIT_EVENT_NAME_CHANGE, + EDIT_EVENT_DESCRIPTION_CHANGE, + EDIT_EVENT_START_TIME_CHANGE, + EDIT_EVENT_END_TIME_CHANGE, + EDIT_EVENT_HAS_END_TIME_CHANGE, + EDIT_EVENT_APPROVAL_REQUIRED_CHANGE, + EDIT_EVENT_LOCATION_CHANGE, + EVENT_BANNER_UPLOAD_REQUEST, + EVENT_BANNER_UPLOAD_PROGRESS, + EVENT_BANNER_UPLOAD_SUCCESS, + EVENT_BANNER_UPLOAD_FAIL, + EVENT_BANNER_UPLOAD_UNDO, + EVENT_SUBMIT_REQUEST, + EVENT_SUBMIT_SUCCESS, + EVENT_SUBMIT_FAIL, + EVENT_JOIN_REQUEST, + EVENT_JOIN_SUCCESS, + EVENT_JOIN_FAIL, + EVENT_LEAVE_REQUEST, + EVENT_LEAVE_SUCCESS, + EVENT_LEAVE_FAIL, + EVENT_PARTICIPATIONS_FETCH_REQUEST, + EVENT_PARTICIPATIONS_FETCH_SUCCESS, + EVENT_PARTICIPATIONS_FETCH_FAIL, + EVENT_PARTICIPATIONS_EXPAND_REQUEST, + EVENT_PARTICIPATIONS_EXPAND_SUCCESS, + EVENT_PARTICIPATIONS_EXPAND_FAIL, + EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST, + EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS, + EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL, + EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST, + EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS, + EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL, + EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST, + EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS, + EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL, + EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST, + EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS, + EVENT_PARTICIPATION_REQUEST_REJECT_FAIL, + EVENT_COMPOSE_CANCEL, + EVENT_FORM_SET, + RECENT_EVENTS_FETCH_REQUEST, + RECENT_EVENTS_FETCH_SUCCESS, + RECENT_EVENTS_FETCH_FAIL, + JOINED_EVENTS_FETCH_REQUEST, + JOINED_EVENTS_FETCH_SUCCESS, + JOINED_EVENTS_FETCH_FAIL, + locationSearch, + changeEditEventName, + changeEditEventDescription, + changeEditEventStartTime, + changeEditEventEndTime, + changeEditEventHasEndTime, + changeEditEventApprovalRequired, + changeEditEventLocation, + uploadEventBanner, + uploadEventBannerRequest, + uploadEventBannerProgress, + uploadEventBannerSuccess, + uploadEventBannerFail, + undoUploadEventBanner, + submitEvent, + submitEventRequest, + submitEventSuccess, + submitEventFail, + joinEvent, + joinEventRequest, + joinEventSuccess, + joinEventFail, + leaveEvent, + leaveEventRequest, + leaveEventSuccess, + leaveEventFail, + fetchEventParticipations, + fetchEventParticipationsRequest, + fetchEventParticipationsSuccess, + fetchEventParticipationsFail, + expandEventParticipations, + expandEventParticipationsRequest, + expandEventParticipationsSuccess, + expandEventParticipationsFail, + fetchEventParticipationRequests, + fetchEventParticipationRequestsRequest, + fetchEventParticipationRequestsSuccess, + fetchEventParticipationRequestsFail, + expandEventParticipationRequests, + expandEventParticipationRequestsRequest, + expandEventParticipationRequestsSuccess, + expandEventParticipationRequestsFail, + authorizeEventParticipationRequest, + authorizeEventParticipationRequestRequest, + authorizeEventParticipationRequestSuccess, + authorizeEventParticipationRequestFail, + rejectEventParticipationRequest, + rejectEventParticipationRequestRequest, + rejectEventParticipationRequestSuccess, + rejectEventParticipationRequestFail, + fetchEventIcs, + cancelEventCompose, + editEvent, + fetchRecentEvents, + fetchJoinedEvents, + type EventsAction, +}; diff --git a/src/actions/export-data.ts b/src/actions/export-data.ts new file mode 100644 index 0000000..b219c3f --- /dev/null +++ b/src/actions/export-data.ts @@ -0,0 +1,125 @@ +import { defineMessages } from 'react-intl'; + +import { MastodonResponse } from 'soapbox/api/MastodonResponse.ts'; +import api from 'soapbox/api/index.ts'; +import { normalizeAccount } from 'soapbox/normalizers/index.ts'; +import toast from 'soapbox/toast.tsx'; + +import type { RootState } from 'soapbox/store.ts'; + +export const EXPORT_FOLLOWS_REQUEST = 'EXPORT_FOLLOWS_REQUEST'; +export const EXPORT_FOLLOWS_SUCCESS = 'EXPORT_FOLLOWS_SUCCESS'; +export const EXPORT_FOLLOWS_FAIL = 'EXPORT_FOLLOWS_FAIL'; + +export const EXPORT_BLOCKS_REQUEST = 'EXPORT_BLOCKS_REQUEST'; +export const EXPORT_BLOCKS_SUCCESS = 'EXPORT_BLOCKS_SUCCESS'; +export const EXPORT_BLOCKS_FAIL = 'EXPORT_BLOCKS_FAIL'; + +export const EXPORT_MUTES_REQUEST = 'EXPORT_MUTES_REQUEST'; +export const EXPORT_MUTES_SUCCESS = 'EXPORT_MUTES_SUCCESS'; +export const EXPORT_MUTES_FAIL = 'EXPORT_MUTES_FAIL'; + +const messages = defineMessages({ + blocksSuccess: { id: 'export_data.success.blocks', defaultMessage: 'Blocks exported successfully' }, + followersSuccess: { id: 'export_data.success.followers', defaultMessage: 'Followers exported successfully' }, + mutesSuccess: { id: 'export_data.success.mutes', defaultMessage: 'Mutes exported successfully' }, +}); + +type ExportDataActions = { + type: typeof EXPORT_FOLLOWS_REQUEST + | typeof EXPORT_FOLLOWS_SUCCESS + | typeof EXPORT_FOLLOWS_FAIL + | typeof EXPORT_BLOCKS_REQUEST + | typeof EXPORT_BLOCKS_SUCCESS + | typeof EXPORT_BLOCKS_FAIL + | typeof EXPORT_MUTES_REQUEST + | typeof EXPORT_MUTES_SUCCESS + | typeof EXPORT_MUTES_FAIL; + error?: any; +} + +function fileExport(content: string, fileName: string) { + const fileToDownload = document.createElement('a'); + + fileToDownload.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(content)); + fileToDownload.setAttribute('download', fileName); + fileToDownload.style.display = 'none'; + document.body.appendChild(fileToDownload); + fileToDownload.click(); + document.body.removeChild(fileToDownload); +} + +const listAccounts = (getState: () => RootState) => { + return async(response: MastodonResponse) => { + let { next } = response.pagination(); + const data = await response.json(); + + const map = new Map>(); + + for (const account of data) { + map.set(account.id, account); + } + + while (next) { + const response = await api(getState).get(next); + next = response.pagination().next; + const data = await response.json(); + + for (const account of data) { + map.set(account.id, account); + } + } + + const accts = [...map.values()].map((account) => normalizeAccount(account).fqn); + + return accts; + }; +}; + +export const exportFollows = () => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: EXPORT_FOLLOWS_REQUEST }); + const me = getState().me; + return api(getState) + .get(`/api/v1/accounts/${me}/following?limit=40`) + .then(listAccounts(getState)) + .then((followings) => { + followings = followings.map(fqn => fqn + ',true'); + followings.unshift('Account address,Show boosts'); + fileExport(followings.join('\n'), 'export_followings.csv'); + + toast.success(messages.followersSuccess); + dispatch({ type: EXPORT_FOLLOWS_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_FOLLOWS_FAIL, error }); + }); +}; + +export const exportBlocks = () => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: EXPORT_BLOCKS_REQUEST }); + return api(getState) + .get('/api/v1/blocks?limit=40') + .then(listAccounts(getState)) + .then((blocks) => { + fileExport(blocks.join('\n'), 'export_block.csv'); + + toast.success(messages.blocksSuccess); + dispatch({ type: EXPORT_BLOCKS_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_BLOCKS_FAIL, error }); + }); +}; + +export const exportMutes = () => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: EXPORT_MUTES_REQUEST }); + return api(getState) + .get('/api/v1/mutes?limit=40') + .then(listAccounts(getState)) + .then((mutes) => { + fileExport(mutes.join('\n'), 'export_mutes.csv'); + + toast.success(messages.mutesSuccess); + dispatch({ type: EXPORT_MUTES_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_MUTES_FAIL, error }); + }); +}; diff --git a/src/actions/external-auth.ts b/src/actions/external-auth.ts new file mode 100644 index 0000000..ba2bc1d --- /dev/null +++ b/src/actions/external-auth.ts @@ -0,0 +1,100 @@ +/** + * External Auth: workflow for logging in to remote servers. + * @module soapbox/actions/external_auth + * @see module:soapbox/actions/auth + * @see module:soapbox/actions/apps + * @see module:soapbox/actions/oauth + */ + +import { createApp } from 'soapbox/actions/apps.ts'; +import { authLoggedIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth.ts'; +import { obtainOAuthToken } from 'soapbox/actions/oauth.ts'; +import { InstanceV1, instanceV1Schema } from 'soapbox/schemas/instance.ts'; +import { parseBaseURL } from 'soapbox/utils/auth.ts'; +import sourceCode from 'soapbox/utils/code.ts'; +import { getInstanceScopes } from 'soapbox/utils/scopes.ts'; + +import { baseClient } from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const fetchExternalInstance = (baseURL?: string) => { + return baseClient(null, baseURL) + .get('/api/v1/instance') + .then((response) => response.json()).then((instance) => instanceV1Schema.parse(instance)) + .catch(error => { + if (error.response?.status === 401) { + // Authenticated fetch is enabled. + // Continue with a limited featureset. + return instanceV1Schema.parse({}); + } else { + throw error; + } + }); +}; + +const createExternalApp = (instance: InstanceV1, baseURL?: string) => + (dispatch: AppDispatch, _getState: () => RootState) => { + const params = { + client_name: sourceCode.displayName, + redirect_uris: `${window.location.origin}/login/external`, + website: sourceCode.homepage, + scopes: getInstanceScopes(instance.version), + }; + + return dispatch(createApp(params, baseURL)); + }; + +const externalAuthorize = (instance: InstanceV1, baseURL: string) => + (dispatch: AppDispatch, _getState: () => RootState) => { + const scopes = getInstanceScopes(instance.version); + + return dispatch(createExternalApp(instance, baseURL)).then((app) => { + const { client_id, redirect_uri } = app as Record; + + const query = new URLSearchParams({ + client_id, + redirect_uri, + response_type: 'code', + scope: scopes, + }); + + localStorage.setItem('soapbox:external:app', JSON.stringify(app)); + localStorage.setItem('soapbox:external:baseurl', baseURL); + localStorage.setItem('soapbox:external:scopes', scopes); + + window.location.href = `${baseURL}/oauth/authorize?${query.toString()}`; + }); + }; + +export const externalLogin = (host: string) => + (dispatch: AppDispatch) => { + const baseURL = parseBaseURL(host) || parseBaseURL(`https://${host}`); + + return fetchExternalInstance(baseURL).then((instance) => { + dispatch(externalAuthorize(instance, baseURL)); + }); + }; + +export const loginWithCode = (code: string) => + (dispatch: AppDispatch) => { + const { client_id, client_secret, redirect_uri } = JSON.parse(localStorage.getItem('soapbox:external:app')!); + const baseURL = localStorage.getItem('soapbox:external:baseurl')!; + const scope = localStorage.getItem('soapbox:external:scopes')!; + + const params: Record = { + client_id, + client_secret, + redirect_uri, + grant_type: 'authorization_code', + scope, + code, + }; + + return dispatch(obtainOAuthToken(params, baseURL)) + .then((token: Record) => dispatch(authLoggedIn(token))) + .then(({ access_token }: any) => dispatch(verifyCredentials(access_token as string, baseURL))) + .then((account: { id: string }) => dispatch(switchAccount(account.id))) + .then(() => localStorage.removeItem('soapbox:external:baseurl')) + .then(() => window.location.href = '/'); + }; diff --git a/src/actions/familiar-followers.ts b/src/actions/familiar-followers.ts new file mode 100644 index 0000000..1f52bea --- /dev/null +++ b/src/actions/familiar-followers.ts @@ -0,0 +1,38 @@ +import { AppDispatch, RootState } from 'soapbox/store.ts'; + +import api from '../api/index.ts'; + +import { fetchRelationships } from './accounts.ts'; +import { importFetchedAccounts } from './importer/index.ts'; + +import type { APIEntity } from 'soapbox/types/entities.ts'; + +export const FAMILIAR_FOLLOWERS_FETCH_REQUEST = 'FAMILIAR_FOLLOWERS_FETCH_REQUEST'; +export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCESS'; +export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL'; + +export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_REQUEST, + id: accountId, + }); + + api(getState).get(`/api/v1/accounts/familiar_followers?id[]=${accountId}`) + .then((response) => response.json()).then((data) => { + const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts; + + dispatch(importFetchedAccounts(accounts)); + dispatch(fetchRelationships(accounts.map((item: APIEntity) => item.id))); + dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_SUCCESS, + id: accountId, + accounts, + }); + }) + .catch(error => dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_FAIL, + id: accountId, + error, + skipAlert: true, + })); +}; diff --git a/src/actions/favourites.ts b/src/actions/favourites.ts new file mode 100644 index 0000000..286f57a --- /dev/null +++ b/src/actions/favourites.ts @@ -0,0 +1,211 @@ +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import { importFetchedStatuses } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; +const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; +const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; + +const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; +const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; +const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; + +const ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST'; +const ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS'; +const ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL'; + +const ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST'; +const ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS'; +const ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL'; + +const fetchFavouritedStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + if (getState().status_lists.get('favourites')?.isLoading) { + return; + } + + dispatch(fetchFavouritedStatusesRequest()); + + api(getState).get('/api/v1/favourites').then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedStatuses(data)); + dispatch(fetchFavouritedStatusesSuccess(data, next)); + }).catch(error => { + dispatch(fetchFavouritedStatusesFail(error)); + }); + }; + +const fetchFavouritedStatusesRequest = () => ({ + type: FAVOURITED_STATUSES_FETCH_REQUEST, + skipLoading: true, +}); + +const fetchFavouritedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: FAVOURITED_STATUSES_FETCH_SUCCESS, + statuses, + next, + skipLoading: true, +}); + +const fetchFavouritedStatusesFail = (error: unknown) => ({ + type: FAVOURITED_STATUSES_FETCH_FAIL, + error, + skipLoading: true, +}); + +const expandFavouritedStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().status_lists.get('favourites')?.next || null; + + if (url === null || getState().status_lists.get('favourites')?.isLoading) { + return; + } + + dispatch(expandFavouritedStatusesRequest()); + + api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedStatuses(data)); + dispatch(expandFavouritedStatusesSuccess(data, next)); + }).catch(error => { + dispatch(expandFavouritedStatusesFail(error)); + }); + }; + +const expandFavouritedStatusesRequest = () => ({ + type: FAVOURITED_STATUSES_EXPAND_REQUEST, +}); + +const expandFavouritedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: FAVOURITED_STATUSES_EXPAND_SUCCESS, + statuses, + next, +}); + +const expandFavouritedStatusesFail = (error: unknown) => ({ + type: FAVOURITED_STATUSES_EXPAND_FAIL, + error, +}); + +const fetchAccountFavouritedStatuses = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + if (getState().status_lists.get(`favourites:${accountId}`)?.isLoading) { + return; + } + + dispatch(fetchAccountFavouritedStatusesRequest(accountId)); + + api(getState).get(`/api/v1/pleroma/accounts/${accountId}/favourites`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedStatuses(data)); + dispatch(fetchAccountFavouritedStatusesSuccess(accountId, data, next)); + }).catch(error => { + dispatch(fetchAccountFavouritedStatusesFail(accountId, error)); + }); + }; + +const fetchAccountFavouritedStatusesRequest = (accountId: string) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST, + accountId, + skipLoading: true, +}); + +const fetchAccountFavouritedStatusesSuccess = (accountId: string, statuses: APIEntity, next: string | null) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS, + accountId, + statuses, + next, + skipLoading: true, +}); + +const fetchAccountFavouritedStatusesFail = (accountId: string, error: unknown) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL, + accountId, + error, + skipLoading: true, +}); + +const expandAccountFavouritedStatuses = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().status_lists.get(`favourites:${accountId}`)?.next || null; + + if (url === null || getState().status_lists.get(`favourites:${accountId}`)?.isLoading) { + return; + } + + dispatch(expandAccountFavouritedStatusesRequest(accountId)); + + api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedStatuses(data)); + dispatch(expandAccountFavouritedStatusesSuccess(accountId, data, next)); + }).catch(error => { + dispatch(expandAccountFavouritedStatusesFail(accountId, error)); + }); + }; + +const expandAccountFavouritedStatusesRequest = (accountId: string) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST, + accountId, +}); + +const expandAccountFavouritedStatusesSuccess = (accountId: string, statuses: APIEntity[], next: string | null) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS, + accountId, + statuses, + next, +}); + +const expandAccountFavouritedStatusesFail = (accountId: string, error: unknown) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL, + accountId, + error, +}); + +export { + FAVOURITED_STATUSES_FETCH_REQUEST, + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_FETCH_FAIL, + FAVOURITED_STATUSES_EXPAND_REQUEST, + FAVOURITED_STATUSES_EXPAND_SUCCESS, + FAVOURITED_STATUSES_EXPAND_FAIL, + ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST, + ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS, + ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL, + ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST, + ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS, + ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL, + fetchFavouritedStatuses, + fetchFavouritedStatusesRequest, + fetchFavouritedStatusesSuccess, + fetchFavouritedStatusesFail, + expandFavouritedStatuses, + expandFavouritedStatusesRequest, + expandFavouritedStatusesSuccess, + expandFavouritedStatusesFail, + fetchAccountFavouritedStatuses, + fetchAccountFavouritedStatusesRequest, + fetchAccountFavouritedStatusesSuccess, + fetchAccountFavouritedStatusesFail, + expandAccountFavouritedStatuses, + expandAccountFavouritedStatusesRequest, + expandAccountFavouritedStatusesSuccess, + expandAccountFavouritedStatusesFail, +}; diff --git a/src/actions/filters.ts b/src/actions/filters.ts new file mode 100644 index 0000000..9bd656a --- /dev/null +++ b/src/actions/filters.ts @@ -0,0 +1,294 @@ +import { defineMessages } from 'react-intl'; + +import toast from 'soapbox/toast.tsx'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; +const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; +const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; + +const FILTER_FETCH_REQUEST = 'FILTER_FETCH_REQUEST'; +const FILTER_FETCH_SUCCESS = 'FILTER_FETCH_SUCCESS'; +const FILTER_FETCH_FAIL = 'FILTER_FETCH_FAIL'; + +const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; +const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; +const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; + +const FILTERS_UPDATE_REQUEST = 'FILTERS_UPDATE_REQUEST'; +const FILTERS_UPDATE_SUCCESS = 'FILTERS_UPDATE_SUCCESS'; +const FILTERS_UPDATE_FAIL = 'FILTERS_UPDATE_FAIL'; + +const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; +const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; +const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; + +const messages = defineMessages({ + added: { id: 'filters.added', defaultMessage: 'Filter added.' }, + removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, +}); + +type FilterKeywords = { keyword: string; whole_word: boolean }[]; + +const fetchFiltersV1 = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get('/api/v1/filters') + .then((response) => response.json()).then((data) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFiltersV2 = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get('/api/v2/filters') + .then((response) => response.json()).then((data) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilters = (fromFiltersPage = false) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2 && fromFiltersPage) return dispatch(fetchFiltersV2()); + + if (features.filters) return dispatch(fetchFiltersV1()); + }; + +const fetchFilterV1 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTER_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get(`/api/v1/filters/${id}`) + .then((response) => response.json()).then((data) => dispatch({ + type: FILTER_FETCH_SUCCESS, + filter: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTER_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilterV2 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTER_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get(`/api/v2/filters/${id}`) + .then((response) => response.json()).then((data) => dispatch({ + type: FILTER_FETCH_SUCCESS, + filter: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTER_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilter = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(fetchFilterV2(id)); + + if (features.filters) return dispatch(fetchFilterV1(id)); + }; + +const createFilterV1 = (title: string, expires_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_CREATE_REQUEST }); + return api(getState).post('/api/v1/filters', { + phrase: keywords[0].keyword, + context, + irreversible: hide, + whole_word: keywords[0].whole_word, + expires_in, + }).then((response) => response.json()).then((data) => { + dispatch({ type: FILTERS_CREATE_SUCCESS, filter: data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_CREATE_FAIL, error }); + }); + }; + +const createFilterV2 = (title: string, expires_in: string | null, context: Array, hide: boolean, keywords_attributes: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_CREATE_REQUEST }); + return api(getState).post('/api/v2/filters', { + title, + context, + filter_action: hide ? 'hide' : 'warn', + expires_in, + keywords_attributes, + }).then((response) => response.json()).then((data) => { + dispatch({ type: FILTERS_CREATE_SUCCESS, filter: data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_CREATE_FAIL, error }); + }); + }; + +const createFilter = (title: string, expires_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(createFilterV2(title, expires_in, context, hide, keywords)); + + return dispatch(createFilterV1(title, expires_in, context, hide, keywords)); + }; + +const updateFilterV1 = (id: string, title: string, expires_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_UPDATE_REQUEST }); + return api(getState).patch(`/api/v1/filters/${id}`, { + phrase: keywords[0].keyword, + context, + irreversible: hide, + whole_word: keywords[0].whole_word, + expires_in, + }).then((response) => response.json()).then((data) => { + dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_UPDATE_FAIL, error }); + }); + }; + +const updateFilterV2 = (id: string, title: string, expires_in: string | null, context: Array, hide: boolean, keywords_attributes: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_UPDATE_REQUEST }); + return api(getState).patch(`/api/v2/filters/${id}`, { + title, + context, + filter_action: hide ? 'hide' : 'warn', + expires_in, + keywords_attributes, + }).then((response) => response.json()).then((data) => { + dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_UPDATE_FAIL, error }); + }); + }; + +const updateFilter = (id: string, title: string, expires_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(updateFilterV2(id, title, expires_in, context, hide, keywords)); + + return dispatch(updateFilterV1(id, title, expires_in, context, hide, keywords)); + }; + +const deleteFilterV1 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_DELETE_REQUEST }); + return api(getState).delete(`/api/v1/filters/${id}`).then((response) => response.json()).then((data) => { + dispatch({ type: FILTERS_DELETE_SUCCESS, filter: data }); + toast.success(messages.removed); + }).catch(error => { + dispatch({ type: FILTERS_DELETE_FAIL, error }); + }); + }; + +const deleteFilterV2 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_DELETE_REQUEST }); + return api(getState).delete(`/api/v2/filters/${id}`).then((response) => response.json()).then((data) => { + dispatch({ type: FILTERS_DELETE_SUCCESS, filter: data }); + toast.success(messages.removed); + }).catch(error => { + dispatch({ type: FILTERS_DELETE_FAIL, error }); + }); + }; + +const deleteFilter = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(deleteFilterV2(id)); + + return dispatch(deleteFilterV1(id)); + }; + +export { + FILTERS_FETCH_REQUEST, + FILTERS_FETCH_SUCCESS, + FILTERS_FETCH_FAIL, + FILTER_FETCH_REQUEST, + FILTER_FETCH_SUCCESS, + FILTER_FETCH_FAIL, + FILTERS_CREATE_REQUEST, + FILTERS_CREATE_SUCCESS, + FILTERS_CREATE_FAIL, + FILTERS_UPDATE_REQUEST, + FILTERS_UPDATE_SUCCESS, + FILTERS_UPDATE_FAIL, + FILTERS_DELETE_REQUEST, + FILTERS_DELETE_SUCCESS, + FILTERS_DELETE_FAIL, + fetchFilters, + fetchFilter, + createFilter, + updateFilter, + deleteFilter, +}; diff --git a/src/actions/groups.ts b/src/actions/groups.ts new file mode 100644 index 0000000..ac96ba4 --- /dev/null +++ b/src/actions/groups.ts @@ -0,0 +1,759 @@ +import { deleteEntities } from 'soapbox/entity-store/actions.ts'; + +import api from '../api/index.ts'; + +import { fetchRelationships } from './accounts.ts'; +import { importFetchedGroups, importFetchedAccounts } from './importer/index.ts'; + +import type { GroupRole } from 'soapbox/reducers/group-memberships.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST'; +const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS'; +const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL'; + +const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST'; +const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS'; +const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL'; + +const GROUP_DELETE_REQUEST = 'GROUP_DELETE_REQUEST'; +const GROUP_DELETE_SUCCESS = 'GROUP_DELETE_SUCCESS'; +const GROUP_DELETE_FAIL = 'GROUP_DELETE_FAIL'; + +const GROUP_FETCH_REQUEST = 'GROUP_FETCH_REQUEST'; +const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS'; +const GROUP_FETCH_FAIL = 'GROUP_FETCH_FAIL'; + +const GROUPS_FETCH_REQUEST = 'GROUPS_FETCH_REQUEST'; +const GROUPS_FETCH_SUCCESS = 'GROUPS_FETCH_SUCCESS'; +const GROUPS_FETCH_FAIL = 'GROUPS_FETCH_FAIL'; + +const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST'; +const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS'; +const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL'; + +const GROUP_KICK_REQUEST = 'GROUP_KICK_REQUEST'; +const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS'; +const GROUP_KICK_FAIL = 'GROUP_KICK_FAIL'; + +const GROUP_BLOCKS_FETCH_REQUEST = 'GROUP_BLOCKS_FETCH_REQUEST'; +const GROUP_BLOCKS_FETCH_SUCCESS = 'GROUP_BLOCKS_FETCH_SUCCESS'; +const GROUP_BLOCKS_FETCH_FAIL = 'GROUP_BLOCKS_FETCH_FAIL'; + +const GROUP_BLOCKS_EXPAND_REQUEST = 'GROUP_BLOCKS_EXPAND_REQUEST'; +const GROUP_BLOCKS_EXPAND_SUCCESS = 'GROUP_BLOCKS_EXPAND_SUCCESS'; +const GROUP_BLOCKS_EXPAND_FAIL = 'GROUP_BLOCKS_EXPAND_FAIL'; + +const GROUP_BLOCK_REQUEST = 'GROUP_BLOCK_REQUEST'; +const GROUP_BLOCK_SUCCESS = 'GROUP_BLOCK_SUCCESS'; +const GROUP_BLOCK_FAIL = 'GROUP_BLOCK_FAIL'; + +const GROUP_UNBLOCK_REQUEST = 'GROUP_UNBLOCK_REQUEST'; +const GROUP_UNBLOCK_SUCCESS = 'GROUP_UNBLOCK_SUCCESS'; +const GROUP_UNBLOCK_FAIL = 'GROUP_UNBLOCK_FAIL'; + +const GROUP_PROMOTE_REQUEST = 'GROUP_PROMOTE_REQUEST'; +const GROUP_PROMOTE_SUCCESS = 'GROUP_PROMOTE_SUCCESS'; +const GROUP_PROMOTE_FAIL = 'GROUP_PROMOTE_FAIL'; + +const GROUP_DEMOTE_REQUEST = 'GROUP_DEMOTE_REQUEST'; +const GROUP_DEMOTE_SUCCESS = 'GROUP_DEMOTE_SUCCESS'; +const GROUP_DEMOTE_FAIL = 'GROUP_DEMOTE_FAIL'; + +const GROUP_MEMBERSHIPS_FETCH_REQUEST = 'GROUP_MEMBERSHIPS_FETCH_REQUEST'; +const GROUP_MEMBERSHIPS_FETCH_SUCCESS = 'GROUP_MEMBERSHIPS_FETCH_SUCCESS'; +const GROUP_MEMBERSHIPS_FETCH_FAIL = 'GROUP_MEMBERSHIPS_FETCH_FAIL'; + +const GROUP_MEMBERSHIPS_EXPAND_REQUEST = 'GROUP_MEMBERSHIPS_EXPAND_REQUEST'; +const GROUP_MEMBERSHIPS_EXPAND_SUCCESS = 'GROUP_MEMBERSHIPS_EXPAND_SUCCESS'; +const GROUP_MEMBERSHIPS_EXPAND_FAIL = 'GROUP_MEMBERSHIPS_EXPAND_FAIL'; + +const GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST'; +const GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS'; +const GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL'; + +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST'; +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS'; +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL'; + +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST'; +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS'; +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL'; + +const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST'; +const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS'; +const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL'; + +const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(deleteEntities([id], 'Group')); + + return api(getState).delete(`/api/v1/groups/${id}`) + .then(() => dispatch(deleteGroupSuccess(id))) + .catch(err => dispatch(deleteGroupFail(id, err))); +}; + +const deleteGroupRequest = (id: string) => ({ + type: GROUP_DELETE_REQUEST, + id, +}); + +const deleteGroupSuccess = (id: string) => ({ + type: GROUP_DELETE_SUCCESS, + id, +}); + +const deleteGroupFail = (id: string, error: unknown) => ({ + type: GROUP_DELETE_FAIL, + id, + error, +}); + +const fetchGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupRelationships([id])); + dispatch(fetchGroupRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}`) + .then((response) => response.json()).then((data) => { + dispatch(importFetchedGroups([data])); + dispatch(fetchGroupSuccess(data)); + }) + .catch(err => dispatch(fetchGroupFail(id, err))); +}; + +const fetchGroupRequest = (id: string) => ({ + type: GROUP_FETCH_REQUEST, + id, +}); + +const fetchGroupSuccess = (group: APIEntity) => ({ + type: GROUP_FETCH_SUCCESS, + group, +}); + +const fetchGroupFail = (id: string, error: unknown) => ({ + type: GROUP_FETCH_FAIL, + id, + error, +}); + +const fetchGroups = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupsRequest()); + + return api(getState).get('/api/v1/groups') + .then((response) => response.json()).then((data) => { + dispatch(importFetchedGroups(data)); + dispatch(fetchGroupsSuccess(data)); + dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id))); + }).catch(err => dispatch(fetchGroupsFail(err))); +}; + +const fetchGroupsRequest = () => ({ + type: GROUPS_FETCH_REQUEST, +}); + +const fetchGroupsSuccess = (groups: APIEntity[]) => ({ + type: GROUPS_FETCH_SUCCESS, + groups, +}); + +const fetchGroupsFail = (error: unknown) => ({ + type: GROUPS_FETCH_FAIL, + error, +}); + +const fetchGroupRelationships = (groupIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const loadedRelationships = state.group_relationships; + const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null); + + if (!state.me || newGroupIds.length === 0) { + return; + } + + dispatch(fetchGroupRelationshipsRequest(newGroupIds)); + + return api(getState).get(`/api/v1/groups/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then((response) => response.json()).then((data) => { + dispatch(fetchGroupRelationshipsSuccess(data)); + }).catch(error => { + dispatch(fetchGroupRelationshipsFail(error)); + }); + }; + +const fetchGroupRelationshipsRequest = (ids: string[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, +}); + +const fetchGroupRelationshipsSuccess = (relationships: APIEntity[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, +}); + +const fetchGroupRelationshipsFail = (error: unknown) => ({ + type: GROUP_RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, + skipNotFound: true, +}); + +const groupKick = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupKickRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/kick`, { account_ids: [accountId] }) + .then(() => dispatch(groupKickSuccess(groupId, accountId))) + .catch(err => dispatch(groupKickFail(groupId, accountId, err))); + }; + +const groupKickRequest = (groupId: string, accountId: string) => ({ + type: GROUP_KICK_REQUEST, + groupId, + accountId, +}); + +const groupKickSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_KICK_SUCCESS, + groupId, + accountId, +}); + +const groupKickFail = (groupId: string, accountId: string, error: unknown) => ({ + type: GROUP_KICK_SUCCESS, + groupId, + accountId, + error, +}); + +const fetchGroupBlocks = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupBlocksRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}/blocks`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data)); + dispatch(fetchGroupBlocksSuccess(id, data, next)); + }).catch(error => { + dispatch(fetchGroupBlocksFail(id, error)); + }); + }; + +const fetchGroupBlocksRequest = (id: string) => ({ + type: GROUP_BLOCKS_FETCH_REQUEST, + id, +}); + +const fetchGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_BLOCKS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchGroupBlocksFail = (id: string, error: unknown) => ({ + type: GROUP_BLOCKS_FETCH_FAIL, + id, + error, + skipNotFound: true, +}); + +const expandGroupBlocks = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.group_blocks.get(id)?.next || null; + + if (url === null) { + return; + } + + dispatch(expandGroupBlocksRequest(id)); + + return api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data)); + dispatch(expandGroupBlocksSuccess(id, data, next)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandGroupBlocksFail(id, error)); + }); + }; + +const expandGroupBlocksRequest = (id: string) => ({ + type: GROUP_BLOCKS_EXPAND_REQUEST, + id, +}); + +const expandGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_BLOCKS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandGroupBlocksFail = (id: string, error: unknown) => ({ + type: GROUP_BLOCKS_EXPAND_FAIL, + id, + error, +}); + +const groupBlock = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupBlockRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/blocks`, { account_ids: [accountId] }) + .then(() => dispatch(groupBlockSuccess(groupId, accountId))) + .catch(err => dispatch(groupBlockFail(groupId, accountId, err))); + }; + +const groupBlockRequest = (groupId: string, accountId: string) => ({ + type: GROUP_BLOCK_REQUEST, + groupId, + accountId, +}); + +const groupBlockSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_BLOCK_SUCCESS, + groupId, + accountId, +}); + +const groupBlockFail = (groupId: string, accountId: string, error: unknown) => ({ + type: GROUP_BLOCK_FAIL, + groupId, + accountId, + error, +}); + +const groupUnblock = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupUnblockRequest(groupId, accountId)); + + return api(getState).delete(`/api/v1/groups/${groupId}/blocks?account_ids[]=${accountId}`) + .then(() => dispatch(groupUnblockSuccess(groupId, accountId))) + .catch(err => dispatch(groupUnblockFail(groupId, accountId, err))); + }; + +const groupUnblockRequest = (groupId: string, accountId: string) => ({ + type: GROUP_UNBLOCK_REQUEST, + groupId, + accountId, +}); + +const groupUnblockSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_UNBLOCK_SUCCESS, + groupId, + accountId, +}); + +const groupUnblockFail = (groupId: string, accountId: string, error: unknown) => ({ + type: GROUP_UNBLOCK_FAIL, + groupId, + accountId, + error, +}); + +const groupPromoteAccount = (groupId: string, accountId: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupPromoteAccountRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/promote`, { account_ids: [accountId], role: role }) + .then((response) => response.json()) + .then((data) => dispatch(groupPromoteAccountSuccess(groupId, accountId, data))) + .catch(err => dispatch(groupPromoteAccountFail(groupId, accountId, err))); + }; + +const groupPromoteAccountRequest = (groupId: string, accountId: string) => ({ + type: GROUP_PROMOTE_REQUEST, + groupId, + accountId, +}); + +const groupPromoteAccountSuccess = (groupId: string, accountId: string, memberships: APIEntity[]) => ({ + type: GROUP_PROMOTE_SUCCESS, + groupId, + accountId, + memberships, +}); + +const groupPromoteAccountFail = (groupId: string, accountId: string, error: unknown) => ({ + type: GROUP_PROMOTE_FAIL, + groupId, + accountId, + error, +}); + +const groupDemoteAccount = (groupId: string, accountId: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupDemoteAccountRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/demote`, { account_ids: [accountId], role: role }) + .then((response) => response.json()) + .then((data) => dispatch(groupDemoteAccountSuccess(groupId, accountId, data))) + .catch(err => dispatch(groupDemoteAccountFail(groupId, accountId, err))); + }; + +const groupDemoteAccountRequest = (groupId: string, accountId: string) => ({ + type: GROUP_DEMOTE_REQUEST, + groupId, + accountId, +}); + +const groupDemoteAccountSuccess = (groupId: string, accountId: string, memberships: APIEntity[]) => ({ + type: GROUP_DEMOTE_SUCCESS, + groupId, + accountId, + memberships, +}); + +const groupDemoteAccountFail = (groupId: string, accountId: string, error: unknown) => ({ + type: GROUP_DEMOTE_FAIL, + groupId, + accountId, + error, +}); + +const fetchGroupMemberships = (id: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupMembershipsRequest(id, role)); + + return api(getState).get(`/api/v1/groups/${id}/memberships`, { searchParams: { role } }).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data.map((membership: APIEntity) => membership.account))); + dispatch(fetchGroupMembershipsSuccess(id, role, data, next)); + }).catch(error => { + dispatch(fetchGroupMembershipsFail(id, role, error)); + }); + }; + +const fetchGroupMembershipsRequest = (id: string, role: GroupRole) => ({ + type: GROUP_MEMBERSHIPS_FETCH_REQUEST, + id, + role, +}); + +const fetchGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIPS_FETCH_SUCCESS, + id, + role, + memberships, + next, +}); + +const fetchGroupMembershipsFail = (id: string, role: GroupRole, error: unknown) => ({ + type: GROUP_MEMBERSHIPS_FETCH_FAIL, + id, + role, + error, + skipNotFound: true, +}); + +const expandGroupMemberships = (id: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().group_memberships.get(role).get(id)?.next || null; + + if (url === null) { + return; + } + + dispatch(expandGroupMembershipsRequest(id, role)); + + return api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data.map((membership: APIEntity) => membership.account))); + dispatch(expandGroupMembershipsSuccess(id, role, data, next)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandGroupMembershipsFail(id, role, error)); + }); + }; + +const expandGroupMembershipsRequest = (id: string, role: GroupRole) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_REQUEST, + id, + role, +}); + +const expandGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_SUCCESS, + id, + role, + memberships, + next, +}); + +const expandGroupMembershipsFail = (id: string, role: GroupRole, error: unknown) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_FAIL, + id, + role, + error, +}); + +const fetchGroupMembershipRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupMembershipRequestsRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}/membership_requests`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data)); + dispatch(fetchGroupMembershipRequestsSuccess(id, data, next)); + }).catch(error => { + dispatch(fetchGroupMembershipRequestsFail(id, error)); + }); + }; + +const fetchGroupMembershipRequestsRequest = (id: string) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST, + id, +}); + +const fetchGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchGroupMembershipRequestsFail = (id: string, error: unknown) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL, + id, + error, + skipNotFound: true, +}); + +const expandGroupMembershipRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.membership_requests.get(id)?.next || null; + + if (url === null) { + return; + } + + dispatch(expandGroupMembershipRequestsRequest(id)); + + return api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + dispatch(importFetchedAccounts(data)); + dispatch(expandGroupMembershipRequestsSuccess(id, data, next)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandGroupMembershipRequestsFail(id, error)); + }); + }; + +const expandGroupMembershipRequestsRequest = (id: string) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST, + id, +}); + +const expandGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandGroupMembershipRequestsFail = (id: string, error: unknown) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL, + id, + error, +}); + +const authorizeGroupMembershipRequest = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(authorizeGroupMembershipRequestRequest(groupId, accountId)); + + return api(getState) + .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`) + .then(() => dispatch(authorizeGroupMembershipRequestSuccess(groupId, accountId))) + .catch(error => dispatch(authorizeGroupMembershipRequestFail(groupId, accountId, error))); + }; + +const authorizeGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST, + groupId, + accountId, +}); + +const authorizeGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS, + groupId, + accountId, +}); + +const authorizeGroupMembershipRequestFail = (groupId: string, accountId: string, error: unknown) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL, + groupId, + accountId, + error, +}); + +const rejectGroupMembershipRequest = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(rejectGroupMembershipRequestRequest(groupId, accountId)); + + return api(getState) + .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`) + .then(() => dispatch(rejectGroupMembershipRequestSuccess(groupId, accountId))) + .catch(error => dispatch(rejectGroupMembershipRequestFail(groupId, accountId, error))); + }; + +const rejectGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST, + groupId, + accountId, +}); + +const rejectGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, + groupId, + accountId, +}); + +const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, error?: unknown) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, + groupId, + accountId, + error, +}); + +export { + GROUP_CREATE_REQUEST, + GROUP_CREATE_SUCCESS, + GROUP_CREATE_FAIL, + GROUP_UPDATE_REQUEST, + GROUP_UPDATE_SUCCESS, + GROUP_UPDATE_FAIL, + GROUP_DELETE_REQUEST, + GROUP_DELETE_SUCCESS, + GROUP_DELETE_FAIL, + GROUP_FETCH_REQUEST, + GROUP_FETCH_SUCCESS, + GROUP_FETCH_FAIL, + GROUPS_FETCH_REQUEST, + GROUPS_FETCH_SUCCESS, + GROUPS_FETCH_FAIL, + GROUP_RELATIONSHIPS_FETCH_REQUEST, + GROUP_RELATIONSHIPS_FETCH_SUCCESS, + GROUP_RELATIONSHIPS_FETCH_FAIL, + GROUP_KICK_REQUEST, + GROUP_KICK_SUCCESS, + GROUP_KICK_FAIL, + GROUP_BLOCKS_FETCH_REQUEST, + GROUP_BLOCKS_FETCH_SUCCESS, + GROUP_BLOCKS_FETCH_FAIL, + GROUP_BLOCKS_EXPAND_REQUEST, + GROUP_BLOCKS_EXPAND_SUCCESS, + GROUP_BLOCKS_EXPAND_FAIL, + GROUP_BLOCK_REQUEST, + GROUP_BLOCK_SUCCESS, + GROUP_BLOCK_FAIL, + GROUP_UNBLOCK_REQUEST, + GROUP_UNBLOCK_SUCCESS, + GROUP_UNBLOCK_FAIL, + GROUP_PROMOTE_REQUEST, + GROUP_PROMOTE_SUCCESS, + GROUP_PROMOTE_FAIL, + GROUP_DEMOTE_REQUEST, + GROUP_DEMOTE_SUCCESS, + GROUP_DEMOTE_FAIL, + GROUP_MEMBERSHIPS_FETCH_REQUEST, + GROUP_MEMBERSHIPS_FETCH_SUCCESS, + GROUP_MEMBERSHIPS_FETCH_FAIL, + GROUP_MEMBERSHIPS_EXPAND_REQUEST, + GROUP_MEMBERSHIPS_EXPAND_SUCCESS, + GROUP_MEMBERSHIPS_EXPAND_FAIL, + GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST, + GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS, + GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL, + GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST, + GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, + GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, + deleteGroup, + deleteGroupRequest, + deleteGroupSuccess, + deleteGroupFail, + fetchGroup, + fetchGroupRequest, + fetchGroupSuccess, + fetchGroupFail, + fetchGroups, + fetchGroupsRequest, + fetchGroupsSuccess, + fetchGroupsFail, + fetchGroupRelationships, + fetchGroupRelationshipsRequest, + fetchGroupRelationshipsSuccess, + fetchGroupRelationshipsFail, + groupKick, + groupKickRequest, + groupKickSuccess, + groupKickFail, + fetchGroupBlocks, + fetchGroupBlocksRequest, + fetchGroupBlocksSuccess, + fetchGroupBlocksFail, + expandGroupBlocks, + expandGroupBlocksRequest, + expandGroupBlocksSuccess, + expandGroupBlocksFail, + groupBlock, + groupBlockRequest, + groupBlockSuccess, + groupBlockFail, + groupUnblock, + groupUnblockRequest, + groupUnblockSuccess, + groupUnblockFail, + groupPromoteAccount, + groupPromoteAccountRequest, + groupPromoteAccountSuccess, + groupPromoteAccountFail, + groupDemoteAccount, + groupDemoteAccountRequest, + groupDemoteAccountSuccess, + groupDemoteAccountFail, + fetchGroupMemberships, + fetchGroupMembershipsRequest, + fetchGroupMembershipsSuccess, + fetchGroupMembershipsFail, + expandGroupMemberships, + expandGroupMembershipsRequest, + expandGroupMembershipsSuccess, + expandGroupMembershipsFail, + fetchGroupMembershipRequests, + fetchGroupMembershipRequestsRequest, + fetchGroupMembershipRequestsSuccess, + fetchGroupMembershipRequestsFail, + expandGroupMembershipRequests, + expandGroupMembershipRequestsRequest, + expandGroupMembershipRequestsSuccess, + expandGroupMembershipRequestsFail, + authorizeGroupMembershipRequest, + authorizeGroupMembershipRequestRequest, + authorizeGroupMembershipRequestSuccess, + authorizeGroupMembershipRequestFail, + rejectGroupMembershipRequest, + rejectGroupMembershipRequestRequest, + rejectGroupMembershipRequestSuccess, + rejectGroupMembershipRequestFail, +}; diff --git a/src/actions/history.ts b/src/actions/history.ts new file mode 100644 index 0000000..7c05c77 --- /dev/null +++ b/src/actions/history.ts @@ -0,0 +1,52 @@ +import api from 'soapbox/api/index.ts'; + +import { importFetchedAccounts } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST'; +const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS'; +const HISTORY_FETCH_FAIL = 'HISTORY_FETCH_FAIL'; + +const fetchHistory = (statusId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const loading = getState().history.getIn([statusId, 'loading']); + + if (loading) { + return; + } + + dispatch(fetchHistoryRequest(statusId)); + + api(getState).get(`/api/v1/statuses/${statusId}/history`).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data.map((x: APIEntity) => x.account))); + dispatch(fetchHistorySuccess(statusId, data)); + }).catch(error => dispatch(fetchHistoryFail(error))); + }; + +const fetchHistoryRequest = (statusId: string) => ({ + type: HISTORY_FETCH_REQUEST, + statusId, +}); + +const fetchHistorySuccess = (statusId: String, history: APIEntity[]) => ({ + type: HISTORY_FETCH_SUCCESS, + statusId, + history, +}); + +const fetchHistoryFail = (error: unknown) => ({ + type: HISTORY_FETCH_FAIL, + error, +}); + +export { + HISTORY_FETCH_REQUEST, + HISTORY_FETCH_SUCCESS, + HISTORY_FETCH_FAIL, + fetchHistory, + fetchHistoryRequest, + fetchHistorySuccess, + fetchHistoryFail, +}; \ No newline at end of file diff --git a/src/actions/import-data.ts b/src/actions/import-data.ts new file mode 100644 index 0000000..e0bec24 --- /dev/null +++ b/src/actions/import-data.ts @@ -0,0 +1,78 @@ +import { defineMessages } from 'react-intl'; + +import toast from 'soapbox/toast.tsx'; + +import api from '../api/index.ts'; + +import type { RootState } from 'soapbox/store.ts'; + +export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; +export const IMPORT_FOLLOWS_SUCCESS = 'IMPORT_FOLLOWS_SUCCESS'; +export const IMPORT_FOLLOWS_FAIL = 'IMPORT_FOLLOWS_FAIL'; + +export const IMPORT_BLOCKS_REQUEST = 'IMPORT_BLOCKS_REQUEST'; +export const IMPORT_BLOCKS_SUCCESS = 'IMPORT_BLOCKS_SUCCESS'; +export const IMPORT_BLOCKS_FAIL = 'IMPORT_BLOCKS_FAIL'; + +export const IMPORT_MUTES_REQUEST = 'IMPORT_MUTES_REQUEST'; +export const IMPORT_MUTES_SUCCESS = 'IMPORT_MUTES_SUCCESS'; +export const IMPORT_MUTES_FAIL = 'IMPORT_MUTES_FAIL'; + +type ImportDataActions = { + type: typeof IMPORT_FOLLOWS_REQUEST + | typeof IMPORT_FOLLOWS_SUCCESS + | typeof IMPORT_FOLLOWS_FAIL + | typeof IMPORT_BLOCKS_REQUEST + | typeof IMPORT_BLOCKS_SUCCESS + | typeof IMPORT_BLOCKS_FAIL + | typeof IMPORT_MUTES_REQUEST + | typeof IMPORT_MUTES_SUCCESS + | typeof IMPORT_MUTES_FAIL; + error?: any; + config?: string; +} + +const messages = defineMessages({ + blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' }, + followersSuccess: { id: 'import_data.success.followers', defaultMessage: 'Followers imported successfully' }, + mutesSuccess: { id: 'import_data.success.mutes', defaultMessage: 'Mutes imported successfully' }, +}); + +export const importFollows = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: IMPORT_FOLLOWS_REQUEST }); + return api(getState) + .post('/api/pleroma/follow_import', params) + .then((response) => response.json()).then((data) => { + toast.success(messages.followersSuccess); + dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: data }); + }).catch(error => { + dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); + }); + }; + +export const importBlocks = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: IMPORT_BLOCKS_REQUEST }); + return api(getState) + .post('/api/pleroma/blocks_import', params) + .then((response) => response.json()).then((data) => { + toast.success(messages.blocksSuccess); + dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: data }); + }).catch(error => { + dispatch({ type: IMPORT_BLOCKS_FAIL, error }); + }); + }; + +export const importMutes = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: IMPORT_MUTES_REQUEST }); + return api(getState) + .post('/api/pleroma/mutes_import', params) + .then((response) => response.json()).then((data) => { + toast.success(messages.mutesSuccess); + dispatch({ type: IMPORT_MUTES_SUCCESS, config: data }); + }).catch(error => { + dispatch({ type: IMPORT_MUTES_FAIL, error }); + }); + }; diff --git a/src/actions/importer/index.ts b/src/actions/importer/index.ts new file mode 100644 index 0000000..07bddfa --- /dev/null +++ b/src/actions/importer/index.ts @@ -0,0 +1,228 @@ +import { importEntities } from 'soapbox/entity-store/actions.ts'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { Group, accountSchema, groupSchema } from 'soapbox/schemas/index.ts'; +import { filteredArray } from 'soapbox/schemas/utils.ts'; + +import { getSettings } from '../settings.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; +const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; +const GROUP_IMPORT = 'GROUP_IMPORT'; +const GROUPS_IMPORT = 'GROUPS_IMPORT'; +const STATUS_IMPORT = 'STATUS_IMPORT'; +const STATUSES_IMPORT = 'STATUSES_IMPORT'; +const POLLS_IMPORT = 'POLLS_IMPORT'; +const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP'; + +const importAccount = (data: APIEntity) => + (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch({ type: ACCOUNT_IMPORT, account: data }); + try { + const account = accountSchema.parse(data); + dispatch(importEntities([account], Entities.ACCOUNTS)); + } catch (e) { + // + } + }; + +const importAccounts = (data: APIEntity[]) => + (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch({ type: ACCOUNTS_IMPORT, accounts: data }); + try { + const accounts = filteredArray(accountSchema).parse(data); + dispatch(importEntities(accounts, Entities.ACCOUNTS)); + } catch (e) { + // + } + }; + +const importGroup = (group: Group) => + importEntities([group], Entities.GROUPS); + +const importGroups = (groups: Group[]) => + importEntities(groups, Entities.GROUPS); + +const importStatus = (status: APIEntity, idempotencyKey?: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const expandSpoilers = getSettings(getState()).get('expandSpoilers'); + return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers }); + }; + +const importStatuses = (statuses: APIEntity[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const expandSpoilers = getSettings(getState()).get('expandSpoilers'); + return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers }); + }; + +const importPolls = (polls: APIEntity[]) => + ({ type: POLLS_IMPORT, polls }); + +const importFetchedAccount = (account: APIEntity) => + importFetchedAccounts([account]); + +const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => { + const { should_refetch } = args; + const normalAccounts: APIEntity[] = []; + + const processAccount = (account: APIEntity) => { + if (!account.id) return; + + if (should_refetch) { + account.should_refetch = true; + } + + normalAccounts.push(account); + + if (account.moved) { + processAccount(account.moved); + } + }; + + accounts.forEach(processAccount); + + return importAccounts(normalAccounts); +}; + +const importFetchedGroup = (group: APIEntity) => + importFetchedGroups([group]); + +const importFetchedGroups = (groups: APIEntity[]) => { + const entities = filteredArray(groupSchema).parse(groups); + return importGroups(entities); +}; + +const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => + (dispatch: AppDispatch) => { + // Skip broken statuses + if (isBroken(status)) return; + + if (status.reblog?.id) { + dispatch(importFetchedStatus(status.reblog)); + } + + // Fedibird quotes + if (status.quote?.id) { + dispatch(importFetchedStatus(status.quote)); + } + + // Pleroma quotes + if (status.pleroma?.quote?.id) { + dispatch(importFetchedStatus(status.pleroma.quote)); + } + + // Fedibird quote from reblog + if (status.reblog?.quote?.id) { + dispatch(importFetchedStatus(status.reblog.quote)); + } + + // Pleroma quote from reblog + if (status.reblog?.pleroma?.quote?.id) { + dispatch(importFetchedStatus(status.reblog.pleroma.quote)); + } + + if (status.poll?.id) { + dispatch(importFetchedPoll(status.poll)); + } + + if (status.group?.id) { + dispatch(importFetchedGroup(status.group)); + } + + dispatch(importFetchedAccount(status.account)); + dispatch(importStatus(status, idempotencyKey)); + }; + +// Sometimes Pleroma can return an empty account, +// or a repost can appear of a deleted account. Skip these statuses. +const isBroken = (status: APIEntity) => { + try { + // Skip empty accounts + // https://gitlab.com/soapbox-pub/soapbox/-/issues/424 + if (!status.account.id) return true; + // Skip broken reposts + // https://gitlab.com/soapbox-pub/rebased/-/issues/28 + if (status.reblog && !status.reblog.account.id) return true; + return false; + } catch (e) { + return true; + } +}; + +const importFetchedStatuses = (statuses: APIEntity[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const accounts: APIEntity[] = []; + const normalStatuses: APIEntity[] = []; + const polls: APIEntity[] = []; + + function processStatus(status: APIEntity) { + // Skip broken statuses + if (isBroken(status)) return; + + normalStatuses.push(status); + accounts.push(status.account); + + if (status.reblog?.id) { + processStatus(status.reblog); + } + + // Fedibird quotes + if (status.quote?.id) { + processStatus(status.quote); + } + + if (status.pleroma?.quote?.id) { + processStatus(status.pleroma.quote); + } + + if (status.poll?.id) { + polls.push(status.poll); + } + + if (status.group?.id) { + dispatch(importFetchedGroup(status.group)); + } + } + + statuses.forEach(processStatus); + + dispatch(importPolls(polls)); + dispatch(importFetchedAccounts(accounts)); + dispatch(importStatuses(normalStatuses)); + }; + +const importFetchedPoll = (poll: APIEntity) => + (dispatch: AppDispatch) => { + dispatch(importPolls([poll])); + }; + +const importErrorWhileFetchingAccountByUsername = (username: string) => + ({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username }); + +export { + ACCOUNT_IMPORT, + ACCOUNTS_IMPORT, + GROUP_IMPORT, + GROUPS_IMPORT, + STATUS_IMPORT, + STATUSES_IMPORT, + POLLS_IMPORT, + ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, + importAccount, + importAccounts, + importGroup, + importGroups, + importStatus, + importStatuses, + importPolls, + importFetchedAccount, + importFetchedAccounts, + importFetchedGroup, + importFetchedGroups, + importFetchedStatus, + importFetchedStatuses, + importFetchedPoll, + importErrorWhileFetchingAccountByUsername, +}; diff --git a/src/actions/instance.ts b/src/actions/instance.ts new file mode 100644 index 0000000..371c542 --- /dev/null +++ b/src/actions/instance.ts @@ -0,0 +1,58 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; + +import { instanceV1Schema, instanceV2Schema } from 'soapbox/schemas/instance.ts'; +import { RootState } from 'soapbox/store.ts'; +import { getAuthUserUrl, getMeUrl } from 'soapbox/utils/auth.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +/** Figure out the appropriate instance to fetch depending on the state */ +export const getHost = (state: RootState) => { + const accountUrl = getMeUrl(state) || getAuthUserUrl(state) as string; + + try { + return new URL(accountUrl).host; + } catch { + return null; + } +}; + +interface InstanceData { + instance: Record; + host: string | null | undefined; +} + +export const fetchInstance = createAsyncThunk( + 'instance/fetch', + async(host, { dispatch, getState, rejectWithValue }) => { + try { + const response = await api(getState).get('/api/v1/instance'); + const data = await response.json(); + const instance = instanceV1Schema.parse(data); + const features = getFeatures(instance); + + if (features.instanceV2) { + dispatch(fetchInstanceV2(host)); + } + + return { instance, host }; + } catch (e) { + return rejectWithValue(e); + } + }, +); + +export const fetchInstanceV2 = createAsyncThunk( + 'instanceV2/fetch', + async(host, { getState, rejectWithValue }) => { + try { + const response = await api(getState).get('/api/v2/instance'); + const data = await response.json(); + const instance = instanceV2Schema.parse(data); + return { instance, host }; + } catch (e) { + return rejectWithValue(e); + } + }, +); diff --git a/src/actions/interactions.ts b/src/actions/interactions.ts new file mode 100644 index 0000000..dd86555 --- /dev/null +++ b/src/actions/interactions.ts @@ -0,0 +1,870 @@ +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import { fetchRelationships } from './accounts.ts'; +import { importFetchedAccounts, importFetchedStatus } from './importer/index.ts'; +import { expandGroupFeaturedTimeline } from './timelines.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { Account as AccountEntity, APIEntity, Group, Status as StatusEntity } from 'soapbox/types/entities.ts'; + +const REBLOG_REQUEST = 'REBLOG_REQUEST'; +const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; +const REBLOG_FAIL = 'REBLOG_FAIL'; + +const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; +const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; +const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; + +const DISLIKE_REQUEST = 'DISLIKE_REQUEST'; +const DISLIKE_SUCCESS = 'DISLIKE_SUCCESS'; +const DISLIKE_FAIL = 'DISLIKE_FAIL'; + +const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; +const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; +const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; + +const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; +const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; +const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; + +const UNDISLIKE_REQUEST = 'UNDISLIKE_REQUEST'; +const UNDISLIKE_SUCCESS = 'UNDISLIKE_SUCCESS'; +const UNDISLIKE_FAIL = 'UNDISLIKE_FAIL'; + +const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; +const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; +const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; + +const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; +const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; +const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; + +const DISLIKES_FETCH_REQUEST = 'DISLIKES_FETCH_REQUEST'; +const DISLIKES_FETCH_SUCCESS = 'DISLIKES_FETCH_SUCCESS'; +const DISLIKES_FETCH_FAIL = 'DISLIKES_FETCH_FAIL'; + +const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST'; +const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS'; +const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL'; + +const PIN_REQUEST = 'PIN_REQUEST'; +const PIN_SUCCESS = 'PIN_SUCCESS'; +const PIN_FAIL = 'PIN_FAIL'; + +const UNPIN_REQUEST = 'UNPIN_REQUEST'; +const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; +const UNPIN_FAIL = 'UNPIN_FAIL'; + +const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST'; +const BOOKMARK_FAIL = 'BOOKMARKED_FAIL'; + +const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; +const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; + +const REMOTE_INTERACTION_REQUEST = 'REMOTE_INTERACTION_REQUEST'; +const REMOTE_INTERACTION_SUCCESS = 'REMOTE_INTERACTION_SUCCESS'; +const REMOTE_INTERACTION_FAIL = 'REMOTE_INTERACTION_FAIL'; + +const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS'; +const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL'; + +const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; +const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL'; + +const ZAP_REQUEST = 'ZAP_REQUEST'; +const ZAP_SUCCESS = 'ZAP_SUCCESS'; +const ZAP_FAIL = 'ZAP_FAIL'; + +const ZAPS_FETCH_REQUEST = 'ZAPS_FETCH_REQUEST'; +const ZAPS_FETCH_SUCCESS = 'ZAPS_FETCH_SUCCESS'; +const ZAPS_FETCH_FAIL = 'ZAPS_FETCH_FAIL'; + +const ZAPS_EXPAND_SUCCESS = 'ZAPS_EXPAND_SUCCESS'; +const ZAPS_EXPAND_FAIL = 'ZAPS_EXPAND_FAIL'; + +type ReblogEffects = { + reblogEffect: (statusId: string) => void; + unreblogEffect: (statusId: string) => void; +} + +const reblog = (status: StatusEntity, effects?: ReblogEffects) => + function(dispatch: AppDispatch, getState: () => RootState) { + if (!isLoggedIn(getState)) return; + + dispatch(reblogRequest(status)); + effects?.reblogEffect(status.id); + + api(getState).post(`/api/v1/statuses/${status.id}/reblog`).then((response) => response.json()).then((data) => { + // The reblog API method returns a new status wrapped around the original. In this case we are only + // interested in how the original is modified, hence passing it skipping the wrapper + dispatch(importFetchedStatus(data.reblog)); + dispatch(reblogSuccess(status)); + }).catch(error => { + dispatch(reblogFail(status, error)); + effects?.unreblogEffect(status.id); + }); + }; + +const unreblog = (status: StatusEntity, effects?: ReblogEffects) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unreblogRequest(status)); + effects?.unreblogEffect(status.id); + + api(getState).post(`/api/v1/statuses/${status.id}/unreblog`).then(() => { + dispatch(unreblogSuccess(status)); + }).catch(error => { + dispatch(unreblogFail(status, error)); + effects?.reblogEffect(status.id); + }); + }; + +const toggleReblog = (status: StatusEntity, effects?: ReblogEffects) => + (dispatch: AppDispatch) => { + if (status.reblogged) { + dispatch(unreblog(status, effects)); + } else { + dispatch(reblog(status, effects)); + } + }; + +const reblogRequest = (status: StatusEntity) => ({ + type: REBLOG_REQUEST, + status: status, + skipLoading: true, +}); + +const reblogSuccess = (status: StatusEntity) => ({ + type: REBLOG_SUCCESS, + status: status, + skipLoading: true, +}); + +const reblogFail = (status: StatusEntity, error: unknown) => ({ + type: REBLOG_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const unreblogRequest = (status: StatusEntity) => ({ + type: UNREBLOG_REQUEST, + status: status, + skipLoading: true, +}); + +const unreblogSuccess = (status: StatusEntity) => ({ + type: UNREBLOG_SUCCESS, + status: status, + skipLoading: true, +}); + +const unreblogFail = (status: StatusEntity, error: unknown) => ({ + type: UNREBLOG_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const favourite = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(favouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.id}/favourite`).then(function(response) { + dispatch(favouriteSuccess(status)); + }).catch(function(error) { + dispatch(favouriteFail(status, error)); + }); + }; + +const unfavourite = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unfavouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.id}/unfavourite`).then(() => { + dispatch(unfavouriteSuccess(status)); + }).catch(error => { + dispatch(unfavouriteFail(status, error)); + }); + }; + +const toggleFavourite = (status: StatusEntity) => + (dispatch: AppDispatch) => { + if (status.favourited) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; + +const favouriteRequest = (status: StatusEntity) => ({ + type: FAVOURITE_REQUEST, + status: status, + skipLoading: true, +}); + +const favouriteSuccess = (status: StatusEntity) => ({ + type: FAVOURITE_SUCCESS, + status: status, + skipLoading: true, +}); + +const favouriteFail = (status: StatusEntity, error: unknown) => ({ + type: FAVOURITE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const unfavouriteRequest = (status: StatusEntity) => ({ + type: UNFAVOURITE_REQUEST, + status: status, + skipLoading: true, +}); + +const unfavouriteSuccess = (status: StatusEntity) => ({ + type: UNFAVOURITE_SUCCESS, + status: status, + skipLoading: true, +}); + +const unfavouriteFail = (status: StatusEntity, error: unknown) => ({ + type: UNFAVOURITE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const dislike = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(dislikeRequest(status)); + + api(getState).post(`/api/friendica/statuses/${status.id}/dislike`).then(function() { + dispatch(dislikeSuccess(status)); + }).catch(function(error) { + dispatch(dislikeFail(status, error)); + }); + }; + +const undislike = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(undislikeRequest(status)); + + api(getState).post(`/api/friendica/statuses/${status.id}/undislike`).then(() => { + dispatch(undislikeSuccess(status)); + }).catch(error => { + dispatch(undislikeFail(status, error)); + }); + }; + +const toggleDislike = (status: StatusEntity) => + (dispatch: AppDispatch) => { + if (status.disliked) { + dispatch(undislike(status)); + } else { + dispatch(dislike(status)); + } + }; + +const dislikeRequest = (status: StatusEntity) => ({ + type: DISLIKE_REQUEST, + status: status, + skipLoading: true, +}); + +const dislikeSuccess = (status: StatusEntity) => ({ + type: DISLIKE_SUCCESS, + status: status, + skipLoading: true, +}); + +const dislikeFail = (status: StatusEntity, error: unknown) => ({ + type: DISLIKE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const undislikeRequest = (status: StatusEntity) => ({ + type: UNDISLIKE_REQUEST, + status: status, + skipLoading: true, +}); + +const undislikeSuccess = (status: StatusEntity) => ({ + type: UNDISLIKE_SUCCESS, + status: status, + skipLoading: true, +}); + +const undislikeFail = (status: StatusEntity, error: unknown) => ({ + type: UNDISLIKE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const zap = (account: AccountEntity, status: StatusEntity | undefined, amount: number, comment: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + if (status) dispatch(zapRequest(status)); + + return api(getState).post('/api/v1/ditto/zap', { amount, comment, account_id: account.id, status_id: status?.id }).then(async (response) => { + const { invoice } = await response.json(); + if (!invoice) throw Error('Could not generate invoice'); + if (!window.webln) return invoice; + + try { + await window.webln?.enable(); + await window.webln?.sendPayment(invoice); + if (status) dispatch(zapSuccess(status)); + return undefined; + } catch (e) { // In case it fails we just return the invoice so the QR code can be created + return invoice; + } + }).catch(function(e) { + if (status) dispatch(zapFail(status, e)); + }); +}; + +const zapRequest = (status: StatusEntity) => ({ + type: ZAP_REQUEST, + status: status, + skipLoading: true, +}); + +const zapSuccess = (status: StatusEntity) => ({ + type: ZAP_SUCCESS, + status: status, + skipLoading: true, +}); + +const zapFail = (status: StatusEntity, error: unknown) => ({ + type: ZAP_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const bookmarkRequest = (status: StatusEntity) => ({ + type: BOOKMARK_REQUEST, + status: status, +}); + +const bookmarkFail = (status: StatusEntity, error: unknown) => ({ + type: BOOKMARK_FAIL, + status: status, + error: error, +}); + +const unbookmarkRequest = (status: StatusEntity) => ({ + type: UNBOOKMARK_REQUEST, + status: status, +}); + +const unbookmarkFail = (status: StatusEntity, error: unknown) => ({ + type: UNBOOKMARK_FAIL, + status: status, + error, +}); + +const fetchReblogs = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchReblogsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + dispatch(fetchReblogsSuccess(id, data, next)); + }).catch(error => { + dispatch(fetchReblogsFail(id, error)); + }); + }; + +const fetchReblogsRequest = (id: string) => ({ + type: REBLOGS_FETCH_REQUEST, + id, +}); + +const fetchReblogsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: REBLOGS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchReblogsFail = (id: string, error: unknown) => ({ + type: REBLOGS_FETCH_FAIL, + id, + error, +}); + +const expandReblogs = (id: string, path: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + api(getState).get(path).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + dispatch(expandReblogsSuccess(id, data, next)); + }).catch(error => { + dispatch(expandReblogsFail(id, error)); + }); + }; + +const expandReblogsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: REBLOGS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandReblogsFail = (id: string, error: unknown) => ({ + type: REBLOGS_EXPAND_FAIL, + id, + error, +}); + +const fetchFavourites = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchFavouritesRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + dispatch(fetchFavouritesSuccess(id, data, next)); + }).catch(error => { + dispatch(fetchFavouritesFail(id, error)); + }); + }; + +const fetchFavouritesRequest = (id: string) => ({ + type: FAVOURITES_FETCH_REQUEST, + id, +}); + +const fetchFavouritesSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FAVOURITES_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchFavouritesFail = (id: string, error: unknown) => ({ + type: FAVOURITES_FETCH_FAIL, + id, + error, +}); + +const expandFavourites = (id: string, path: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + api(getState).get(path).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + dispatch(expandFavouritesSuccess(id, data, next)); + }).catch(error => { + dispatch(expandFavouritesFail(id, error)); + }); + }; + +const expandFavouritesSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FAVOURITES_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandFavouritesFail = (id: string, error: unknown) => ({ + type: FAVOURITES_EXPAND_FAIL, + id, + error, +}); + +const fetchDislikes = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchDislikesRequest(id)); + + api(getState).get(`/api/friendica/statuses/${id}/disliked_by`).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.id))); + dispatch(fetchDislikesSuccess(id, data)); + }).catch(error => { + dispatch(fetchDislikesFail(id, error)); + }); + }; + +const fetchDislikesRequest = (id: string) => ({ + type: DISLIKES_FETCH_REQUEST, + id, +}); + +const fetchDislikesSuccess = (id: string, accounts: APIEntity[]) => ({ + type: DISLIKES_FETCH_SUCCESS, + id, + accounts, +}); + +const fetchDislikesFail = (id: string, error: unknown) => ({ + type: DISLIKES_FETCH_FAIL, + id, + error, +}); + +const fetchReactions = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchReactionsRequest(id)); + + api(getState).get(`/api/v1/pleroma/statuses/${id}/reactions`).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts((data as APIEntity[]).map(({ accounts }) => accounts).flat())); + dispatch(fetchReactionsSuccess(id, data)); + }).catch(error => { + dispatch(fetchReactionsFail(id, error)); + }); + }; + +const fetchReactionsRequest = (id: string) => ({ + type: REACTIONS_FETCH_REQUEST, + id, +}); + +const fetchReactionsSuccess = (id: string, reactions: APIEntity[]) => ({ + type: REACTIONS_FETCH_SUCCESS, + id, + reactions, +}); + +const fetchReactionsFail = (id: string, error: unknown) => ({ + type: REACTIONS_FETCH_FAIL, + id, + error, +}); + +const fetchZaps = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchZapsRequest(id)); + + api(getState).get(`/api/v1/ditto/statuses/${id}/zapped_by`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts((data as APIEntity[]).map(({ account }) => account).flat())); + dispatch(fetchZapsSuccess(id, data, next)); + }).catch(error => { + dispatch(fetchZapsFail(id, error)); + }); + }; + +const fetchZapsRequest = (id: string) => ({ + type: ZAPS_FETCH_REQUEST, + id, +}); + +const fetchZapsSuccess = (id: string, zaps: APIEntity[], next: string | null) => ({ + type: ZAPS_FETCH_SUCCESS, + id, + zaps, + next, +}); + +const fetchZapsFail = (id: string, error: unknown) => ({ + type: REACTIONS_FETCH_FAIL, + id, + error, +}); + +const expandZaps = (id: string, path: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + api(getState).get(path).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedAccounts(data.map((item: APIEntity) => item.account))); + dispatch(fetchRelationships(data.map((item: APIEntity) => item.account.id))); + dispatch(expandZapsSuccess(id, data, next)); + }).catch(error => { + dispatch(expandZapsFail(id, error)); + }); + }; + +const expandZapsSuccess = (id: string, zaps: APIEntity[], next: string | null) => ({ + type: ZAPS_EXPAND_SUCCESS, + id, + zaps, + next, +}); + +const expandZapsFail = (id: string, error: unknown) => ({ + type: ZAPS_EXPAND_FAIL, + id, + error, +}); + +const pin = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(pinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.id}/pin`).then((response) => response.json()).then((data) => { + dispatch(importFetchedStatus(data)); + dispatch(pinSuccess(status)); + }).catch(error => { + dispatch(pinFail(status, error)); + }); + }; + +const pinToGroup = (status: StatusEntity, group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState) + .post(`/api/v1/groups/${group.id}/statuses/${status.id}/pin`) + .then(() => dispatch(expandGroupFeaturedTimeline(group.id))); + }; + +const unpinFromGroup = (status: StatusEntity, group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState) + .post(`/api/v1/groups/${group.id}/statuses/${status.id}/unpin`) + .then(() => dispatch(expandGroupFeaturedTimeline(group.id))); + }; + +const pinRequest = (status: StatusEntity) => ({ + type: PIN_REQUEST, + status, + skipLoading: true, +}); + +const pinSuccess = (status: StatusEntity) => ({ + type: PIN_SUCCESS, + status, + skipLoading: true, +}); + +const pinFail = (status: StatusEntity, error: unknown) => ({ + type: PIN_FAIL, + status, + error, + skipLoading: true, +}); + +const unpin = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unpinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.id}/unpin`).then((response) => response.json()).then((data) => { + dispatch(importFetchedStatus(data)); + dispatch(unpinSuccess(status)); + }).catch(error => { + dispatch(unpinFail(status, error)); + }); + }; + +const togglePin = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.pinned) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }; + +const unpinRequest = (status: StatusEntity) => ({ + type: UNPIN_REQUEST, + status, + skipLoading: true, +}); + +const unpinSuccess = (status: StatusEntity) => ({ + type: UNPIN_SUCCESS, + status, + skipLoading: true, +}); + +const unpinFail = (status: StatusEntity, error: unknown) => ({ + type: UNPIN_FAIL, + status, + error, + skipLoading: true, +}); + +const remoteInteraction = (ap_id: string, profile: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(remoteInteractionRequest(ap_id, profile)); + + return api(getState).post('/api/v1/pleroma/remote_interaction', { ap_id, profile }).then((response) => response.json()).then((data) => { + if (data.error) throw new Error(data.error); + + dispatch(remoteInteractionSuccess(ap_id, profile, data.url)); + + return data.url; + }).catch(error => { + dispatch(remoteInteractionFail(ap_id, profile, error)); + throw error; + }); + }; + +const remoteInteractionRequest = (ap_id: string, profile: string) => ({ + type: REMOTE_INTERACTION_REQUEST, + ap_id, + profile, +}); + +const remoteInteractionSuccess = (ap_id: string, profile: string, url: string) => ({ + type: REMOTE_INTERACTION_SUCCESS, + ap_id, + profile, + url, +}); + +const remoteInteractionFail = (ap_id: string, profile: string, error: unknown) => ({ + type: REMOTE_INTERACTION_FAIL, + ap_id, + profile, + error, +}); + +export { + REBLOG_REQUEST, + REBLOG_SUCCESS, + REBLOG_FAIL, + FAVOURITE_REQUEST, + FAVOURITE_SUCCESS, + FAVOURITE_FAIL, + DISLIKE_REQUEST, + DISLIKE_SUCCESS, + DISLIKE_FAIL, + UNREBLOG_REQUEST, + UNREBLOG_SUCCESS, + UNREBLOG_FAIL, + UNFAVOURITE_REQUEST, + UNFAVOURITE_SUCCESS, + UNFAVOURITE_FAIL, + UNDISLIKE_REQUEST, + UNDISLIKE_SUCCESS, + UNDISLIKE_FAIL, + REBLOGS_FETCH_REQUEST, + REBLOGS_FETCH_SUCCESS, + REBLOGS_FETCH_FAIL, + FAVOURITES_FETCH_REQUEST, + FAVOURITES_FETCH_SUCCESS, + FAVOURITES_FETCH_FAIL, + DISLIKES_FETCH_REQUEST, + DISLIKES_FETCH_SUCCESS, + DISLIKES_FETCH_FAIL, + REACTIONS_FETCH_REQUEST, + REACTIONS_FETCH_SUCCESS, + REACTIONS_FETCH_FAIL, + PIN_REQUEST, + PIN_SUCCESS, + PIN_FAIL, + UNPIN_REQUEST, + UNPIN_SUCCESS, + UNPIN_FAIL, + BOOKMARK_REQUEST, + BOOKMARK_FAIL, + UNBOOKMARK_REQUEST, + UNBOOKMARK_FAIL, + REMOTE_INTERACTION_REQUEST, + REMOTE_INTERACTION_SUCCESS, + REMOTE_INTERACTION_FAIL, + FAVOURITES_EXPAND_SUCCESS, + FAVOURITES_EXPAND_FAIL, + REBLOGS_EXPAND_SUCCESS, + REBLOGS_EXPAND_FAIL, + ZAP_REQUEST, + ZAP_FAIL, + ZAPS_FETCH_REQUEST, + ZAPS_FETCH_SUCCESS, + ZAPS_FETCH_FAIL, + ZAPS_EXPAND_SUCCESS, + ZAPS_EXPAND_FAIL, + reblog, + unreblog, + toggleReblog, + reblogRequest, + reblogSuccess, + reblogFail, + unreblogRequest, + unreblogSuccess, + unreblogFail, + favourite, + unfavourite, + toggleFavourite, + favouriteRequest, + favouriteSuccess, + favouriteFail, + unfavouriteRequest, + unfavouriteSuccess, + unfavouriteFail, + dislike, + undislike, + toggleDislike, + dislikeRequest, + dislikeSuccess, + dislikeFail, + undislikeRequest, + undislikeSuccess, + undislikeFail, + bookmarkRequest, + bookmarkFail, + unbookmarkRequest, + unbookmarkFail, + fetchReblogs, + fetchReblogsRequest, + fetchReblogsSuccess, + fetchReblogsFail, + expandReblogs, + fetchFavourites, + fetchFavouritesRequest, + fetchFavouritesSuccess, + fetchFavouritesFail, + expandFavourites, + fetchDislikes, + fetchDislikesRequest, + fetchDislikesSuccess, + fetchDislikesFail, + fetchReactions, + fetchReactionsRequest, + fetchReactionsSuccess, + fetchReactionsFail, + pin, + pinRequest, + pinSuccess, + pinFail, + unpin, + unpinRequest, + unpinSuccess, + unpinFail, + togglePin, + pinToGroup, + unpinFromGroup, + remoteInteraction, + remoteInteractionRequest, + remoteInteractionSuccess, + remoteInteractionFail, + zap, + fetchZaps, + expandZaps, +}; diff --git a/src/actions/lists.ts b/src/actions/lists.ts new file mode 100644 index 0000000..aa7cc09 --- /dev/null +++ b/src/actions/lists.ts @@ -0,0 +1,489 @@ +import { selectAccount } from 'soapbox/selectors/index.ts'; +import toast from 'soapbox/toast.tsx'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import { importFetchedAccounts } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; +const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; +const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; + +const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; +const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; +const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; + +const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; +const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; +const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; + +const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; +const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; +const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; + +const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; +const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; +const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; + +const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; +const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; +const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; + +const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; +const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; +const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; + +const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; +const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; +const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; + +const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; +const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; +const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; + +const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; +const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; +const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; + +const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; +const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; + +const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; +const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; +const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; + +const fetchList = (id: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + if (getState().lists.get(String(id))) { + return; + } + + dispatch(fetchListRequest(id)); + + api(getState).get(`/api/v1/lists/${id}`) + .then((response) => response.json()).then((data) => dispatch(fetchListSuccess(data))) + .catch(err => dispatch(fetchListFail(id, err))); +}; + +const fetchListRequest = (id: string | number) => ({ + type: LIST_FETCH_REQUEST, + id, +}); + +const fetchListSuccess = (list: APIEntity) => ({ + type: LIST_FETCH_SUCCESS, + list, +}); + +const fetchListFail = (id: string | number, error: unknown) => ({ + type: LIST_FETCH_FAIL, + id, + error, +}); + +const fetchLists = () => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchListsRequest()); + + api(getState).get('/api/v1/lists') + .then((response) => response.json()).then((data) => dispatch(fetchListsSuccess(data))) + .catch(err => dispatch(fetchListsFail(err))); +}; + +const fetchListsRequest = () => ({ + type: LISTS_FETCH_REQUEST, +}); + +const fetchListsSuccess = (lists: APIEntity[]) => ({ + type: LISTS_FETCH_SUCCESS, + lists, +}); + +const fetchListsFail = (error: unknown) => ({ + type: LISTS_FETCH_FAIL, + error, +}); + +const submitListEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { + const listId = getState().listEditor.listId!; + const title = getState().listEditor.title; + + if (listId === null) { + dispatch(createList(title, shouldReset)); + } else { + dispatch(updateList(listId, title, shouldReset)); + } +}; + +const setupListEditor = (listId: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: LIST_EDITOR_SETUP, + list: getState().lists.get(String(listId)), + }); + + dispatch(fetchListAccounts(listId)); +}; + +const changeListEditorTitle = (value: string) => ({ + type: LIST_EDITOR_TITLE_CHANGE, + value, +}); + +const createList = (title: string, shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(createListRequest()); + + api(getState).post('/api/v1/lists', { title }).then((response) => response.json()).then((data) => { + dispatch(createListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(createListFail(err))); +}; + +const createListRequest = () => ({ + type: LIST_CREATE_REQUEST, +}); + +const createListSuccess = (list: APIEntity) => ({ + type: LIST_CREATE_SUCCESS, + list, +}); + +const createListFail = (error: unknown) => ({ + type: LIST_CREATE_FAIL, + error, +}); + +const updateList = (id: string | number, title: string, shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(updateListRequest(id)); + + api(getState).put(`/api/v1/lists/${id}`, { title }).then((response) => response.json()).then((data) => { + dispatch(updateListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(updateListFail(id, err))); +}; + +const updateListRequest = (id: string | number) => ({ + type: LIST_UPDATE_REQUEST, + id, +}); + +const updateListSuccess = (list: APIEntity) => ({ + type: LIST_UPDATE_SUCCESS, + list, +}); + +const updateListFail = (id: string | number, error: unknown) => ({ + type: LIST_UPDATE_FAIL, + id, + error, +}); + +const resetListEditor = () => ({ + type: LIST_EDITOR_RESET, +}); + +const deleteList = (id: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(deleteListRequest(id)); + + api(getState).delete(`/api/v1/lists/${id}`) + .then(() => dispatch(deleteListSuccess(id))) + .catch(err => dispatch(deleteListFail(id, err))); +}; + +const deleteListRequest = (id: string | number) => ({ + type: LIST_DELETE_REQUEST, + id, +}); + +const deleteListSuccess = (id: string | number) => ({ + type: LIST_DELETE_SUCCESS, + id, +}); + +const deleteListFail = (id: string | number, error: unknown) => ({ + type: LIST_DELETE_FAIL, + id, + error, +}); + +const fetchListAccounts = (listId: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchListAccountsRequest(listId)); + + api(getState).get(`/api/v1/lists/${listId}/accounts`, { searchParams: { limit: 0 } }).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListAccountsSuccess(listId, data, null)); + }).catch(err => dispatch(fetchListAccountsFail(listId, err))); +}; + +const fetchListAccountsRequest = (id: string | number) => ({ + type: LIST_ACCOUNTS_FETCH_REQUEST, + id, +}); + +const fetchListAccountsSuccess = (id: string | number, accounts: APIEntity[], next: string | null) => ({ + type: LIST_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchListAccountsFail = (id: string | number, error: unknown) => ({ + type: LIST_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const searchParams = { + q, + resolve: false, + limit: 4, + following: true, + }; + + api(getState).get('/api/v1/accounts/search', { searchParams }).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListSuggestionsReady(q, data)); + }).catch(error => toast.showAlertForError(error)); +}; + +const fetchListSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ + type: LIST_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +const clearListSuggestions = () => ({ + type: LIST_EDITOR_SUGGESTIONS_CLEAR, +}); + +const changeListSuggestions = (value: string) => ({ + type: LIST_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +const addToListEditor = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(addToList(getState().listEditor.listId!, accountId)); +}; + +const addToList = (listId: string | number, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(addToListRequest(listId, accountId)); + + api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToListSuccess(listId, accountId))) + .catch(err => dispatch(addToListFail(listId, accountId, err))); +}; + +const addToListRequest = (listId: string | number, accountId: string) => ({ + type: LIST_EDITOR_ADD_REQUEST, + listId, + accountId, +}); + +const addToListSuccess = (listId: string | number, accountId: string) => ({ + type: LIST_EDITOR_ADD_SUCCESS, + listId, + accountId, +}); + +const addToListFail = (listId: string | number, accountId: string, error: APIEntity) => ({ + type: LIST_EDITOR_ADD_FAIL, + listId, + accountId, + error, +}); + +const removeFromListEditor = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(removeFromList(getState().listEditor.listId!, accountId)); +}; + +const removeFromList = (listId: string | number, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(removeFromListRequest(listId, accountId)); + + const data = new FormData(); + data.append('account_ids[]', accountId); + + api(getState).request('DELETE', `/api/v1/lists/${listId}/accounts`, data) + .then(() => dispatch(removeFromListSuccess(listId, accountId))) + .catch(err => dispatch(removeFromListFail(listId, accountId, err))); +}; + +const removeFromListRequest = (listId: string | number, accountId: string) => ({ + type: LIST_EDITOR_REMOVE_REQUEST, + listId, + accountId, +}); + +const removeFromListSuccess = (listId: string | number, accountId: string) => ({ + type: LIST_EDITOR_REMOVE_SUCCESS, + listId, + accountId, +}); + +const removeFromListFail = (listId: string | number, accountId: string, error: unknown) => ({ + type: LIST_EDITOR_REMOVE_FAIL, + listId, + accountId, + error, +}); + +const resetListAdder = () => ({ + type: LIST_ADDER_RESET, +}); + +const setupListAdder = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: LIST_ADDER_SETUP, + account: selectAccount(getState(), accountId), + }); + dispatch(fetchLists()); + dispatch(fetchAccountLists(accountId)); +}; + +const fetchAccountLists = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchAccountListsRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/lists`) + .then((response) => response.json()).then((data) => dispatch(fetchAccountListsSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountListsFail(accountId, err))); +}; + +const fetchAccountListsRequest = (id: string) => ({ + type: LIST_ADDER_LISTS_FETCH_REQUEST, + id, +}); + +const fetchAccountListsSuccess = (id: string, lists: APIEntity[]) => ({ + type: LIST_ADDER_LISTS_FETCH_SUCCESS, + id, + lists, +}); + +const fetchAccountListsFail = (id: string, err: unknown) => ({ + type: LIST_ADDER_LISTS_FETCH_FAIL, + id, + err, +}); + +const addToListAdder = (listId: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(addToList(listId, getState().listAdder.accountId!)); +}; + +const removeFromListAdder = (listId: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(removeFromList(listId, getState().listAdder.accountId!)); +}; + +export { + LIST_FETCH_REQUEST, + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_REQUEST, + LISTS_FETCH_SUCCESS, + LISTS_FETCH_FAIL, + LIST_EDITOR_TITLE_CHANGE, + LIST_EDITOR_RESET, + LIST_EDITOR_SETUP, + LIST_CREATE_REQUEST, + LIST_CREATE_SUCCESS, + LIST_CREATE_FAIL, + LIST_UPDATE_REQUEST, + LIST_UPDATE_SUCCESS, + LIST_UPDATE_FAIL, + LIST_DELETE_REQUEST, + LIST_DELETE_SUCCESS, + LIST_DELETE_FAIL, + LIST_ACCOUNTS_FETCH_REQUEST, + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_ACCOUNTS_FETCH_FAIL, + LIST_EDITOR_SUGGESTIONS_CHANGE, + LIST_EDITOR_SUGGESTIONS_READY, + LIST_EDITOR_SUGGESTIONS_CLEAR, + LIST_EDITOR_ADD_REQUEST, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_ADD_FAIL, + LIST_EDITOR_REMOVE_REQUEST, + LIST_EDITOR_REMOVE_SUCCESS, + LIST_EDITOR_REMOVE_FAIL, + LIST_ADDER_RESET, + LIST_ADDER_SETUP, + LIST_ADDER_LISTS_FETCH_REQUEST, + LIST_ADDER_LISTS_FETCH_SUCCESS, + LIST_ADDER_LISTS_FETCH_FAIL, + fetchList, + fetchListRequest, + fetchListSuccess, + fetchListFail, + fetchLists, + fetchListsRequest, + fetchListsSuccess, + fetchListsFail, + submitListEditor, + setupListEditor, + changeListEditorTitle, + createList, + createListRequest, + createListSuccess, + createListFail, + updateList, + updateListRequest, + updateListSuccess, + updateListFail, + resetListEditor, + deleteList, + deleteListRequest, + deleteListSuccess, + deleteListFail, + fetchListAccounts, + fetchListAccountsRequest, + fetchListAccountsSuccess, + fetchListAccountsFail, + fetchListSuggestions, + fetchListSuggestionsReady, + clearListSuggestions, + changeListSuggestions, + addToListEditor, + addToList, + addToListRequest, + addToListSuccess, + addToListFail, + removeFromListEditor, + removeFromList, + removeFromListRequest, + removeFromListSuccess, + removeFromListFail, + resetListAdder, + setupListAdder, + fetchAccountLists, + fetchAccountListsRequest, + fetchAccountListsSuccess, + fetchAccountListsFail, + addToListAdder, + removeFromListAdder, +}; diff --git a/src/actions/markers.ts b/src/actions/markers.ts new file mode 100644 index 0000000..ad0f2a3 --- /dev/null +++ b/src/actions/markers.ts @@ -0,0 +1,43 @@ +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const MARKER_FETCH_REQUEST = 'MARKER_FETCH_REQUEST'; +const MARKER_FETCH_SUCCESS = 'MARKER_FETCH_SUCCESS'; +const MARKER_FETCH_FAIL = 'MARKER_FETCH_FAIL'; + +const MARKER_SAVE_REQUEST = 'MARKER_SAVE_REQUEST'; +const MARKER_SAVE_SUCCESS = 'MARKER_SAVE_SUCCESS'; +const MARKER_SAVE_FAIL = 'MARKER_SAVE_FAIL'; + +const fetchMarker = (timeline: Array) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MARKER_FETCH_REQUEST }); + return api(getState).get('/api/v1/markers', { searchParams: { timeline } }).then((response) => response.json()).then((marker) => { + dispatch({ type: MARKER_FETCH_SUCCESS, marker }); + }).catch(error => { + dispatch({ type: MARKER_FETCH_FAIL, error }); + }); + }; + +const saveMarker = (marker: APIEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MARKER_SAVE_REQUEST, marker }); + return api(getState).post('/api/v1/markers', marker).then((response) => response.json()).then((marker) => { + dispatch({ type: MARKER_SAVE_SUCCESS, marker }); + }).catch(error => { + dispatch({ type: MARKER_SAVE_FAIL, error }); + }); + }; + +export { + MARKER_FETCH_REQUEST, + MARKER_FETCH_SUCCESS, + MARKER_FETCH_FAIL, + MARKER_SAVE_REQUEST, + MARKER_SAVE_SUCCESS, + MARKER_SAVE_FAIL, + fetchMarker, + saveMarker, +}; diff --git a/src/actions/me.ts b/src/actions/me.ts new file mode 100644 index 0000000..1176ad5 --- /dev/null +++ b/src/actions/me.ts @@ -0,0 +1,140 @@ +import { selectAccount } from 'soapbox/selectors/index.ts'; +import { setSentryAccount } from 'soapbox/sentry.ts'; +import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import { verifyCredentials } from './auth.ts'; +import { importFetchedAccount } from './importer/index.ts'; + +import type { Account } from 'soapbox/schemas/index.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const ME_FETCH_REQUEST = 'ME_FETCH_REQUEST' as const; +const ME_FETCH_SUCCESS = 'ME_FETCH_SUCCESS' as const; +const ME_FETCH_FAIL = 'ME_FETCH_FAIL' as const; +const ME_FETCH_SKIP = 'ME_FETCH_SKIP' as const; + +const ME_PATCH_REQUEST = 'ME_PATCH_REQUEST' as const; +const ME_PATCH_SUCCESS = 'ME_PATCH_SUCCESS' as const; +const ME_PATCH_FAIL = 'ME_PATCH_FAIL' as const; + +const noOp = () => new Promise(f => f(undefined)); + +const getMeId = (state: RootState) => state.me || getAuthUserId(state); + +const getMeUrl = (state: RootState) => { + const accountId = getMeId(state); + if (accountId) { + return selectAccount(state, accountId)?.url || getAuthUserUrl(state); + } +}; + +function getMeToken(state: RootState): string | undefined { + // Fallback for upgrading IDs to URLs + const accountUrl = getMeUrl(state) || state.auth.me; + return state.auth.users[accountUrl!]?.access_token; +} + +const fetchMe = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const token = getMeToken(state); + const accountUrl = getMeUrl(state); + + if (!token) { + dispatch({ type: ME_FETCH_SKIP }); + return noOp(); + } + + dispatch(fetchMeRequest()); + return dispatch(verifyCredentials(token, accountUrl!)) + .catch(error => dispatch(fetchMeFail(error))); + }; + +const patchMe = (params: Record | FormData) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(patchMeRequest()); + + return api(getState) + .patch('/api/v1/accounts/update_credentials', params) + .then((response) => response.json()).then((data) => { + dispatch(patchMeSuccess(data)); + }).catch(error => { + dispatch(patchMeFail(error)); + throw error; + }); + }; + +const fetchMeRequest = () => ({ + type: ME_FETCH_REQUEST, +}); + +const fetchMeSuccess = (account: Account) => { + setSentryAccount(account); + + return { + type: ME_FETCH_SUCCESS, + me: account, + }; +}; + +const fetchMeFail = (error: APIEntity) => ({ + type: ME_FETCH_FAIL, + error, + skipAlert: true, +}); + +const patchMeRequest = () => ({ + type: ME_PATCH_REQUEST, +}); + +interface MePatchSuccessAction { + type: typeof ME_PATCH_SUCCESS; + me: APIEntity; +} + +const patchMeSuccess = (me: APIEntity) => + (dispatch: AppDispatch) => { + const action: MePatchSuccessAction = { + type: ME_PATCH_SUCCESS, + me, + }; + + dispatch(importFetchedAccount(me)); + dispatch(action); + }; + +const patchMeFail = (error: unknown) => ({ + type: ME_PATCH_FAIL, + error, + skipAlert: true, +}); + +type MeAction = + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | MePatchSuccessAction + | ReturnType; + +export { + ME_FETCH_REQUEST, + ME_FETCH_SUCCESS, + ME_FETCH_FAIL, + ME_FETCH_SKIP, + ME_PATCH_REQUEST, + ME_PATCH_SUCCESS, + ME_PATCH_FAIL, + fetchMe, + patchMe, + fetchMeRequest, + fetchMeSuccess, + fetchMeFail, + patchMeRequest, + patchMeSuccess, + patchMeFail, + type MeAction, +}; diff --git a/src/actions/media.ts b/src/actions/media.ts new file mode 100644 index 0000000..c19895a --- /dev/null +++ b/src/actions/media.ts @@ -0,0 +1,128 @@ +import { defineMessages, type IntlShape } from 'react-intl'; + +import toast from 'soapbox/toast.tsx'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; +import { formatBytes, getVideoDuration } from 'soapbox/utils/media.ts'; +import resizeImage from 'soapbox/utils/resize-image.ts'; + +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const messages = defineMessages({ + exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, + exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, + exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' }, +}); + +const noOp = (e: any) => {}; + +const fetchMedia = (mediaId: string) => + (dispatch: any, getState: () => RootState) => { + return api(getState).get(`/api/v1/media/${mediaId}`); + }; + +const updateMedia = (mediaId: string, params: Record) => + (dispatch: any, getState: () => RootState) => { + return api(getState).put(`/api/v1/media/${mediaId}`, params); + }; + +const uploadMediaV1 = (data: FormData, onUploadProgress = noOp) => + (dispatch: any, getState: () => RootState) => + api(getState).post('/api/v1/media', data, { onUploadProgress }); + +const uploadMediaV2 = (data: FormData, onUploadProgress = noOp) => + (dispatch: any, getState: () => RootState) => + api(getState).post('/api/v2/media', data, { onUploadProgress }); + +const uploadMedia = (data: FormData, onUploadProgress = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.mediaV2) { + return dispatch(uploadMediaV2(data, onUploadProgress)); + } else { + return dispatch(uploadMediaV1(data, onUploadProgress)); + } + }; + +const uploadFile = ( + file: File, + intl: IntlShape, + onSuccess: (data: APIEntity) => void = () => {}, + onFail: (error: unknown) => void = () => {}, + onUploadProgress: (e: ProgressEvent) => void = () => {}, +) => + async (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const maxImageSize = getState().instance.configuration.media_attachments.image_size_limit; + const maxVideoSize = getState().instance.configuration.media_attachments.video_size_limit; + const maxVideoDuration = getState().instance.configuration.media_attachments.video_duration_limit; + + const isImage = file.type.match(/image.*/); + const isVideo = file.type.match(/video.*/); + const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(file) : 0; + + if (isImage && maxImageSize && (file.size > maxImageSize)) { + const limit = formatBytes(maxImageSize); + const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); + toast.error(message); + onFail(true); + return; + } else if (isVideo && maxVideoSize && (file.size > maxVideoSize)) { + const limit = formatBytes(maxVideoSize); + const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); + toast.error(message); + onFail(true); + return; + } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { + const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); + toast.error(message); + onFail(true); + return; + } + + // FIXME: Don't define const in loop + resizeImage(file).then((resized) => { + const data = new FormData(); + data.append('file', resized); + + return dispatch(uploadMedia(data, onUploadProgress)) + .then(async (response) => { + const { status } = response; + const data = await response.json(); + // If server-side processing of the media attachment has not completed yet, + // poll the server until it is, before showing the media attachment as uploaded + if (status === 200) { + onSuccess(data); + } else if (status === 202) { + const poll = () => { + dispatch(fetchMedia(data.id)).then(async (response) => { + const { status } = response; + const data = await response.json(); + if (status === 200) { + onSuccess(data); + } else if (status === 206) { + setTimeout(() => poll(), 1000); + } + }).catch(error => onFail(error)); + }; + + poll(); + } + }); + }).catch(error => onFail(error)); + }; + +export { + fetchMedia, + updateMedia, + uploadMediaV1, + uploadMediaV2, + uploadMedia, + uploadFile, +}; diff --git a/src/actions/mfa.ts b/src/actions/mfa.ts new file mode 100644 index 0000000..6d62f13 --- /dev/null +++ b/src/actions/mfa.ts @@ -0,0 +1,104 @@ +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const MFA_FETCH_REQUEST = 'MFA_FETCH_REQUEST'; +const MFA_FETCH_SUCCESS = 'MFA_FETCH_SUCCESS'; +const MFA_FETCH_FAIL = 'MFA_FETCH_FAIL'; + +const MFA_BACKUP_CODES_FETCH_REQUEST = 'MFA_BACKUP_CODES_FETCH_REQUEST'; +const MFA_BACKUP_CODES_FETCH_SUCCESS = 'MFA_BACKUP_CODES_FETCH_SUCCESS'; +const MFA_BACKUP_CODES_FETCH_FAIL = 'MFA_BACKUP_CODES_FETCH_FAIL'; + +const MFA_SETUP_REQUEST = 'MFA_SETUP_REQUEST'; +const MFA_SETUP_SUCCESS = 'MFA_SETUP_SUCCESS'; +const MFA_SETUP_FAIL = 'MFA_SETUP_FAIL'; + +const MFA_CONFIRM_REQUEST = 'MFA_CONFIRM_REQUEST'; +const MFA_CONFIRM_SUCCESS = 'MFA_CONFIRM_SUCCESS'; +const MFA_CONFIRM_FAIL = 'MFA_CONFIRM_FAIL'; + +const MFA_DISABLE_REQUEST = 'MFA_DISABLE_REQUEST'; +const MFA_DISABLE_SUCCESS = 'MFA_DISABLE_SUCCESS'; +const MFA_DISABLE_FAIL = 'MFA_DISABLE_FAIL'; + +const fetchMfa = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MFA_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa').then((response) => response.json()).then((data) => { + dispatch({ type: MFA_FETCH_SUCCESS, data }); + }).catch(() => { + dispatch({ type: MFA_FETCH_FAIL }); + }); + }; + +const fetchBackupCodes = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MFA_BACKUP_CODES_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then((response) => response.json()).then((data) => { + dispatch({ type: MFA_BACKUP_CODES_FETCH_SUCCESS, data }); + return data; + }).catch(() => { + dispatch({ type: MFA_BACKUP_CODES_FETCH_FAIL }); + }); + }; + +const setupMfa = (method: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MFA_SETUP_REQUEST, method }); + return api(getState).get(`/api/pleroma/accounts/mfa/setup/${method}`).then((response) => response.json()).then((data) => { + dispatch({ type: MFA_SETUP_SUCCESS, data }); + return data; + }).catch((error: unknown) => { + dispatch({ type: MFA_SETUP_FAIL }); + throw error; + }); + }; + +const confirmMfa = (method: string, code: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const params = { code, password }; + dispatch({ type: MFA_CONFIRM_REQUEST, method, code }); + return api(getState).post(`/api/pleroma/accounts/mfa/confirm/${method}`, params).then((response) => response.json()).then((data) => { + dispatch({ type: MFA_CONFIRM_SUCCESS, method, code }); + return data; + }).catch((error: unknown) => { + dispatch({ type: MFA_CONFIRM_FAIL, method, code, error, skipAlert: true }); + throw error; + }); + }; + +const disableMfa = (method: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MFA_DISABLE_REQUEST, method }); + return api(getState).request('DELETE', `/api/pleroma/accounts/mfa/${method}`, { password }).then((response) => response.json()).then((data) => { + dispatch({ type: MFA_DISABLE_SUCCESS, method }); + return data; + }).catch((error: unknown) => { + dispatch({ type: MFA_DISABLE_FAIL, method, skipAlert: true }); + throw error; + }); + }; + +export { + MFA_FETCH_REQUEST, + MFA_FETCH_SUCCESS, + MFA_FETCH_FAIL, + MFA_BACKUP_CODES_FETCH_REQUEST, + MFA_BACKUP_CODES_FETCH_SUCCESS, + MFA_BACKUP_CODES_FETCH_FAIL, + MFA_SETUP_REQUEST, + MFA_SETUP_SUCCESS, + MFA_SETUP_FAIL, + MFA_CONFIRM_REQUEST, + MFA_CONFIRM_SUCCESS, + MFA_CONFIRM_FAIL, + MFA_DISABLE_REQUEST, + MFA_DISABLE_SUCCESS, + MFA_DISABLE_FAIL, + fetchMfa, + fetchBackupCodes, + setupMfa, + confirmMfa, + disableMfa, +}; diff --git a/src/actions/modals.ts b/src/actions/modals.ts new file mode 100644 index 0000000..a1d6af4 --- /dev/null +++ b/src/actions/modals.ts @@ -0,0 +1,28 @@ +import { AppDispatch } from 'soapbox/store.ts'; + +import type { ModalType } from 'soapbox/features/ui/components/modal-root.tsx'; + +export const MODAL_OPEN = 'MODAL_OPEN'; +export const MODAL_CLOSE = 'MODAL_CLOSE'; + +/** Open a modal of the given type */ +export function openModal(type: ModalType, props?: any) { + return (dispatch: AppDispatch) => { + dispatch(closeModal(type)); + dispatch(openModalSuccess(type, props)); + }; +} + +const openModalSuccess = (type: ModalType, props?: any) => ({ + type: MODAL_OPEN, + modalType: type, + modalProps: props, +}); + +/** Close the modal */ +export function closeModal(type?: ModalType) { + return { + type: MODAL_CLOSE, + modalType: type, + }; +} diff --git a/src/actions/moderation.tsx b/src/actions/moderation.tsx new file mode 100644 index 0000000..077e7a3 --- /dev/null +++ b/src/actions/moderation.tsx @@ -0,0 +1,164 @@ +import alertTriangleIcon from '@tabler/icons/outline/alert-triangle.svg'; +import trashIcon from '@tabler/icons/outline/trash.svg'; +import userMinusIcon from '@tabler/icons/outline/user-minus.svg'; +import userOffIcon from '@tabler/icons/outline/user-off.svg'; +import { defineMessages, IntlShape } from 'react-intl'; + +import { fetchAccountByUsername } from 'soapbox/actions/accounts.ts'; +import { deactivateUsers, deleteUser, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin.ts'; +import { openModal } from 'soapbox/actions/modals.ts'; +import OutlineBox from 'soapbox/components/outline-box.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import AccountContainer from 'soapbox/containers/account-container.tsx'; +import { selectAccount } from 'soapbox/selectors/index.ts'; +import toast from 'soapbox/toast.tsx'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const messages = defineMessages({ + deactivateUserHeading: { id: 'confirmations.admin.deactivate_user.heading', defaultMessage: 'Deactivate @{acct}' }, + deactivateUserPrompt: { id: 'confirmations.admin.deactivate_user.message', defaultMessage: 'You are about to deactivate @{acct}. Deactivating a user is a reversible action.' }, + deactivateUserConfirm: { id: 'confirmations.admin.deactivate_user.confirm', defaultMessage: 'Deactivate @{name}' }, + userDeactivated: { id: 'admin.users.user_deactivated_message', defaultMessage: '@{acct} was deactivated' }, + deleteUserHeading: { id: 'confirmations.admin.delete_user.heading', defaultMessage: 'Delete @{acct}' }, + deleteUserPrompt: { id: 'confirmations.admin.delete_user.message', defaultMessage: 'You are about to delete @{acct}. THIS IS A DESTRUCTIVE ACTION THAT CANNOT BE UNDONE.' }, + deleteUserConfirm: { id: 'confirmations.admin.delete_user.confirm', defaultMessage: 'Delete @{name}' }, + deleteLocalUserCheckbox: { id: 'confirmations.admin.delete_local_user.checkbox', defaultMessage: 'I understand that I am about to delete a local user.' }, + userDeleted: { id: 'admin.users.user_deleted_message', defaultMessage: '@{acct} was deleted' }, + deleteStatusHeading: { id: 'confirmations.admin.delete_status.heading', defaultMessage: 'Delete post' }, + deleteStatusPrompt: { id: 'confirmations.admin.delete_status.message', defaultMessage: 'You are about to delete a post by @{acct}. This action cannot be undone.' }, + deleteStatusConfirm: { id: 'confirmations.admin.delete_status.confirm', defaultMessage: 'Delete post' }, + rejectUserHeading: { id: 'confirmations.admin.reject_user.heading', defaultMessage: 'Reject @{acct}' }, + rejectUserPrompt: { id: 'confirmations.admin.reject_user.message', defaultMessage: 'You are about to reject @{acct} registration request. This action cannot be undone.' }, + rejectUserConfirm: { id: 'confirmations.admin.reject_user.confirm', defaultMessage: 'Reject @{name}' }, + statusDeleted: { id: 'admin.statuses.status_deleted_message', defaultMessage: 'Post by @{acct} was deleted' }, + markStatusSensitiveHeading: { id: 'confirmations.admin.mark_status_sensitive.heading', defaultMessage: 'Mark post sensitive' }, + markStatusNotSensitiveHeading: { id: 'confirmations.admin.mark_status_not_sensitive.heading', defaultMessage: 'Mark post not sensitive.' }, + markStatusSensitivePrompt: { id: 'confirmations.admin.mark_status_sensitive.message', defaultMessage: 'You are about to mark a post by @{acct} sensitive.' }, + markStatusNotSensitivePrompt: { id: 'confirmations.admin.mark_status_not_sensitive.message', defaultMessage: 'You are about to mark a post by @{acct} not sensitive.' }, + markStatusSensitiveConfirm: { id: 'confirmations.admin.mark_status_sensitive.confirm', defaultMessage: 'Mark post sensitive' }, + markStatusNotSensitiveConfirm: { id: 'confirmations.admin.mark_status_not_sensitive.confirm', defaultMessage: 'Mark post not sensitive' }, + statusMarkedSensitive: { id: 'admin.statuses.status_marked_message_sensitive', defaultMessage: 'Post by @{acct} was marked sensitive' }, + statusMarkedNotSensitive: { id: 'admin.statuses.status_marked_message_not_sensitive', defaultMessage: 'Post by @{acct} was marked not sensitive' }, +}); + +const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const acct = selectAccount(state, accountId)!.acct; + const name = selectAccount(state, accountId)!.username; + + const message = ( + + + + + + + {intl.formatMessage(messages.deactivateUserPrompt, { acct })} + + + ); + + dispatch(openModal('CONFIRM', { + icon: userOffIcon, + heading: intl.formatMessage(messages.deactivateUserHeading, { acct }), + message, + confirm: intl.formatMessage(messages.deactivateUserConfirm, { name }), + onConfirm: () => { + dispatch(deactivateUsers([accountId])).then(() => { + const message = intl.formatMessage(messages.userDeactivated, { acct }); + toast.success(message); + afterConfirm(); + }).catch(() => {}); + }, + })); + }; + +const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const account = selectAccount(state, accountId)!; + const acct = account.acct; + const name = account.username; + const local = account.local; + + const message = ( + + + + + + + {intl.formatMessage(messages.deleteUserPrompt, { acct })} + + + ); + + const confirm = intl.formatMessage(messages.deleteUserConfirm, { name }); + const checkbox = local ? intl.formatMessage(messages.deleteLocalUserCheckbox) : false; + + dispatch(openModal('CONFIRM', { + icon: userMinusIcon, + heading: intl.formatMessage(messages.deleteUserHeading, { acct }), + message, + confirm, + checkbox, + onConfirm: () => { + dispatch(deleteUser(accountId)).then(() => { + const message = intl.formatMessage(messages.userDeleted, { acct }); + dispatch(fetchAccountByUsername(acct)); + toast.success(message); + afterConfirm(); + }).catch(() => {}); + }, + })); + }; + +const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensitive: boolean, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const acct = state.statuses.get(statusId)!.account.acct; + + dispatch(openModal('CONFIRM', { + icon: alertTriangleIcon, + heading: intl.formatMessage(sensitive === false ? messages.markStatusSensitiveHeading : messages.markStatusNotSensitiveHeading), + message: intl.formatMessage(sensitive === false ? messages.markStatusSensitivePrompt : messages.markStatusNotSensitivePrompt, { acct }), + confirm: intl.formatMessage(sensitive === false ? messages.markStatusSensitiveConfirm : messages.markStatusNotSensitiveConfirm), + onConfirm: () => { + dispatch(toggleStatusSensitivity(statusId, sensitive)).then(() => { + const message = intl.formatMessage(sensitive === false ? messages.statusMarkedSensitive : messages.statusMarkedNotSensitive, { acct }); + toast.success(message); + }).catch(() => {}); + afterConfirm(); + }, + })); + }; + +const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const acct = state.statuses.get(statusId)!.account.acct; + + dispatch(openModal('CONFIRM', { + icon: trashIcon, + heading: intl.formatMessage(messages.deleteStatusHeading), + message: intl.formatMessage(messages.deleteStatusPrompt, { acct: {acct} }), + confirm: intl.formatMessage(messages.deleteStatusConfirm), + onConfirm: () => { + dispatch(deleteStatus(statusId)).then(() => { + const message = intl.formatMessage(messages.statusDeleted, { acct }); + toast.success(message); + }).catch(() => {}); + afterConfirm(); + }, + })); + }; + +export { + deactivateUserModal, + deleteUserModal, + toggleStatusSensitivityModal, + deleteStatusModal, +}; diff --git a/src/actions/mrf.ts b/src/actions/mrf.ts new file mode 100644 index 0000000..25e2db7 --- /dev/null +++ b/src/actions/mrf.ts @@ -0,0 +1,35 @@ +import { Set as ImmutableSet } from 'immutable'; + +import ConfigDB from 'soapbox/utils/config-db.ts'; + +import { fetchConfig, updateConfig } from './admin.ts'; + +import type { MRFSimple } from 'soapbox/schemas/pleroma.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions: Record) => { + const entries = Object.entries(simplePolicy).map(([key, hosts]) => { + const isRestricted = restrictions[key]; + + if (isRestricted) { + return [key, ImmutableSet(hosts).add(host).toJS()]; + } else { + return [key, ImmutableSet(hosts).delete(host).toJS()]; + } + }); + + return Object.fromEntries(entries); +}; + +const updateMrf = (host: string, restrictions: Record) => + (dispatch: AppDispatch, getState: () => RootState) => + dispatch(fetchConfig()) + .then(() => { + const configs = getState().admin.get('configs'); + const simplePolicy = ConfigDB.toSimplePolicy(configs); + const merged = simplePolicyMerge(simplePolicy, host, restrictions); + const config = ConfigDB.fromSimplePolicy(merged); + return dispatch(updateConfig(config.toJS() as Array>)); + }); + +export { updateMrf }; diff --git a/src/actions/mutes.ts b/src/actions/mutes.ts new file mode 100644 index 0000000..350fd93 --- /dev/null +++ b/src/actions/mutes.ts @@ -0,0 +1,41 @@ +import { openModal } from './modals.ts'; + +import type { Account } from 'soapbox/schemas/index.ts'; +import type { AppDispatch } from 'soapbox/store.ts'; +import type { Account as AccountEntity } from 'soapbox/types/entities.ts'; + +const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; +const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; + +const initMuteModal = (account: AccountEntity | Account) => + (dispatch: AppDispatch) => { + dispatch({ + type: MUTES_INIT_MODAL, + account, + }); + + dispatch(openModal('MUTE')); + }; + +const toggleHideNotifications = () => + (dispatch: AppDispatch) => { + dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); + }; + +const changeMuteDuration = (duration: number) => + (dispatch: AppDispatch) => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; + +export { + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, + initMuteModal, + toggleHideNotifications, + changeMuteDuration, +}; diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts new file mode 100644 index 0000000..87f13f2 --- /dev/null +++ b/src/actions/nostr.ts @@ -0,0 +1,100 @@ +import { NostrSigner, NRelay1, NSecSigner } from '@nostrify/nostrify'; +import { generateSecretKey } from 'nostr-tools'; + +import { NBunker } from 'soapbox/features/nostr/NBunker.ts'; +import { keyring } from 'soapbox/features/nostr/keyring.ts'; +import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore.ts'; +import { type AppDispatch } from 'soapbox/store.ts'; + +import { authLoggedIn, verifyCredentials } from './auth.ts'; +import { obtainOAuthToken } from './oauth.ts'; + +const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET'; + +/** Log in with a Nostr pubkey. */ +function logInNostr(signer: NostrSigner, relay: NRelay1) { + return async (dispatch: AppDispatch) => { + const authorization = generateBunkerAuth(); + + const pubkey = await signer.getPublicKey(); + const bunkerPubkey = await authorization.signer.getPublicKey(); + + let authorizedPubkey: string | undefined; + + const bunker = new NBunker({ + relay, + userSigner: signer, + bunkerSigner: authorization.signer, + onConnect(request, event) { + const [, secret] = request.params; + + if (secret === authorization.secret) { + bunker.authorize(event.pubkey); + authorizedPubkey = event.pubkey; + return { id: request.id, result: 'ack' }; + } else { + return { id: request.id, result: '', error: 'Invalid secret' }; + } + }, + }); + + await bunker.waitReady; + + const token = await dispatch(obtainOAuthToken({ + grant_type: 'nostr_bunker', + pubkey: bunkerPubkey, + relays: [relay.socket.url], + secret: authorization.secret, + })); + + if (!authorizedPubkey) { + throw new Error('Authorization failed'); + } + + const accessToken = dispatch(authLoggedIn(token)).access_token as string; + const bunkerState = useBunkerStore.getState(); + + keyring.add(authorization.seckey); + + bunkerState.connect({ + pubkey, + accessToken, + authorizedPubkey, + bunkerPubkey, + }); + + await dispatch(verifyCredentials(accessToken)); + bunker.close(); + }; +} + +/** Log in with a Nostr extension. */ +function nostrExtensionLogIn(relay: NRelay1) { + return async (dispatch: AppDispatch) => { + if (!window.nostr) { + throw new Error('No Nostr signer available'); + } + return dispatch(logInNostr(window.nostr, relay)); + }; +} + +/** Generate a bunker authorization object. */ +function generateBunkerAuth() { + const secret = crypto.randomUUID(); + const seckey = generateSecretKey(); + + return { + secret, + seckey, + signer: new NSecSigner(seckey), + }; +} + +function setNostrPubkey(pubkey: string | undefined) { + return { + type: NOSTR_PUBKEY_SET, + pubkey, + }; +} + +export { logInNostr, nostrExtensionLogIn, setNostrPubkey, NOSTR_PUBKEY_SET }; diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts new file mode 100644 index 0000000..04d7e5d --- /dev/null +++ b/src/actions/notifications.ts @@ -0,0 +1,359 @@ +import IntlMessageFormat from 'intl-messageformat'; +import 'intl-pluralrules'; +import { defineMessages } from 'react-intl'; + +import api from 'soapbox/api/index.ts'; +import { getFilters, regexFromFilters } from 'soapbox/selectors/index.ts'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; +import { compareId } from 'soapbox/utils/comparators.ts'; +import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features.ts'; +import { htmlToPlaintext } from 'soapbox/utils/html.ts'; +import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from 'soapbox/utils/notification.ts'; + +import { fetchRelationships } from './accounts.ts'; +import { fetchGroupRelationships } from './groups.ts'; +import { + importFetchedAccount, + importFetchedAccounts, + importFetchedStatus, + importFetchedStatuses, +} from './importer/index.ts'; +import { saveMarker } from './markers.ts'; +import { getSettings, saveSettings } from './settings.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity, Status } from 'soapbox/types/entities.ts'; + +const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; +const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; +const NOTIFICATIONS_UPDATE_QUEUE = 'NOTIFICATIONS_UPDATE_QUEUE'; +const NOTIFICATIONS_DEQUEUE = 'NOTIFICATIONS_DEQUEUE'; + +const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; +const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; +const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; + +const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; + +const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; + +const NOTIFICATIONS_MARK_READ_REQUEST = 'NOTIFICATIONS_MARK_READ_REQUEST'; +const NOTIFICATIONS_MARK_READ_SUCCESS = 'NOTIFICATIONS_MARK_READ_SUCCESS'; +const NOTIFICATIONS_MARK_READ_FAIL = 'NOTIFICATIONS_MARK_READ_FAIL'; + +const MAX_QUEUED_NOTIFICATIONS = 40; + +defineMessages({ + mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, + group: { id: 'notifications.group', defaultMessage: '{count, plural, one {# notification} other {# notifications}}' }, +}); + +const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => { + const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); + + if (accountIds.length > 0) { + dispatch(fetchRelationships(accountIds)); + } +}; + +const updateNotifications = (notification: APIEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + const showInColumn = getSettings(getState()).getIn(['notifications', 'shows', notification.type], true); + + if (notification.account) { + dispatch(importFetchedAccount(notification.account)); + } + + // Used by Move notification + if (notification.target) { + dispatch(importFetchedAccount(notification.target)); + } + + if (notification.status) { + dispatch(importFetchedStatus(notification.status)); + } + + if (showInColumn) { + dispatch({ + type: NOTIFICATIONS_UPDATE, + notification, + }); + + fetchRelatedRelationships(dispatch, [notification]); + } + }; + +const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record, intlLocale: string, curPath: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!notification.type) return; // drop invalid notifications + if (notification.type === 'pleroma:chat_mention') return; // Drop chat notifications, handle them per-chat + if (notification.type === 'chat') return; // Drop Truth Social chat notifications. + + const showAlert = getSettings(getState()).getIn(['notifications', 'alerts', notification.type]); + const filters = getFilters(getState(), { contextType: 'notifications' }); + const playSound = getSettings(getState()).getIn(['notifications', 'sounds', notification.type]); + + let filtered: boolean | null = false; + + const isOnNotificationsPage = curPath === '/notifications'; + + if (['mention', 'status'].includes(notification.type)) { + const regex = regexFromFilters(filters); + const searchIndex = notification.status.spoiler_text + '\n' + htmlToPlaintext(notification.status.content); + filtered = regex && regex.test(searchIndex); + } + + // Desktop notifications + try { + // eslint-disable-next-line compat/compat + const isNotificationsEnabled = window.Notification?.permission === 'granted'; + + if (showAlert && !filtered && isNotificationsEnabled) { + const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : htmlToPlaintext(notification.status ? notification.status.content : ''); + + navigator.serviceWorker.ready.then(serviceWorkerRegistration => { + serviceWorkerRegistration.showNotification(title, { + body, + icon: notification.account.avatar, + tag: notification.id, + data: { + url: '/notifications', + }, + }).catch(console.error); + }).catch(console.error); + } + } catch (e) { + console.warn(e); + } + + if (playSound && !filtered) { + dispatch({ + type: NOTIFICATIONS_UPDATE_NOOP, + meta: { sound: 'boop' }, + }); + } + + if (isOnNotificationsPage) { + dispatch({ + type: NOTIFICATIONS_UPDATE_QUEUE, + notification, + intlMessages, + intlLocale, + }); + } else { + dispatch(updateNotifications(notification)); + } + }; + +const dequeueNotifications = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const queuedNotifications = getState().notifications.queuedNotifications; + const totalQueuedNotificationsCount = getState().notifications.totalQueuedNotificationsCount; + + if (totalQueuedNotificationsCount === 0) { + return; + } else if (totalQueuedNotificationsCount > 0 && totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) { + queuedNotifications.forEach((block) => { + dispatch(updateNotifications(block.notification)); + }); + } else { + dispatch(expandNotifications()); + } + + dispatch({ + type: NOTIFICATIONS_DEQUEUE, + }); + dispatch(markReadNotifications()); + }; + +const excludeTypesFromFilter = (filter: string) => { + return NOTIFICATION_TYPES.filter(item => item !== filter); +}; + +const noOp = () => new Promise(f => f(undefined)); + +const expandNotifications = ({ maxId }: Record = {}, done: () => any = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp); + + const state = getState(); + const features = getFeatures(state.instance); + const activeFilter = getSettings(state).getIn(['notifications', 'quickFilter', 'active']) as string; + const notifications = state.notifications; + const isLoadingMore = !!maxId; + + if (notifications.isLoading) { + done(); + return dispatch(noOp); + } + + const params: Record = { + max_id: maxId, + }; + + if (activeFilter === 'all') { + if (features.notificationsIncludeTypes) { + params.types = NOTIFICATION_TYPES.filter(type => !EXCLUDE_TYPES.includes(type as any)); + } else { + params.exclude_types = EXCLUDE_TYPES; + } + } else { + if (features.notificationsIncludeTypes) { + params.types = [activeFilter]; + } else { + params.exclude_types = excludeTypesFromFilter(activeFilter); + } + } + + if (!maxId && notifications.items.size > 0) { + params.since_id = notifications.getIn(['items', 0, 'id']); + } + + dispatch(expandNotificationsRequest(isLoadingMore)); + + return api(getState).get('/api/v1/notifications', { searchParams: params }).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + const entries = (data as APIEntity[]).reduce((acc, item) => { + if (item.account?.id) { + acc.accounts[item.account.id] = item.account; + } + + // Used by Move notification + if (item.target?.id) { + acc.accounts[item.target.id] = item.target; + } + + if (item.status?.id) { + acc.statuses[item.status.id] = item.status; + } + + return acc; + }, { accounts: {}, statuses: {} }); + + dispatch(importFetchedAccounts(Object.values(entries.accounts))); + dispatch(importFetchedStatuses(Object.values(entries.statuses))); + + const statusesFromGroups = (Object.values(entries.statuses) as Status[]).filter((status) => !!status.group); + dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id))); + + dispatch(expandNotificationsSuccess(data, next, isLoadingMore)); + fetchRelatedRelationships(dispatch, data); + done(); + }).catch(error => { + dispatch(expandNotificationsFail(error, isLoadingMore)); + done(); + }); + }; + +const expandNotificationsRequest = (isLoadingMore: boolean) => ({ + type: NOTIFICATIONS_EXPAND_REQUEST, + skipLoading: !isLoadingMore, +}); + +const expandNotificationsSuccess = (notifications: APIEntity[], next: string | null, isLoadingMore: boolean) => ({ + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + next, + skipLoading: !isLoadingMore, +}); + +const expandNotificationsFail = (error: unknown, isLoadingMore: boolean) => ({ + type: NOTIFICATIONS_EXPAND_FAIL, + error, + skipLoading: !isLoadingMore, +}); + +const clearNotifications = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ + type: NOTIFICATIONS_CLEAR, + }); + + api(getState).post('/api/v1/notifications/clear'); + }; + +const scrollTopNotifications = (top: boolean) => + (dispatch: AppDispatch) => { + dispatch({ + type: NOTIFICATIONS_SCROLL_TOP, + top, + }); + dispatch(markReadNotifications()); + }; + +const setFilter = (filterType: string) => + (dispatch: AppDispatch) => { + dispatch({ + type: NOTIFICATIONS_FILTER_SET, + path: ['notifications', 'quickFilter', 'active'], + value: filterType, + }); + dispatch(expandNotifications()); + dispatch(saveSettings()); + }; + +// Of course Markers don't work properly in Pleroma. +// https://git.pleroma.social/pleroma/pleroma/-/issues/2769 +const markReadPleroma = (max_id: string | number) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState).post('/api/v1/pleroma/notifications/read', { max_id }); + }; + +const markReadNotifications = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + const topNotificationId = state.notifications.items.first()?.id; + const lastReadId = state.notifications.lastRead; + const v = parseVersion(state.instance.version); + + if (topNotificationId && (lastReadId === -1 || compareId(topNotificationId, lastReadId) > 0)) { + const marker = { + notifications: { + last_read_id: topNotificationId, + }, + }; + + dispatch(saveMarker(marker)); + + if (v.software === PLEROMA) { + dispatch(markReadPleroma(topNotificationId)); + } + } + }; + +export { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_UPDATE_NOOP, + NOTIFICATIONS_UPDATE_QUEUE, + NOTIFICATIONS_DEQUEUE, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_EXPAND_SUCCESS, + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_FILTER_SET, + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_MARK_READ_REQUEST, + NOTIFICATIONS_MARK_READ_SUCCESS, + NOTIFICATIONS_MARK_READ_FAIL, + MAX_QUEUED_NOTIFICATIONS, + updateNotifications, + updateNotificationsQueue, + dequeueNotifications, + expandNotifications, + expandNotificationsRequest, + expandNotificationsSuccess, + expandNotificationsFail, + clearNotifications, + scrollTopNotifications, + setFilter, + markReadPleroma, + markReadNotifications, +}; diff --git a/src/actions/oauth.ts b/src/actions/oauth.ts new file mode 100644 index 0000000..2ee114c --- /dev/null +++ b/src/actions/oauth.ts @@ -0,0 +1,46 @@ +/** + * OAuth: create and revoke tokens. + * Tokens can be used by users and apps. + * https://docs.joinmastodon.org/methods/apps/oauth/ + * @module soapbox/actions/oauth + * @see module:soapbox/actions/auth + */ + +import { getBaseURL } from 'soapbox/utils/state.ts'; + +import { baseClient } from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +export const OAUTH_TOKEN_CREATE_REQUEST = 'OAUTH_TOKEN_CREATE_REQUEST'; +export const OAUTH_TOKEN_CREATE_SUCCESS = 'OAUTH_TOKEN_CREATE_SUCCESS'; +export const OAUTH_TOKEN_CREATE_FAIL = 'OAUTH_TOKEN_CREATE_FAIL'; + +export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST'; +export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS'; +export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL'; + +export const obtainOAuthToken = (params: Record, baseURL?: string) => + (dispatch: AppDispatch) => { + dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); + return baseClient(null, baseURL).post('/oauth/token', params).then((response) => response.json()).then((token) => { + dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token }); + return token; + }).catch(error => { + dispatch({ type: OAUTH_TOKEN_CREATE_FAIL, params, error, skipAlert: true }); + throw error; + }); + }; + +export const revokeOAuthToken = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: OAUTH_TOKEN_REVOKE_REQUEST, params }); + const baseURL = getBaseURL(getState()); + return baseClient(null, baseURL).post('/oauth/revoke', params).then((response) => response.json()).then((data) => { + dispatch({ type: OAUTH_TOKEN_REVOKE_SUCCESS, params, data }); + return data; + }).catch(error => { + dispatch({ type: OAUTH_TOKEN_REVOKE_FAIL, params, error }); + throw error; + }); + }; diff --git a/src/actions/onboarding.test.ts b/src/actions/onboarding.test.ts new file mode 100644 index 0000000..c528776 --- /dev/null +++ b/src/actions/onboarding.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { mockStore, mockWindowProperty, rootState } from 'soapbox/jest/test-helpers.tsx'; + +import { checkOnboardingStatus, startOnboarding, endOnboarding } from './onboarding.ts'; + +describe('checkOnboarding()', () => { + let mockGetItem: any; + + mockWindowProperty('localStorage', { + getItem: (key: string) => mockGetItem(key), + }); + + beforeEach(() => { + mockGetItem = vi.fn().mockReturnValue(null); + }); + + it('does nothing if localStorage item is not set', async() => { + mockGetItem = vi.fn().mockReturnValue(null); + + const state = { ...rootState }; + state.onboarding.needsOnboarding = false; + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('does nothing if localStorage item is invalid', async() => { + mockGetItem = vi.fn().mockReturnValue('invalid'); + + const state = { ...rootState }; + state.onboarding.needsOnboarding = false; + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('dispatches the correct action', async() => { + mockGetItem = vi.fn().mockReturnValue('1'); + + const state = { ...rootState }; + state.onboarding.needsOnboarding = false; + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); +}); + +describe('startOnboarding()', () => { + let mockSetItem: any; + + mockWindowProperty('localStorage', { + setItem: (key: string, value: string) => mockSetItem(key, value), + }); + + beforeEach(() => { + mockSetItem = vi.fn(); + }); + + it('dispatches the correct action', async() => { + const state = { ...rootState }; + state.onboarding.needsOnboarding = false; + const store = mockStore(state); + + await store.dispatch(startOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockSetItem.mock.calls.length).toBe(1); + }); +}); + +describe('endOnboarding()', () => { + let mockRemoveItem: any; + + mockWindowProperty('localStorage', { + removeItem: (key: string) => mockRemoveItem(key), + }); + + beforeEach(() => { + mockRemoveItem = vi.fn(); + }); + + it('dispatches the correct action', async() => { + const state = { ...rootState }; + state.onboarding.needsOnboarding = false; + const store = mockStore(state); + + await store.dispatch(endOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_END' }]); + expect(mockRemoveItem.mock.calls.length).toBe(1); + }); +}); diff --git a/src/actions/onboarding.ts b/src/actions/onboarding.ts new file mode 100644 index 0000000..f75a89c --- /dev/null +++ b/src/actions/onboarding.ts @@ -0,0 +1,40 @@ +const ONBOARDING_START = 'ONBOARDING_START'; +const ONBOARDING_END = 'ONBOARDING_END'; + +const ONBOARDING_LOCAL_STORAGE_KEY = 'soapbox:onboarding'; + +type OnboardingStartAction = { + type: typeof ONBOARDING_START; +} + +type OnboardingEndAction = { + type: typeof ONBOARDING_END; +} + +export type OnboardingActions = OnboardingStartAction | OnboardingEndAction + +const checkOnboardingStatus = () => (dispatch: React.Dispatch) => { + const needsOnboarding = localStorage.getItem(ONBOARDING_LOCAL_STORAGE_KEY) === '1'; + + if (needsOnboarding) { + dispatch({ type: ONBOARDING_START }); + } +}; + +const startOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.setItem(ONBOARDING_LOCAL_STORAGE_KEY, '1'); + dispatch({ type: ONBOARDING_START }); +}; + +const endOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.removeItem(ONBOARDING_LOCAL_STORAGE_KEY); + dispatch({ type: ONBOARDING_END }); +}; + +export { + ONBOARDING_END, + ONBOARDING_START, + checkOnboardingStatus, + endOnboarding, + startOnboarding, +}; diff --git a/src/actions/patron.ts b/src/actions/patron.ts new file mode 100644 index 0000000..414d629 --- /dev/null +++ b/src/actions/patron.ts @@ -0,0 +1,70 @@ +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const PATRON_INSTANCE_FETCH_REQUEST = 'PATRON_INSTANCE_FETCH_REQUEST'; +const PATRON_INSTANCE_FETCH_SUCCESS = 'PATRON_INSTANCE_FETCH_SUCCESS'; +const PATRON_INSTANCE_FETCH_FAIL = 'PATRON_INSTANCE_FETCH_FAIL'; + +const PATRON_ACCOUNT_FETCH_REQUEST = 'PATRON_ACCOUNT_FETCH_REQUEST'; +const PATRON_ACCOUNT_FETCH_SUCCESS = 'PATRON_ACCOUNT_FETCH_SUCCESS'; +const PATRON_ACCOUNT_FETCH_FAIL = 'PATRON_ACCOUNT_FETCH_FAIL'; + +const fetchPatronInstance = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: PATRON_INSTANCE_FETCH_REQUEST }); + return api(getState).get('/api/patron/v1/instance').then((response) => response.json()).then((data) => { + dispatch(importFetchedInstance(data)); + }).catch(error => { + dispatch(fetchInstanceFail(error)); + }); + }; + +const fetchPatronAccount = (apId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + apId = encodeURIComponent(apId); + dispatch({ type: PATRON_ACCOUNT_FETCH_REQUEST }); + api(getState).get(`/api/patron/v1/accounts/${apId}`).then((response) => response.json()).then((data) => { + dispatch(importFetchedAccount(data)); + }).catch(error => { + dispatch(fetchAccountFail(error)); + }); + }; + +const importFetchedInstance = (instance: APIEntity) => ({ + type: PATRON_INSTANCE_FETCH_SUCCESS, + instance, +}); + +const fetchInstanceFail = (error: unknown) => ({ + type: PATRON_INSTANCE_FETCH_FAIL, + error, + skipAlert: true, +}); + +const importFetchedAccount = (account: APIEntity) => ({ + type: PATRON_ACCOUNT_FETCH_SUCCESS, + account, +}); + +const fetchAccountFail = (error: unknown) => ({ + type: PATRON_ACCOUNT_FETCH_FAIL, + error, + skipAlert: true, +}); + +export { + PATRON_INSTANCE_FETCH_REQUEST, + PATRON_INSTANCE_FETCH_SUCCESS, + PATRON_INSTANCE_FETCH_FAIL, + PATRON_ACCOUNT_FETCH_REQUEST, + PATRON_ACCOUNT_FETCH_SUCCESS, + PATRON_ACCOUNT_FETCH_FAIL, + fetchPatronInstance, + fetchPatronAccount, + importFetchedInstance, + fetchInstanceFail, + importFetchedAccount, + fetchAccountFail, +}; diff --git a/src/actions/pin-statuses.ts b/src/actions/pin-statuses.ts new file mode 100644 index 0000000..8504566 --- /dev/null +++ b/src/actions/pin-statuses.ts @@ -0,0 +1,52 @@ +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import api from '../api/index.ts'; + +import { importFetchedStatuses } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; +const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; +const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; + +const fetchPinnedStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const me = getState().me; + + dispatch(fetchPinnedStatusesRequest()); + + api(getState).get(`/api/v1/accounts/${me}/statuses`, { searchParams: { pinned: true } }).then((response) => response.json()).then((data) => { + dispatch(importFetchedStatuses(data)); + dispatch(fetchPinnedStatusesSuccess(data, null)); + }).catch(error => { + dispatch(fetchPinnedStatusesFail(error)); + }); + }; + +const fetchPinnedStatusesRequest = () => ({ + type: PINNED_STATUSES_FETCH_REQUEST, +}); + +const fetchPinnedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: PINNED_STATUSES_FETCH_SUCCESS, + statuses, + next, +}); + +const fetchPinnedStatusesFail = (error: unknown) => ({ + type: PINNED_STATUSES_FETCH_FAIL, + error, +}); + +export { + PINNED_STATUSES_FETCH_REQUEST, + PINNED_STATUSES_FETCH_SUCCESS, + PINNED_STATUSES_FETCH_FAIL, + fetchPinnedStatuses, + fetchPinnedStatusesRequest, + fetchPinnedStatusesSuccess, + fetchPinnedStatusesFail, +}; diff --git a/src/actions/polls.ts b/src/actions/polls.ts new file mode 100644 index 0000000..4357ed7 --- /dev/null +++ b/src/actions/polls.ts @@ -0,0 +1,83 @@ +import api from '../api/index.ts'; + +import { importFetchedPoll } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; +const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; +const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; + +const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; +const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; +const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; + +const vote = (pollId: string, choices: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(voteRequest()); + + api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) + .then((response) => response.json()).then((data) => { + dispatch(importFetchedPoll(data)); + dispatch(voteSuccess(data)); + }) + .catch(err => dispatch(voteFail(err))); + }; + +const fetchPoll = (pollId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchPollRequest()); + + api(getState).get(`/api/v1/polls/${pollId}`) + .then((response) => response.json()).then((data) => { + dispatch(importFetchedPoll(data)); + dispatch(fetchPollSuccess(data)); + }) + .catch(err => dispatch(fetchPollFail(err))); + }; + +const voteRequest = () => ({ + type: POLL_VOTE_REQUEST, +}); + +const voteSuccess = (poll: APIEntity) => ({ + type: POLL_VOTE_SUCCESS, + poll, +}); + +const voteFail = (error: unknown) => ({ + type: POLL_VOTE_FAIL, + error, +}); + +const fetchPollRequest = () => ({ + type: POLL_FETCH_REQUEST, +}); + +const fetchPollSuccess = (poll: APIEntity) => ({ + type: POLL_FETCH_SUCCESS, + poll, +}); + +const fetchPollFail = (error: unknown) => ({ + type: POLL_FETCH_FAIL, + error, +}); + +export { + POLL_VOTE_REQUEST, + POLL_VOTE_SUCCESS, + POLL_VOTE_FAIL, + POLL_FETCH_REQUEST, + POLL_FETCH_SUCCESS, + POLL_FETCH_FAIL, + vote, + fetchPoll, + voteRequest, + voteSuccess, + voteFail, + fetchPollRequest, + fetchPollSuccess, + fetchPollFail, +}; diff --git a/src/actions/preload.ts b/src/actions/preload.ts new file mode 100644 index 0000000..7e16e3c --- /dev/null +++ b/src/actions/preload.ts @@ -0,0 +1,69 @@ +import { mapValues } from 'es-toolkit'; + +import { verifyCredentials } from './auth.ts'; +import { importFetchedAccounts } from './importer/index.ts'; + +import type { AppDispatch } from 'soapbox/store.ts'; + +const PLEROMA_PRELOAD_IMPORT = 'PLEROMA_PRELOAD_IMPORT'; +const MASTODON_PRELOAD_IMPORT = 'MASTODON_PRELOAD_IMPORT'; + +// https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1176/diffs +const decodeUTF8Base64 = (data: string) => { + const rawData = atob(data); + const array = Uint8Array.from(rawData.split('').map((char) => char.charCodeAt(0))); + const text = new TextDecoder().decode(array); + return text; +}; + +const decodePleromaData = (data: Record) => { + return mapValues(data, base64string => JSON.parse(decodeUTF8Base64(base64string))); +}; + +const pleromaDecoder = (json: string) => decodePleromaData(JSON.parse(json)); + +// This will throw if it fails. +// Should be called inside a try-catch. +const decodeFromMarkup = (elementId: string, decoder: (json: string) => Record) => { + const { textContent } = document.getElementById(elementId)!; + return decoder(textContent as string); +}; + +const preloadFromMarkup = (elementId: string, decoder: (json: string) => Record, action: (data: Record) => any) => + (dispatch: AppDispatch) => { + try { + const data = decodeFromMarkup(elementId, decoder); + dispatch(action(data)); + } catch { + // Do nothing + } + }; + +const preload = () => + (dispatch: AppDispatch) => { + dispatch(preloadFromMarkup('initial-results', pleromaDecoder, preloadPleroma)); + dispatch(preloadFromMarkup('initial-state', JSON.parse, preloadMastodon)); + }; + +const preloadPleroma = (data: Record) => ({ + type: PLEROMA_PRELOAD_IMPORT, + data, +}); + +const preloadMastodon = (data: Record) => + (dispatch: AppDispatch) => { + const { me, access_token } = data.meta; + const { url } = data.accounts[me]; + + dispatch(importFetchedAccounts(Object.values(data.accounts))); + dispatch(verifyCredentials(access_token, url)); + dispatch({ type: MASTODON_PRELOAD_IMPORT, data }); + }; + +export { + PLEROMA_PRELOAD_IMPORT, + MASTODON_PRELOAD_IMPORT, + preload, + preloadPleroma, + preloadMastodon, +}; diff --git a/src/actions/profile-hover-card.ts b/src/actions/profile-hover-card.ts new file mode 100644 index 0000000..3675d75 --- /dev/null +++ b/src/actions/profile-hover-card.ts @@ -0,0 +1,27 @@ +const PROFILE_HOVER_CARD_OPEN = 'PROFILE_HOVER_CARD_OPEN'; +const PROFILE_HOVER_CARD_UPDATE = 'PROFILE_HOVER_CARD_UPDATE'; +const PROFILE_HOVER_CARD_CLOSE = 'PROFILE_HOVER_CARD_CLOSE'; + +const openProfileHoverCard = (ref: React.MutableRefObject, accountId: string) => ({ + type: PROFILE_HOVER_CARD_OPEN, + ref, + accountId, +}); + +const updateProfileHoverCard = () => ({ + type: PROFILE_HOVER_CARD_UPDATE, +}); + +const closeProfileHoverCard = (force = false) => ({ + type: PROFILE_HOVER_CARD_CLOSE, + force, +}); + +export { + PROFILE_HOVER_CARD_OPEN, + PROFILE_HOVER_CARD_UPDATE, + PROFILE_HOVER_CARD_CLOSE, + openProfileHoverCard, + updateProfileHoverCard, + closeProfileHoverCard, +}; diff --git a/src/actions/push-notifications/registerer.ts b/src/actions/push-notifications/registerer.ts new file mode 100644 index 0000000..a698371 --- /dev/null +++ b/src/actions/push-notifications/registerer.ts @@ -0,0 +1,108 @@ +/* eslint-disable compat/compat */ +import { HTTPError } from 'soapbox/api/HTTPError.ts'; +import { MastodonClient } from 'soapbox/api/MastodonClient.ts'; +import { WebPushSubscription, webPushSubscriptionSchema } from 'soapbox/schemas/web-push.ts'; +import { decodeBase64Url } from 'soapbox/utils/base64.ts'; + +// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); + +/** + * Register web push notifications. + * This function creates a subscription if one hasn't been created already, and syncronizes it with the backend. + */ +export async function registerPushNotifications(api: MastodonClient, vapidKey: string) { + if (!supportsPushNotifications) { + console.warn('Your browser does not support Web Push Notifications.'); + return; + } + + const { subscription, created } = await getOrCreateSubscription(vapidKey); + + if (created) { + await sendSubscriptionToBackend(api, subscription); + return; + } + + // We have a subscription, check if it is still valid. + const backend = await getBackendSubscription(api); + + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid. + if (backend && subscriptionMatchesBackend(subscription, backend)) { + return; + } else { + // Something went wrong, try to subscribe again. + await subscription.unsubscribe(); + const newSubscription = await createSubscription(vapidKey); + await sendSubscriptionToBackend(api, newSubscription); + } +} + +/** Get an existing subscription object from the browser if it exists, or ask the browser to create one. */ +async function getOrCreateSubscription(vapidKey: string): Promise<{ subscription: PushSubscription; created: boolean }> { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + return { subscription, created: false }; + } else { + const subscription = await createSubscription(vapidKey); + return { subscription, created: true }; + } +} + +/** Request a subscription object from the web browser. */ +async function createSubscription(vapidKey: string): Promise { + const registration = await navigator.serviceWorker.ready; + + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: decodeBase64Url(vapidKey), + }); +} + +/** Fetch the API for an existing subscription saved in the backend, if any. */ +async function getBackendSubscription(api: MastodonClient): Promise { + try { + const response = await api.get('/api/v1/push/subscription'); + const data = await response.json(); + return webPushSubscriptionSchema.parse(data); + } catch (e) { + if (e instanceof HTTPError && e.response.status === 404) { + return null; + } else { + throw e; + } + } +} + +/** Publish a new subscription to the backend. */ +async function sendSubscriptionToBackend(api: MastodonClient, subscription: PushSubscription): Promise { + const params = { + subscription: subscription.toJSON(), + }; + + const response = await api.post('/api/v1/push/subscription', params); + const data = await response.json(); + + return webPushSubscriptionSchema.parse(data); +} + +/** Check if the VAPID key and endpoint of the subscription match the data in the backend. */ +function subscriptionMatchesBackend(subscription: PushSubscription, backend: WebPushSubscription): boolean { + const { applicationServerKey } = subscription.options; + + if (subscription.endpoint !== backend.endpoint) { + return false; + } + + if (!applicationServerKey) { + return false; + } + + const backendKeyBytes: Uint8Array = decodeBase64Url(backend.server_key); + const subscriptionKeyBytes: Uint8Array = new Uint8Array(applicationServerKey); + + return backendKeyBytes.toString() === subscriptionKeyBytes.toString(); +} \ No newline at end of file diff --git a/src/actions/remote-timeline.ts b/src/actions/remote-timeline.ts new file mode 100644 index 0000000..0f2640a --- /dev/null +++ b/src/actions/remote-timeline.ts @@ -0,0 +1,30 @@ +import { getSettings, changeSetting } from 'soapbox/actions/settings.ts'; + +import type { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const getPinnedHosts = (state: RootState) => { + const settings = getSettings(state); + return settings.getIn(['remote_timeline', 'pinnedHosts']) as ImmutableList | ImmutableOrderedSet; +}; + +const pinHost = (host: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const pinnedHosts = getPinnedHosts(state); + + return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.toOrderedSet().add(host))); + }; + +const unpinHost = (host: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const pinnedHosts = getPinnedHosts(state); + + return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.toOrderedSet().remove(host))); + }; + +export { + pinHost, + unpinHost, +}; diff --git a/src/actions/reports.ts b/src/actions/reports.ts new file mode 100644 index 0000000..5a98d08 --- /dev/null +++ b/src/actions/reports.ts @@ -0,0 +1,133 @@ +import api from '../api/index.ts'; + +import { openModal } from './modals.ts'; + +import type { Account } from 'soapbox/schemas/index.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { ChatMessage, Group, Status } from 'soapbox/types/entities.ts'; + +const REPORT_INIT = 'REPORT_INIT'; +const REPORT_CANCEL = 'REPORT_CANCEL'; + +const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; +const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; +const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; + +const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; +const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; +const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; +const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE'; + +const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE'; + +enum ReportableEntities { + ACCOUNT = 'ACCOUNT', + CHAT_MESSAGE = 'CHAT_MESSAGE', + GROUP = 'GROUP', + STATUS = 'STATUS' +} + +type ReportedEntity = { + status?: Status; + chatMessage?: ChatMessage; + group?: Group; +} + +const initReport = (entityType: ReportableEntities, account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => { + const { status, chatMessage, group } = entities || {}; + + dispatch({ + type: REPORT_INIT, + entityType, + account, + status, + chatMessage, + group, + }); + + return dispatch(openModal('REPORT')); +}; + +const cancelReport = () => ({ + type: REPORT_CANCEL, +}); + +const toggleStatusReport = (statusId: string, checked: boolean) => ({ + type: REPORT_STATUS_TOGGLE, + statusId, + checked, +}); + +const submitReport = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(submitReportRequest()); + const { reports } = getState(); + + return api(getState).post('/api/v1/reports', { + account_id: reports.getIn(['new', 'account_id']), + status_ids: reports.getIn(['new', 'status_ids']), + message_ids: [reports.getIn(['new', 'chat_message', 'id'])].filter(Boolean), + group_id: reports.getIn(['new', 'group', 'id']), + rule_ids: reports.getIn(['new', 'rule_ids']), + comment: reports.getIn(['new', 'comment']), + forward: reports.getIn(['new', 'forward']), + }); + }; + +const submitReportRequest = () => ({ + type: REPORT_SUBMIT_REQUEST, +}); + +const submitReportSuccess = () => ({ + type: REPORT_SUBMIT_SUCCESS, +}); + +const submitReportFail = (error: unknown) => ({ + type: REPORT_SUBMIT_FAIL, + error, +}); + +const changeReportComment = (comment: string) => ({ + type: REPORT_COMMENT_CHANGE, + comment, +}); + +const changeReportForward = (forward: boolean) => ({ + type: REPORT_FORWARD_CHANGE, + forward, +}); + +const changeReportBlock = (block: boolean) => ({ + type: REPORT_BLOCK_CHANGE, + block, +}); + +const changeReportRule = (ruleId: string) => ({ + type: REPORT_RULE_CHANGE, + rule_id: ruleId, +}); + +export { + ReportableEntities, + REPORT_INIT, + REPORT_CANCEL, + REPORT_SUBMIT_REQUEST, + REPORT_SUBMIT_SUCCESS, + REPORT_SUBMIT_FAIL, + REPORT_STATUS_TOGGLE, + REPORT_COMMENT_CHANGE, + REPORT_FORWARD_CHANGE, + REPORT_BLOCK_CHANGE, + REPORT_RULE_CHANGE, + initReport, + cancelReport, + toggleStatusReport, + submitReport, + submitReportRequest, + submitReportSuccess, + submitReportFail, + changeReportComment, + changeReportForward, + changeReportBlock, + changeReportRule, +}; diff --git a/src/actions/scheduled-statuses.ts b/src/actions/scheduled-statuses.ts new file mode 100644 index 0000000..c149fe8 --- /dev/null +++ b/src/actions/scheduled-statuses.ts @@ -0,0 +1,122 @@ +import { getFeatures } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const SCHEDULED_STATUSES_FETCH_REQUEST = 'SCHEDULED_STATUSES_FETCH_REQUEST'; +const SCHEDULED_STATUSES_FETCH_SUCCESS = 'SCHEDULED_STATUSES_FETCH_SUCCESS'; +const SCHEDULED_STATUSES_FETCH_FAIL = 'SCHEDULED_STATUSES_FETCH_FAIL'; + +const SCHEDULED_STATUSES_EXPAND_REQUEST = 'SCHEDULED_STATUSES_EXPAND_REQUEST'; +const SCHEDULED_STATUSES_EXPAND_SUCCESS = 'SCHEDULED_STATUSES_EXPAND_SUCCESS'; +const SCHEDULED_STATUSES_EXPAND_FAIL = 'SCHEDULED_STATUSES_EXPAND_FAIL'; + +const SCHEDULED_STATUS_CANCEL_REQUEST = 'SCHEDULED_STATUS_CANCEL_REQUEST'; +const SCHEDULED_STATUS_CANCEL_SUCCESS = 'SCHEDULED_STATUS_CANCEL_SUCCESS'; +const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL'; + +const fetchScheduledStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + if (state.status_lists.get('scheduled_statuses')?.isLoading) { + return; + } + + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.scheduledStatuses) return; + + dispatch(fetchScheduledStatusesRequest()); + + api(getState).get('/api/v1/scheduled_statuses').then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(fetchScheduledStatusesSuccess(data, next)); + }).catch(error => { + dispatch(fetchScheduledStatusesFail(error)); + }); + }; + +const cancelScheduledStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_REQUEST, id }); + api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then((response) => response.json()).then((data) => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_SUCCESS, id, data }); + }).catch(error => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_FAIL, id, error }); + }); + }; + +const fetchScheduledStatusesRequest = () => ({ + type: SCHEDULED_STATUSES_FETCH_REQUEST, +}); + +const fetchScheduledStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: SCHEDULED_STATUSES_FETCH_SUCCESS, + statuses, + next, +}); + +const fetchScheduledStatusesFail = (error: unknown) => ({ + type: SCHEDULED_STATUSES_FETCH_FAIL, + error, +}); + +const expandScheduledStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().status_lists.get('scheduled_statuses')?.next || null; + + if (url === null || getState().status_lists.get('scheduled_statuses')?.isLoading) { + return; + } + + dispatch(expandScheduledStatusesRequest()); + + api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(expandScheduledStatusesSuccess(data, next)); + }).catch(error => { + dispatch(expandScheduledStatusesFail(error)); + }); + }; + +const expandScheduledStatusesRequest = () => ({ + type: SCHEDULED_STATUSES_EXPAND_REQUEST, +}); + +const expandScheduledStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: SCHEDULED_STATUSES_EXPAND_SUCCESS, + statuses, + next, +}); + +const expandScheduledStatusesFail = (error: unknown) => ({ + type: SCHEDULED_STATUSES_EXPAND_FAIL, + error, +}); + +export { + SCHEDULED_STATUSES_FETCH_REQUEST, + SCHEDULED_STATUSES_FETCH_SUCCESS, + SCHEDULED_STATUSES_FETCH_FAIL, + SCHEDULED_STATUSES_EXPAND_REQUEST, + SCHEDULED_STATUSES_EXPAND_SUCCESS, + SCHEDULED_STATUSES_EXPAND_FAIL, + SCHEDULED_STATUS_CANCEL_REQUEST, + SCHEDULED_STATUS_CANCEL_SUCCESS, + SCHEDULED_STATUS_CANCEL_FAIL, + fetchScheduledStatuses, + cancelScheduledStatus, + fetchScheduledStatusesRequest, + fetchScheduledStatusesSuccess, + fetchScheduledStatusesFail, + expandScheduledStatuses, + expandScheduledStatusesRequest, + expandScheduledStatusesSuccess, + expandScheduledStatusesFail, +}; diff --git a/src/actions/search.ts b/src/actions/search.ts new file mode 100644 index 0000000..7e84feb --- /dev/null +++ b/src/actions/search.ts @@ -0,0 +1,221 @@ +import api from '../api/index.ts'; + +import { fetchRelationships } from './accounts.ts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer/index.ts'; + +import type { SearchFilter } from 'soapbox/reducers/search.ts'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const SEARCH_CHANGE = 'SEARCH_CHANGE'; +const SEARCH_CLEAR = 'SEARCH_CLEAR'; +const SEARCH_SHOW = 'SEARCH_SHOW'; +const SEARCH_RESULTS_CLEAR = 'SEARCH_RESULTS_CLEAR'; + +const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; +const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; +const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; + +const SEARCH_FILTER_SET = 'SEARCH_FILTER_SET'; + +const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; +const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; +const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; + +const SEARCH_ACCOUNT_SET = 'SEARCH_ACCOUNT_SET'; + +const changeSearch = (value: string) => + (dispatch: AppDispatch) => { + // If backspaced all the way, clear the search + if (value.length === 0) { + dispatch(clearSearchResults()); + return dispatch({ + type: SEARCH_CHANGE, + value, + }); + } else { + return dispatch({ + type: SEARCH_CHANGE, + value, + }); + } + }; + +const clearSearch = () => ({ + type: SEARCH_CLEAR, +}); + +const clearSearchResults = () => ({ + type: SEARCH_RESULTS_CLEAR, +}); + +const submitSearch = (filter?: SearchFilter) => + (dispatch: AppDispatch, getState: () => RootState) => { + const value = getState().search.value; + const type = filter || getState().search.filter || 'statuses'; + const accountId = getState().search.accountId; + + // An empty search doesn't return any results + if (value.length === 0) { + return; + } + + dispatch(fetchSearchRequest(value)); + + const params: Record = { + q: value, + resolve: true, + limit: 20, + type, + }; + + if (accountId) params.account_id = accountId; + + api(getState).get('/api/v2/search', { + searchParams: params, + }).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + } + + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } + + dispatch(fetchSearchSuccess(data, value, type, next)); + dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchSearchFail(error)); + }); + }; + +const fetchSearchRequest = (value: string) => ({ + type: SEARCH_FETCH_REQUEST, + value, +}); + +const fetchSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter, next: string | null) => ({ + type: SEARCH_FETCH_SUCCESS, + results, + searchTerm, + searchType, + next, +}); + +const fetchSearchFail = (error: unknown) => ({ + type: SEARCH_FETCH_FAIL, + error, +}); + +const setFilter = (filterType: SearchFilter) => + (dispatch: AppDispatch) => { + dispatch(submitSearch(filterType)); + + dispatch({ + type: SEARCH_FILTER_SET, + path: ['search', 'filter'], + value: filterType, + }); + }; + +const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => { + const value = getState().search.value; + const offset = getState().search.results[type].size; + const accountId = getState().search.accountId; + + dispatch(expandSearchRequest(type)); + + let url = getState().search.next as string; + let params: Record = {}; + + // if no URL was extracted from the Link header, + // fall back on querying with the offset + if (!url) { + url = '/api/v2/search'; + params = { + q: value, + type, + offset, + }; + if (accountId) params.account_id = accountId; + } + + api(getState).get(url, { + searchParams: params, + }).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + } + + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } + + dispatch(expandSearchSuccess(data, value, type, next)); + dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandSearchFail(error)); + }); +}; + +const expandSearchRequest = (searchType: SearchFilter) => ({ + type: SEARCH_EXPAND_REQUEST, + searchType, +}); + +const expandSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter, next: string | null) => ({ + type: SEARCH_EXPAND_SUCCESS, + results, + searchTerm, + searchType, + next, +}); + +const expandSearchFail = (error: unknown) => ({ + type: SEARCH_EXPAND_FAIL, + error, +}); + +const showSearch = () => ({ + type: SEARCH_SHOW, +}); + +const setSearchAccount = (accountId: string | null) => ({ + type: SEARCH_ACCOUNT_SET, + accountId, +}); + +export { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_SHOW, + SEARCH_RESULTS_CLEAR, + SEARCH_FETCH_REQUEST, + SEARCH_FETCH_SUCCESS, + SEARCH_FETCH_FAIL, + SEARCH_FILTER_SET, + SEARCH_EXPAND_REQUEST, + SEARCH_EXPAND_SUCCESS, + SEARCH_EXPAND_FAIL, + SEARCH_ACCOUNT_SET, + changeSearch, + clearSearch, + clearSearchResults, + submitSearch, + fetchSearchRequest, + fetchSearchSuccess, + fetchSearchFail, + setFilter, + expandSearch, + expandSearchRequest, + expandSearchSuccess, + expandSearchFail, + showSearch, + setSearchAccount, +}; diff --git a/src/actions/security.ts b/src/actions/security.ts new file mode 100644 index 0000000..c64c143 --- /dev/null +++ b/src/actions/security.ts @@ -0,0 +1,211 @@ +/** + * Security: Pleroma-specific account management features. + * @module soapbox/actions/security + * @see module:soapbox/actions/auth + */ + +import toast from 'soapbox/toast.tsx'; +import { getLoggedInAccount } from 'soapbox/utils/auth.ts'; +import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features.ts'; +import { normalizeUsername } from 'soapbox/utils/input.ts'; + +import api from '../api/index.ts'; + +import { AUTH_LOGGED_OUT, messages } from './auth.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const FETCH_TOKENS_REQUEST = 'FETCH_TOKENS_REQUEST'; +const FETCH_TOKENS_SUCCESS = 'FETCH_TOKENS_SUCCESS'; +const FETCH_TOKENS_FAIL = 'FETCH_TOKENS_FAIL'; + +const REVOKE_TOKEN_REQUEST = 'REVOKE_TOKEN_REQUEST'; +const REVOKE_TOKEN_SUCCESS = 'REVOKE_TOKEN_SUCCESS'; +const REVOKE_TOKEN_FAIL = 'REVOKE_TOKEN_FAIL'; + +const RESET_PASSWORD_REQUEST = 'RESET_PASSWORD_REQUEST'; +const RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_SUCCESS'; +const RESET_PASSWORD_FAIL = 'RESET_PASSWORD_FAIL'; + +const RESET_PASSWORD_CONFIRM_REQUEST = 'RESET_PASSWORD_CONFIRM_REQUEST'; +const RESET_PASSWORD_CONFIRM_SUCCESS = 'RESET_PASSWORD_CONFIRM_SUCCESS'; +const RESET_PASSWORD_CONFIRM_FAIL = 'RESET_PASSWORD_CONFIRM_FAIL'; + +const CHANGE_PASSWORD_REQUEST = 'CHANGE_PASSWORD_REQUEST'; +const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS'; +const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL'; + +const CHANGE_EMAIL_REQUEST = 'CHANGE_EMAIL_REQUEST'; +const CHANGE_EMAIL_SUCCESS = 'CHANGE_EMAIL_SUCCESS'; +const CHANGE_EMAIL_FAIL = 'CHANGE_EMAIL_FAIL'; + +const DELETE_ACCOUNT_REQUEST = 'DELETE_ACCOUNT_REQUEST'; +const DELETE_ACCOUNT_SUCCESS = 'DELETE_ACCOUNT_SUCCESS'; +const DELETE_ACCOUNT_FAIL = 'DELETE_ACCOUNT_FAIL'; + +const MOVE_ACCOUNT_REQUEST = 'MOVE_ACCOUNT_REQUEST'; +const MOVE_ACCOUNT_SUCCESS = 'MOVE_ACCOUNT_SUCCESS'; +const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL'; + +const fetchOAuthTokens = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FETCH_TOKENS_REQUEST }); + return api(getState).get('/api/oauth_tokens').then((response) => response.json()).then((tokens) => { + dispatch({ type: FETCH_TOKENS_SUCCESS, tokens }); + }).catch(() => { + dispatch({ type: FETCH_TOKENS_FAIL }); + }); + }; + +const revokeOAuthTokenById = (id: number) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: REVOKE_TOKEN_REQUEST, id }); + return api(getState).delete(`/api/oauth_tokens/${id}`).then(() => { + dispatch({ type: REVOKE_TOKEN_SUCCESS, id }); + }).catch(() => { + dispatch({ type: REVOKE_TOKEN_FAIL, id }); + }); + }; + +const changePassword = (oldPassword: string, newPassword: string, confirmation: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CHANGE_PASSWORD_REQUEST }); + return api(getState).post('/api/pleroma/change_password', { + password: oldPassword, + new_password: newPassword, + new_password_confirmation: confirmation, + }).then((response) => response.json()).then((data) => { + if (data.error) throw data.error; // This endpoint returns HTTP 200 even on failure + dispatch({ type: CHANGE_PASSWORD_SUCCESS, data }); + }).catch(error => { + dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true }); + throw error; + }); + }; + +const resetPassword = (usernameOrEmail: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const input = normalizeUsername(usernameOrEmail); + const state = getState(); + const v = parseVersion(state.instance.version); + + dispatch({ type: RESET_PASSWORD_REQUEST }); + + const params = + input.includes('@') + ? { email: input } + : { nickname: input, username: input }; + + const endpoint = + v.software === TRUTHSOCIAL + ? '/api/v1/truth/password_reset/request' + : '/auth/password'; + + return api(getState).post(endpoint, params).then(() => { + dispatch({ type: RESET_PASSWORD_SUCCESS }); + }).catch(error => { + dispatch({ type: RESET_PASSWORD_FAIL, error }); + throw error; + }); + }; + +const resetPasswordConfirm = (password: string, token: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const params = { password, reset_password_token: token }; + dispatch({ type: RESET_PASSWORD_CONFIRM_REQUEST }); + + return api(getState).post('/api/v1/truth/password_reset/confirm', params).then(() => { + dispatch({ type: RESET_PASSWORD_CONFIRM_SUCCESS }); + }).catch(error => { + dispatch({ type: RESET_PASSWORD_CONFIRM_FAIL, error }); + throw error; + }); + }; + +const changeEmail = (email: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CHANGE_EMAIL_REQUEST, email }); + return api(getState).post('/api/pleroma/change_email', { + email, + password, + }).then((response) => response.json()).then((data) => { + if (data.error) throw data.error; // This endpoint returns HTTP 200 even on failure + dispatch({ type: CHANGE_EMAIL_SUCCESS, email, data }); + }).catch(error => { + dispatch({ type: CHANGE_EMAIL_FAIL, email, error, skipAlert: true }); + throw error; + }); + }; + +const confirmChangedEmail = (token: string) => + (_dispatch: AppDispatch, getState: () => RootState) => + api(getState).get(`/api/v1/truth/email/confirm?confirmation_token=${token}`); + +const deleteAccount = (password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const account = getLoggedInAccount(getState()); + + dispatch({ type: DELETE_ACCOUNT_REQUEST }); + return api(getState).post('/api/pleroma/delete_account', { + password, + }).then((response) => response.json()).then((data) => { + if (data.error) throw data.error; // This endpoint returns HTTP 200 even on failure + dispatch({ type: DELETE_ACCOUNT_SUCCESS, data }); + dispatch({ type: AUTH_LOGGED_OUT, account }); + toast.success(messages.loggedOut); + }).catch(error => { + dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true }); + throw error; + }); + }; + +const moveAccount = (targetAccount: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MOVE_ACCOUNT_REQUEST }); + return api(getState).post('/api/pleroma/move_account', { + password, + target_account: targetAccount, + }).then((response) => response.json()).then((data) => { + if (data.error) throw data.error; // This endpoint returns HTTP 200 even on failure + dispatch({ type: MOVE_ACCOUNT_SUCCESS, data }); + }).catch(error => { + dispatch({ type: MOVE_ACCOUNT_FAIL, error, skipAlert: true }); + throw error; + }); + }; + +export { + FETCH_TOKENS_REQUEST, + FETCH_TOKENS_SUCCESS, + FETCH_TOKENS_FAIL, + REVOKE_TOKEN_REQUEST, + REVOKE_TOKEN_SUCCESS, + REVOKE_TOKEN_FAIL, + RESET_PASSWORD_REQUEST, + RESET_PASSWORD_SUCCESS, + RESET_PASSWORD_FAIL, + RESET_PASSWORD_CONFIRM_REQUEST, + RESET_PASSWORD_CONFIRM_SUCCESS, + RESET_PASSWORD_CONFIRM_FAIL, + CHANGE_PASSWORD_REQUEST, + CHANGE_PASSWORD_SUCCESS, + CHANGE_PASSWORD_FAIL, + CHANGE_EMAIL_REQUEST, + CHANGE_EMAIL_SUCCESS, + CHANGE_EMAIL_FAIL, + DELETE_ACCOUNT_REQUEST, + DELETE_ACCOUNT_SUCCESS, + DELETE_ACCOUNT_FAIL, + MOVE_ACCOUNT_REQUEST, + MOVE_ACCOUNT_SUCCESS, + MOVE_ACCOUNT_FAIL, + fetchOAuthTokens, + revokeOAuthTokenById, + changePassword, + resetPassword, + resetPasswordConfirm, + changeEmail, + confirmChangedEmail, + deleteAccount, + moveAccount, +}; diff --git a/src/actions/settings.ts b/src/actions/settings.ts new file mode 100644 index 0000000..50204e0 --- /dev/null +++ b/src/actions/settings.ts @@ -0,0 +1,265 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { defineMessage } from 'react-intl'; +import { createSelector } from 'reselect'; + +import { patchMe } from 'soapbox/actions/me.ts'; +import messages from 'soapbox/messages.ts'; +import toast from 'soapbox/toast.tsx'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const SETTING_CHANGE = 'SETTING_CHANGE' as const; +const SETTING_SAVE = 'SETTING_SAVE' as const; +const SETTINGS_UPDATE = 'SETTINGS_UPDATE' as const; + +const FE_NAME = 'soapbox_fe'; + +/** Options when changing/saving settings. */ +type SettingOpts = { + /** Whether to display an alert when settings are saved. */ + showAlert?: boolean; +} + +const saveSuccessMessage = defineMessage({ id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' }); + +const defaultSettings = ImmutableMap({ + onboarded: false, + skinTone: 1, + reduceMotion: false, + underlineLinks: false, + autoPlayGif: true, + displayMedia: 'default', + expandSpoilers: false, + unfollowModal: false, + boostModal: false, + deleteModal: true, + missingDescriptionModal: false, + defaultPrivacy: 'public', + defaultContentType: 'text/plain', + themeMode: 'system', + locale: navigator.language || 'en', + showExplanationBox: true, + explanationBox: true, + autoloadTimelines: true, + autoloadMore: true, + preserveSpoilers: false, + + systemFont: false, + demetricator: false, + + isDeveloper: false, + + chats: ImmutableMap({ + panes: ImmutableList(), + mainWindow: 'minimized', + sound: true, + }), + + home: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + direct: false, + }), + + regex: ImmutableMap({ + body: '', + }), + }), + + notifications: ImmutableMap({ + alerts: ImmutableMap({ + follow: true, + follow_request: false, + favourite: true, + reblog: true, + mention: true, + poll: true, + move: true, + 'pleroma:emoji_reaction': true, + }), + + quickFilter: ImmutableMap({ + active: 'all', + show: true, + advanced: false, + }), + + shows: ImmutableMap({ + follow: true, + follow_request: true, + favourite: true, + reblog: true, + mention: true, + poll: true, + move: true, + 'pleroma:emoji_reaction': true, + }), + + sounds: ImmutableMap({ + follow: false, + follow_request: false, + favourite: false, + reblog: false, + mention: false, + poll: false, + move: false, + 'pleroma:emoji_reaction': false, + }), + + birthdays: ImmutableMap({ + show: true, + }), + }), + + community: ImmutableMap({ + shows: ImmutableMap({ + reblog: false, + reply: true, + direct: false, + }), + other: ImmutableMap({ + onlyMedia: false, + }), + regex: ImmutableMap({ + body: '', + }), + }), + + public: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + direct: false, + }), + other: ImmutableMap({ + onlyMedia: false, + }), + regex: ImmutableMap({ + body: '', + }), + }), + + direct: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + account_timeline: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + pinned: true, + direct: false, + }), + }), + + groups: ImmutableMap({}), + + trends: ImmutableMap({ + show: true, + }), + + columns: ImmutableList([ + ImmutableMap({ id: 'COMPOSE', uuid: crypto.randomUUID(), params: {} }), + ImmutableMap({ id: 'HOME', uuid: crypto.randomUUID(), params: {} }), + ImmutableMap({ id: 'NOTIFICATIONS', uuid: crypto.randomUUID(), params: {} }), + ]), + + remote_timeline: ImmutableMap({ + pinnedHosts: ImmutableList(), + }), +}); + +const getSettings = createSelector([ + (state: RootState) => state.soapbox.get('defaultSettings'), + (state: RootState) => state.settings, +], (soapboxSettings, settings) => { + return defaultSettings + .mergeDeep(soapboxSettings) + .mergeDeep(settings); +}); + +interface SettingChangeAction { + type: typeof SETTING_CHANGE; + path: string[]; + value: any; +} + +const changeSettingImmediate = (path: string[], value: any, opts?: SettingOpts) => + (dispatch: AppDispatch) => { + const action: SettingChangeAction = { + type: SETTING_CHANGE, + path, + value, + }; + + dispatch(action); + dispatch(saveSettingsImmediate(opts)); + }; + +const changeSetting = (path: string[], value: any, opts?: SettingOpts) => + (dispatch: AppDispatch) => { + const action: SettingChangeAction = { + type: SETTING_CHANGE, + path, + value, + }; + + dispatch(action); + return dispatch(saveSettings(opts)); + }; + +const saveSettingsImmediate = (opts?: SettingOpts) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + if (getSettings(state).getIn(['saved'])) return; + + const data = state.settings.delete('saved').toJS(); + + dispatch(patchMe({ + pleroma_settings_store: { + [FE_NAME]: data, + }, + })).then(() => { + dispatch({ type: SETTING_SAVE }); + + if (opts?.showAlert) { + toast.success(saveSuccessMessage); + } + }).catch(error => { + toast.showAlertForError(error); + }); + }; + +const saveSettings = (opts?: SettingOpts) => + (dispatch: AppDispatch) => dispatch(saveSettingsImmediate(opts)); + +const getLocale = (state: RootState, fallback = 'en') => { + const localeWithVariant = (getSettings(state).get('locale') as string).replace('_', '-'); + const locale = localeWithVariant.split('-')[0]; + const fallbackLocale = Object.keys(messages).includes(locale) ? locale : fallback; + return Object.keys(messages).includes(localeWithVariant) ? localeWithVariant : fallbackLocale; +}; + +type SettingsAction = + | SettingChangeAction + | { type: typeof SETTING_SAVE } + +export { + SETTING_CHANGE, + SETTING_SAVE, + SETTINGS_UPDATE, + FE_NAME, + defaultSettings, + getSettings, + changeSettingImmediate, + changeSetting, + saveSettingsImmediate, + saveSettings, + getLocale, + type SettingsAction, +}; diff --git a/src/actions/sidebar.ts b/src/actions/sidebar.ts new file mode 100644 index 0000000..3c9d05c --- /dev/null +++ b/src/actions/sidebar.ts @@ -0,0 +1,17 @@ +const SIDEBAR_OPEN = 'SIDEBAR_OPEN'; +const SIDEBAR_CLOSE = 'SIDEBAR_CLOSE'; + +const openSidebar = () => ({ + type: SIDEBAR_OPEN, +}); + +const closeSidebar = () => ({ + type: SIDEBAR_CLOSE, +}); + +export { + SIDEBAR_OPEN, + SIDEBAR_CLOSE, + openSidebar, + closeSidebar, +}; diff --git a/src/actions/soapbox.ts b/src/actions/soapbox.ts new file mode 100644 index 0000000..b01c089 --- /dev/null +++ b/src/actions/soapbox.ts @@ -0,0 +1,100 @@ +import { createSelector } from 'reselect'; + +import { normalizeSoapboxConfig } from 'soapbox/normalizers/index.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const SOAPBOX_CONFIG_REQUEST_SUCCESS = 'SOAPBOX_CONFIG_REQUEST_SUCCESS'; +const SOAPBOX_CONFIG_REQUEST_FAIL = 'SOAPBOX_CONFIG_REQUEST_FAIL'; + +const SOAPBOX_CONFIG_REMEMBER_REQUEST = 'SOAPBOX_CONFIG_REMEMBER_REQUEST'; +const SOAPBOX_CONFIG_REMEMBER_SUCCESS = 'SOAPBOX_CONFIG_REMEMBER_SUCCESS'; +const SOAPBOX_CONFIG_REMEMBER_FAIL = 'SOAPBOX_CONFIG_REMEMBER_FAIL'; + +const getSoapboxConfig = createSelector([ + (state: RootState) => state.soapbox, + (state: RootState) => getFeatures(state.instance), +], (soapbox, features) => { + // Do some additional normalization with the state + return normalizeSoapboxConfig(soapbox).withMutations(soapboxConfig => { + + // If displayFqn isn't set, infer it from federation + if (soapbox.get('displayFqn') === undefined) { + soapboxConfig.set('displayFqn', features.federating); + } + }); +}); + +const fetchFrontendConfigurations = () => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState) + .get('/api/pleroma/frontend_configurations') + .then((response) => response.json()); + +/** Conditionally fetches Soapbox config depending on backend features */ +const fetchSoapboxConfig = (host: string | null = null) => + (dispatch: AppDispatch, getState: () => RootState) => { + const features = getFeatures(getState().instance); + + if (features.frontendConfigurations) { + return dispatch(fetchFrontendConfigurations()).then((data) => { + if (data.soapbox_fe) { + dispatch(importSoapboxConfig(data.soapbox_fe, host)); + return data.soapbox_fe; + } else { + return dispatch(soapboxConfigFail(new Error('Not found'), host)); + } + }); + } else { + return dispatch(fetchSoapboxJson(host)); + } + }; + +const fetchSoapboxJson = (host: string | null) => + (dispatch: AppDispatch) => + fetch('/instance/soapbox.json').then((response) => response.json()).then((data) => { + if (!isObject(data)) throw 'soapbox.json failed'; + dispatch(importSoapboxConfig(data, host)); + return data; + }).catch(error => { + dispatch(soapboxConfigFail(error, host)); + }); + +const importSoapboxConfig = (soapboxConfig: APIEntity, host: string | null) => { + if (!soapboxConfig.brandColor) { + soapboxConfig.brandColor = '#0482d8'; + } + return { + type: SOAPBOX_CONFIG_REQUEST_SUCCESS, + soapboxConfig, + host, + }; +}; + +const soapboxConfigFail = (error: unknown, host: string | null) => ({ + type: SOAPBOX_CONFIG_REQUEST_FAIL, + error, + skipAlert: true, + host, +}); + +// https://stackoverflow.com/a/46663081 +const isObject = (o: any) => o instanceof Object && o.constructor === Object; + +export { + SOAPBOX_CONFIG_REQUEST_SUCCESS, + SOAPBOX_CONFIG_REQUEST_FAIL, + SOAPBOX_CONFIG_REMEMBER_REQUEST, + SOAPBOX_CONFIG_REMEMBER_SUCCESS, + SOAPBOX_CONFIG_REMEMBER_FAIL, + getSoapboxConfig, + fetchFrontendConfigurations, + fetchSoapboxConfig, + fetchSoapboxJson, + importSoapboxConfig, + soapboxConfigFail, +}; diff --git a/src/actions/status-hover-card.ts b/src/actions/status-hover-card.ts new file mode 100644 index 0000000..2ce24a7 --- /dev/null +++ b/src/actions/status-hover-card.ts @@ -0,0 +1,27 @@ +const STATUS_HOVER_CARD_OPEN = 'STATUS_HOVER_CARD_OPEN'; +const STATUS_HOVER_CARD_UPDATE = 'STATUS_HOVER_CARD_UPDATE'; +const STATUS_HOVER_CARD_CLOSE = 'STATUS_HOVER_CARD_CLOSE'; + +const openStatusHoverCard = (ref: React.MutableRefObject, statusId: string) => ({ + type: STATUS_HOVER_CARD_OPEN, + ref, + statusId, +}); + +const updateStatusHoverCard = () => ({ + type: STATUS_HOVER_CARD_UPDATE, +}); + +const closeStatusHoverCard = (force = false) => ({ + type: STATUS_HOVER_CARD_CLOSE, + force, +}); + +export { + STATUS_HOVER_CARD_OPEN, + STATUS_HOVER_CARD_UPDATE, + STATUS_HOVER_CARD_CLOSE, + openStatusHoverCard, + updateStatusHoverCard, + closeStatusHoverCard, +}; diff --git a/src/actions/status-quotes.ts b/src/actions/status-quotes.ts new file mode 100644 index 0000000..c34d673 --- /dev/null +++ b/src/actions/status-quotes.ts @@ -0,0 +1,76 @@ +import api from '../api/index.ts'; + +import { importFetchedStatuses } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +export const STATUS_QUOTES_FETCH_REQUEST = 'STATUS_QUOTES_FETCH_REQUEST'; +export const STATUS_QUOTES_FETCH_SUCCESS = 'STATUS_QUOTES_FETCH_SUCCESS'; +export const STATUS_QUOTES_FETCH_FAIL = 'STATUS_QUOTES_FETCH_FAIL'; + +export const STATUS_QUOTES_EXPAND_REQUEST = 'STATUS_QUOTES_EXPAND_REQUEST'; +export const STATUS_QUOTES_EXPAND_SUCCESS = 'STATUS_QUOTES_EXPAND_SUCCESS'; +export const STATUS_QUOTES_EXPAND_FAIL = 'STATUS_QUOTES_EXPAND_FAIL'; + +const noOp = () => new Promise(f => f(null)); + +export const fetchStatusQuotes = (statusId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.getIn([`quotes:${statusId}`, 'isLoading'])) { + return dispatch(noOp); + } + + dispatch({ + statusId, + type: STATUS_QUOTES_FETCH_REQUEST, + }); + + return api(getState).get(`/api/v1/pleroma/statuses/${statusId}/quotes`).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(importFetchedStatuses(data)); + return dispatch({ + type: STATUS_QUOTES_FETCH_SUCCESS, + statusId, + statuses: data, + next, + }); + }).catch(error => { + dispatch({ + type: STATUS_QUOTES_FETCH_FAIL, + statusId, + error, + }); + }); + }; + +export const expandStatusQuotes = (statusId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().status_lists.getIn([`quotes:${statusId}`, 'next'], null) as string | null; + + if (url === null || getState().status_lists.getIn([`quotes:${statusId}`, 'isLoading'])) { + return dispatch(noOp); + } + + dispatch({ + type: STATUS_QUOTES_EXPAND_REQUEST, + statusId, + }); + + return api(getState).get(url).then(async (response) => { + const data = await response.json(); + dispatch(importFetchedStatuses(data)); + dispatch({ + type: STATUS_QUOTES_EXPAND_SUCCESS, + statusId, + statuses: data, + next: response.next(), + }); + }).catch(error => { + dispatch({ + type: STATUS_QUOTES_EXPAND_FAIL, + statusId, + error, + }); + }); + }; diff --git a/src/actions/statuses.ts b/src/actions/statuses.ts new file mode 100644 index 0000000..ae704e4 --- /dev/null +++ b/src/actions/statuses.ts @@ -0,0 +1,404 @@ +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; +import { shouldHaveCard } from 'soapbox/utils/status.ts'; + +import api from '../api/index.ts'; + +import { setComposeToStatus } from './compose-status.ts'; +import { fetchGroupRelationships } from './groups.ts'; +import { importFetchedStatus, importFetchedStatuses } from './importer/index.ts'; +import { openModal } from './modals.ts'; +import { deleteFromTimelines } from './timelines.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity, Status } from 'soapbox/types/entities.ts'; + +const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST'; +const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS'; +const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL'; + +const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; +const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS'; +const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL'; + +const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; +const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; +const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; + +const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; +const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; +const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; + +const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; +const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; +const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; + +const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; +const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; +const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; + +const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; +const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; +const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; + +const STATUS_REVEAL = 'STATUS_REVEAL'; +const STATUS_HIDE = 'STATUS_HIDE'; + +const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST'; +const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; +const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; +const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; + +const STATUS_UNFILTER = 'STATUS_UNFILTER'; + +const statusExists = (getState: () => RootState, statusId: string) => { + return (getState().statuses.get(statusId) || null) !== null; +}; + +const createStatus = (params: Record, idempotencyKey: string, statusId: string | null) => { + return (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId }); + + const method = statusId === null ? 'POST' : 'PUT'; + const path = statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`; + const headers = { 'Idempotency-Key': idempotencyKey }; + + return api(getState).request(method, path, params, { headers }).then(async (response) => { + const status = await response.json(); + + // The backend might still be processing the rich media attachment + if (!status.card && shouldHaveCard(status)) { + status.expectsCard = true; + } + + dispatch(importFetchedStatus(status, idempotencyKey)); + dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey, editing: !!statusId }); + + // Poll the backend for the updated card + if (status.expectsCard) { + const delay = 1000; + + const poll = (retries = 5) => { + api(getState).get(`/api/v1/statuses/${status.id}`).then((response) => response.json()).then((data) => { + if (data?.card) { + dispatch(importFetchedStatus(data)); + } else if (retries > 0 && response.status === 200) { + setTimeout(() => poll(retries - 1), delay); + } + }).catch(console.error); + }; + + setTimeout(() => poll(), delay); + } + + return status; + }).catch(error => { + dispatch({ type: STATUS_CREATE_FAIL, error, params, idempotencyKey, editing: !!statusId }); + throw error; + }); + }; +}; + +const editStatus = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + let status = getState().statuses.get(id)!; + + if (status.poll) { + status = status.set('poll', getState().polls.get(status.poll) as any); + } + + dispatch({ type: STATUS_FETCH_SOURCE_REQUEST }); + + api(getState).get(`/api/v1/statuses/${id}/source`).then((response) => response.json()).then((data) => { + dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); + dispatch(setComposeToStatus(status, data.text, data.spoiler_text, data.content_type, false)); + dispatch(openModal('COMPOSE')); + }).catch(error => { + dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); + + }); +}; + +const fetchStatus = (id: string) => { + return (dispatch: AppDispatch, getState: () => RootState) => { + const skipLoading = statusExists(getState, id); + + dispatch({ type: STATUS_FETCH_REQUEST, id, skipLoading }); + + return api(getState).get(`/api/v1/statuses/${id}`).then((response) => response.json()).then((status) => { + dispatch(importFetchedStatus(status)); + if (status.group) { + dispatch(fetchGroupRelationships([status.group.id])); + } + dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading }); + return status; + }).catch(error => { + dispatch({ type: STATUS_FETCH_FAIL, id, error, skipLoading, skipAlert: true }); + }); + }; +}; + +const deleteStatus = (id: string, withRedraft = false) => { + return (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + let status = getState().statuses.get(id)!; + + if (status.poll) { + status = status.set('poll', getState().polls.get(status.poll) as any); + } + + dispatch({ type: STATUS_DELETE_REQUEST, params: status }); + + return api(getState) + .delete(`/api/v1/statuses/${id}`) + .then((response) => response.json()).then((data) => { + dispatch({ type: STATUS_DELETE_SUCCESS, id }); + dispatch(deleteFromTimelines(id)); + + if (withRedraft) { + dispatch(setComposeToStatus(status, data.text, data.spoiler_text, data.pleroma?.content_type, withRedraft)); + dispatch(openModal('COMPOSE')); + } + }) + .catch(error => { + dispatch({ type: STATUS_DELETE_FAIL, params: status, error }); + }); + }; +}; + +const updateStatus = (status: APIEntity) => (dispatch: AppDispatch) => + dispatch(importFetchedStatus(status)); + +const fetchContext = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CONTEXT_FETCH_REQUEST, id }); + + return api(getState).get(`/api/v1/statuses/${id}/context`).then((response) => response.json()).then((context) => { + if (Array.isArray(context)) { + // Mitra: returns a list of statuses + dispatch(importFetchedStatuses(context)); + } else if (typeof context === 'object') { + // Standard Mastodon API returns a map with `ancestors` and `descendants` + const { ancestors, descendants } = context; + const statuses = ancestors.concat(descendants); + dispatch(importFetchedStatuses(statuses)); + dispatch({ type: CONTEXT_FETCH_SUCCESS, id, ancestors, descendants }); + } else { + throw context; + } + return context; + }).catch(error => { + if (error.response?.status === 404) { + dispatch(deleteFromTimelines(id)); + } + + dispatch({ type: CONTEXT_FETCH_FAIL, id, error, skipAlert: true }); + }); + }; + +const fetchNext = (statusId: string, next: string) => + async(dispatch: AppDispatch, getState: () => RootState) => { + const response = await api(getState).get(next); + const data = await response.json(); + + dispatch(importFetchedStatuses(data)); + + dispatch({ + type: CONTEXT_FETCH_SUCCESS, + id: statusId, + ancestors: [], + descendants: data, + }); + + return { next: response.pagination().next }; + }; + +const fetchAncestors = (id: string) => + async(dispatch: AppDispatch, getState: () => RootState) => { + const response = await api(getState).get(`/api/v1/statuses/${id}/context/ancestors`); + const data = await response.json(); + dispatch(importFetchedStatuses(data)); + return response; + }; + +const fetchDescendants = (id: string) => + async(dispatch: AppDispatch, getState: () => RootState) => { + const response = await api(getState).get(`/api/v1/statuses/${id}/context/descendants`); + const data = await response.json(); + dispatch(importFetchedStatuses(data)); + return response; + }; + +const fetchStatusWithContext = (id: string) => + async(dispatch: AppDispatch, getState: () => RootState) => { + const features = getFeatures(getState().instance); + + if (features.paginatedContext) { + await dispatch(fetchStatus(id)); + + const [ancestors, descendants] = await Promise.all([ + dispatch(fetchAncestors(id)), + dispatch(fetchDescendants(id)), + ]); + + dispatch({ + type: CONTEXT_FETCH_SUCCESS, + id, + ancestors: await ancestors.json(), + descendants: await descendants.json(), + }); + + return descendants.pagination(); + } else { + await Promise.all([ + dispatch(fetchContext(id)), + dispatch(fetchStatus(id)), + ]); + return { next: null, prev: null }; + } + }; + +const muteStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ type: STATUS_MUTE_REQUEST, id }); + api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { + dispatch({ type: STATUS_MUTE_SUCCESS, id }); + }).catch(error => { + dispatch({ type: STATUS_MUTE_FAIL, id, error }); + }); + }; + +const unmuteStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ type: STATUS_UNMUTE_REQUEST, id }); + api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { + dispatch({ type: STATUS_UNMUTE_SUCCESS, id }); + }).catch(error => { + dispatch({ type: STATUS_UNMUTE_FAIL, id, error }); + }); + }; + +const toggleMuteStatus = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.muted) { + dispatch(unmuteStatus(status.id)); + } else { + dispatch(muteStatus(status.id)); + } + }; + +const hideStatus = (ids: string[] | string) => { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: STATUS_HIDE, + ids, + }; +}; + +const revealStatus = (ids: string[] | string) => { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: STATUS_REVEAL, + ids, + }; +}; + +const toggleStatusHidden = (status: Status) => { + if (status.hidden) { + return revealStatus(status.id); + } else { + return hideStatus(status.id); + } +}; + +const translateStatus = (id: string, lang?: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: STATUS_TRANSLATE_REQUEST, id }); + + api(getState).post(`/api/v1/statuses/${id}/translate`, { + lang, // Mastodon API + target_language: lang, // HACK: Rebased and Pleroma compatibility + }).then((response) => response.json()).then((data) => { + dispatch({ + type: STATUS_TRANSLATE_SUCCESS, + id, + translation: data, + }); + }).catch(error => { + dispatch({ + type: STATUS_TRANSLATE_FAIL, + id, + error, + }); + }); +}; + +const undoStatusTranslation = (id: string) => ({ + type: STATUS_TRANSLATE_UNDO, + id, +}); + +const unfilterStatus = (id: string) => ({ + type: STATUS_UNFILTER, + id, +}); + +export { + STATUS_CREATE_REQUEST, + STATUS_CREATE_SUCCESS, + STATUS_CREATE_FAIL, + STATUS_FETCH_SOURCE_REQUEST, + STATUS_FETCH_SOURCE_SUCCESS, + STATUS_FETCH_SOURCE_FAIL, + STATUS_FETCH_REQUEST, + STATUS_FETCH_SUCCESS, + STATUS_FETCH_FAIL, + STATUS_DELETE_REQUEST, + STATUS_DELETE_SUCCESS, + STATUS_DELETE_FAIL, + CONTEXT_FETCH_REQUEST, + CONTEXT_FETCH_SUCCESS, + CONTEXT_FETCH_FAIL, + STATUS_MUTE_REQUEST, + STATUS_MUTE_SUCCESS, + STATUS_MUTE_FAIL, + STATUS_UNMUTE_REQUEST, + STATUS_UNMUTE_SUCCESS, + STATUS_UNMUTE_FAIL, + STATUS_REVEAL, + STATUS_HIDE, + STATUS_TRANSLATE_REQUEST, + STATUS_TRANSLATE_SUCCESS, + STATUS_TRANSLATE_FAIL, + STATUS_TRANSLATE_UNDO, + STATUS_UNFILTER, + createStatus, + editStatus, + fetchStatus, + deleteStatus, + updateStatus, + fetchContext, + fetchNext, + fetchAncestors, + fetchDescendants, + fetchStatusWithContext, + muteStatus, + unmuteStatus, + toggleMuteStatus, + hideStatus, + revealStatus, + toggleStatusHidden, + translateStatus, + undoStatusTranslation, + unfilterStatus, +}; diff --git a/src/actions/streaming.ts b/src/actions/streaming.ts new file mode 100644 index 0000000..d6f6ea5 --- /dev/null +++ b/src/actions/streaming.ts @@ -0,0 +1,255 @@ +import { getLocale, getSettings } from 'soapbox/actions/settings.ts'; +import { updateReactions } from 'soapbox/api/hooks/announcements/useAnnouncements.ts'; +import { importEntities } from 'soapbox/entity-store/actions.ts'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { selectEntity } from 'soapbox/entity-store/selectors.ts'; +import messages from 'soapbox/messages.ts'; +import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats.ts'; +import { queryClient } from 'soapbox/queries/client.ts'; +import { announcementSchema, type Announcement, type Relationship } from 'soapbox/schemas/index.ts'; +import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats.ts'; +import { removePageItem } from 'soapbox/utils/queries.ts'; +import { play, soundCache } from 'soapbox/utils/sounds.ts'; + +import { connectStream } from '../stream.ts'; + +import { updateConversations } from './conversations.ts'; +import { fetchFilters } from './filters.ts'; +import { MARKER_FETCH_SUCCESS } from './markers.ts'; +import { updateNotificationsQueue } from './notifications.ts'; +import { updateStatus } from './statuses.ts'; +import { + // deleteFromTimelines, + connectTimeline, + disconnectTimeline, + processTimelineUpdate, +} from './timelines.ts'; + +import type { IStatContext } from 'soapbox/contexts/stat-context.tsx'; +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity, Chat } from 'soapbox/types/entities.ts'; + +const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; + +const removeChatMessage = (payload: string) => { + const data = JSON.parse(payload); + const chatId = data.chat_id; + const chatMessageId = data.deleted_message_id; + + // If the user just deleted the "last_message", then let's invalidate + // the Chat Search query so the Chat List will show the new "last_message". + if (isLastMessage(chatMessageId)) { + queryClient.invalidateQueries({ + queryKey: ChatKeys.chatSearch(), + }); + } + + removePageItem(ChatKeys.chatMessages(chatId), chatMessageId, (o: any, n: any) => String(o.id) === String(n)); +}; + +// Update the specific Chat query data. +const updateChatQuery = (chat: IChat) => { + const cachedChat = queryClient.getQueryData(ChatKeys.chat(chat.id)); + if (!cachedChat) { + return; + } + + const newChat = { + ...cachedChat, + latest_read_message_by_account: chat.latest_read_message_by_account, + latest_read_message_created_at: chat.latest_read_message_created_at, + }; + queryClient.setQueryData(ChatKeys.chat(chat.id), newChat as any); +}; + +const updateAnnouncementReactions = ({ announcement_id: id, name, count }: APIEntity) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => { + if (value.id !== id) return value; + + return announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, -1, true), + }); + }), + ); +}; + +const updateAnnouncement = (announcement: APIEntity) => + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => { + let updated = false; + + const result = prevResult.map(value => value.id === announcement.id + ? (updated = true, announcementSchema.parse(announcement)) + : value); + + if (!updated) return [announcementSchema.parse(announcement), ...result]; + }); + +const deleteAnnouncement = (id: string) => + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.filter(value => value.id !== id), + ); + +interface TimelineStreamOpts { + statContext?: IStatContext; + enabled?: boolean; +} + +const connectTimelineStream = ( + timelineId: string, + path: string, + accept: ((status: APIEntity) => boolean) | null = null, + opts?: TimelineStreamOpts, +) => connectStream(path, (dispatch: AppDispatch, getState: () => RootState) => { + const locale = getLocale(getState()); + + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + }, + + onDisconnect() { + dispatch(disconnectTimeline(timelineId)); + }, + + onReceive(websocket, data: any) { + switch (data.event) { + case 'update': + dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept)); + break; + case 'status.update': + dispatch(updateStatus(JSON.parse(data.payload))); + break; + // FIXME: We think delete & redraft is causing jumpy timelines. + // Fix that in ScrollableList then re-enable this! + // + // case 'delete': + // dispatch(deleteFromTimelines(data.payload)); + // break; + case 'notification': + messages[locale]().then(messages => { + dispatch( + updateNotificationsQueue( + JSON.parse(data.payload), + messages, + locale, + window.location.pathname, + ), + ); + }).catch(error => { + console.error(error); + }); + break; + case 'conversation': + dispatch(updateConversations(JSON.parse(data.payload))); + break; + case 'filters_changed': + dispatch(fetchFilters()); + break; + case 'pleroma:chat_update': + case 'chat_message.created': // TruthSocial + dispatch((_dispatch: AppDispatch, getState: () => RootState) => { + const chat = JSON.parse(data.payload); + const me = getState().me; + const messageOwned = chat.last_message?.account_id === me; + const settings = getSettings(getState()); + + // Don't update own messages from streaming + if (!messageOwned) { + updateChatListItem(chat); + + if (settings.getIn(['chats', 'sound'])) { + play(soundCache.chat); + } + + // Increment unread counter + opts?.statContext?.setUnreadChatsCount(getUnreadChatsCount()); + } + }); + break; + case 'chat_message.deleted': // TruthSocial + removeChatMessage(data.payload); + break; + case 'chat_message.read': // TruthSocial + dispatch((_dispatch: AppDispatch, getState: () => RootState) => { + const chat = JSON.parse(data.payload); + const me = getState().me; + const isFromOtherUser = chat.account.id !== me; + if (isFromOtherUser) { + updateChatQuery(JSON.parse(data.payload)); + } + }); + break; + case 'chat_message.reaction': // TruthSocial + updateChatMessage(JSON.parse(data.payload)); + break; + case 'pleroma:follow_relationships_update': + dispatch(updateFollowRelationships(JSON.parse(data.payload))); + break; + case 'announcement': + updateAnnouncement(JSON.parse(data.payload)); + break; + case 'announcement.reaction': + updateAnnouncementReactions(JSON.parse(data.payload)); + break; + case 'announcement.delete': + deleteAnnouncement(data.payload); + break; + case 'marker': + dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) }); + break; + } + }, + }; +}); + +function followStateToRelationship(followState: string) { + switch (followState) { + case 'follow_pending': + return { following: false, requested: true }; + case 'follow_accept': + return { following: true, requested: false }; + case 'follow_reject': + return { following: false, requested: false }; + default: + return {}; + } +} + +interface FollowUpdate { + state: 'follow_pending' | 'follow_accept' | 'follow_reject'; + follower: { + id: string; + follower_count: number; + following_count: number; + }; + following: { + id: string; + follower_count: number; + following_count: number; + }; +} + +function updateFollowRelationships(update: FollowUpdate) { + return (dispatch: AppDispatch, getState: () => RootState) => { + const me = getState().me; + const relationship = selectEntity(getState(), Entities.RELATIONSHIPS, update.following.id); + + if (update.follower.id === me && relationship) { + const updated = { + ...relationship, + ...followStateToRelationship(update.state), + }; + + // Add a small delay to deal with API race conditions. + setTimeout(() => dispatch(importEntities([updated], Entities.RELATIONSHIPS)), 300); + } + }; +} + +export { + STREAMING_CHAT_UPDATE, + connectTimelineStream, + type TimelineStreamOpts, +}; diff --git a/src/actions/suggestions.ts b/src/actions/suggestions.ts new file mode 100644 index 0000000..2a836bd --- /dev/null +++ b/src/actions/suggestions.ts @@ -0,0 +1,111 @@ +import { isLoggedIn } from 'soapbox/utils/auth.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +import { fetchRelationships } from './accounts.ts'; +import { importFetchedAccounts } from './importer/index.ts'; +import { insertSuggestionsIntoTimeline } from './timelines.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; +const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; +const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; + +const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; + +const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST'; +const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS'; +const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL'; + +const fetchSuggestionsV1 = (params: Record = {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true }); + return api(getState).get('/api/v1/suggestions', { searchParams: params }).then((response) => response.json()).then((accounts) => { + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: SUGGESTIONS_FETCH_SUCCESS, accounts, skipLoading: true }); + return accounts; + }).catch(error => { + dispatch({ type: SUGGESTIONS_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); + throw error; + }); + }; + +const fetchSuggestionsV2 = (params: Record = {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const next = getState().suggestions.next; + + dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true }); + + return api(getState).get(next ?? '/api/v2/suggestions', next ? {} : { searchParams: params }).then(async (response) => { + const suggestions: APIEntity[] = await response.json(); + const accounts = suggestions.map(({ account }) => account); + const next = response.next(); + + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, next, skipLoading: true }); + return suggestions; + }).catch(error => { + dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); + throw error; + }); + }; + +const fetchSuggestions = (params: Record = { limit: 50 }) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.suggestionsV2) { + return dispatch(fetchSuggestionsV2(params)) + .then((suggestions: APIEntity[]) => { + const accountIds = suggestions.map(({ account }) => account.id); + dispatch(fetchRelationships(accountIds)); + }) + .catch(() => { }); + } else if (features.suggestions) { + return dispatch(fetchSuggestionsV1(params)) + .then((accounts: APIEntity[]) => { + const accountIds = accounts.map(({ id }) => id); + dispatch(fetchRelationships(accountIds)); + }) + .catch(() => { }); + } else { + // Do nothing + return null; + } + }; + +const fetchSuggestionsForTimeline = () => (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch(fetchSuggestions({ limit: 20 }))?.then(() => dispatch(insertSuggestionsIntoTimeline())); +}; + +const dismissSuggestion = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ + type: SUGGESTIONS_DISMISS, + id: accountId, + }); + + api(getState).delete(`/api/v1/suggestions/${accountId}`); + }; + +export { + SUGGESTIONS_FETCH_REQUEST, + SUGGESTIONS_FETCH_SUCCESS, + SUGGESTIONS_FETCH_FAIL, + SUGGESTIONS_DISMISS, + SUGGESTIONS_V2_FETCH_REQUEST, + SUGGESTIONS_V2_FETCH_SUCCESS, + SUGGESTIONS_V2_FETCH_FAIL, + fetchSuggestionsV1, + fetchSuggestionsV2, + fetchSuggestions, + fetchSuggestionsForTimeline, + dismissSuggestion, +}; diff --git a/src/actions/sw.ts b/src/actions/sw.ts new file mode 100644 index 0000000..c12dc83 --- /dev/null +++ b/src/actions/sw.ts @@ -0,0 +1,15 @@ +import type { AnyAction } from 'redux'; + +/** Sets the ServiceWorker updating state. */ +const SW_UPDATING = 'SW_UPDATING'; + +/** Dispatch when the ServiceWorker is being updated to display a loading screen. */ +const setSwUpdating = (isUpdating: boolean): AnyAction => ({ + type: SW_UPDATING, + isUpdating, +}); + +export { + SW_UPDATING, + setSwUpdating, +}; diff --git a/src/actions/tags.ts b/src/actions/tags.ts new file mode 100644 index 0000000..5a2981d --- /dev/null +++ b/src/actions/tags.ts @@ -0,0 +1,202 @@ +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; +const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; +const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; + +const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; +const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; +const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; + +const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; +const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; +const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; + +const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; +const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; +const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; + +const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST'; +const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; +const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; + +const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchHashtagRequest()); + + api(getState).get(`/api/v1/tags/${name}`).then((response) => response.json()).then((data) => { + dispatch(fetchHashtagSuccess(name, data)); + }).catch(err => { + dispatch(fetchHashtagFail(err)); + }); +}; + +const fetchHashtagRequest = () => ({ + type: HASHTAG_FETCH_REQUEST, +}); + +const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FETCH_SUCCESS, + name, + tag, +}); + +const fetchHashtagFail = (error: unknown) => ({ + type: HASHTAG_FETCH_FAIL, + error, +}); + +const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(followHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/follow`).then((response) => response.json()).then((data) => { + dispatch(followHashtagSuccess(name, data)); + }).catch(err => { + dispatch(followHashtagFail(name, err)); + }); +}; + +const followHashtagRequest = (name: string) => ({ + type: HASHTAG_FOLLOW_REQUEST, + name, +}); + +const followHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FOLLOW_SUCCESS, + name, + tag, +}); + +const followHashtagFail = (name: string, error: unknown) => ({ + type: HASHTAG_FOLLOW_FAIL, + name, + error, +}); + +const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(unfollowHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/unfollow`).then((response) => response.json()).then((data) => { + dispatch(unfollowHashtagSuccess(name, data)); + }).catch(err => { + dispatch(unfollowHashtagFail(name, err)); + }); +}; + +const unfollowHashtagRequest = (name: string) => ({ + type: HASHTAG_UNFOLLOW_REQUEST, + name, +}); + +const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_UNFOLLOW_SUCCESS, + name, + tag, +}); + +const unfollowHashtagFail = (name: string, error: unknown) => ({ + type: HASHTAG_UNFOLLOW_FAIL, + name, + error, +}); + +const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowedHashtagsRequest()); + + api(getState).get('/api/v1/followed_tags').then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(fetchFollowedHashtagsSuccess(data, next)); + }).catch(err => { + dispatch(fetchFollowedHashtagsFail(err)); + }); +}; + +const fetchFollowedHashtagsRequest = () => ({ + type: FOLLOWED_HASHTAGS_FETCH_REQUEST, +}); + +const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({ + type: FOLLOWED_HASHTAGS_FETCH_SUCCESS, + followed_tags, + next, +}); + +const fetchFollowedHashtagsFail = (error: unknown) => ({ + type: FOLLOWED_HASHTAGS_FETCH_FAIL, + error, +}); + +const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().followed_tags.next; + + if (url === null) { + return; + } + + dispatch(expandFollowedHashtagsRequest()); + + api(getState).get(url).then(async (response) => { + const next = response.next(); + const data = await response.json(); + dispatch(expandFollowedHashtagsSuccess(data, next)); + }).catch(error => { + dispatch(expandFollowedHashtagsFail(error)); + }); +}; + +const expandFollowedHashtagsRequest = () => ({ + type: FOLLOWED_HASHTAGS_EXPAND_REQUEST, +}); + +const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({ + type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + followed_tags, + next, +}); + +const expandFollowedHashtagsFail = (error: unknown) => ({ + type: FOLLOWED_HASHTAGS_EXPAND_FAIL, + error, +}); + + +export { + HASHTAG_FETCH_REQUEST, + HASHTAG_FETCH_SUCCESS, + HASHTAG_FETCH_FAIL, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_SUCCESS, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_SUCCESS, + HASHTAG_UNFOLLOW_FAIL, + FOLLOWED_HASHTAGS_FETCH_REQUEST, + FOLLOWED_HASHTAGS_FETCH_SUCCESS, + FOLLOWED_HASHTAGS_FETCH_FAIL, + FOLLOWED_HASHTAGS_EXPAND_REQUEST, + FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + FOLLOWED_HASHTAGS_EXPAND_FAIL, + fetchHashtag, + fetchHashtagRequest, + fetchHashtagSuccess, + fetchHashtagFail, + followHashtag, + followHashtagRequest, + followHashtagSuccess, + followHashtagFail, + unfollowHashtag, + unfollowHashtagRequest, + unfollowHashtagSuccess, + unfollowHashtagFail, + fetchFollowedHashtags, + fetchFollowedHashtagsRequest, + fetchFollowedHashtagsSuccess, + fetchFollowedHashtagsFail, + expandFollowedHashtags, + expandFollowedHashtagsRequest, + expandFollowedHashtagsSuccess, + expandFollowedHashtagsFail, +}; diff --git a/src/actions/timelines.ts b/src/actions/timelines.ts new file mode 100644 index 0000000..efbc988 --- /dev/null +++ b/src/actions/timelines.ts @@ -0,0 +1,362 @@ +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import { getSettings } from 'soapbox/actions/settings.ts'; +import { normalizeStatus } from 'soapbox/normalizers/index.ts'; +import { shouldFilter } from 'soapbox/utils/timelines.ts'; + +import api from '../api/index.ts'; + +import { fetchGroupRelationships } from './groups.ts'; +import { importFetchedStatus, importFetchedStatuses } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity, Status } from 'soapbox/types/entities.ts'; + +const TIMELINE_UPDATE = 'TIMELINE_UPDATE' as const; +const TIMELINE_DELETE = 'TIMELINE_DELETE' as const; +const TIMELINE_CLEAR = 'TIMELINE_CLEAR' as const; +const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE' as const; +const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE' as const; +const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP' as const; + +const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST' as const; +const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS' as const; +const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL' as const; + +const TIMELINE_CONNECT = 'TIMELINE_CONNECT' as const; +const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT' as const; + +const TIMELINE_INSERT = 'TIMELINE_INSERT' as const; + +const MAX_QUEUED_ITEMS = 40; + +const processTimelineUpdate = (timeline: string, status: APIEntity, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + const me = getState().me; + const ownStatus = status.account?.id === me; + const hasPendingStatuses = !getState().pending_statuses.isEmpty(); + + const columnSettings = getSettings(getState()).get(timeline, ImmutableMap()); + const shouldSkipQueue = shouldFilter(normalizeStatus(status) as Status, columnSettings as any); + + if (ownStatus && hasPendingStatuses) { + // WebSockets push statuses without the Idempotency-Key, + // so if we have pending statuses, don't import it from here. + // We implement optimistic non-blocking statuses. + return; + } + + dispatch(importFetchedStatus(status)); + + if (shouldSkipQueue) { + dispatch(updateTimeline(timeline, status.id, accept)); + } else { + dispatch(updateTimelineQueue(timeline, status.id, accept)); + } + }; + +const updateTimeline = (timeline: string, statusId: string, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch) => { + // if (typeof accept === 'function' && !accept(status)) { + // return; + // } + + dispatch({ + type: TIMELINE_UPDATE, + timeline, + statusId, + }); + }; + +const updateTimelineQueue = (timeline: string, statusId: string, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch) => { + // if (typeof accept === 'function' && !accept(status)) { + // return; + // } + + dispatch({ + type: TIMELINE_UPDATE_QUEUE, + timeline, + statusId, + }); + }; + +const dequeueTimeline = (timelineId: string, expandFunc?: (lastStatusId: string) => void, optionalExpandArgs?: any) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const queuedCount = state.timelines.get(timelineId)?.totalQueuedItemsCount || 0; + + if (queuedCount <= 0) return; + + if (queuedCount <= MAX_QUEUED_ITEMS) { + dispatch({ type: TIMELINE_DEQUEUE, timeline: timelineId }); + return; + } + + if (typeof expandFunc === 'function') { + dispatch(clearTimeline(timelineId)); + // @ts-ignore + expandFunc(); + } else { + if (timelineId === 'home') { + dispatch(clearTimeline(timelineId)); + dispatch(expandHomeTimeline(optionalExpandArgs)); + } else if (timelineId === 'community') { + dispatch(clearTimeline(timelineId)); + dispatch(expandCommunityTimeline(optionalExpandArgs)); + } + } + }; + +interface TimelineDeleteAction { + type: typeof TIMELINE_DELETE; + id: string; + accountId: string; + references: ImmutableMap; + reblogOf: unknown; +} + +const deleteFromTimelines = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const accountId = getState().statuses.get(id)?.account?.id!; + const references = getState().statuses.filter(status => status.reblog === id).map(status => [status.id, status.account.id] as const); + const reblogOf = getState().statuses.getIn([id, 'reblog'], null); + + const action: TimelineDeleteAction = { + type: TIMELINE_DELETE, + id, + accountId, + references, + reblogOf, + }; + + dispatch(action); + }; + +const clearTimeline = (timeline: string) => + (dispatch: AppDispatch) => + dispatch({ type: TIMELINE_CLEAR, timeline }); + +const noOp = () => { }; +const noOpAsync = () => () => new Promise(f => f(undefined)); + +const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none') => { + return (tags[mode] || []).map((tag) => { + return tag.value; + }); +}; + +const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + const timeline = getState().timelines.get(timelineId) || {} as Record; + const isLoadingMore = !!params.max_id; + + if (timeline.isLoading) { + done(); + return dispatch(noOpAsync()); + } + + if ( + !params.max_id && + !params.pinned && + (timeline.items || ImmutableOrderedSet()).size > 0 && + !path.includes('max_id=') + ) { + params.since_id = timeline.getIn(['items', 0]); + } + + const isLoadingRecent = !!params.since_id; + + dispatch(expandTimelineRequest(timelineId, isLoadingMore)); + + return api(getState).get(path, { searchParams: params }).then(async (response) => { + const { next, prev } = response.pagination(); + const data: APIEntity[] = await response.json(); + + dispatch(importFetchedStatuses(data)); + + const statusesFromGroups = (data as Status[]).filter((status) => !!status.group); + dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id))); + + dispatch(expandTimelineSuccess( + timelineId, + data, + next, + prev, + response.status === 206, + isLoadingRecent, + isLoadingMore, + )); + done(); + }).catch(error => { + dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); + done(); + }); + }; + +interface ExpandHomeTimelineOpts { + maxId?: string; + url?: string; +} + +interface HomeTimelineParams { + max_id?: string; + exclude_replies?: boolean; + with_muted?: boolean; +} + +const expandHomeTimeline = ({ url, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => { + const endpoint = url || '/api/v1/timelines/home'; + const params: HomeTimelineParams = {}; + + if (!url && maxId) { + params.max_id = maxId; + } + + return expandTimeline('home', endpoint, params, done); +}; + +const expandPublicTimeline = ({ url, maxId, onlyMedia, language }: Record = {}, done = noOp) => + expandTimeline(`public${onlyMedia ? ':media' : ''}`, url || '/api/v1/timelines/public', url ? {} : { max_id: maxId, only_media: !!onlyMedia, language: language || undefined }, done); + +const expandRemoteTimeline = (instance: string, { url, maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, url || '/api/v1/timelines/public', url ? {} : { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done); + +const expandCommunityTimeline = ({ url, maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`community${onlyMedia ? ':media' : ''}`, url || '/api/v1/timelines/public', url ? {} : { local: true, max_id: maxId, only_media: !!onlyMedia }, done); + +const expandDirectTimeline = ({ url, maxId }: Record = {}, done = noOp) => + expandTimeline('direct', url || '/api/v1/timelines/direct', url ? {} : { max_id: maxId }, done); + +const expandAccountTimeline = (accountId: string, { url, maxId, withReplies }: Record = {}) => + expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, url || `/api/v1/accounts/${accountId}/statuses`, url ? {} : { exclude_replies: !withReplies, max_id: maxId, with_muted: true }); + +const expandAccountFeaturedTimeline = (accountId: string) => + expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, with_muted: true }); + +const expandAccountMediaTimeline = (accountId: string | number, { url, maxId }: Record = {}) => + expandTimeline(`account:${accountId}:media`, url || `/api/v1/accounts/${accountId}/statuses`, url ? {} : { max_id: maxId, only_media: true, limit: 40, with_muted: true }); + +const expandListTimeline = (id: string, { url, maxId }: Record = {}, done = noOp) => + expandTimeline(`list:${id}`, url || `/api/v1/timelines/list/${id}`, url ? {} : { max_id: maxId }, done); + +const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => + expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); + +const expandGroupFeaturedTimeline = (id: string) => + expandTimeline(`group:${id}:pinned`, `/api/v1/timelines/group/${id}`, { pinned: true }); + +const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Record = {}, done = noOp) => + expandTimeline(`group:tags:${id}:${tagName}`, `/api/v1/timelines/group/${id}/tags/${tagName}`, { max_id: maxId }, done); + +const expandGroupMediaTimeline = (id: string | number, { maxId }: Record = {}) => + expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); + +const expandHashtagTimeline = (hashtag: string, { url, maxId, tags }: Record = {}, done = noOp) => { + return expandTimeline(`hashtag:${hashtag}`, url || `/api/v1/timelines/tag/${hashtag}`, url ? {} : { + max_id: maxId, + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), + }, done); +}; + +const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({ + type: TIMELINE_EXPAND_REQUEST, + timeline, + skipLoading: !isLoadingMore, +}); + +const expandTimelineSuccess = ( + timeline: string, + statuses: APIEntity[], + next: string | null, + prev: string | null, + partial: boolean, + isLoadingRecent: boolean, + isLoadingMore: boolean, +) => ({ + type: TIMELINE_EXPAND_SUCCESS, + timeline, + statuses, + next, + prev, + partial, + isLoadingRecent, + skipLoading: !isLoadingMore, +}); + +const expandTimelineFail = (timeline: string, error: unknown, isLoadingMore: boolean) => ({ + type: TIMELINE_EXPAND_FAIL, + timeline, + error, + skipLoading: !isLoadingMore, +}); + +const connectTimeline = (timeline: string) => ({ + type: TIMELINE_CONNECT, + timeline, +}); + +const disconnectTimeline = (timeline: string) => ({ + type: TIMELINE_DISCONNECT, + timeline, +}); + +const scrollTopTimeline = (timeline: string, top: boolean) => ({ + type: TIMELINE_SCROLL_TOP, + timeline, + top, +}); + +const insertSuggestionsIntoTimeline = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: TIMELINE_INSERT, timeline: 'home' }); +}; + +// TODO: other actions +type TimelineAction = TimelineDeleteAction; + +export { + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_CLEAR, + TIMELINE_UPDATE_QUEUE, + TIMELINE_DEQUEUE, + TIMELINE_SCROLL_TOP, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_FAIL, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT, + TIMELINE_INSERT, + MAX_QUEUED_ITEMS, + processTimelineUpdate, + updateTimeline, + updateTimelineQueue, + dequeueTimeline, + deleteFromTimelines, + clearTimeline, + expandTimeline, + expandHomeTimeline, + expandPublicTimeline, + expandRemoteTimeline, + expandCommunityTimeline, + expandDirectTimeline, + expandAccountTimeline, + expandAccountFeaturedTimeline, + expandAccountMediaTimeline, + expandListTimeline, + expandGroupTimeline, + expandGroupFeaturedTimeline, + expandGroupTimelineFromTag, + expandGroupMediaTimeline, + expandHashtagTimeline, + expandTimelineRequest, + expandTimelineSuccess, + expandTimelineFail, + connectTimeline, + disconnectTimeline, + scrollTopTimeline, + insertSuggestionsIntoTimeline, + type TimelineAction, +}; diff --git a/src/actions/trending-statuses.ts b/src/actions/trending-statuses.ts new file mode 100644 index 0000000..c1c88c3 --- /dev/null +++ b/src/actions/trending-statuses.ts @@ -0,0 +1,86 @@ +import { APIEntity } from 'soapbox/types/entities.ts'; +import { getFeatures } from 'soapbox/utils/features.ts'; + +import api from '../api/index.ts'; + +import { importFetchedStatuses } from './importer/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; + +const TRENDING_STATUSES_FETCH_REQUEST = 'TRENDING_STATUSES_FETCH_REQUEST'; +const TRENDING_STATUSES_FETCH_SUCCESS = 'TRENDING_STATUSES_FETCH_SUCCESS'; +const TRENDING_STATUSES_FETCH_FAIL = 'TRENDING_STATUSES_FETCH_FAIL'; +const TRENDING_STATUSES_EXPAND_FAIL = 'TRENDING_STATUSES_EXPAND_FAIL'; +const TRENDING_STATUSES_EXPAND_SUCCESS = 'TRENDING_STATUSES_EXPAND_SUCCESS'; + +const fetchTrendingStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.trendingStatuses) return; + + dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST }); + return api(getState).get('/api/v1/trends/statuses').then(async (response) => { + const next = response.next(); + const data = await response.json(); + + const statuses = data; + + dispatch(importFetchedStatuses(statuses)); + dispatch(fetchTrendingStatusesSuccess(statuses, next)); + return statuses; + }).catch(error => { + dispatch(fetchTrendingStatusesFail(error)); + }); + }; + +const fetchTrendingStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: TRENDING_STATUSES_FETCH_SUCCESS, + statuses, + next, +}); + + +const fetchTrendingStatusesFail = (error: unknown) => ({ + type: TRENDING_STATUSES_FETCH_FAIL, + error, +}); + +const expandTrendingStatuses = (path: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + api(getState).get(path).then(async (response) => { + const next = response.next(); + const data = await response.json(); + + const statuses = data; + + dispatch(importFetchedStatuses(statuses)); + dispatch(expandTrendingStatusesSuccess(statuses, next)); + }).catch(error => { + dispatch(expandTrendingStatusesFail(error)); + }); + }; + +const expandTrendingStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: TRENDING_STATUSES_EXPAND_SUCCESS, + statuses, + next, +}); + +const expandTrendingStatusesFail = (error: unknown) => ({ + type: TRENDING_STATUSES_EXPAND_FAIL, + error, +}); + +export { + TRENDING_STATUSES_FETCH_REQUEST, + TRENDING_STATUSES_FETCH_SUCCESS, + TRENDING_STATUSES_FETCH_FAIL, + TRENDING_STATUSES_EXPAND_SUCCESS, + TRENDING_STATUSES_EXPAND_FAIL, + fetchTrendingStatuses, + expandTrendingStatuses, +}; diff --git a/src/actions/trends.ts b/src/actions/trends.ts new file mode 100644 index 0000000..530b782 --- /dev/null +++ b/src/actions/trends.ts @@ -0,0 +1,45 @@ +import api from '../api/index.ts'; + +import type { AppDispatch, RootState } from 'soapbox/store.ts'; +import type { APIEntity } from 'soapbox/types/entities.ts'; + +const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; +const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; +const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; + +const fetchTrends = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchTrendsRequest()); + + api(getState).get('/api/v1/trends').then((response) => response.json()).then(data => { + dispatch(fetchTrendsSuccess(data)); + }).catch(error => dispatch(fetchTrendsFail(error))); + }; + +const fetchTrendsRequest = () => ({ + type: TRENDS_FETCH_REQUEST, + skipLoading: true, +}); + +const fetchTrendsSuccess = (tags: APIEntity[]) => ({ + type: TRENDS_FETCH_SUCCESS, + tags, + skipLoading: true, +}); + +const fetchTrendsFail = (error: unknown) => ({ + type: TRENDS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export { + TRENDS_FETCH_REQUEST, + TRENDS_FETCH_SUCCESS, + TRENDS_FETCH_FAIL, + fetchTrends, + fetchTrendsRequest, + fetchTrendsSuccess, + fetchTrendsFail, +}; diff --git a/src/api/HTTPError.ts b/src/api/HTTPError.ts new file mode 100644 index 0000000..ea2874d --- /dev/null +++ b/src/api/HTTPError.ts @@ -0,0 +1,14 @@ +import { MastodonResponse } from 'soapbox/api/MastodonResponse.ts'; + +export class HTTPError extends Error { + + response: MastodonResponse; + request: Request; + + constructor(response: MastodonResponse, request: Request) { + super(response.statusText); + this.response = response; + this.request = request; + } + +} \ No newline at end of file diff --git a/src/api/MastodonClient.ts b/src/api/MastodonClient.ts new file mode 100644 index 0000000..78ad663 --- /dev/null +++ b/src/api/MastodonClient.ts @@ -0,0 +1,176 @@ +import { HTTPError } from './HTTPError.ts'; +import { MastodonResponse } from './MastodonResponse.ts'; + +interface Opts { + searchParams?: URLSearchParams | Record; + onUploadProgress?: (e: ProgressEvent) => void; + headers?: Record; + signal?: AbortSignal; +} + +export class MastodonClient { + + readonly baseUrl: string; + + private fetch: typeof fetch; + private accessToken?: string; + + constructor(baseUrl: string, accessToken?: string, fetch = globalThis.fetch.bind(globalThis)) { + this.fetch = fetch; + this.baseUrl = baseUrl; + this.accessToken = accessToken; + } + + async get(path: string, opts: Opts = {}): Promise { + return this.request('GET', path, undefined, opts); + } + + async post(path: string, data?: unknown, opts: Opts = {}): Promise { + return this.request('POST', path, data, opts); + } + + async put(path: string, data?: unknown, opts: Opts = {}): Promise { + return this.request('PUT', path, data, opts); + } + + async delete(path: string, opts: Opts = {}): Promise { + return this.request('DELETE', path, undefined, opts); + } + + async patch(path: string, data: unknown, opts: Opts = {}): Promise { + return this.request('PATCH', path, data, opts); + } + + async head(path: string, opts: Opts = {}): Promise { + return this.request('HEAD', path, undefined, opts); + } + + async options(path: string, opts: Opts = {}): Promise { + return this.request('OPTIONS', path, undefined, opts); + } + + async request(method: string, path: string, data: unknown, opts: Opts = {}): Promise { + const url = new URL(path, this.baseUrl); + + if (opts.searchParams) { + const params = opts.searchParams instanceof URLSearchParams + ? opts.searchParams + : Object + .entries(opts.searchParams) + .reduce((acc, [key, value]) => { + if (Array.isArray(value)) { + for (const v of value) { + acc.append(`${key}[]`, String(v)); + } + } else if (value !== undefined && value !== null) { + acc.append(key, String(value)); + } + return acc; + }, new URLSearchParams()); + + // Merge search params. + // If a key exists in the URL, it will be replaced. Otherwise it will be added. + for (const key of params.keys()) { + url.searchParams.delete(key); + } + for (const [key, value] of params) { + url.searchParams.append(key, value); + } + } + + const headers = new Headers(opts.headers); + + if (this.accessToken) { + headers.set('Authorization', `Bearer ${this.accessToken}`); + } + + let body: BodyInit | undefined; + + if (data instanceof FormData) { + body = data; + } else if (data !== undefined) { + headers.set('Content-Type', 'application/json'); + body = JSON.stringify(data); + } + + const request = new Request(url, { + method, + headers, + signal: opts.signal, + body, + }); + + const response = opts.onUploadProgress + ? await this.xhr(request, opts) + : MastodonResponse.fromResponse(await this.fetch(request)); + + if (!response.ok) { + throw new HTTPError(response, request); + } + + return response; + } + + /** + * Perform an XHR request from the native `Request` object and get back a `MastodonResponse`. + * This is needed because unfortunately `fetch` does not support upload progress. + */ + private async xhr(request: Request, opts: Opts = {}): Promise { + const xhr = new XMLHttpRequest(); + const { resolve, reject, promise } = Promise.withResolvers(); + + xhr.responseType = 'arraybuffer'; + + xhr.onreadystatechange = () => { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return; + } + + const headers = new Headers( + xhr.getAllResponseHeaders() + .trim() + .split(/[\r\n]+/) + .map((line): [string, string] => { + const [name, ...rest] = line.split(': '); + const value = rest.join(': '); + return [name, value]; + }), + ); + + const response = new MastodonResponse(xhr.response, { + status: xhr.status, + statusText: xhr.statusText, + headers, + }); + + resolve(response); + }; + + xhr.onerror = () => { + reject(new TypeError('Network request failed')); + }; + + xhr.onabort = () => { + reject(new DOMException('The request was aborted', 'AbortError')); + }; + + if (opts.onUploadProgress) { + xhr.upload.onprogress = opts.onUploadProgress; + } + + if (opts.signal) { + opts.signal.addEventListener('abort', () => xhr.abort(), { once: true }); + } + + xhr.open(request.method, request.url, true); + + for (const [name, value] of request.headers) { + xhr.setRequestHeader(name, value); + } + + xhr.send(await request.arrayBuffer()); + + return promise; + } + +} \ No newline at end of file diff --git a/src/api/MastodonResponse.ts b/src/api/MastodonResponse.ts new file mode 100644 index 0000000..6bd1a1a --- /dev/null +++ b/src/api/MastodonResponse.ts @@ -0,0 +1,87 @@ +import LinkHeader from 'http-link-header'; +import { z } from 'zod'; + +/** Mastodon JSON error response. */ +export interface MastodonError { + /** Error message in plaintext, to be displayed in the UI. */ + error: string; + /** Map of field validation errors. See: https://github.com/mastodon/mastodon/pull/15803 */ + detail?: Record; +} + +/** Parsed Mastodon `Link` header. */ +export interface MastodonLink { + rel: string; + uri: string; +} + +export class MastodonResponse extends Response { + + /** Construct a `MastodonResponse` from a regular `Response` object. */ + static fromResponse(response: Response): MastodonResponse { + // Fix for non-compliant browsers. + // https://developer.mozilla.org/en-US/docs/Web/API/Response/body + if (response.status === 204) { + return new MastodonResponse(null, response); + } + + return new MastodonResponse(response.body, response); + } + + /** Parses the `Link` header and returns an array of URLs and their rel values. */ + links(): MastodonLink[] { + const header = this.headers.get('link'); + + if (header) { + return new LinkHeader(header).refs; + } else { + return []; + } + } + + /** Parses the `Link` header and returns URLs for the `prev` and `next` pages of this response, if any. */ + pagination(): { prev: string | null; next: string | null } { + const links = this.links(); + + return { + next: links.find((link) => link.rel === 'next')?.uri ?? null, + prev: links.find((link) => link.rel === 'prev')?.uri ?? null, + }; + } + + /** Returns the `next` URI from the `Link` header, if applicable. */ + next(): string | null { + const links = this.links(); + return links.find((link) => link.rel === 'next')?.uri ?? null; + } + + /** Returns the `prev` URI from the `Link` header, if applicable. */ + prev(): string | null { + const links = this.links(); + return links.find((link) => link.rel === 'prev')?.uri ?? null; + } + + /** Extracts the error JSON from the response body, if possible. Otherwise returns `null`. */ + async error(): Promise { + const data = await this.json(); + const result = MastodonResponse.errorSchema().safeParse(data); + + if (result.success) { + return result.data; + } else { + return null; + } + } + + /** Validates the error response schema. */ + private static errorSchema(): z.ZodType { + return z.object({ + error: z.string(), + detail: z.record( + z.string(), + z.object({ error: z.string(), description: z.string() }).array(), + ).optional(), + }).passthrough(); + } + +} diff --git a/src/api/hooks/accounts/useAccount.ts b/src/api/hooks/accounts/useAccount.ts new file mode 100644 index 0000000..17cdb35 --- /dev/null +++ b/src/api/hooks/accounts/useAccount.ts @@ -0,0 +1,59 @@ +import { useEffect, useMemo } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; +import { type Account, accountSchema } from 'soapbox/schemas/index.ts'; + +import { useRelationship } from './useRelationship.ts'; + +interface UseAccountOpts { + withRelationship?: boolean; +} + +function useAccount(accountId?: string, opts: UseAccountOpts = {}) { + const api = useApi(); + const history = useHistory(); + const features = useFeatures(); + const { me } = useLoggedIn(); + const { withRelationship } = opts; + + const { entity, isUnauthorized, ...result } = useEntity( + [Entities.ACCOUNTS, accountId!], + () => api.get(`/api/v1/accounts/${accountId}`), + { schema: accountSchema, enabled: !!accountId }, + ); + + const { + relationship, + isLoading: isRelationshipLoading, + } = useRelationship(accountId, { enabled: withRelationship }); + + const isBlocked = relationship?.blocked_by === true; + const isUnavailable = (me === entity?.id) ? false : (isBlocked && !features.blockersVisible); + + const account = useMemo( + () => entity ? { ...entity, relationship } : undefined, + [entity, relationship], + ); + + useEffect(() => { + if (isUnauthorized) { + history.push('/login'); + } + }, [isUnauthorized]); + + return { + ...result, + isLoading: result.isLoading, + isRelationshipLoading, + isUnauthorized, + isUnavailable, + account, + }; +} + +export { useAccount }; \ No newline at end of file diff --git a/src/api/hooks/accounts/useAccountList.ts b/src/api/hooks/accounts/useAccountList.ts new file mode 100644 index 0000000..9d4d139 --- /dev/null +++ b/src/api/hooks/accounts/useAccountList.ts @@ -0,0 +1,70 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { Account, accountSchema } from 'soapbox/schemas/index.ts'; + +import { useRelationships } from './useRelationships.ts'; + +import type { EntityFn } from 'soapbox/entity-store/hooks/types.ts'; + +interface useAccountListOpts { + enabled?: boolean; +} + +function useAccountList(listKey: string[], entityFn: EntityFn, opts: useAccountListOpts = {}) { + const { entities, ...rest } = useEntities( + [Entities.ACCOUNTS, ...listKey], + entityFn, + { schema: accountSchema, enabled: opts.enabled }, + ); + + const { relationships } = useRelationships( + listKey, + entities.map(({ id }) => id), + ); + + const accounts: Account[] = entities.map((account) => ({ + ...account, + relationship: relationships[account.id], + })); + + return { accounts, ...rest }; +} + +function useBlocks() { + const api = useApi(); + return useAccountList(['blocks'], () => api.get('/api/v1/blocks')); +} + +function useMutes() { + const api = useApi(); + return useAccountList(['mutes'], () => api.get('/api/v1/mutes')); +} + +function useFollowing(accountId: string | undefined) { + const api = useApi(); + + return useAccountList( + [accountId!, 'following'], + () => api.get(`/api/v1/accounts/${accountId}/following`), + { enabled: !!accountId }, + ); +} + +function useFollowers(accountId: string | undefined) { + const api = useApi(); + + return useAccountList( + [accountId!, 'followers'], + () => api.get(`/api/v1/accounts/${accountId}/followers`), + { enabled: !!accountId }, + ); +} + +export { + useAccountList, + useBlocks, + useMutes, + useFollowing, + useFollowers, +}; \ No newline at end of file diff --git a/src/api/hooks/accounts/useAccountLookup.ts b/src/api/hooks/accounts/useAccountLookup.ts new file mode 100644 index 0000000..ad3605d --- /dev/null +++ b/src/api/hooks/accounts/useAccountLookup.ts @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityLookup } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; +import { type Account, accountSchema } from 'soapbox/schemas/index.ts'; + +import { useRelationship } from './useRelationship.ts'; + +interface UseAccountLookupOpts { + withRelationship?: boolean; +} + +function useAccountLookup(acct: string | undefined, opts: UseAccountLookupOpts = {}) { + const api = useApi(); + const features = useFeatures(); + const history = useHistory(); + const { me } = useLoggedIn(); + const { withRelationship } = opts; + + const { entity: account, isUnauthorized, ...result } = useEntityLookup( + Entities.ACCOUNTS, + (account) => account.acct.toLowerCase() === acct?.toLowerCase(), + () => api.get(`/api/v1/accounts/lookup?acct=${acct}`), + { schema: accountSchema, enabled: !!acct }, + ); + + const { + relationship, + isLoading: isRelationshipLoading, + } = useRelationship(account?.id, { enabled: withRelationship }); + + const isBlocked = relationship?.blocked_by === true; + const isUnavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible); + + useEffect(() => { + if (isUnauthorized) { + history.push('/login'); + } + }, [isUnauthorized]); + + return { + ...result, + isLoading: result.isLoading, + isRelationshipLoading, + isUnauthorized, + isUnavailable, + account: account ? { ...account, relationship } : undefined, + }; +} + +export { useAccountLookup }; \ No newline at end of file diff --git a/src/api/hooks/accounts/useFollow.ts b/src/api/hooks/accounts/useFollow.ts new file mode 100644 index 0000000..c0cce5c --- /dev/null +++ b/src/api/hooks/accounts/useFollow.ts @@ -0,0 +1,89 @@ +import { importEntities } from 'soapbox/entity-store/actions.ts'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useTransaction } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; +import { relationshipSchema } from 'soapbox/schemas/index.ts'; + +interface FollowOpts { + reblogs?: boolean; + notify?: boolean; + languages?: string[]; +} + +function useFollow() { + const api = useApi(); + const dispatch = useAppDispatch(); + const { isLoggedIn } = useLoggedIn(); + const { transaction } = useTransaction(); + + function followEffect(accountId: string) { + transaction({ + Accounts: { + [accountId]: (account) => ({ + ...account, + followers_count: account.followers_count + 1, + }), + }, + Relationships: { + [accountId]: (relationship) => ({ + ...relationship, + following: true, + }), + }, + }); + } + + function unfollowEffect(accountId: string) { + transaction({ + Accounts: { + [accountId]: (account) => ({ + ...account, + followers_count: Math.max(0, account.followers_count - 1), + }), + }, + Relationships: { + [accountId]: (relationship) => ({ + ...relationship, + following: false, + }), + }, + }); + } + + async function follow(accountId: string, options: FollowOpts = {}) { + if (!isLoggedIn) return; + followEffect(accountId); + + try { + const response = await api.post(`/api/v1/accounts/${accountId}/follow`, options); + const result = relationshipSchema.safeParse(await response.json()); + if (result.success) { + dispatch(importEntities([result.data], Entities.RELATIONSHIPS)); + } + } catch (e) { + unfollowEffect(accountId); + } + } + + async function unfollow(accountId: string) { + if (!isLoggedIn) return; + unfollowEffect(accountId); + + try { + await api.post(`/api/v1/accounts/${accountId}/unfollow`); + } catch (e) { + followEffect(accountId); + } + } + + return { + follow, + unfollow, + followEffect, + unfollowEffect, + }; +} + +export { useFollow }; \ No newline at end of file diff --git a/src/api/hooks/accounts/usePatronUser.ts b/src/api/hooks/accounts/usePatronUser.ts new file mode 100644 index 0000000..ff9eb58 --- /dev/null +++ b/src/api/hooks/accounts/usePatronUser.ts @@ -0,0 +1,22 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; +import { type PatronUser, patronUserSchema } from 'soapbox/schemas/index.ts'; + +function usePatronUser(url?: string) { + const api = useApi(); + const soapboxConfig = useSoapboxConfig(); + + const patronEnabled = soapboxConfig.getIn(['extensions', 'patron', 'enabled']) === true; + + const { entity: patronUser, ...result } = useEntity( + [Entities.PATRON_USERS, url || ''], + () => api.get(`/api/patron/v1/accounts/${encodeURIComponent(url!)}`), + { schema: patronUserSchema, enabled: patronEnabled && !!url }, + ); + + return { patronUser, ...result }; +} + +export { usePatronUser }; \ No newline at end of file diff --git a/src/api/hooks/accounts/useRelationship.ts b/src/api/hooks/accounts/useRelationship.ts new file mode 100644 index 0000000..b1e80fa --- /dev/null +++ b/src/api/hooks/accounts/useRelationship.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { type Relationship, relationshipSchema } from 'soapbox/schemas/index.ts'; + +interface UseRelationshipOpts { + enabled?: boolean; +} + +function useRelationship(accountId: string | undefined, opts: UseRelationshipOpts = {}) { + const api = useApi(); + const { enabled = false } = opts; + + const { entity: relationship, ...result } = useEntity( + [Entities.RELATIONSHIPS, accountId!], + () => api.get(`/api/v1/accounts/relationships?id[]=${accountId}`), + { + enabled: enabled && !!accountId, + schema: z.array(relationshipSchema).nonempty().transform(arr => arr[0]), + }, + ); + + return { relationship, ...result }; +} + +export { useRelationship }; \ No newline at end of file diff --git a/src/api/hooks/accounts/useRelationships.ts b/src/api/hooks/accounts/useRelationships.ts new file mode 100644 index 0000000..b9f18ba --- /dev/null +++ b/src/api/hooks/accounts/useRelationships.ts @@ -0,0 +1,45 @@ +import { MastodonResponse } from 'soapbox/api/MastodonResponse.ts'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useBatchedEntities } from 'soapbox/entity-store/hooks/useBatchedEntities.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; +import { type Relationship, relationshipSchema } from 'soapbox/schemas/index.ts'; + +function useRelationships(listKey: string[], ids: string[]) { + const api = useApi(); + const { isLoggedIn } = useLoggedIn(); + + async function fetchRelationships(ids: string[]) { + const results: Relationship[] = []; + + for (const id of chunkArray(ids, 20)) { + const response = await api.get('/api/v1/accounts/relationships', { searchParams: { id } }); + const json = await response.json(); + + results.push(...json); + } + + return new MastodonResponse(JSON.stringify(results), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { entityMap: relationships, ...result } = useBatchedEntities( + [Entities.RELATIONSHIPS, ...listKey], + ids, + fetchRelationships, + { schema: relationshipSchema, enabled: isLoggedIn }, + ); + + return { relationships, ...result }; +} + +function* chunkArray(array: T[], chunkSize: number): Iterable { + if (chunkSize <= 0) throw new Error('Chunk size must be greater than zero.'); + + for (let i = 0; i < array.length; i += chunkSize) { + yield array.slice(i, i + chunkSize); + } +} + +export { useRelationships }; \ No newline at end of file diff --git a/src/api/hooks/admin/index.ts b/src/api/hooks/admin/index.ts new file mode 100644 index 0000000..472cdc8 --- /dev/null +++ b/src/api/hooks/admin/index.ts @@ -0,0 +1,6 @@ +export { useDomains } from './useDomains.ts'; +export { useModerationLog } from './useModerationLog.ts'; +export { useRelays } from './useRelays.ts'; +export { useRules } from './useRules.ts'; +export { useSuggest } from './useSuggest.ts'; +export { useVerify } from './useVerify.ts'; \ No newline at end of file diff --git a/src/api/hooks/admin/useAdminAccounts.ts b/src/api/hooks/admin/useAdminAccounts.ts new file mode 100644 index 0000000..2c4b2bf --- /dev/null +++ b/src/api/hooks/admin/useAdminAccounts.ts @@ -0,0 +1,38 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { adminAccountSchema } from 'soapbox/schemas/admin-account.ts'; + +interface MastodonAdminFilters { + local?: boolean; + remote?: boolean; + active?: boolean; + pending?: boolean; + disabled?: boolean; + silenced?: boolean; + suspended?: boolean; + sensitized?: boolean; +} + +/** https://docs.joinmastodon.org/methods/admin/accounts/#v1 */ +export function useAdminAccounts(filters: MastodonAdminFilters, limit?: number) { + const api = useApi(); + + const searchParams = new URLSearchParams(); + + for (const [name, value] of Object.entries(filters)) { + searchParams.append(name, value.toString()); + } + + if (typeof limit === 'number') { + searchParams.append('limit', limit.toString()); + } + + const { entities, ...rest } = useEntities( + [Entities.ACCOUNTS, searchParams.toString()], + () => api.get('/api/v1/admin/accounts', { searchParams }), + { schema: adminAccountSchema.transform(({ account }) => account) }, + ); + + return { accounts: entities, ...rest }; +} \ No newline at end of file diff --git a/src/api/hooks/admin/useAnnouncements.ts b/src/api/hooks/admin/useAnnouncements.ts new file mode 100644 index 0000000..96544d5 --- /dev/null +++ b/src/api/hooks/admin/useAnnouncements.ts @@ -0,0 +1,92 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { queryClient } from 'soapbox/queries/client.ts'; +import { adminAnnouncementSchema, type AdminAnnouncement } from 'soapbox/schemas/index.ts'; + +import { useAnnouncements as useUserAnnouncements } from '../announcements/index.ts'; + +interface CreateAnnouncementParams { + content: string; + starts_at?: string | null; + ends_at?: string | null; + all_day?: boolean; +} + +interface UpdateAnnouncementParams extends CreateAnnouncementParams { + id: string; +} + +const useAnnouncements = () => { + const api = useApi(); + const userAnnouncements = useUserAnnouncements(); + + const getAnnouncements = async () => { + const response = await api.get('/api/v1/pleroma/admin/announcements'); + const data: AdminAnnouncement[] = await response.json(); + + const normalizedData = data.map((announcement) => adminAnnouncementSchema.parse(announcement)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['admin', 'announcements'], + queryFn: getAnnouncements, + placeholderData: [], + }); + + const { + mutate: createAnnouncement, + isPending: isCreating, + } = useMutation({ + mutationFn: (params: CreateAnnouncementParams) => api.post('/api/v1/pleroma/admin/announcements', params), + retry: false, + onSuccess: async (response: Response) => { + const data = await response.json(); + return queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray) => + [...prevResult, adminAnnouncementSchema.parse(data)], + ); + }, + onSettled: () => userAnnouncements.refetch(), + }); + + const { + mutate: updateAnnouncement, + isPending: isUpdating, + } = useMutation({ + mutationFn: ({ id, ...params }: UpdateAnnouncementParams) => api.patch(`/api/v1/pleroma/admin/announcements/${id}`, params), + retry: false, + onSuccess: async (response: Response) => { + const data = await response.json(); + return queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray) => + prevResult.map((announcement) => announcement.id === data.id ? adminAnnouncementSchema.parse(data) : announcement), + ); + }, + onSettled: () => userAnnouncements.refetch(), + }); + + const { + mutate: deleteAnnouncement, + isPending: isDeleting, + } = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/pleroma/admin/announcements/${id}`), + retry: false, + onSuccess: (_, id) => + queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray) => + prevResult.filter(({ id: announcementId }) => announcementId !== id), + ), + onSettled: () => userAnnouncements.refetch(), + }); + + return { + ...result, + createAnnouncement, + isCreating, + updateAnnouncement, + isUpdating, + deleteAnnouncement, + isDeleting, + }; +}; + +export { useAnnouncements }; diff --git a/src/api/hooks/admin/useCreateDomain.ts b/src/api/hooks/admin/useCreateDomain.ts new file mode 100644 index 0000000..3fc0669 --- /dev/null +++ b/src/api/hooks/admin/useCreateDomain.ts @@ -0,0 +1,26 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useCreateEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { domainSchema } from 'soapbox/schemas/index.ts'; + +interface CreateDomainParams { + domain: string; + public: boolean; +} + +const useCreateDomain = () => { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity( + [Entities.DOMAINS], + (params: CreateDomainParams) => api.post('/api/v1/pleroma/admin/domains', params), + { schema: domainSchema }, + ); + + return { + createDomain: createEntity, + ...rest, + }; +}; + +export { useCreateDomain, type CreateDomainParams }; diff --git a/src/api/hooks/admin/useDeleteDomain.ts b/src/api/hooks/admin/useDeleteDomain.ts new file mode 100644 index 0000000..7e05c6b --- /dev/null +++ b/src/api/hooks/admin/useDeleteDomain.ts @@ -0,0 +1,21 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useDeleteEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; + +const useDeleteDomain = () => { + const api = useApi(); + + const { deleteEntity, ...rest } = useDeleteEntity(Entities.DOMAINS, (id: string) => + api.delete(`/api/v1/pleroma/admin/domains/${id}`, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + })); + + return { + mutate: deleteEntity, + ...rest, + }; +}; + +export { useDeleteDomain }; diff --git a/src/api/hooks/admin/useDomains.ts b/src/api/hooks/admin/useDomains.ts new file mode 100644 index 0000000..8312b03 --- /dev/null +++ b/src/api/hooks/admin/useDomains.ts @@ -0,0 +1,85 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { queryClient } from 'soapbox/queries/client.ts'; +import { domainSchema, type Domain } from 'soapbox/schemas/index.ts'; + +interface CreateDomainParams { + domain: string; + public: boolean; +} + +interface UpdateDomainParams { + id: string; + public: boolean; +} + +const useDomains = () => { + const api = useApi(); + + const getDomains = async () => { + const response = await api.get('/api/v1/pleroma/admin/domains'); + const data: Domain[] = await response.json(); + + const normalizedData = data.map((domain) => domainSchema.parse(domain)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['admin', 'domains'], + queryFn: getDomains, + placeholderData: [], + }); + + const { + mutate: createDomain, + isPending: isCreating, + } = useMutation({ + mutationFn: (params: CreateDomainParams) => api.post('/api/v1/pleroma/admin/domains', params), + retry: false, + onSuccess: async (response: Response) => { + const data = await response.json(); + return queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray) => + [...prevResult, domainSchema.parse(data)], + ); + }, + }); + + const { + mutate: updateDomain, + isPending: isUpdating, + } = useMutation({ + mutationFn: ({ id, ...params }: UpdateDomainParams) => api.patch(`/api/v1/pleroma/admin/domains/${id}`, params), + retry: false, + onSuccess: async (response: Response) => { + const data = await response.json(); + return queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray) => + prevResult.map((domain) => domain.id === data.id ? domainSchema.parse(data) : domain), + ); + }, + }); + + const { + mutate: deleteDomain, + isPending: isDeleting, + } = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/pleroma/admin/domains/${id}`), + retry: false, + onSuccess: (_, id) => + queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray) => + prevResult.filter(({ id: domainId }) => domainId !== id), + ), + }); + + return { + ...result, + createDomain, + isCreating, + updateDomain, + isUpdating, + deleteDomain, + isDeleting, + }; +}; + +export { useDomains }; diff --git a/src/api/hooks/admin/useManageZapSplit.ts b/src/api/hooks/admin/useManageZapSplit.ts new file mode 100644 index 0000000..5a891e6 --- /dev/null +++ b/src/api/hooks/admin/useManageZapSplit.ts @@ -0,0 +1,150 @@ +import { useState, useEffect } from 'react'; +import { defineMessages } from 'react-intl'; + +import { type INewAccount } from 'soapbox/features/admin/manage-zap-split.tsx'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { baseZapAccountSchema, ZapSplitData } from 'soapbox/schemas/zap-split.ts'; +import toast from 'soapbox/toast.tsx'; + + +const messages = defineMessages({ + zapSplitFee: { id: 'manage.zap_split.fees_error_message', defaultMessage: 'The fees cannot exceed 50% of the total zap.' }, + fetchErrorMessage: { id: 'manage.zap_split.fetch_fail_request', defaultMessage: 'Failed to fetch Zap Split data.' }, + errorMessage: { id: 'manage.zap_split.fail_request', defaultMessage: 'Failed to update fees.' }, + sucessMessage: { id: 'manage.zap_split.success_request', defaultMessage: 'Fees updated successfully.' }, +}); + +/** +* Custom hook that manages the logic for handling Zap Split data, including fetching, updating, and removing accounts. +* It handles the state for formatted data, weights, and messages associated with the Zap Split accounts. +* +* @returns An object with data, weights, message, and functions to manipulate them. +*/ +export const useManageZapSplit = () => { + const api = useApi(); + const [formattedData, setFormattedData] = useState([]); + const [weights, setWeights] = useState<{ [id: string]: number }>({}); + const [message, setMessage] = useState<{ [id: string]: string }>({}); + + /** + * Fetches the Zap Split data from the API, parses it, and sets the state for formatted data, weights, and messages. + * Displays an error toast if the request fails. + */ + const fetchZapSplitData = async () => { + try { + const response = await api.get('/api/v1/ditto/zap_splits'); + const data: ZapSplitData[] = await response.json(); + if (data) { + const normalizedData = data.map((dataSplit) => baseZapAccountSchema.parse(dataSplit)); + setFormattedData(normalizedData); + + const initialWeights = normalizedData.reduce((acc, item) => { + acc[item.account.id] = item.weight; + return acc; + }, {} as { [id: string]: number }); + setWeights(initialWeights); + + const initialMessages = normalizedData.reduce((acc, item) => { + acc[item.account.id] = item.message; + return acc; + }, {} as { [id: string]: string }); + setMessage(initialMessages); + } + } catch (error) { + toast.error(messages.fetchErrorMessage); + } + }; + + useEffect(() => { + fetchZapSplitData(); + }, []); + + /** + * Updates the weight of a specific account. + * + * @param accountId - The ID of the account whose weight is being changed. + * @param newWeight - The new weight value to be assigned to the account. + */ + const handleWeightChange = (accountId: string, newWeight: number) => { + setWeights((prevWeights) => ({ + ...prevWeights, + [accountId]: newWeight, + })); + }; + + /** + * Updates the message of a specific account. + * + * @param accountId - The ID of the account whose weight is being changed. + * @param newMessage - The new message to be assigned to the account. + */ + const handleMessageChange = (accountId: string, newMessage: string) => { + setMessage((prevMessage) => ({ + ...prevMessage, + [accountId]: newMessage, + })); + }; + + /** + * Sends the updated Zap Split data to the API, including any new account or message changes. + * If the total weight exceeds 50%, displays an error toast and aborts the operation. + * + * @param newAccount - (Optional) A new account object to be added to the Zap Split data. + */ + const sendNewSplit = async (newAccount?: INewAccount) => { + try { + const updatedZapSplits = formattedData.reduce((acc: { [id: string]: { message: string; weight: number } }, zapData) => { + acc[zapData.account.id] = { + message: message[zapData.account.id] || zapData.message, + weight: weights[zapData.account.id] || zapData.weight, + }; + return acc; + }, {}); + + if (newAccount) { + updatedZapSplits[newAccount.acc] = { + message: newAccount.message, + weight: newAccount.weight, + }; + } + + const totalWeight = Object.values(updatedZapSplits).reduce((acc, currentValue) => { + return acc + currentValue.weight; + }, 0); + + if (totalWeight > 50) { + toast.error(messages.zapSplitFee); + return; + } + + await api.put('/api/v1/admin/ditto/zap_splits', updatedZapSplits); + } catch (error) { + toast.error(messages.errorMessage); + return; + } + + await fetchZapSplitData(); + toast.success(messages.sucessMessage); + }; + + /** + * Removes an account from the Zap Split by making a DELETE request to the API, and then refetches the updated data. + * + * @param accountId - The ID of the account to be removed. + */ + const removeAccount = async (accountId: string) => { + await api.request('DELETE', '/api/v1/admin/ditto/zap_splits', [accountId]); + await fetchZapSplitData(); + }; + + + return { + formattedData, + weights, + message, + handleMessageChange, + handleWeightChange, + sendNewSplit, + removeAccount, + }; +}; \ No newline at end of file diff --git a/src/api/hooks/admin/useModerationLog.ts b/src/api/hooks/admin/useModerationLog.ts new file mode 100644 index 0000000..3c34089 --- /dev/null +++ b/src/api/hooks/admin/useModerationLog.ts @@ -0,0 +1,43 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { moderationLogEntrySchema, type ModerationLogEntry } from 'soapbox/schemas/index.ts'; + +interface ModerationLogResult { + items: ModerationLogEntry[]; + total: number; +} + +const flattenPages = (pages?: ModerationLogResult[]): ModerationLogEntry[] => (pages || []).map(({ items }) => items).flat(); + +const useModerationLog = () => { + const api = useApi(); + + const getModerationLog = async (page: number): Promise => { + const response = await api.get('/api/v1/pleroma/admin/moderation_log', { searchParams: { page } }); + const data: ModerationLogResult = await response.json(); + + const normalizedData = data.items.map((domain) => moderationLogEntrySchema.parse(domain)); + + return { + items: normalizedData, + total: data.total, + }; + }; + + const queryInfo = useInfiniteQuery({ + queryKey: ['admin', 'moderation_log'], + queryFn: ({ pageParam }) => getModerationLog(pageParam), + initialPageParam: 1, + getNextPageParam: (page, allPages) => flattenPages(allPages)!.length >= page.total ? undefined : allPages.length + 1, + }); + + const data = flattenPages(queryInfo.data?.pages); + + return { + ...queryInfo, + data, + }; +}; + +export { useModerationLog }; diff --git a/src/api/hooks/admin/useRelays.ts b/src/api/hooks/admin/useRelays.ts new file mode 100644 index 0000000..ee119ca --- /dev/null +++ b/src/api/hooks/admin/useRelays.ts @@ -0,0 +1,61 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { queryClient } from 'soapbox/queries/client.ts'; +import { relaySchema, type Relay } from 'soapbox/schemas/index.ts'; + +const useRelays = () => { + const api = useApi(); + + const getRelays = async () => { + const response = await api.get('/api/v1/pleroma/admin/relay'); + const relays: Relay[] = await response.json(); + + const normalizedData = relays?.map((relay) => relaySchema.parse(relay)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['admin', 'relays'], + queryFn: getRelays, + placeholderData: [], + }); + + const { + mutate: followRelay, + isPending: isPendingFollow, + } = useMutation({ + mutationFn: (relayUrl: string) => api.post('/api/v1/pleroma/admin/relays', { relay_url: relayUrl }), + retry: false, + onSuccess: async (response: Response) => { + const data = await response.json(); + return queryClient.setQueryData(['admin', 'relays'], (prevResult: ReadonlyArray) => + [...prevResult, relaySchema.parse(data)], + ); + }, + }); + + const { + mutate: unfollowRelay, + isPending: isPendingUnfollow, + } = useMutation({ + mutationFn: async (relayUrl: string) => { + await api.request('DELETE', '/api/v1/pleroma/admin/relays', { relay_url: relayUrl }); + }, + retry: false, + onSuccess: (_, relayUrl) => + queryClient.setQueryData(['admin', 'relays'], (prevResult: ReadonlyArray) => + prevResult.filter(({ actor }) => actor !== relayUrl), + ), + }); + + return { + ...result, + followRelay, + isPendingFollow, + unfollowRelay, + isPendingUnfollow, + }; +}; + +export { useRelays }; diff --git a/src/api/hooks/admin/useRules.ts b/src/api/hooks/admin/useRules.ts new file mode 100644 index 0000000..4cce712 --- /dev/null +++ b/src/api/hooks/admin/useRules.ts @@ -0,0 +1,88 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { queryClient } from 'soapbox/queries/client.ts'; +import { adminRuleSchema, type AdminRule } from 'soapbox/schemas/index.ts'; + +interface CreateRuleParams { + priority?: number; + text: string; + hint?: string; +} + +interface UpdateRuleParams { + id: string; + priority?: number; + text?: string; + hint?: string; +} + +const useRules = () => { + const api = useApi(); + + const getRules = async () => { + const response = await api.get('/api/v1/pleroma/admin/rules'); + const data: AdminRule[] = await response.json(); + + const normalizedData = data.map((rule) => adminRuleSchema.parse(rule)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['admin', 'rules'], + queryFn: getRules, + placeholderData: [], + }); + + const { + mutate: createRule, + isPending: isCreating, + } = useMutation({ + mutationFn: (params: CreateRuleParams) => api.post('/api/v1/pleroma/admin/rules', params), + retry: false, + onSuccess: async (response: Response) => { + const data = await response.json(); + return queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + [...prevResult, adminRuleSchema.parse(data)], + ); + }, + }); + + const { + mutate: updateRule, + isPending: isUpdating, + } = useMutation({ + mutationFn: ({ id, ...params }: UpdateRuleParams) => api.patch(`/api/v1/pleroma/admin/rules/${id}`, params), + retry: false, + onSuccess: async (response: Response) => { + const data = await response.json(); + return queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + prevResult.map((rule) => rule.id === data.id ? adminRuleSchema.parse(data) : rule), + ); + }, + }); + + const { + mutate: deleteRule, + isPending: isDeleting, + } = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/pleroma/admin/rules/${id}`), + retry: false, + onSuccess: (_, id) => + queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + prevResult.filter(({ id: ruleId }) => ruleId !== id), + ), + }); + + return { + ...result, + createRule, + isCreating, + updateRule, + isUpdating, + deleteRule, + isDeleting, + }; +}; + +export { useRules }; diff --git a/src/api/hooks/admin/useSuggest.ts b/src/api/hooks/admin/useSuggest.ts new file mode 100644 index 0000000..46a2727 --- /dev/null +++ b/src/api/hooks/admin/useSuggest.ts @@ -0,0 +1,59 @@ +import { useTransaction } from 'soapbox/entity-store/hooks/index.ts'; +import { EntityCallbacks } from 'soapbox/entity-store/hooks/types.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useGetState } from 'soapbox/hooks/useGetState.ts'; +import { accountIdsToAccts } from 'soapbox/selectors/index.ts'; + +import type { Account } from 'soapbox/schemas/index.ts'; + +function useSuggest() { + const api = useApi(); + const getState = useGetState(); + const { transaction } = useTransaction(); + + function suggestEffect(accountIds: string[], suggested: boolean) { + const updater = (account: Account): Account => { + if (account.pleroma) { + account.pleroma.is_suggested = suggested; + } + return account; + }; + + transaction({ + Accounts: accountIds.reduce Account>>( + (result, id) => ({ ...result, [id]: updater }), + {}), + }); + } + + async function suggest(accountIds: string[], callbacks?: EntityCallbacks) { + const accts = accountIdsToAccts(getState(), accountIds); + suggestEffect(accountIds, true); + try { + await api.patch('/api/v1/pleroma/admin/users/suggest', { nicknames: accts }); + callbacks?.onSuccess?.(); + } catch (e) { + callbacks?.onError?.(e); + suggestEffect(accountIds, false); + } + } + + async function unsuggest(accountIds: string[], callbacks?: EntityCallbacks) { + const accts = accountIdsToAccts(getState(), accountIds); + suggestEffect(accountIds, false); + try { + await api.patch('/api/v1/pleroma/admin/users/unsuggest', { nicknames: accts }); + callbacks?.onSuccess?.(); + } catch (e) { + callbacks?.onError?.(e); + suggestEffect(accountIds, true); + } + } + + return { + suggest, + unsuggest, + }; +} + +export { useSuggest }; \ No newline at end of file diff --git a/src/api/hooks/admin/useUpdateDomain.ts b/src/api/hooks/admin/useUpdateDomain.ts new file mode 100644 index 0000000..8ac43cc --- /dev/null +++ b/src/api/hooks/admin/useUpdateDomain.ts @@ -0,0 +1,23 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useCreateEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { domainSchema } from 'soapbox/schemas/index.ts'; + +import type { CreateDomainParams } from './useCreateDomain.ts'; + +const useUpdateDomain = (id: string) => { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity( + [Entities.DOMAINS], + (params: Omit) => api.patch(`/api/v1/pleroma/admin/domains/${id}`, params), + { schema: domainSchema }, + ); + + return { + updateDomain: createEntity, + ...rest, + }; +}; + +export { useUpdateDomain }; diff --git a/src/api/hooks/admin/useVerify.ts b/src/api/hooks/admin/useVerify.ts new file mode 100644 index 0000000..4fee04d --- /dev/null +++ b/src/api/hooks/admin/useVerify.ts @@ -0,0 +1,64 @@ +import { useTransaction } from 'soapbox/entity-store/hooks/index.ts'; +import { EntityCallbacks } from 'soapbox/entity-store/hooks/types.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useGetState } from 'soapbox/hooks/useGetState.ts'; +import { accountIdsToAccts } from 'soapbox/selectors/index.ts'; + +import type { Account } from 'soapbox/schemas/index.ts'; + +function useVerify() { + const api = useApi(); + const getState = useGetState(); + const { transaction } = useTransaction(); + + function verifyEffect(accountIds: string[], verified: boolean) { + const updater = (account: Account): Account => { + if (account.pleroma) { + const tags = account.pleroma.tags.filter((tag) => tag !== 'verified'); + if (verified) { + tags.push('verified'); + } + account.pleroma.tags = tags; + } + account.verified = verified; + return account; + }; + + transaction({ + Accounts: accountIds.reduce Account>>( + (result, id) => ({ ...result, [id]: updater }), + {}), + }); + } + + async function verify(accountIds: string[], callbacks?: EntityCallbacks) { + const accts = accountIdsToAccts(getState(), accountIds); + verifyEffect(accountIds, true); + try { + await api.put('/api/v1/pleroma/admin/users/tag', { nicknames: accts, tags: ['verified'] }); + callbacks?.onSuccess?.(); + } catch (e) { + callbacks?.onError?.(e); + verifyEffect(accountIds, false); + } + } + + async function unverify(accountIds: string[], callbacks?: EntityCallbacks) { + const accts = accountIdsToAccts(getState(), accountIds); + verifyEffect(accountIds, false); + try { + await api.request('DELETE', '/api/v1/pleroma/admin/users/tag', { nicknames: accts, tags: ['verified'] }); + callbacks?.onSuccess?.(); + } catch (e) { + callbacks?.onError?.(e); + verifyEffect(accountIds, true); + } + } + + return { + verify, + unverify, + }; +} + +export { useVerify }; \ No newline at end of file diff --git a/src/api/hooks/announcements/index.ts b/src/api/hooks/announcements/index.ts new file mode 100644 index 0000000..ddb8ac3 --- /dev/null +++ b/src/api/hooks/announcements/index.ts @@ -0,0 +1 @@ +export { useAnnouncements } from './useAnnouncements.ts'; diff --git a/src/api/hooks/announcements/useAnnouncements.ts b/src/api/hooks/announcements/useAnnouncements.ts new file mode 100644 index 0000000..b839465 --- /dev/null +++ b/src/api/hooks/announcements/useAnnouncements.ts @@ -0,0 +1,104 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { queryClient } from 'soapbox/queries/client.ts'; +import { announcementReactionSchema, announcementSchema, type Announcement, type AnnouncementReaction } from 'soapbox/schemas/index.ts'; + +const updateReaction = (reaction: AnnouncementReaction, count: number, me?: boolean, overwrite?: boolean) => announcementReactionSchema.parse({ + ...reaction, + me: typeof me === 'boolean' ? me : reaction.me, + count: overwrite ? count : (reaction.count + count), +}); + +export const updateReactions = (reactions: AnnouncementReaction[], name: string, count: number, me?: boolean, overwrite?: boolean) => { + const idx = reactions.findIndex(reaction => reaction.name === name); + + if (idx > -1) { + reactions = reactions.map(reaction => reaction.name === name ? updateReaction(reaction, count, me, overwrite) : reaction); + } + + return [...reactions, updateReaction(announcementReactionSchema.parse({ name }), count, me, overwrite)]; +}; + +const useAnnouncements = () => { + const api = useApi(); + + const getAnnouncements = async () => { + const response = await api.get('/api/v1/announcements'); + const data: Announcement[] = await response.json(); + + const normalizedData = data?.map((announcement) => announcementSchema.parse(announcement)); + return normalizedData; + }; + + const { data, ...result } = useQuery>({ + queryKey: ['announcements'], + queryFn: getAnnouncements, + placeholderData: [], + }); + + const { + mutate: addReaction, + } = useMutation({ + mutationFn: async ({ announcementId, name }: { announcementId: string; name: string }): Promise => { + const response = await api.put(`/api/v1/announcements/${announcementId}/reactions/${name}`); + return response.json(); + }, + retry: false, + onMutate: ({ announcementId: id, name }) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => value.id !== id ? value : announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, 1, true), + })), + ); + }, + onError: (_, { announcementId: id, name }) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => value.id !== id ? value : announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, -1, false), + })), + ); + }, + }); + + const { + mutate: removeReaction, + } = useMutation({ + mutationFn: async ({ announcementId, name }: { announcementId: string; name: string }): Promise => { + const response = await api.delete(`/api/v1/announcements/${announcementId}/reactions/${name}`); + return response.json(); + }, + retry: false, + onMutate: ({ announcementId: id, name }) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => value.id !== id ? value : announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, -1, false), + })), + ); + }, + onError: (_, { announcementId: id, name }) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => value.id !== id ? value : announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, 1, true), + })), + ); + }, + }); + + return { + data: data ? [...data].sort(compareAnnouncements) : undefined, + ...result, + addReaction, + removeReaction, + }; +}; + +function compareAnnouncements(a: Announcement, b: Announcement): number { + return new Date(a.starts_at || a.published_at).getDate() - new Date(b.starts_at || b.published_at).getDate(); +} + +export { useAnnouncements }; diff --git a/src/api/hooks/captcha/useCaptcha.ts b/src/api/hooks/captcha/useCaptcha.ts new file mode 100644 index 0000000..2463b86 --- /dev/null +++ b/src/api/hooks/captcha/useCaptcha.ts @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { closeModal } from 'soapbox/actions/modals.ts'; +import { HTTPError } from 'soapbox/api/HTTPError.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useInstance } from 'soapbox/hooks/useInstance.ts'; +import { captchaSchema, type CaptchaData } from 'soapbox/schemas/captcha.ts'; +import toast from 'soapbox/toast.tsx'; + + + +const messages = defineMessages({ + sucessMessage: { id: 'nostr_signup.captcha_message.sucess', defaultMessage: 'Incredible! You\'ve successfully completed the captcha.' }, + wrongMessage: { id: 'nostr_signup.captcha_message.wrong', defaultMessage: 'Oops! It looks like your captcha response was incorrect. Please try again.' }, + errorMessage: { id: 'nostr_signup.captcha_message.error', defaultMessage: 'It seems an error has occurred. Please try again. If the problem persists, please contact us.' }, + misbehavingMessage: { id: 'nostr_signup.captcha_message.misbehaving', defaultMessage: 'It looks like we\'re experiencing issues with the {instance}. Please try again. If the error persists, try again later.' }, +}); + +function getRandomNumber(min: number, max: number): number { + return Number((Math.random() * (max - min) + min).toFixed()); +} + +const useCaptcha = () => { + const api = useApi(); + const { instance } = useInstance(); + const dispatch = useAppDispatch(); + const intl = useIntl(); + const [captcha, setCaptcha] = useState(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tryAgain, setTryAgain] = useState(false); + const [yPosition, setYPosition] = useState(); + const [xPosition, setXPosition] = useState(); + + const loadCaptcha = async () => { + try { + const topI = getRandomNumber(0, (356 - 61)); + const leftI = getRandomNumber(0, (330 - 61)); + const response = await api.get('/api/v1/ditto/captcha'); + const data = captchaSchema.parse(await response.json()); + setCaptcha(data); + setYPosition(topI); + setXPosition(leftI); + } catch (error) { + toast.error('Error loading captcha'); + } + }; + + useEffect(() => { + loadCaptcha(); + }, []); + + const handleChangePosition = (point: { x: number; y: number }) => { + setXPosition(point.x); + setYPosition(point.y); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + + if (captcha) { + const result = { + x: xPosition, + y: yPosition, + }; + + try { + await api.post(`/api/v1/ditto/captcha/${captcha.id}/verify`, result).then(() => { + setTryAgain(true); + + dispatch(closeModal('CAPTCHA')); + toast.success(messages.sucessMessage); + }); + } catch (error) { + setTryAgain(true); + + const status = error instanceof HTTPError ? error.response.status : undefined; + let message; + + switch (status) { + case 400: + message = intl.formatMessage(messages.wrongMessage); + break; + case 422: + message = intl.formatMessage(messages.misbehavingMessage, { instance: instance.title }); + break; + default: + message = intl.formatMessage(messages.errorMessage); + console.error(error); + break; + } + + toast.error(message); + } + setIsSubmitting(false); + } + }; + + return { + captcha, + loadCaptcha, + handleChangePosition, + handleSubmit, + isSubmitting, + tryAgain, + yPosition, + xPosition, + }; +}; + +export default useCaptcha; \ No newline at end of file diff --git a/src/api/hooks/groups/useBlockGroupMember.ts b/src/api/hooks/groups/useBlockGroupMember.ts new file mode 100644 index 0000000..1d10b5b --- /dev/null +++ b/src/api/hooks/groups/useBlockGroupMember.ts @@ -0,0 +1,15 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; + +import type { Account, Group, GroupMember } from 'soapbox/schemas/index.ts'; + +function useBlockGroupMember(group: Group, account: Account) { + const { createEntity } = useEntityActions( + [Entities.GROUP_MEMBERSHIPS, account.id], + { post: `/api/v1/groups/${group?.id}/blocks` }, + ); + + return createEntity; +} + +export { useBlockGroupMember }; \ No newline at end of file diff --git a/src/api/hooks/groups/useCancelMembershipRequest.ts b/src/api/hooks/groups/useCancelMembershipRequest.ts new file mode 100644 index 0000000..032cb71 --- /dev/null +++ b/src/api/hooks/groups/useCancelMembershipRequest.ts @@ -0,0 +1,23 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useCreateEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; + +import type { Group } from 'soapbox/schemas/index.ts'; + +function useCancelMembershipRequest(group: Group) { + const api = useApi(); + const { account: me } = useOwnAccount(); + + const { createEntity, isSubmitting } = useCreateEntity( + [Entities.GROUP_RELATIONSHIPS], + () => api.post(`/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject`), + ); + + return { + mutate: createEntity, + isSubmitting, + }; +} + +export { useCancelMembershipRequest }; diff --git a/src/api/hooks/groups/useCreateGroup.ts b/src/api/hooks/groups/useCreateGroup.ts new file mode 100644 index 0000000..d354a7a --- /dev/null +++ b/src/api/hooks/groups/useCreateGroup.ts @@ -0,0 +1,33 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useCreateEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { groupSchema } from 'soapbox/schemas/index.ts'; + +interface CreateGroupParams { + display_name?: string; + note?: string; + avatar?: File; + header?: File; + group_visibility?: 'members_only' | 'everyone'; + discoverable?: boolean; + tags?: string[]; +} + +function useCreateGroup() { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS, 'search', ''], (params: CreateGroupParams) => { + return api.post('/api/v1/groups', params, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, { schema: groupSchema }); + + return { + createGroup: createEntity, + ...rest, + }; +} + +export { useCreateGroup, type CreateGroupParams }; \ No newline at end of file diff --git a/src/api/hooks/groups/useDeleteGroup.ts b/src/api/hooks/groups/useDeleteGroup.ts new file mode 100644 index 0000000..b586e36 --- /dev/null +++ b/src/api/hooks/groups/useDeleteGroup.ts @@ -0,0 +1,18 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; + +import type { Group } from 'soapbox/schemas/index.ts'; + +function useDeleteGroup() { + const { deleteEntity, isSubmitting } = useEntityActions( + [Entities.GROUPS], + { delete: '/api/v1/groups/:id' }, + ); + + return { + mutate: deleteEntity, + isSubmitting, + }; +} + +export { useDeleteGroup }; \ No newline at end of file diff --git a/src/api/hooks/groups/useDeleteGroupStatus.ts b/src/api/hooks/groups/useDeleteGroupStatus.ts new file mode 100644 index 0000000..95874dc --- /dev/null +++ b/src/api/hooks/groups/useDeleteGroupStatus.ts @@ -0,0 +1,20 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useDeleteEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; + +import type { Group } from 'soapbox/schemas/index.ts'; + +function useDeleteGroupStatus(group: Group, statusId: string) { + const api = useApi(); + const { deleteEntity, isSubmitting } = useDeleteEntity( + Entities.STATUSES, + () => api.delete(`/api/v1/groups/${group.id}/statuses/${statusId}`), + ); + + return { + mutate: deleteEntity, + isSubmitting, + }; +} + +export { useDeleteGroupStatus }; \ No newline at end of file diff --git a/src/api/hooks/groups/useDemoteGroupMember.ts b/src/api/hooks/groups/useDemoteGroupMember.ts new file mode 100644 index 0000000..3d6a10e --- /dev/null +++ b/src/api/hooks/groups/useDemoteGroupMember.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; +import { groupMemberSchema } from 'soapbox/schemas/index.ts'; + +import type { Group, GroupMember } from 'soapbox/schemas/index.ts'; + +function useDemoteGroupMember(group: Group, groupMember: GroupMember) { + const { createEntity } = useEntityActions( + [Entities.GROUP_MEMBERSHIPS, groupMember.id], + { post: `/api/v1/groups/${group.id}/demote` }, + { schema: z.array(groupMemberSchema).transform((arr) => arr[0]) }, + ); + + return createEntity; +} + +export { useDemoteGroupMember }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroup.ts b/src/api/hooks/groups/useGroup.ts new file mode 100644 index 0000000..5da2c4c --- /dev/null +++ b/src/api/hooks/groups/useGroup.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { type Group, groupSchema } from 'soapbox/schemas/index.ts'; + +import { useGroupRelationship } from './useGroupRelationship.ts'; + +function useGroup(groupId: string, refetch = true) { + const api = useApi(); + const history = useHistory(); + + const { entity: group, isUnauthorized, ...result } = useEntity( + [Entities.GROUPS, groupId], + () => api.get(`/api/v1/groups/${groupId}`), + { + schema: groupSchema, + refetch, + enabled: !!groupId, + }, + ); + const { groupRelationship: relationship } = useGroupRelationship(groupId); + + useEffect(() => { + if (isUnauthorized) { + history.push('/login'); + } + }, [isUnauthorized]); + + return { + ...result, + isUnauthorized, + group: group ? { ...group, relationship: relationship || null } : undefined, + }; +} + +export { useGroup }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupLookup.ts b/src/api/hooks/groups/useGroupLookup.ts new file mode 100644 index 0000000..f1790c6 --- /dev/null +++ b/src/api/hooks/groups/useGroupLookup.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityLookup } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { groupSchema } from 'soapbox/schemas/index.ts'; + +import { useGroupRelationship } from './useGroupRelationship.ts'; + +function useGroupLookup(slug: string) { + const api = useApi(); + const features = useFeatures(); + const history = useHistory(); + + const { entity: group, isUnauthorized, ...result } = useEntityLookup( + Entities.GROUPS, + (group) => group.slug.toLowerCase() === slug.toLowerCase(), + () => api.get(`/api/v1/groups/lookup?name=${slug}`), + { schema: groupSchema, enabled: features.groups && !!slug }, + ); + + const { groupRelationship: relationship } = useGroupRelationship(group?.id); + + useEffect(() => { + if (isUnauthorized) { + history.push('/login'); + } + }, [isUnauthorized]); + + return { + ...result, + isUnauthorized, + entity: group ? { ...group, relationship: relationship || null } : undefined, + }; +} + +export { useGroupLookup }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupMedia.ts b/src/api/hooks/groups/useGroupMedia.ts new file mode 100644 index 0000000..5c2f6bd --- /dev/null +++ b/src/api/hooks/groups/useGroupMedia.ts @@ -0,0 +1,17 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { normalizeStatus } from 'soapbox/normalizers/index.ts'; +import { toSchema } from 'soapbox/utils/normalizers.ts'; + +const statusSchema = toSchema(normalizeStatus); + +function useGroupMedia(groupId: string) { + const api = useApi(); + + return useEntities([Entities.STATUSES, 'groupMedia', groupId], () => { + return api.get(`/api/v1/timelines/group/${groupId}?only_media=true`); + }, { schema: statusSchema }); +} + +export { useGroupMedia }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupMembers.ts b/src/api/hooks/groups/useGroupMembers.ts new file mode 100644 index 0000000..74a7d5f --- /dev/null +++ b/src/api/hooks/groups/useGroupMembers.ts @@ -0,0 +1,23 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { GroupRoles } from 'soapbox/schemas/group-member.ts'; +import { GroupMember, groupMemberSchema } from 'soapbox/schemas/index.ts'; + +import { useApi } from '../../../hooks/useApi.ts'; + +function useGroupMembers(groupId: string, role: GroupRoles) { + const api = useApi(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_MEMBERSHIPS, groupId, role], + () => api.get(`/api/v1/groups/${groupId}/memberships?role=${role}`), + { schema: groupMemberSchema }, + ); + + return { + ...result, + groupMembers: entities, + }; +} + +export { useGroupMembers }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupMembershipRequests.ts b/src/api/hooks/groups/useGroupMembershipRequests.ts new file mode 100644 index 0000000..073dfb6 --- /dev/null +++ b/src/api/hooks/groups/useGroupMembershipRequests.ts @@ -0,0 +1,47 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useDismissEntity, useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { GroupRoles } from 'soapbox/schemas/group-member.ts'; +import { accountSchema } from 'soapbox/schemas/index.ts'; + +import { useGroupRelationship } from './useGroupRelationship.ts'; + +import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types.ts'; + +function useGroupMembershipRequests(groupId: string) { + const api = useApi(); + const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId]; + + const { groupRelationship: relationship } = useGroupRelationship(groupId); + + const { entities, invalidate, fetchEntities, ...rest } = useEntities( + path, + () => api.get(`/api/v1/groups/${groupId}/membership_requests`), + { + schema: accountSchema, + enabled: relationship?.role === GroupRoles.OWNER || relationship?.role === GroupRoles.ADMIN, + }, + ); + + const { dismissEntity: authorize } = useDismissEntity(path, async (accountId: string) => { + const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`); + invalidate(); + return response; + }); + + const { dismissEntity: reject } = useDismissEntity(path, async (accountId: string) => { + const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`); + invalidate(); + return response; + }); + + return { + accounts: entities, + refetch: fetchEntities, + authorize, + reject, + ...rest, + }; +} + +export { useGroupMembershipRequests }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupMutes.ts b/src/api/hooks/groups/useGroupMutes.ts new file mode 100644 index 0000000..1312019 --- /dev/null +++ b/src/api/hooks/groups/useGroupMutes.ts @@ -0,0 +1,25 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { groupSchema } from 'soapbox/schemas/index.ts'; + +import type { Group } from 'soapbox/schemas/index.ts'; + +function useGroupMutes() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_MUTES], + () => api.get('/api/v1/groups/mutes'), + { schema: groupSchema, enabled: features.groupsMuting }, + ); + + return { + ...result, + mutes: entities, + }; +} + +export { useGroupMutes }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupRelationship.ts b/src/api/hooks/groups/useGroupRelationship.ts new file mode 100644 index 0000000..2423436 --- /dev/null +++ b/src/api/hooks/groups/useGroupRelationship.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas/index.ts'; + +function useGroupRelationship(groupId: string | undefined) { + const api = useApi(); + + const { entity: groupRelationship, ...result } = useEntity( + [Entities.GROUP_RELATIONSHIPS, groupId!], + () => api.get(`/api/v1/groups/relationships?id[]=${groupId}`), + { + enabled: !!groupId, + schema: z.array(groupRelationshipSchema).nonempty().transform(arr => arr[0]), + }, + ); + + return { + groupRelationship, + ...result, + }; +} + +export { useGroupRelationship }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupRelationships.ts b/src/api/hooks/groups/useGroupRelationships.ts new file mode 100644 index 0000000..2d2dd03 --- /dev/null +++ b/src/api/hooks/groups/useGroupRelationships.ts @@ -0,0 +1,26 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useBatchedEntities } from 'soapbox/entity-store/hooks/useBatchedEntities.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; +import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas/index.ts'; + +function useGroupRelationships(listKey: string[], ids: string[]) { + const api = useApi(); + const { isLoggedIn } = useLoggedIn(); + + function fetchGroupRelationships(ids: string[]) { + const q = ids.map((id) => `id[]=${id}`).join('&'); + return api.get(`/api/v1/groups/relationships?${q}`); + } + + const { entityMap: relationships, ...result } = useBatchedEntities( + [Entities.RELATIONSHIPS, ...listKey], + ids, + fetchGroupRelationships, + { schema: groupRelationshipSchema, enabled: isLoggedIn }, + ); + + return { relationships, ...result }; +} + +export { useGroupRelationships }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupSearch.ts b/src/api/hooks/groups/useGroupSearch.ts new file mode 100644 index 0000000..6410d90 --- /dev/null +++ b/src/api/hooks/groups/useGroupSearch.ts @@ -0,0 +1,41 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { groupSchema } from 'soapbox/schemas/index.ts'; + +import { useGroupRelationships } from './useGroupRelationships.ts'; + +import type { Group } from 'soapbox/schemas/index.ts'; + +function useGroupSearch(search: string) { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'discover', 'search', search], + () => api.get('/api/v1/groups/search', { + searchParams: { + q: search, + }, + }), + { enabled: features.groupsDiscovery && !!search, schema: groupSchema }, + ); + + const { relationships } = useGroupRelationships( + ['discover', 'search', search], + entities.map(entity => entity.id), + ); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { useGroupSearch }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupTag.ts b/src/api/hooks/groups/useGroupTag.ts new file mode 100644 index 0000000..7ed7ac6 --- /dev/null +++ b/src/api/hooks/groups/useGroupTag.ts @@ -0,0 +1,21 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { type GroupTag, groupTagSchema } from 'soapbox/schemas/index.ts'; + +function useGroupTag(tagId: string) { + const api = useApi(); + + const { entity: tag, ...result } = useEntity( + [Entities.GROUP_TAGS, tagId], + () => api.get(`/api/v1/tags/${tagId }`), + { schema: groupTagSchema }, + ); + + return { + ...result, + tag, + }; +} + +export { useGroupTag }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupTags.ts b/src/api/hooks/groups/useGroupTags.ts new file mode 100644 index 0000000..3b4846f --- /dev/null +++ b/src/api/hooks/groups/useGroupTags.ts @@ -0,0 +1,23 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { groupTagSchema } from 'soapbox/schemas/index.ts'; + +import type { GroupTag } from 'soapbox/schemas/index.ts'; + +function useGroupTags(groupId: string) { + const api = useApi(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_TAGS, groupId], + () => api.get(`/api/v1/truth/trends/groups/${groupId}/tags`), + { schema: groupTagSchema }, + ); + + return { + ...result, + tags: entities, + }; +} + +export { useGroupTags }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroupValidation.ts b/src/api/hooks/groups/useGroupValidation.ts new file mode 100644 index 0000000..40b51c9 --- /dev/null +++ b/src/api/hooks/groups/useGroupValidation.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; + +import { HTTPError } from 'soapbox/api/HTTPError.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; + +type Validation = { + error: string; + message: string; +} + +const ValidationKeys = { + validation: (name: string) => ['group', 'validation', name] as const, +}; + +function useGroupValidation(name: string = '') { + const api = useApi(); + const features = useFeatures(); + + const getValidation = async () => { + try { + const response = await api.get('/api/v1/groups/validate', { searchParams: { name } }); + return response.json(); + } catch (e) { + if (e instanceof HTTPError && e.response.status === 422) { + return e.response.json(); + } + + throw e; + } + }; + + const queryInfo = useQuery({ + queryKey: ValidationKeys.validation(name), + queryFn: getValidation, + enabled: features.groupsValidation && !!name, + }); + + return { + ...queryInfo, + data: { + ...queryInfo.data, + isValid: !queryInfo.data?.error, + }, + }; +} + +export { useGroupValidation }; \ No newline at end of file diff --git a/src/api/hooks/groups/useGroups.ts b/src/api/hooks/groups/useGroups.ts new file mode 100644 index 0000000..4123342 --- /dev/null +++ b/src/api/hooks/groups/useGroups.ts @@ -0,0 +1,34 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { groupSchema, type Group } from 'soapbox/schemas/group.ts'; + +import { useGroupRelationships } from './useGroupRelationships.ts'; + +function useGroups(q: string = '') { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'search', q], + () => api.get('/api/v1/groups', { searchParams: { q } }), + { enabled: features.groups, schema: groupSchema }, + ); + const { relationships } = useGroupRelationships( + ['search', q], + entities.map(entity => entity.id), + ); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { useGroups }; diff --git a/src/api/hooks/groups/useGroupsFromTag.ts b/src/api/hooks/groups/useGroupsFromTag.ts new file mode 100644 index 0000000..f3229b7 --- /dev/null +++ b/src/api/hooks/groups/useGroupsFromTag.ts @@ -0,0 +1,39 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { groupSchema } from 'soapbox/schemas/index.ts'; + +import { useGroupRelationships } from './useGroupRelationships.ts'; + +import type { Group } from 'soapbox/schemas/index.ts'; + +function useGroupsFromTag(tagId: string) { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'tags', tagId], + () => api.get(`/api/v1/tags/${tagId}/groups`), + { + schema: groupSchema, + enabled: features.groupsDiscovery, + }, + ); + const { relationships } = useGroupRelationships( + ['tags', tagId], + entities.map(entity => entity.id), + ); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { useGroupsFromTag }; \ No newline at end of file diff --git a/src/api/hooks/groups/useJoinGroup.ts b/src/api/hooks/groups/useJoinGroup.ts new file mode 100644 index 0000000..4d1cf23 --- /dev/null +++ b/src/api/hooks/groups/useJoinGroup.ts @@ -0,0 +1,25 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; +import { groupRelationshipSchema } from 'soapbox/schemas/index.ts'; + +import { useGroups } from './useGroups.ts'; + +import type { Group, GroupRelationship } from 'soapbox/schemas/index.ts'; + +function useJoinGroup(group: Group) { + const { invalidate } = useGroups(); + + const { createEntity, isSubmitting } = useEntityActions( + [Entities.GROUP_RELATIONSHIPS, group.id], + { post: `/api/v1/groups/${group.id}/join` }, + { schema: groupRelationshipSchema }, + ); + + return { + mutate: createEntity, + isSubmitting, + invalidate, + }; +} + +export { useJoinGroup }; \ No newline at end of file diff --git a/src/api/hooks/groups/useLeaveGroup.ts b/src/api/hooks/groups/useLeaveGroup.ts new file mode 100644 index 0000000..5ff0095 --- /dev/null +++ b/src/api/hooks/groups/useLeaveGroup.ts @@ -0,0 +1,25 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; +import { groupRelationshipSchema } from 'soapbox/schemas/index.ts'; + +import { useGroups } from './useGroups.ts'; + +import type { Group, GroupRelationship } from 'soapbox/schemas/index.ts'; + +function useLeaveGroup(group: Group) { + const { invalidate } = useGroups(); + + const { createEntity, isSubmitting } = useEntityActions( + [Entities.GROUP_RELATIONSHIPS, group.id], + { post: `/api/v1/groups/${group.id}/leave` }, + { schema: groupRelationshipSchema }, + ); + + return { + mutate: createEntity, + isSubmitting, + invalidate, + }; +} + +export { useLeaveGroup }; diff --git a/src/api/hooks/groups/useMuteGroup.ts b/src/api/hooks/groups/useMuteGroup.ts new file mode 100644 index 0000000..1bef33e --- /dev/null +++ b/src/api/hooks/groups/useMuteGroup.ts @@ -0,0 +1,18 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; +import { type Group, groupRelationshipSchema } from 'soapbox/schemas/index.ts'; + +function useMuteGroup(group?: Group) { + const { createEntity, isSubmitting } = useEntityActions( + [Entities.GROUP_RELATIONSHIPS, group?.id as string], + { post: `/api/v1/groups/${group?.id}/mute` }, + { schema: groupRelationshipSchema }, + ); + + return { + mutate: createEntity, + isSubmitting, + }; +} + +export { useMuteGroup }; \ No newline at end of file diff --git a/src/api/hooks/groups/usePendingGroups.ts b/src/api/hooks/groups/usePendingGroups.ts new file mode 100644 index 0000000..5d4f301 --- /dev/null +++ b/src/api/hooks/groups/usePendingGroups.ts @@ -0,0 +1,32 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; +import { Group, groupSchema } from 'soapbox/schemas/index.ts'; + +function usePendingGroups() { + const api = useApi(); + const { account } = useOwnAccount(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, account?.id!, 'pending'], + () => api.get('/api/v1/groups', { + searchParams: { + pending: true, + }, + }), + { + schema: groupSchema, + enabled: !!account && features.groupsPending, + }, + ); + + return { + ...result, + groups: entities, + }; +} + +export { usePendingGroups }; \ No newline at end of file diff --git a/src/api/hooks/groups/usePopularGroups.ts b/src/api/hooks/groups/usePopularGroups.ts new file mode 100644 index 0000000..57dde7c --- /dev/null +++ b/src/api/hooks/groups/usePopularGroups.ts @@ -0,0 +1,36 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { Group, groupSchema } from 'soapbox/schemas/index.ts'; + +import { useApi } from '../../../hooks/useApi.ts'; +import { useFeatures } from '../../../hooks/useFeatures.ts'; + +import { useGroupRelationships } from './useGroupRelationships.ts'; + +function usePopularGroups() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'popular'], + () => api.get('/api/v1/truth/trends/groups'), + { + schema: groupSchema, + enabled: features.groupsDiscovery, + }, + ); + + const { relationships } = useGroupRelationships(['popular'], entities.map(entity => entity.id)); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { usePopularGroups }; \ No newline at end of file diff --git a/src/api/hooks/groups/usePopularTags.ts b/src/api/hooks/groups/usePopularTags.ts new file mode 100644 index 0000000..a3d4fb6 --- /dev/null +++ b/src/api/hooks/groups/usePopularTags.ts @@ -0,0 +1,26 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { type GroupTag, groupTagSchema } from 'soapbox/schemas/index.ts'; + +function usePopularTags() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_TAGS], + () => api.get('/api/v1/groups/tags'), + { + schema: groupTagSchema, + enabled: features.groupsDiscovery, + }, + ); + + return { + ...result, + tags: entities, + }; +} + +export { usePopularTags }; \ No newline at end of file diff --git a/src/api/hooks/groups/usePromoteGroupMember.ts b/src/api/hooks/groups/usePromoteGroupMember.ts new file mode 100644 index 0000000..36b09a6 --- /dev/null +++ b/src/api/hooks/groups/usePromoteGroupMember.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; +import { groupMemberSchema } from 'soapbox/schemas/index.ts'; + +import type { Group, GroupMember } from 'soapbox/schemas/index.ts'; + +function usePromoteGroupMember(group: Group, groupMember: GroupMember) { + const { createEntity } = useEntityActions( + [Entities.GROUP_MEMBERSHIPS, groupMember.account.id], + { post: `/api/v1/groups/${group.id}/promote` }, + { schema: z.array(groupMemberSchema).transform((arr) => arr[0]) }, + ); + + return createEntity; +} + +export { usePromoteGroupMember }; \ No newline at end of file diff --git a/src/api/hooks/groups/useSuggestedGroups.ts b/src/api/hooks/groups/useSuggestedGroups.ts new file mode 100644 index 0000000..de0a645 --- /dev/null +++ b/src/api/hooks/groups/useSuggestedGroups.ts @@ -0,0 +1,35 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { type Group, groupSchema } from 'soapbox/schemas/index.ts'; + +import { useGroupRelationships } from './useGroupRelationships.ts'; + +function useSuggestedGroups() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'suggested'], + () => api.get('/api/v1/truth/suggestions/groups'), + { + schema: groupSchema, + enabled: features.groupsDiscovery, + }, + ); + + const { relationships } = useGroupRelationships(['suggested'], entities.map(entity => entity.id)); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { useSuggestedGroups }; \ No newline at end of file diff --git a/src/api/hooks/groups/useUnmuteGroup.ts b/src/api/hooks/groups/useUnmuteGroup.ts new file mode 100644 index 0000000..9402b4a --- /dev/null +++ b/src/api/hooks/groups/useUnmuteGroup.ts @@ -0,0 +1,18 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; +import { type Group, groupRelationshipSchema } from 'soapbox/schemas/index.ts'; + +function useUnmuteGroup(group?: Group) { + const { createEntity, isSubmitting } = useEntityActions( + [Entities.GROUP_RELATIONSHIPS, group?.id as string], + { post: `/api/v1/groups/${group?.id}/unmute` }, + { schema: groupRelationshipSchema }, + ); + + return { + mutate: createEntity, + isSubmitting, + }; +} + +export { useUnmuteGroup }; \ No newline at end of file diff --git a/src/api/hooks/groups/useUpdateGroup.ts b/src/api/hooks/groups/useUpdateGroup.ts new file mode 100644 index 0000000..da660e7 --- /dev/null +++ b/src/api/hooks/groups/useUpdateGroup.ts @@ -0,0 +1,33 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useCreateEntity } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { groupSchema } from 'soapbox/schemas/index.ts'; + +interface UpdateGroupParams { + display_name?: string; + note?: string; + avatar?: File | ''; + header?: File | ''; + group_visibility?: string; + discoverable?: boolean; + tags?: string[]; +} + +function useUpdateGroup(groupId: string) { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS], (params: UpdateGroupParams) => { + return api.put(`/api/v1/groups/${groupId}`, params, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, { schema: groupSchema }); + + return { + updateGroup: createEntity, + ...rest, + }; +} + +export { useUpdateGroup }; diff --git a/src/api/hooks/groups/useUpdateGroupTag.ts b/src/api/hooks/groups/useUpdateGroupTag.ts new file mode 100644 index 0000000..203b4f4 --- /dev/null +++ b/src/api/hooks/groups/useUpdateGroupTag.ts @@ -0,0 +1,18 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntityActions } from 'soapbox/entity-store/hooks/index.ts'; + +import type { GroupTag } from 'soapbox/schemas/index.ts'; + +function useUpdateGroupTag(groupId: string, tagId: string) { + const { updateEntity, ...rest } = useEntityActions( + [Entities.GROUP_TAGS, groupId, tagId], + { patch: `/api/v1/groups/${groupId}/tags/${tagId}` }, + ); + + return { + updateGroupTag: updateEntity, + ...rest, + }; +} + +export { useUpdateGroupTag }; \ No newline at end of file diff --git a/src/api/hooks/index.ts b/src/api/hooks/index.ts new file mode 100644 index 0000000..8a958fd --- /dev/null +++ b/src/api/hooks/index.ts @@ -0,0 +1,60 @@ +// Accounts +export { useAccount } from './accounts/useAccount.ts'; +export { useAccountLookup } from './accounts/useAccountLookup.ts'; +export { + useBlocks, + useMutes, + useFollowers, + useFollowing, +} from './accounts/useAccountList.ts'; +export { useFollow } from './accounts/useFollow.ts'; +export { useRelationships } from './accounts/useRelationships.ts'; +export { usePatronUser } from './accounts/usePatronUser.ts'; + +// Groups +export { useBlockGroupMember } from './groups/useBlockGroupMember.ts'; +export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest.ts'; +export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup.ts'; +export { useDeleteGroup } from './groups/useDeleteGroup.ts'; +export { useDemoteGroupMember } from './groups/useDemoteGroupMember.ts'; +export { useGroup } from './groups/useGroup.ts'; +export { useGroupLookup } from './groups/useGroupLookup.ts'; +export { useGroupMedia } from './groups/useGroupMedia.ts'; +export { useGroupMembers } from './groups/useGroupMembers.ts'; +export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests.ts'; +export { useGroupMutes } from './groups/useGroupMutes.ts'; +export { useGroupRelationship } from './groups/useGroupRelationship.ts'; +export { useGroupRelationships } from './groups/useGroupRelationships.ts'; +export { useGroupSearch } from './groups/useGroupSearch.ts'; +export { useGroupTag } from './groups/useGroupTag.ts'; +export { useGroupTags } from './groups/useGroupTags.ts'; +export { useGroupValidation } from './groups/useGroupValidation.ts'; +export { useGroups } from './groups/useGroups.ts'; +export { useGroupsFromTag } from './groups/useGroupsFromTag.ts'; +export { useJoinGroup } from './groups/useJoinGroup.ts'; +export { useMuteGroup } from './groups/useMuteGroup.ts'; +export { useLeaveGroup } from './groups/useLeaveGroup.ts'; +export { usePendingGroups } from './groups/usePendingGroups.ts'; +export { usePopularGroups } from './groups/usePopularGroups.ts'; +export { usePopularTags } from './groups/usePopularTags.ts'; +export { usePromoteGroupMember } from './groups/usePromoteGroupMember.ts'; +export { useSuggestedGroups } from './groups/useSuggestedGroups.ts'; +export { useUnmuteGroup } from './groups/useUnmuteGroup.ts'; +export { useUpdateGroup } from './groups/useUpdateGroup.ts'; +export { useUpdateGroupTag } from './groups/useUpdateGroupTag.ts'; + +// Statuses +export { useBookmarks } from './statuses/useBookmarks.ts'; +export { useBookmark } from './statuses/useBookmark.ts'; +export { useFavourite } from './statuses/useFavourite.ts'; +export { useReaction } from './statuses/useReaction.ts'; + +// Streaming +export { useUserStream } from './streaming/useUserStream.ts'; +export { useCommunityStream } from './streaming/useCommunityStream.ts'; +export { usePublicStream } from './streaming/usePublicStream.ts'; +export { useDirectStream } from './streaming/useDirectStream.ts'; +export { useHashtagStream } from './streaming/useHashtagStream.ts'; +export { useListStream } from './streaming/useListStream.ts'; +export { useGroupStream } from './streaming/useGroupStream.ts'; +export { useRemoteStream } from './streaming/useRemoteStream.ts'; diff --git a/src/api/hooks/instance/useInstanceV1.ts b/src/api/hooks/instance/useInstanceV1.ts new file mode 100644 index 0000000..1aef6cd --- /dev/null +++ b/src/api/hooks/instance/useInstanceV1.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { InstanceV1, instanceV1Schema } from 'soapbox/schemas/instance.ts'; + +interface Opts { + /** The base URL of the instance. */ + baseUrl?: string; + enabled?: boolean; + retryOnMount?: boolean; + staleTime?: number; +} + +/** Get the Instance for the current backend. */ +export function useInstanceV1(opts: Opts = {}) { + const api = useApi(); + + const { baseUrl } = opts; + + const { data: instance, ...rest } = useQuery({ + queryKey: ['instance', baseUrl ?? api.baseUrl, 'v1'], + queryFn: async () => { + const response = await api.get('/api/v1/instance'); + const data = await response.json(); + return instanceV1Schema.parse(data); + }, + ...opts, + }); + + return { instance, ...rest }; +} diff --git a/src/api/hooks/instance/useInstanceV2.ts b/src/api/hooks/instance/useInstanceV2.ts new file mode 100644 index 0000000..8c05183 --- /dev/null +++ b/src/api/hooks/instance/useInstanceV2.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { InstanceV2, instanceV2Schema } from 'soapbox/schemas/instance.ts'; + +interface Opts { + /** The base URL of the instance. */ + baseUrl?: string; + enabled?: boolean; + retryOnMount?: boolean; + staleTime?: number; +} + +/** Get the Instance for the current backend. */ +export function useInstanceV2(opts: Opts = {}) { + const api = useApi(); + + const { baseUrl } = opts; + + const { data: instance, ...rest } = useQuery({ + queryKey: ['instance', baseUrl ?? api.baseUrl, 'v2'], + queryFn: async () => { + const response = await api.get('/api/v2/instance'); + const data = await response.json(); + return instanceV2Schema.parse(data); + }, + ...opts, + }); + + return { instance, ...rest }; +} diff --git a/src/api/hooks/statuses/useBookmark.ts b/src/api/hooks/statuses/useBookmark.ts new file mode 100644 index 0000000..a64da3d --- /dev/null +++ b/src/api/hooks/statuses/useBookmark.ts @@ -0,0 +1,94 @@ +import { importEntities } from 'soapbox/entity-store/actions.ts'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useDismissEntity, useTransaction } from 'soapbox/entity-store/hooks/index.ts'; +import { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; +import { statusSchema } from 'soapbox/schemas/index.ts'; + +/** + * Bookmark and undo a bookmark, with optimistic update. + * + * https://docs.joinmastodon.org/methods/statuses/#bookmark + * POST /api/v1/statuses/:id/bookmark + * + * https://docs.joinmastodon.org/methods/statuses/#unbookmark + * POST /api/v1/statuses/:id/unbookmark + */ +function useBookmark() { + const api = useApi(); + const dispatch = useAppDispatch(); + const { isLoggedIn } = useLoggedIn(); + const { transaction } = useTransaction(); + + type Success = { success: boolean } + + const path: ExpandedEntitiesPath = [Entities.STATUSES, 'bookmarks']; + + const { dismissEntity } = useDismissEntity(path, async (statusId: string) => { + const response = await api.post(`/api/v1/statuses/${statusId}/unbookmark`); + return response; + }); + + function bookmarkEffect(statusId: string) { + transaction({ + Statuses: { + [statusId]: (status) => ({ + ...status, + bookmarked: true, + }), + }, + }); + } + + function unbookmarkEffect(statusId: string) { + transaction({ + Statuses: { + [statusId]: (status) => ({ + ...status, + bookmarked: false, + }), + }, + }); + } + + async function bookmark(statusId: string): Promise { + if (!isLoggedIn) return { success: false }; + bookmarkEffect(statusId); + + try { + const response = await api.post(`/api/v1/statuses/${statusId}/bookmark`); + const result = statusSchema.parse(await response.json()); + if (result) { + dispatch(importEntities([result], Entities.STATUSES, 'bookmarks', 'start')); + } + return { success: true }; + } catch (e) { + unbookmarkEffect(statusId); + return { success: false }; + } + } + + async function unbookmark(statusId: string): Promise { + if (!isLoggedIn) return { success: false }; + unbookmarkEffect(statusId); + + try { + await dismissEntity(statusId); + return { success: true }; + } catch (e) { + bookmarkEffect(statusId); + return { success: false }; + } + } + + return { + bookmark, + unbookmark, + bookmarkEffect, + unbookmarkEffect, + }; +} + +export { useBookmark }; \ No newline at end of file diff --git a/src/api/hooks/statuses/useBookmarks.ts b/src/api/hooks/statuses/useBookmarks.ts new file mode 100644 index 0000000..8235d91 --- /dev/null +++ b/src/api/hooks/statuses/useBookmarks.ts @@ -0,0 +1,31 @@ +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useEntities } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { Status as StatusEntity, statusSchema } from 'soapbox/schemas/index.ts'; + +/** + * Get all the statuses the user has bookmarked. + * https://docs.joinmastodon.org/methods/bookmarks/#get + * GET /api/v1/bookmarks + * TODO: add 'limit' + */ +function useBookmarks() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.STATUSES, 'bookmarks'], + () => api.get('/api/v1/bookmarks'), + { enabled: features.bookmarks, schema: statusSchema }, + ); + + const bookmarks = entities; + + return { + ...result, + bookmarks, + }; +} + +export { useBookmarks }; \ No newline at end of file diff --git a/src/api/hooks/statuses/useFavourite.ts b/src/api/hooks/statuses/useFavourite.ts new file mode 100644 index 0000000..f406ad7 --- /dev/null +++ b/src/api/hooks/statuses/useFavourite.ts @@ -0,0 +1,61 @@ +import { favourite as favouriteAction, unfavourite as unfavouriteAction, toggleFavourite as toggleFavouriteAction } from 'soapbox/actions/interactions.ts'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { selectEntity } from 'soapbox/entity-store/selectors.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useGetState } from 'soapbox/hooks/useGetState.ts'; +import { normalizeStatus } from 'soapbox/normalizers/index.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; + +import type { Status as LegacyStatus } from 'soapbox/types/entities.ts'; + +export function useFavourite() { + const getState = useGetState(); + const dispatch = useAppDispatch(); + + const favourite = (statusId: string) => { + let status: undefined|LegacyStatus|StatusEntity = getState().statuses.get(statusId); + + if (status) { + dispatch(favouriteAction(status)); + return; + } + + status = selectEntity(getState(), Entities.STATUSES, statusId); + if (status) { + dispatch(favouriteAction(normalizeStatus(status) as LegacyStatus)); + return; + } + }; + + const unfavourite = (statusId: string) => { + let status: undefined|LegacyStatus|StatusEntity = getState().statuses.get(statusId); + + if (status) { + dispatch(unfavouriteAction(status)); + return; + } + + status = selectEntity(getState(), Entities.STATUSES, statusId); + if (status) { + dispatch(unfavouriteAction(normalizeStatus(status) as LegacyStatus)); + return; + } + }; + + const toggleFavourite = (statusId: string) => { + let status: undefined|LegacyStatus|StatusEntity = getState().statuses.get(statusId); + + if (status) { + dispatch(toggleFavouriteAction(status)); + return; + } + + status = selectEntity(getState(), Entities.STATUSES, statusId); + if (status) { + dispatch(toggleFavouriteAction(normalizeStatus(status) as LegacyStatus)); + return; + } + }; + + return { favourite, unfavourite, toggleFavourite }; +} diff --git a/src/api/hooks/statuses/useReaction.ts b/src/api/hooks/statuses/useReaction.ts new file mode 100644 index 0000000..139ea97 --- /dev/null +++ b/src/api/hooks/statuses/useReaction.ts @@ -0,0 +1,125 @@ +import { useFavourite } from 'soapbox/api/hooks/index.ts'; +import { importEntities } from 'soapbox/entity-store/actions.ts'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { useTransaction } from 'soapbox/entity-store/hooks/index.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useGetState } from 'soapbox/hooks/useGetState.ts'; +import { EmojiReaction, Status as StatusEntity, statusSchema } from 'soapbox/schemas/index.ts'; +import { isLoggedIn } from 'soapbox/utils/auth.ts'; + +export function useReaction() { + const api = useApi(); + const getState = useGetState(); + const dispatch = useAppDispatch(); + const { transaction } = useTransaction(); + const { favourite, unfavourite } = useFavourite(); + + function emojiReactEffect(statusId: string, emoji: string) { + transaction({ + Statuses: { + [statusId]: (status) => { + // Get the emoji already present in the status reactions, if it exists. + const currentEmoji = status.reactions.find((value) => value.name === emoji); + // If the emoji doesn't exist, append it to the array and return. + if (!currentEmoji) { + return ({ + ...status, + reactions: [...status.reactions, { me: true, name: emoji, count: 1 }], + }); + } + // if the emoji exists in the status reactions, then just update the array and return. + return ({ + ...status, + reactions: status.reactions.map((val) => { + if (val.name === emoji) { + return { ...val, me: true, count: (val.count ?? 0) + 1 }; + } + return val; + }), + }); + }, + }, + }); + } + + function unemojiReactEffect(statusId: string, emoji: string) { + transaction({ + Statuses: { + [statusId]: (status) => { + return ({ + ...status, + reactions: status.reactions.map((val) => { + if (val.name === emoji && val.me === true) { + return { ...val, me: false, count: (val.count ?? 1) - 1 }; + } + return val; + }), + }); + }, + }, + }); + } + + const emojiReact = async (status: StatusEntity, emoji: string) => { // TODO: add custom emoji support + if (!isLoggedIn(getState)) return; + emojiReactEffect(status.id, emoji); + + try { + const response = await api.put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`); + const result = statusSchema.parse(await response.json()); + if (result) { + dispatch(importEntities([result], Entities.STATUSES)); + } + } catch (e) { + unemojiReactEffect(status.id, emoji); + } + }; + + const unEmojiReact = async (status: StatusEntity, emoji: string) => { + if (!isLoggedIn(getState)) return; + unemojiReactEffect(status.id, emoji); + + try { + const response = await api.delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`); + const result = statusSchema.parse(await response.json()); + if (result) { + dispatch(importEntities([result], Entities.STATUSES)); + } + } catch (e) { + emojiReactEffect(status.id, emoji); + } + }; + + const simpleEmojiReact = async (status: StatusEntity, emoji: string) => { + const emojiReacts: readonly EmojiReaction[] = status.reactions; + + // Undo a standard favourite + if (emoji === '👍' && status.favourited) return unfavourite(status.id); + + // Undo an emoji reaction + const undo = emojiReacts.filter(e => e.me === true && e.name === emoji).length > 0; + if (undo) return unEmojiReact(status, emoji); + + try { + await Promise.all([ + ...emojiReacts + .filter((emojiReact) => emojiReact.me === true) + // Remove all existing emoji reactions by the user before adding a new one. If 'emoji' is an 'apple' and the status already has 'banana' as an emoji, then remove 'banana' + .map(emojiReact => unEmojiReact(status, emojiReact.name)), + // Remove existing standard like, if it exists + status.favourited && unfavourite(status.id), + ]); + + if (emoji === '👍') { + favourite(status.id); + } else { + emojiReact(status, emoji); + } + } catch (err) { + console.error(err); + } + }; + + return { emojiReact, unEmojiReact, simpleEmojiReact }; +} diff --git a/src/api/hooks/streaming/useCommunityStream.ts b/src/api/hooks/streaming/useCommunityStream.ts new file mode 100644 index 0000000..77b0a0c --- /dev/null +++ b/src/api/hooks/streaming/useCommunityStream.ts @@ -0,0 +1,17 @@ +import { useTimelineStream } from './useTimelineStream.ts'; + +interface UseCommunityStreamOpts { + onlyMedia?: boolean; + enabled?: boolean; +} + +function useCommunityStream({ onlyMedia, enabled }: UseCommunityStreamOpts = {}) { + return useTimelineStream( + `community${onlyMedia ? ':media' : ''}`, + `public:local${onlyMedia ? ':media' : ''}`, + undefined, + { enabled }, + ); +} + +export { useCommunityStream }; \ No newline at end of file diff --git a/src/api/hooks/streaming/useDirectStream.ts b/src/api/hooks/streaming/useDirectStream.ts new file mode 100644 index 0000000..dea64d4 --- /dev/null +++ b/src/api/hooks/streaming/useDirectStream.ts @@ -0,0 +1,16 @@ +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; + +import { useTimelineStream } from './useTimelineStream.ts'; + +function useDirectStream() { + const { isLoggedIn } = useLoggedIn(); + + return useTimelineStream( + 'direct', + 'direct', + null, + { enabled: isLoggedIn }, + ); +} + +export { useDirectStream }; \ No newline at end of file diff --git a/src/api/hooks/streaming/useGroupStream.ts b/src/api/hooks/streaming/useGroupStream.ts new file mode 100644 index 0000000..7969537 --- /dev/null +++ b/src/api/hooks/streaming/useGroupStream.ts @@ -0,0 +1,10 @@ +import { useTimelineStream } from './useTimelineStream.ts'; + +function useGroupStream(groupId: string) { + return useTimelineStream( + `group:${groupId}`, + `group&group=${groupId}`, + ); +} + +export { useGroupStream }; \ No newline at end of file diff --git a/src/api/hooks/streaming/useHashtagStream.ts b/src/api/hooks/streaming/useHashtagStream.ts new file mode 100644 index 0000000..788b3f3 --- /dev/null +++ b/src/api/hooks/streaming/useHashtagStream.ts @@ -0,0 +1,10 @@ +import { useTimelineStream } from './useTimelineStream.ts'; + +function useHashtagStream(tag: string) { + return useTimelineStream( + `hashtag:${tag}`, + `hashtag&tag=${tag}`, + ); +} + +export { useHashtagStream }; \ No newline at end of file diff --git a/src/api/hooks/streaming/useListStream.ts b/src/api/hooks/streaming/useListStream.ts new file mode 100644 index 0000000..8cb02bc --- /dev/null +++ b/src/api/hooks/streaming/useListStream.ts @@ -0,0 +1,16 @@ +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; + +import { useTimelineStream } from './useTimelineStream.ts'; + +function useListStream(listId: string) { + const { isLoggedIn } = useLoggedIn(); + + return useTimelineStream( + `list:${listId}`, + `list&list=${listId}`, + null, + { enabled: isLoggedIn }, + ); +} + +export { useListStream }; \ No newline at end of file diff --git a/src/api/hooks/streaming/usePublicStream.ts b/src/api/hooks/streaming/usePublicStream.ts new file mode 100644 index 0000000..356ed07 --- /dev/null +++ b/src/api/hooks/streaming/usePublicStream.ts @@ -0,0 +1,17 @@ +import { useTimelineStream } from './useTimelineStream.ts'; + +interface UsePublicStreamOpts { + onlyMedia?: boolean; + language?: string; +} + +function usePublicStream({ onlyMedia, language }: UsePublicStreamOpts = {}) { + return useTimelineStream( + `public${onlyMedia ? ':media' : ''}`, + `public${onlyMedia ? ':media' : ''}`, + null, + { enabled: !language }, // TODO: support language streaming + ); +} + +export { usePublicStream }; \ No newline at end of file diff --git a/src/api/hooks/streaming/useRemoteStream.ts b/src/api/hooks/streaming/useRemoteStream.ts new file mode 100644 index 0000000..53fa363 --- /dev/null +++ b/src/api/hooks/streaming/useRemoteStream.ts @@ -0,0 +1,15 @@ +import { useTimelineStream } from './useTimelineStream.ts'; + +interface UseRemoteStreamOpts { + instance: string; + onlyMedia?: boolean; +} + +function useRemoteStream({ instance, onlyMedia }: UseRemoteStreamOpts) { + return useTimelineStream( + `remote${onlyMedia ? ':media' : ''}:${instance}`, + `public:remote${onlyMedia ? ':media' : ''}&instance=${instance}`, + ); +} + +export { useRemoteStream }; \ No newline at end of file diff --git a/src/api/hooks/streaming/useTimelineStream.ts b/src/api/hooks/streaming/useTimelineStream.ts new file mode 100644 index 0000000..c47e2c5 --- /dev/null +++ b/src/api/hooks/streaming/useTimelineStream.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react'; + +import { connectTimelineStream } from 'soapbox/actions/streaming.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { useInstance } from 'soapbox/hooks/useInstance.ts'; +import { getAccessToken } from 'soapbox/utils/auth.ts'; + +function useTimelineStream(...args: Parameters) { + // TODO: get rid of streaming.ts and move the actual opts here. + const [timelineId, path] = args; + const { enabled = true } = args[3] ?? {}; + + const dispatch = useAppDispatch(); + const { instance } = useInstance(); + const stream = useRef<(() => void) | null>(null); + + const accessToken = useAppSelector(getAccessToken); + const streamingUrl = instance.configuration.urls.streaming; + + const connect = () => { + if (enabled && streamingUrl && !stream.current) { + stream.current = dispatch(connectTimelineStream(...args)); + } + }; + + const disconnect = () => { + if (stream.current) { + stream.current(); + stream.current = null; + } + }; + + useEffect(() => { + connect(); + return disconnect; + }, [accessToken, streamingUrl, timelineId, path, enabled]); + + return { + disconnect, + }; +} + +export { useTimelineStream }; \ No newline at end of file diff --git a/src/api/hooks/streaming/useUserStream.ts b/src/api/hooks/streaming/useUserStream.ts new file mode 100644 index 0000000..2736761 --- /dev/null +++ b/src/api/hooks/streaming/useUserStream.ts @@ -0,0 +1,18 @@ +import { useStatContext } from 'soapbox/contexts/stat-context.tsx'; +import { useLoggedIn } from 'soapbox/hooks/useLoggedIn.ts'; + +import { useTimelineStream } from './useTimelineStream.ts'; + +function useUserStream() { + const { isLoggedIn } = useLoggedIn(); + const statContext = useStatContext(); + + return useTimelineStream( + 'home', + 'user', + null, + { statContext, enabled: isLoggedIn }, + ); +} + +export { useUserStream }; \ No newline at end of file diff --git a/src/api/hooks/useCustomEmojis.ts b/src/api/hooks/useCustomEmojis.ts new file mode 100644 index 0000000..0104466 --- /dev/null +++ b/src/api/hooks/useCustomEmojis.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import { autosuggestPopulate } from 'soapbox/features/emoji/search.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { CustomEmoji, customEmojiSchema } from 'soapbox/schemas/custom-emoji.ts'; +import { filteredArray } from 'soapbox/schemas/utils.ts'; + +/** Get the Instance for the current backend. */ +export function useCustomEmojis() { + const api = useApi(); + + const { data: customEmojis = [], ...rest } = useQuery({ + queryKey: ['customEmojis', api.baseUrl], + queryFn: async () => { + const response = await api.get('/api/v1/custom_emojis'); + const data = await response.json(); + const customEmojis = filteredArray(customEmojiSchema).parse(data); + + // Add custom emojis to the search index. + autosuggestPopulate(customEmojis); + + return customEmojis; + }, + placeholderData: [], + retryOnMount: false, + }); + + return { customEmojis, ...rest }; +} diff --git a/src/api/hooks/zap-split/useZapSplit.ts b/src/api/hooks/zap-split/useZapSplit.ts new file mode 100644 index 0000000..773932d --- /dev/null +++ b/src/api/hooks/zap-split/useZapSplit.ts @@ -0,0 +1,97 @@ +import { useState, useEffect } from 'react'; + +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { baseZapAccountSchema, type ZapSplitData } from 'soapbox/schemas/zap-split.ts'; + +import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities.ts'; + +interface SplitValue { + id: string; + amountSplit: number; +} + +/** +* Custom hook to handle the logic for zap split calculations. +* +* This hook fetches zap split data from the server and calculates the amount to be received +* by the main account and the split amounts for other associated accounts. +* +* @param {StatusEntity | undefined} status - The current status entity. +* @param {AccountEntity} account - The account for which the zap split calculation is done. +* +* @returns {Object} An object containing the zap split arrays, zap split data, and a function to calculate the received amount. +* +* @property {ZapSplitData[]} zapArrays - Array of zap split data returned from the API. +* @property {Object} zapSplitData - Contains the total split amount, amount to receive, and individual split values. +* @property {Function} receiveAmount - A function to calculate the zap amount based on the split configuration. +*/ +const useZapSplit = (status: StatusEntity | undefined, account: AccountEntity) => { + const api = useApi(); + const [zapArrays, setZapArrays] = useState([]); + const [zapSplitData, setZapSplitData] = useState<{splitAmount: number; receiveAmount: number; splitValues: SplitValue[]}>({ splitAmount: Number(), receiveAmount: Number(), splitValues: [] }); + + const fetchZapSplit = (id: string) => api.get(`/api/v1/ditto/${id}/zap_splits`); + + const loadZapSplitData = async () => { + if (status) { + const response = await fetchZapSplit(status.id); + const data: ZapSplitData[] = await response.json(); + if (data) { + const normalizedData = data.map((dataSplit) => baseZapAccountSchema.parse(dataSplit)); + setZapArrays(normalizedData); + } + } + }; + + /** + * Calculates and updates the zap amount that the main account will receive + * and the split amounts for other accounts. + * + * @param {number} zapAmount - The total amount of zaps to be split. + */ + const receiveAmount = (zapAmount: number) => { + if (zapArrays.length > 0) { + const zapAmountPrincipal = zapArrays.find((zapSplit: ZapSplitData) => zapSplit.account.id === account.id); + const formattedZapAmountPrincipal = { + account: zapAmountPrincipal?.account, + message: zapAmountPrincipal?.message, + weight: zapArrays.filter((zapSplit: ZapSplitData) => zapSplit.account.id === account.id).reduce((acc:number, zapData: ZapSplitData) => acc + zapData.weight, 0), + }; + const zapAmountOthers = zapArrays.filter((zapSplit: ZapSplitData) => zapSplit.account.id !== account.id); + + const totalWeightSplit = zapAmountOthers.reduce((e: number, b: ZapSplitData) => e + b.weight, 0); + const totalWeight = zapArrays.reduce((e: number, b: ZapSplitData) => e + b.weight, 0); + + if (zapAmountPrincipal) { + const receiveZapAmount = Math.floor(formattedZapAmountPrincipal.weight * (zapAmount / totalWeight)); + const splitResult = zapAmount - receiveZapAmount; + + let totalRoundedSplit = 0; + const values = zapAmountOthers.map((zapData) => { + const result = Math.floor(zapData.weight * (splitResult / totalWeightSplit)); + totalRoundedSplit += result; + return { id: zapData.account.id, amountSplit: result }; + }); + + const difference = splitResult - totalRoundedSplit; + + if (difference !== 0 && values.length > 0) { + values[values.length - 1].amountSplit += difference; + } + + if (zapSplitData.receiveAmount !== receiveZapAmount || zapSplitData.splitAmount !== splitResult) { + setZapSplitData({ splitAmount: splitResult, receiveAmount: receiveZapAmount, splitValues: values }); + } + } + } + }; + + useEffect(() => { + loadZapSplitData(); + }, [status]); + + return { zapArrays, zapSplitData, receiveAmount }; +}; + +export default useZapSplit; +export type { SplitValue }; \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..1630a3d --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,39 @@ +import { createSelector } from 'reselect'; + +import { MastodonClient } from 'soapbox/api/MastodonClient.ts'; +import * as BuildConfig from 'soapbox/build-config.ts'; +import { selectAccount } from 'soapbox/selectors/index.ts'; +import { RootState } from 'soapbox/store.ts'; +import { getAccessToken, getAppToken, parseBaseURL } from 'soapbox/utils/auth.ts'; + +const getToken = (state: RootState, authType: string) => { + return authType === 'app' ? getAppToken(state) : getAccessToken(state); +}; + +const getAuthBaseURL = createSelector([ + (state: RootState, me: string | false | null) => me ? selectAccount(state, me)?.url : undefined, + (state: RootState, _me: string | false | null) => state.auth.me, +], (accountUrl, authUserUrl) => { + return parseBaseURL(accountUrl) || parseBaseURL(authUserUrl); +}); + +/** Base client for HTTP requests. */ +export const baseClient = ( + accessToken?: string | null, + baseURL?: string, +): MastodonClient => { + return new MastodonClient(baseURL || BuildConfig.BACKEND_URL || location.origin, accessToken || undefined); +}; + +/** + * Stateful API client. + * Uses credentials from the Redux store if available. + */ +export default (getState: () => RootState, authType: string = 'user'): MastodonClient => { + const state = getState(); + const accessToken = getToken(state, authType); + const me = state.me; + const baseURL = BuildConfig.BACKEND_URL || (me ? getAuthBaseURL(state, me) : undefined) || location.origin; + + return baseClient(accessToken, baseURL); +}; diff --git a/src/assets/cryptocurrency/LICENSE.md b/src/assets/cryptocurrency/LICENSE.md new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/src/assets/cryptocurrency/LICENSE.md @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/src/assets/cryptocurrency/aave.svg b/src/assets/cryptocurrency/aave.svg new file mode 100644 index 0000000..0fc31d9 --- /dev/null +++ b/src/assets/cryptocurrency/aave.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/ada.svg b/src/assets/cryptocurrency/ada.svg new file mode 100644 index 0000000..d558f0d --- /dev/null +++ b/src/assets/cryptocurrency/ada.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/algo.svg b/src/assets/cryptocurrency/algo.svg new file mode 100644 index 0000000..42d635f --- /dev/null +++ b/src/assets/cryptocurrency/algo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/atom.svg b/src/assets/cryptocurrency/atom.svg new file mode 100644 index 0000000..f957f2c --- /dev/null +++ b/src/assets/cryptocurrency/atom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/avax.svg b/src/assets/cryptocurrency/avax.svg new file mode 100644 index 0000000..7bd97ad --- /dev/null +++ b/src/assets/cryptocurrency/avax.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/bch.svg b/src/assets/cryptocurrency/bch.svg new file mode 100644 index 0000000..06d99d7 --- /dev/null +++ b/src/assets/cryptocurrency/bch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/btc.svg b/src/assets/cryptocurrency/btc.svg new file mode 100644 index 0000000..5dc8a39 --- /dev/null +++ b/src/assets/cryptocurrency/btc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/doge.svg b/src/assets/cryptocurrency/doge.svg new file mode 100644 index 0000000..c22bf09 --- /dev/null +++ b/src/assets/cryptocurrency/doge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/dot.svg b/src/assets/cryptocurrency/dot.svg new file mode 100644 index 0000000..7751a9c --- /dev/null +++ b/src/assets/cryptocurrency/dot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/eos.svg b/src/assets/cryptocurrency/eos.svg new file mode 100644 index 0000000..4ca4e48 --- /dev/null +++ b/src/assets/cryptocurrency/eos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/etc.svg b/src/assets/cryptocurrency/etc.svg new file mode 100644 index 0000000..8e11cf9 --- /dev/null +++ b/src/assets/cryptocurrency/etc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/eth.svg b/src/assets/cryptocurrency/eth.svg new file mode 100644 index 0000000..c761cef --- /dev/null +++ b/src/assets/cryptocurrency/eth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/fil.svg b/src/assets/cryptocurrency/fil.svg new file mode 100644 index 0000000..144440b --- /dev/null +++ b/src/assets/cryptocurrency/fil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/generic.svg b/src/assets/cryptocurrency/generic.svg new file mode 100644 index 0000000..b599951 --- /dev/null +++ b/src/assets/cryptocurrency/generic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/icp.svg b/src/assets/cryptocurrency/icp.svg new file mode 100644 index 0000000..642b815 --- /dev/null +++ b/src/assets/cryptocurrency/icp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/link.svg b/src/assets/cryptocurrency/link.svg new file mode 100644 index 0000000..f7e000a --- /dev/null +++ b/src/assets/cryptocurrency/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/ltc.svg b/src/assets/cryptocurrency/ltc.svg new file mode 100644 index 0000000..9afc706 --- /dev/null +++ b/src/assets/cryptocurrency/ltc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/matic.svg b/src/assets/cryptocurrency/matic.svg new file mode 100644 index 0000000..31361b8 --- /dev/null +++ b/src/assets/cryptocurrency/matic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/sol.svg b/src/assets/cryptocurrency/sol.svg new file mode 100644 index 0000000..ce27130 --- /dev/null +++ b/src/assets/cryptocurrency/sol.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/vet.svg b/src/assets/cryptocurrency/vet.svg new file mode 100644 index 0000000..040000e --- /dev/null +++ b/src/assets/cryptocurrency/vet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/xlm.svg b/src/assets/cryptocurrency/xlm.svg new file mode 100644 index 0000000..1d1c2a8 --- /dev/null +++ b/src/assets/cryptocurrency/xlm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/xmr.svg b/src/assets/cryptocurrency/xmr.svg new file mode 100644 index 0000000..360a39a --- /dev/null +++ b/src/assets/cryptocurrency/xmr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/xrp.svg b/src/assets/cryptocurrency/xrp.svg new file mode 100644 index 0000000..250cbfa --- /dev/null +++ b/src/assets/cryptocurrency/xrp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/xtz.svg b/src/assets/cryptocurrency/xtz.svg new file mode 100644 index 0000000..5f9c35b --- /dev/null +++ b/src/assets/cryptocurrency/xtz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/cryptocurrency/zec.svg b/src/assets/cryptocurrency/zec.svg new file mode 100644 index 0000000..52ee264 --- /dev/null +++ b/src/assets/cryptocurrency/zec.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/COPYING.md b/src/assets/icons/COPYING.md new file mode 100644 index 0000000..cfcb90d --- /dev/null +++ b/src/assets/icons/COPYING.md @@ -0,0 +1,8 @@ +# Custom icons + +- chest.svg - Created by bitpopart.com. Dedicated to the public domain. +- coin-chest.svg - Created by bitpopart.com. Dedicated to the public domain. +- coin.svg - Created by bitpopart.com. Dedicated to the public domain. +- money-bag.png - Created by bitpopart.com. Dedicated to the public domain. +- pile-coin.svg - Created by bitpopart.com. Dedicated to the public domain. +- verified.svg - Created by Alex Gleason. CC0. diff --git a/src/assets/icons/chest.png b/src/assets/icons/chest.png new file mode 100644 index 0000000..15c29d7 Binary files /dev/null and b/src/assets/icons/chest.png differ diff --git a/src/assets/icons/chest.svg b/src/assets/icons/chest.svg new file mode 100644 index 0000000..5600ce1 --- /dev/null +++ b/src/assets/icons/chest.svg @@ -0,0 +1,2399 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/coin-stack.png b/src/assets/icons/coin-stack.png new file mode 100644 index 0000000..a3020e6 Binary files /dev/null and b/src/assets/icons/coin-stack.png differ diff --git a/src/assets/icons/coin-stack.svg b/src/assets/icons/coin-stack.svg new file mode 100644 index 0000000..ac08cbe --- /dev/null +++ b/src/assets/icons/coin-stack.svg @@ -0,0 +1,743 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/coin.png b/src/assets/icons/coin.png new file mode 100644 index 0000000..a128585 Binary files /dev/null and b/src/assets/icons/coin.png differ diff --git a/src/assets/icons/coin.svg b/src/assets/icons/coin.svg new file mode 100644 index 0000000..590119c --- /dev/null +++ b/src/assets/icons/coin.svg @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/money-bag.png b/src/assets/icons/money-bag.png new file mode 100644 index 0000000..e23a552 Binary files /dev/null and b/src/assets/icons/money-bag.png differ diff --git a/src/assets/icons/money-bag.svg b/src/assets/icons/money-bag.svg new file mode 100644 index 0000000..4740ee2 --- /dev/null +++ b/src/assets/icons/money-bag.svg @@ -0,0 +1,358 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/pile-coin.png b/src/assets/icons/pile-coin.png new file mode 100644 index 0000000..4ec00ed Binary files /dev/null and b/src/assets/icons/pile-coin.png differ diff --git a/src/assets/icons/pile-coin.svg b/src/assets/icons/pile-coin.svg new file mode 100644 index 0000000..c14d734 --- /dev/null +++ b/src/assets/icons/pile-coin.svg @@ -0,0 +1,4380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/verified.svg b/src/assets/icons/verified.svg new file mode 100644 index 0000000..cfd5b9d --- /dev/null +++ b/src/assets/icons/verified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/audio-placeholder.png b/src/assets/images/audio-placeholder.png new file mode 100644 index 0000000..879131a Binary files /dev/null and b/src/assets/images/audio-placeholder.png differ diff --git a/src/assets/images/avatar-missing.png b/src/assets/images/avatar-missing.png new file mode 100644 index 0000000..6de33a5 Binary files /dev/null and b/src/assets/images/avatar-missing.png differ diff --git a/src/assets/images/avatar-missing.svg b/src/assets/images/avatar-missing.svg new file mode 100644 index 0000000..d5833c9 --- /dev/null +++ b/src/assets/images/avatar-missing.svg @@ -0,0 +1,30 @@ + +image/svg+xml diff --git a/src/assets/images/header-missing.png b/src/assets/images/header-missing.png new file mode 100644 index 0000000..26b59e7 Binary files /dev/null and b/src/assets/images/header-missing.png differ diff --git a/src/assets/images/soapbox-logo-white.svg b/src/assets/images/soapbox-logo-white.svg new file mode 100644 index 0000000..f09e910 --- /dev/null +++ b/src/assets/images/soapbox-logo-white.svg @@ -0,0 +1 @@ + diff --git a/src/assets/images/soapbox-logo.svg b/src/assets/images/soapbox-logo.svg new file mode 100644 index 0000000..270b7b8 --- /dev/null +++ b/src/assets/images/soapbox-logo.svg @@ -0,0 +1 @@ + diff --git a/src/assets/images/video-placeholder.png b/src/assets/images/video-placeholder.png new file mode 100644 index 0000000..01a8d4f Binary files /dev/null and b/src/assets/images/video-placeholder.png differ diff --git a/src/assets/images/void.png b/src/assets/images/void.png new file mode 100644 index 0000000..e8b1307 Binary files /dev/null and b/src/assets/images/void.png differ diff --git a/src/assets/images/web-push/web-push-icon_expand.png b/src/assets/images/web-push/web-push-icon_expand.png new file mode 100644 index 0000000..5c115c7 Binary files /dev/null and b/src/assets/images/web-push/web-push-icon_expand.png differ diff --git a/src/assets/images/web-push/web-push-icon_favourite.png b/src/assets/images/web-push/web-push-icon_favourite.png new file mode 100644 index 0000000..7f30a67 Binary files /dev/null and b/src/assets/images/web-push/web-push-icon_favourite.png differ diff --git a/src/assets/images/web-push/web-push-icon_reblog.png b/src/assets/images/web-push/web-push-icon_reblog.png new file mode 100644 index 0000000..f70203e Binary files /dev/null and b/src/assets/images/web-push/web-push-icon_reblog.png differ diff --git a/src/assets/sounds/LICENSE.md b/src/assets/sounds/LICENSE.md new file mode 100644 index 0000000..0d4a360 --- /dev/null +++ b/src/assets/sounds/LICENSE.md @@ -0,0 +1,6 @@ +# Sound licenses + +- `chat.mp3` +- `chat.ogg` + +© [notificationsounds.com](https://notificationsounds.com/notification-sounds/intuition-561), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by-sa/4.0/). diff --git a/src/assets/sounds/boop.mp3 b/src/assets/sounds/boop.mp3 new file mode 100644 index 0000000..b8f7610 Binary files /dev/null and b/src/assets/sounds/boop.mp3 differ diff --git a/src/assets/sounds/boop.ogg b/src/assets/sounds/boop.ogg new file mode 100644 index 0000000..936d570 Binary files /dev/null and b/src/assets/sounds/boop.ogg differ diff --git a/src/assets/sounds/chat.mp3 b/src/assets/sounds/chat.mp3 new file mode 100644 index 0000000..c861142 Binary files /dev/null and b/src/assets/sounds/chat.mp3 differ diff --git a/src/assets/sounds/chat.ogg b/src/assets/sounds/chat.ogg new file mode 100644 index 0000000..76d4bda Binary files /dev/null and b/src/assets/sounds/chat.ogg differ diff --git a/src/build-config-compiletime.ts b/src/build-config-compiletime.ts new file mode 100644 index 0000000..c588a7a --- /dev/null +++ b/src/build-config-compiletime.ts @@ -0,0 +1,32 @@ +/** + * Build config: configuration set at build time. + * @module soapbox/build-config + */ + +const { + NODE_ENV, + BACKEND_URL, + FE_INSTANCE_SOURCE_DIR, + SENTRY_DSN, +} = process.env; + +const sanitizeURL = (url: string = ''): string => { + try { + return new URL(url).href; + } catch { + return ''; + } +}; + +const env = { + NODE_ENV: NODE_ENV || 'development', + BACKEND_URL: sanitizeURL(BACKEND_URL), + FE_INSTANCE_SOURCE_DIR: FE_INSTANCE_SOURCE_DIR || 'instance', + SENTRY_DSN, +}; + +export type SoapboxEnv = typeof env; + +export default () => ({ + data: env, +}); diff --git a/src/build-config.ts b/src/build-config.ts new file mode 100644 index 0000000..340a691 --- /dev/null +++ b/src/build-config.ts @@ -0,0 +1,8 @@ +import type { SoapboxEnv } from './build-config-compiletime.ts'; + +export const { + NODE_ENV, + BACKEND_URL, + FE_INSTANCE_SOURCE_DIR, + SENTRY_DSN, +} = import.meta.compileTime('./build-config-compiletime.ts'); diff --git a/src/components/__mocks__/react-inlinesvg.tsx b/src/components/__mocks__/react-inlinesvg.tsx new file mode 100644 index 0000000..7b288a7 --- /dev/null +++ b/src/components/__mocks__/react-inlinesvg.tsx @@ -0,0 +1,13 @@ +interface IInlineSVG { + loader?: JSX.Element; +} + +const InlineSVG: React.FC = ({ loader }): JSX.Element => { + if (loader) { + return loader; + } else { + throw 'You used react-inlinesvg without a loader! This will cause jumpy loading during render.'; + } +}; + +export default InlineSVG; diff --git a/src/components/account-search.tsx b/src/components/account-search.tsx new file mode 100644 index 0000000..2ed4f37 --- /dev/null +++ b/src/components/account-search.tsx @@ -0,0 +1,96 @@ +import searchIcon from '@tabler/icons/outline/search.svg'; +import xIcon from '@tabler/icons/outline/x.svg'; +import clsx from 'clsx'; +import { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input.tsx'; + +import SvgIcon from './ui/svg-icon.tsx'; + +const messages = defineMessages({ + placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' }, +}); + +interface IAccountSearch { + /** Callback when a searched account is chosen. */ + onSelected: (accountId: string) => void; + /** Override the default placeholder of the input. */ + placeholder?: string; +} + +/** Input to search for accounts. */ +const AccountSearch: React.FC = ({ onSelected, ...rest }) => { + const intl = useIntl(); + + const [value, setValue] = useState(''); + + const isEmpty = (): boolean => { + return !(value.length > 0); + }; + + const clearState = () => { + setValue(''); + }; + + const handleChange: React.ChangeEventHandler = ({ target }) => { + setValue(target.value); + }; + + const handleSelected = (accountId: string) => { + clearState(); + onSelected(accountId); + }; + + const handleClear: React.MouseEventHandler = e => { + e.preventDefault(); + + if (!isEmpty()) { + setValue(''); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + if (e.key === 'Escape') { + document.querySelector('.ui')?.parentElement?.focus(); + } + }; + + return ( +
+ + +
+ + +
+ + + +
+
+
+ ); +}; + +export default AccountSearch; diff --git a/src/components/account.test.tsx b/src/components/account.test.tsx new file mode 100644 index 0000000..69f9848 --- /dev/null +++ b/src/components/account.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAccount } from 'soapbox/jest/factory.ts'; +import { render, screen } from 'soapbox/jest/test-helpers.tsx'; + +import Account from './account.tsx'; + +describe('', () => { + it('renders account name and username', () => { + const account = buildAccount({ + id: '1', + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + }); + + const store = { + accounts: { + '1': account, + }, + }; + + render(, undefined, store); + expect(screen.getByTestId('account')).toHaveTextContent('Justin L'); + expect(screen.getByTestId('account')).toHaveTextContent(/justin-username/i); + }); + + describe('verification badge', () => { + it('renders verification badge', () => { + const account = buildAccount({ + id: '1', + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + verified: true, + }); + + const store = { + accounts: { + '1': account, + }, + }; + + render(, undefined, store); + expect(screen.getByTestId('verified-badge')).toBeInTheDocument(); + }); + + it('does not render verification badge', () => { + const account = buildAccount({ + id: '1', + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + verified: false, + }); + + const store = { + accounts: { + '1': account, + }, + }; + + render(, undefined, store); + expect(screen.queryAllByTestId('verified-badge')).toHaveLength(0); + }); + }); +}); diff --git a/src/components/account.tsx b/src/components/account.tsx new file mode 100644 index 0000000..76f0a56 --- /dev/null +++ b/src/components/account.tsx @@ -0,0 +1,327 @@ +import pencilIcon from '@tabler/icons/outline/pencil.svg'; +import { useRef, useState } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { Link, useHistory } from 'react-router-dom'; + +import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper.tsx'; +import Markup from 'soapbox/components/markup.tsx'; +import Avatar from 'soapbox/components/ui/avatar.tsx'; +import Emoji from 'soapbox/components/ui/emoji.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import IconButton from 'soapbox/components/ui/icon-button.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import VerificationBadge from 'soapbox/components/verification-badge.tsx'; +import ActionButton from 'soapbox/features/ui/components/action-button.tsx'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { getAcct } from 'soapbox/utils/accounts.ts'; +import { emojifyText } from 'soapbox/utils/emojify.tsx'; +import { displayFqn } from 'soapbox/utils/state.ts'; + +import Badge from './badge.tsx'; +import RelativeTimestamp from './relative-timestamp.tsx'; + +import type { StatusApprovalStatus } from 'soapbox/normalizers/status.ts'; +import type { Account as AccountSchema } from 'soapbox/schemas/index.ts'; + +interface IInstanceFavicon { + account: AccountSchema; + disabled?: boolean; +} + +const messages = defineMessages({ + bot: { id: 'account.badges.bot', defaultMessage: 'Bot' }, +}); + +const InstanceFavicon: React.FC = ({ account, disabled }) => { + const history = useHistory(); + const [missing, setMissing] = useState(false); + + const handleError = () => setMissing(true); + + const handleClick: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + if (disabled) return; + + const timelineUrl = `/timeline/${account.domain}`; + if (!(e.ctrlKey || e.metaKey)) { + history.push(timelineUrl); + } else { + window.open(timelineUrl, '_blank'); + } + }; + + if (missing || !account.pleroma?.favicon) { + return null; + } + + return ( + + ); +}; + +interface IProfilePopper { + condition: boolean; + wrapper: (children: React.ReactNode) => React.ReactNode; + children: React.ReactNode; +} + +const ProfilePopper: React.FC = ({ condition, wrapper, children }) => { + return ( + <> + {condition ? wrapper(children) : children} + + ); +}; + +export interface IAccount { + acct?: string; + account: AccountSchema; + action?: React.ReactElement; + actionAlignment?: 'center' | 'top'; + actionIcon?: string; + actionTitle?: string; + /** Override other actions for specificity like mute/unmute. */ + actionType?: 'muting' | 'blocking' | 'follow_request'; + avatarSize?: number; + hidden?: boolean; + hideActions?: boolean; + id?: string; + onActionClick?: (account: any) => void; + showProfileHoverCard?: boolean; + timestamp?: string; + timestampUrl?: string; + futureTimestamp?: boolean; + withAccountNote?: boolean; + withDate?: boolean; + withLinkToProfile?: boolean; + withRelationship?: boolean; + showEdit?: boolean; + approvalStatus?: StatusApprovalStatus; + emoji?: string; + emojiUrl?: string; + note?: string; +} + +const Account = ({ + acct, + account, + actionType, + action, + actionIcon, + actionTitle, + actionAlignment = 'center', + avatarSize = 42, + hidden = false, + hideActions = false, + onActionClick, + showProfileHoverCard = true, + timestamp, + timestampUrl, + futureTimestamp = false, + withAccountNote = false, + withDate = false, + withLinkToProfile = true, + withRelationship = true, + showEdit = false, + approvalStatus, + emoji, + emojiUrl, + note, +}: IAccount) => { + const overflowRef = useRef(null); + const actionRef = useRef(null); + + const me = useAppSelector((state) => state.me); + const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null); + + const handleAction = () => { + onActionClick!(account); + }; + + const renderAction = () => { + if (action) { + return action; + } + + if (hideActions) { + return null; + } + + if (onActionClick && actionIcon) { + return ( + + ); + } + + if (account.id !== me) { + return ; + } + + return null; + }; + + const intl = useIntl(); + + if (!account) { + return null; + } + + if (hidden) { + return ( + <> + {account.display_name} + {account.username} + + ); + } + + if (withDate) timestamp = account.created_at; + + const LinkEl: any = withLinkToProfile ? Link : 'div'; + const linkProps = withLinkToProfile ? { + to: `/@${account.acct}`, + title: account.acct, + onClick: (event: React.MouseEvent) => event.stopPropagation(), + } : {}; + + return ( +
+ + + {children}} + > + + + {emoji && ( +
+ {emojiUrl ? ( + {emoji} + ) : ( + + )} +
+ )} +
+
+ +
+ {children}} + > + + + + {emojifyText(account.display_name, account.emojis)} + + + {account.verified && } + + {account.bot && } + + + + + + + @{acct ?? username} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + + {account.pleroma?.favicon && ( + + )} + + {(timestamp) ? ( + <> + · {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + + {timestampUrl ? ( + event.stopPropagation()}> + + + ) : ( + + )} + + ) : null} + + {approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && ( + <> + · {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + + + {approvalStatus === 'pending' + ? + : } + + + )} + + {showEdit ? ( + <> + · {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + + + + ) : null} + + {actionType === 'muting' && account.mute_expires_at ? ( + <> + · {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + + + + ) : null} + + + {note ? ( + + {note} + + ) : withAccountNote && ( + + )} + +
+
+ +
+ {withRelationship ? renderAction() : null} +
+
+
+ ); +}; + +export default Account; diff --git a/src/components/animated-number.tsx b/src/components/animated-number.tsx new file mode 100644 index 0000000..42edcf5 --- /dev/null +++ b/src/components/animated-number.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import { FormattedNumber } from 'react-intl'; +import { TransitionMotion, spring } from 'react-motion'; + +import { useSettings } from 'soapbox/hooks/useSettings.ts'; + +const obfuscatedCount = (count: number) => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +interface IAnimatedNumber { + value: number; + obfuscate?: boolean; +} + +const AnimatedNumber: React.FC = ({ value, obfuscate }) => { + const { reduceMotion } = useSettings(); + + const [direction, setDirection] = useState(1); + const [displayedValue, setDisplayedValue] = useState(value); + + useEffect(() => { + if (displayedValue !== undefined) { + if (value > displayedValue) setDirection(1); + else if (value < displayedValue) setDirection(-1); + } + setDisplayedValue(value); + }, [value]); + + const willEnter = () => ({ y: -1 * direction }); + + const willLeave = () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }); + + if (reduceMotion) { + return obfuscate + ? <>{obfuscatedCount(displayedValue)} + : ; + } + + const styles = [{ + key: `${displayedValue}`, + data: displayedValue, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + + {items => ( + + {items.map(({ key, data, style }) => ( + 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }} + > + {obfuscate + ? obfuscatedCount(data) + : } + + ))} + + )} + + ); +}; + +export default AnimatedNumber; \ No newline at end of file diff --git a/src/components/announcements/announcement-content.tsx b/src/components/announcements/announcement-content.tsx new file mode 100644 index 0000000..541a382 --- /dev/null +++ b/src/components/announcements/announcement-content.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { getTextDirection } from 'soapbox/utils/rtl.ts'; + +import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/schemas/index.ts'; + +interface IAnnouncementContent { + announcement: AnnouncementEntity; +} + +const AnnouncementContent: React.FC = ({ announcement }) => { + const history = useHistory(); + + const node = useRef(null); + const direction = getTextDirection(announcement.content); + + useEffect(() => { + updateLinks(); + }); + + const onMentionClick = (mention: MentionEntity, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/@${mention.acct}`); + } + }; + + const onHashtagClick = (hashtag: string, e: MouseEvent) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/tags/${hashtag}`); + } + }; + + const onStatusClick = (status: string, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + history.push(status); + } + }; + + const updateLinks = () => { + if (!node.current) return; + + const links = node.current.querySelectorAll('a'); + + links.forEach(link => { + // Skip already processed + if (link.classList.contains('status-link')) return; + + // Add attributes + link.classList.add('status-link'); + link.setAttribute('rel', 'nofollow noopener'); + link.setAttribute('target', '_blank'); + + const mention = announcement.mentions.find(mention => link.href === `${mention.url}`); + + // Add event listeners on mentions, hashtags and statuses + if (mention) { + link.addEventListener('click', onMentionClick.bind(link, mention), false); + link.setAttribute('title', mention.acct); + } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { + link.addEventListener('click', onHashtagClick.bind(link, link.text), false); + } else { + const status = announcement.statuses[link.href]; + if (status) { + link.addEventListener('click', onStatusClick.bind(this, status), false); + } + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + } + }); + }; + + return ( +
+ ); +}; + +export default AnnouncementContent; diff --git a/src/components/announcements/announcement.tsx b/src/components/announcements/announcement.tsx new file mode 100644 index 0000000..9340529 --- /dev/null +++ b/src/components/announcements/announcement.tsx @@ -0,0 +1,71 @@ +import { FormattedDate } from 'react-intl'; + +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { getTextDirection } from 'soapbox/utils/rtl.ts'; + +import AnnouncementContent from './announcement-content.tsx'; +import ReactionsBar from './reactions-bar.tsx'; + +import type { Announcement as AnnouncementEntity } from 'soapbox/schemas/index.ts'; + +interface IAnnouncement { + announcement: AnnouncementEntity; +} + +const Announcement: React.FC = ({ announcement }) => { + const features = useFeatures(); + + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); + const skipTime = announcement.all_day; + const direction = getTextDirection(announcement.content); + + return ( + + {hasTimeRange && ( + + + {/* eslint-disable formatjs/no-literal-string-in-jsx */} + {' '} + - + {' '} + {/* eslint-enable formatjs/no-literal-string-in-jsx */} + + + )} + + + + {features.announcementsReactions && ( + + )} + + ); +}; + +export default Announcement; diff --git a/src/components/announcements/announcements-panel.tsx b/src/components/announcements/announcements-panel.tsx new file mode 100644 index 0000000..8454095 --- /dev/null +++ b/src/components/announcements/announcements-panel.tsx @@ -0,0 +1,55 @@ +import clsx from 'clsx'; +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; + +import { useAnnouncements } from 'soapbox/api/hooks/announcements/index.ts'; +import { Card } from 'soapbox/components/ui/card.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Widget from 'soapbox/components/ui/widget.tsx'; + +import Announcement from './announcement.tsx'; + +const AnnouncementsPanel = () => { + const [index, setIndex] = useState(0); + const { data: announcements } = useAnnouncements(); + + if (!announcements || announcements.length === 0) return null; + + const handleChangeIndex = (index: number) => { + setIndex(index % announcements.length); + }; + + return ( + }> + + + {announcements!.map((announcement) => ( + + )).reverse()} + + {announcements.length > 1 && ( + + {announcements.map((_, i) => ( + + ); +}; + +export default Reaction; diff --git a/src/components/announcements/reactions-bar.tsx b/src/components/announcements/reactions-bar.tsx new file mode 100644 index 0000000..56f52ff --- /dev/null +++ b/src/components/announcements/reactions-bar.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import { TransitionMotion, spring } from 'react-motion'; + +import { useAnnouncements } from 'soapbox/api/hooks/announcements/index.ts'; +import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container.tsx'; +import { useSettings } from 'soapbox/hooks/useSettings.ts'; + +import Reaction from './reaction.tsx'; + +import type { Emoji, NativeEmoji } from 'soapbox/features/emoji/index.ts'; +import type { AnnouncementReaction } from 'soapbox/schemas/index.ts'; + +interface IReactionsBar { + announcementId: string; + reactions: AnnouncementReaction[]; +} + +const ReactionsBar: React.FC = ({ announcementId, reactions }) => { + const { reduceMotion } = useSettings(); + const { addReaction } = useAnnouncements(); + + const handleEmojiPick = (data: Emoji) => { + addReaction({ announcementId, name: (data as NativeEmoji).native.replace(/:/g, '') }); + }; + + const willEnter = () => ({ scale: reduceMotion ? 1 : 0 }); + + const willLeave = () => ({ scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }); + + const visibleReactions = reactions.filter(x => x.count > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.name, + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} + + {visibleReactions.length < 8 && } +
+ )} +
+ ); +}; + +export default ReactionsBar; diff --git a/src/components/attachment-thumbs.tsx b/src/components/attachment-thumbs.tsx new file mode 100644 index 0000000..2b4aadf --- /dev/null +++ b/src/components/attachment-thumbs.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; + +import { openModal } from 'soapbox/actions/modals.ts'; +import { MediaGallery } from 'soapbox/features/ui/util/async-components.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { Attachment } from 'soapbox/schemas/index.ts'; + +interface IAttachmentThumbs { + media: readonly Attachment[]; + onClick?(): void; + sensitive?: boolean; +} + +const AttachmentThumbs = (props: IAttachmentThumbs) => { + const { media, onClick, sensitive } = props; + const dispatch = useAppDispatch(); + + const fallback =
; + const onOpenMedia = (media: readonly Attachment[], index: number) => dispatch(openModal('MEDIA', { media, index })); + + return ( +
+ + + + + {onClick && ( +
+ ); +}; + +export default AttachmentThumbs; diff --git a/src/components/authorize-reject-buttons.tsx b/src/components/authorize-reject-buttons.tsx new file mode 100644 index 0000000..79f5195 --- /dev/null +++ b/src/components/authorize-reject-buttons.tsx @@ -0,0 +1,185 @@ +import playerStopFilledIcon from '@tabler/icons/filled/player-stop.svg'; +import checkIcon from '@tabler/icons/outline/check.svg'; +import xIcon from '@tabler/icons/outline/x.svg'; +import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import HStack from 'soapbox/components/ui/hstack.tsx'; +import IconButton from 'soapbox/components/ui/icon-button.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; + +interface IAuthorizeRejectButtons { + onAuthorize(): Promise | unknown; + onReject(): Promise | unknown; + countdown?: number; +} + +/** Buttons to approve or reject a pending item, usually an account. */ +const AuthorizeRejectButtons: React.FC = ({ onAuthorize, onReject, countdown }) => { + const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending'); + const timeout = useRef(); + const interval = useRef>(); + + const [progress, setProgress] = useState(0); + + const startProgressInterval = () => { + let startValue = 1; + interval.current = setInterval(() => { + startValue++; + const newValue = startValue * 3.6; // get to 360 (deg) + setProgress(newValue); + + if (newValue >= 360) { + clearInterval(interval.current as NodeJS.Timeout); + setProgress(0); + } + }, (countdown as number) / 100); + }; + + function handleAction( + present: 'authorizing' | 'rejecting', + past: 'authorized' | 'rejected', + action: () => Promise | unknown, + ): void { + if (state === present) { + if (interval.current) { + clearInterval(interval.current); + } + if (timeout.current) { + clearTimeout(timeout.current); + } + setState('pending'); + } else { + const doAction = async () => { + try { + await action(); + setState(past); + } catch (e) { + if (e) console.error(e); + } + }; + if (typeof countdown === 'number') { + setState(present); + timeout.current = setTimeout(doAction, countdown); + startProgressInterval(); + } else { + doAction(); + } + } + } + + const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize); + const handleReject = async () => handleAction('rejecting', 'rejected', onReject); + + const renderStyle = (selectedState: typeof state) => { + if (state === 'authorizing' && selectedState === 'authorizing') { + return { + background: `conic-gradient(rgb(var(--color-primary-500)) ${progress}deg, rgb(var(--color-primary-500) / 0.1) 0deg)`, + }; + } else if (state === 'rejecting' && selectedState === 'rejecting') { + return { + background: `conic-gradient(rgb(var(--color-danger-600)) ${progress}deg, rgb(var(--color-danger-600) / 0.1) 0deg)`, + }; + } + + return {}; + }; + + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + if (interval.current) { + clearInterval(interval.current); + } + }; + }, []); + + switch (state) { + case 'authorized': + return ( + } /> + ); + case 'rejected': + return ( + } /> + ); + default: + return ( + + + + + ); + } +}; + +interface IActionEmblem { + text: React.ReactNode; +} + +const ActionEmblem: React.FC = ({ text }) => { + return ( +
+ + {text} + +
+ ); +}; + +interface IAuthorizeRejectButton { + theme: 'primary' | 'danger'; + icon: string; + action(): void; + isLoading?: boolean; + disabled?: boolean; + style: React.CSSProperties; +} + +const AuthorizeRejectButton: React.FC = ({ theme, icon, action, isLoading, style, disabled }) => { + return ( +
+
+ +
+
+ ); +}; + +export { AuthorizeRejectButtons }; \ No newline at end of file diff --git a/src/components/autosuggest-account-input.tsx b/src/components/autosuggest-account-input.tsx new file mode 100644 index 0000000..c64e925 --- /dev/null +++ b/src/components/autosuggest-account-input.tsx @@ -0,0 +1,96 @@ +import { throttle } from 'es-toolkit'; +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { useState, useRef, useCallback, useEffect } from 'react'; + +import { accountSearch } from 'soapbox/actions/accounts.ts'; +import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; + +import type { Menu } from 'soapbox/components/dropdown-menu/index.ts'; +import type { InputThemes } from 'soapbox/components/ui/input.tsx'; + +const noOp = () => { }; + +interface IAutosuggestAccountInput { + onChange: React.ChangeEventHandler; + onSelected: (accountId: string) => void; + autoFocus?: boolean; + value: string; + limit?: number; + className?: string; + autoSelect?: boolean; + menu?: Menu; + onKeyDown?: React.KeyboardEventHandler; + theme?: InputThemes; +} + +const AutosuggestAccountInput: React.FC = ({ + onChange, + onSelected, + value = '', + limit = 4, + ...rest +}) => { + const dispatch = useAppDispatch(); + const [accountIds, setAccountIds] = useState(ImmutableOrderedSet()); + const controller = useRef(new AbortController()); + + const refreshCancelToken = () => { + controller.current.abort(); + controller.current = new AbortController(); + }; + + const clearResults = () => { + setAccountIds(ImmutableOrderedSet()); + }; + + const handleAccountSearch = useCallback(throttle((q) => { + const params = { q, limit, resolve: false }; + + dispatch(accountSearch(params, controller.current.signal)) + .then((accounts: { id: string }[]) => { + const accountIds = accounts.map(account => account.id); + setAccountIds(ImmutableOrderedSet(accountIds)); + }) + .catch(noOp); + }, 900, { edges: ['leading', 'trailing'] }), [limit]); + + const handleChange: React.ChangeEventHandler = e => { + refreshCancelToken(); + handleAccountSearch(e.target.value); + onChange(e); + }; + + const handleSelected = (_tokenStart: number, _lastToken: string | null, suggestion: AutoSuggestion) => { + if (typeof suggestion === 'string' && suggestion[0] !== '#') { + onSelected(suggestion); + } + }; + + useEffect(() => { + if (rest.autoFocus) { + handleAccountSearch(''); + } + }, []); + + useEffect(() => { + if (value === '') { + clearResults(); + } + }, [value]); + + return ( + + ); +}; + +export default AutosuggestAccountInput; diff --git a/src/components/autosuggest-emoji.test.tsx b/src/components/autosuggest-emoji.test.tsx new file mode 100644 index 0000000..289a6ee --- /dev/null +++ b/src/components/autosuggest-emoji.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'soapbox/jest/test-helpers.tsx'; + +import AutosuggestEmoji from './autosuggest-emoji.tsx'; + +describe('', () => { + it('renders native emoji', () => { + const emoji = { + native: '💙', + colons: ':foobar:', + }; + + render(); + + expect(screen.getByTestId('emoji')).toHaveTextContent('foobar'); + expect(screen.getByRole('img').getAttribute('src')).not.toBe('http://example.com/emoji.png'); + }); + + it('renders emoji with custom url', () => { + const emoji = { + custom: true, + imageUrl: 'http://example.com/emoji.png', + native: 'foobar', + colons: ':foobar:', + }; + + render(); + + expect(screen.getByTestId('emoji')).toHaveTextContent('foobar'); + expect(screen.getByRole('img').getAttribute('src')).toBe('http://example.com/emoji.png'); + }); +}); diff --git a/src/components/autosuggest-emoji.tsx b/src/components/autosuggest-emoji.tsx new file mode 100644 index 0000000..bd617e1 --- /dev/null +++ b/src/components/autosuggest-emoji.tsx @@ -0,0 +1,35 @@ +import EmojiComponent from 'soapbox/components/ui/emoji.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import { isCustomEmoji } from 'soapbox/features/emoji/index.ts'; +import unicodeMapping from 'soapbox/features/emoji/mapping.ts'; + +import type { Emoji } from 'soapbox/features/emoji/index.ts'; + +interface IAutosuggestEmoji { + emoji: Emoji; +} + +const AutosuggestEmoji: React.FC = ({ emoji }) => { + let elem: React.ReactNode; + + if (isCustomEmoji(emoji)) { + elem = {emoji.colons}; + } else { + const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; + + if (!mapping) { + return null; + } + + elem = ; + } + + return ( + + {elem} + {emoji.colons} + + ); +}; + +export default AutosuggestEmoji; diff --git a/src/components/autosuggest-input.tsx b/src/components/autosuggest-input.tsx new file mode 100644 index 0000000..5a9b1bb --- /dev/null +++ b/src/components/autosuggest-input.tsx @@ -0,0 +1,313 @@ +import clsx from 'clsx'; +import { List as ImmutableList } from 'immutable'; +import { PureComponent } from 'react'; + +import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji.tsx'; +import Icon from 'soapbox/components/icon.tsx'; +import Input from 'soapbox/components/ui/input.tsx'; +import Portal from 'soapbox/components/ui/portal.tsx'; +import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account.tsx'; +import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions.ts'; + +import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu/index.ts'; +import type { InputThemes } from 'soapbox/components/ui/input.tsx'; +import type { Emoji } from 'soapbox/features/emoji/index.ts'; + +export type AutoSuggestion = string | Emoji; + +export interface IAutosuggestInput extends Pick, 'onChange' | 'onKeyUp' | 'onKeyDown'> { + value: string; + suggestions: ImmutableList; + disabled?: boolean; + placeholder?: string; + onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void; + onSuggestionsClearRequested: () => void; + onSuggestionsFetchRequested: (token: string) => void; + autoFocus: boolean; + autoSelect: boolean; + className?: string; + id?: string; + searchTokens: string[]; + maxLength?: number; + menu?: Menu; + renderSuggestion?: React.FC<{ id: string }>; + hidePortal?: boolean; + theme?: InputThemes; +} + +export default class AutosuggestInput extends PureComponent { + + static defaultProps = { + autoFocus: false, + autoSelect: true, + searchTokens: ImmutableList(['@', ':', '#']), + }; + + getFirstIndex = () => { + return this.props.autoSelect ? 0 : -1; + }; + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: this.getFirstIndex(), + lastToken: null, + tokenStart: 0, + }; + + input: HTMLInputElement | null = null; + + onChange: React.ChangeEventHandler = (e) => { + const [tokenStart, token] = textAtCursorMatchesToken( + e.target.value, + e.target.selectionStart || 0, + this.props.searchTokens, + ); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + if (this.props.onChange) { + this.props.onChange(e); + } + }; + + onKeyDown: React.KeyboardEventHandler = (e) => { + const { suggestions, menu, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + const firstIndex = this.getFirstIndex(); + const lastIndex = suggestions.size + (menu || []).length - 1; + + if (disabled) { + e.preventDefault(); + return; + } + + if (e.which === 229) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch (e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui')?.parentElement?.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (!suggestionsHidden && (suggestions.size > 0 || menu)) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, lastIndex) }); + } + + break; + case 'ArrowUp': + if (!suggestionsHidden && (suggestions.size > 0 || menu)) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, firstIndex) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (!suggestionsHidden && selectedSuggestion > -1 && (suggestions.size > 0 || menu)) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ selectedSuggestion: firstIndex }); + + if (selectedSuggestion < suggestions.size) { + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } else if (menu) { + const item = menu[selectedSuggestion - suggestions.size]; + this.handleMenuItemAction(item, e); + } + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + }; + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + }; + + onFocus = () => { + this.setState({ focused: true }); + }; + + onSuggestionClick: React.EventHandler = (e) => { + const index = Number(e.currentTarget?.getAttribute('data-index')); + const suggestion = this.props.suggestions.get(index); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.input?.focus(); + e.preventDefault(); + }; + + componentDidUpdate(prevProps: IAutosuggestInput, prevState: any) { + const { suggestions } = this.props; + if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setInput = (c: HTMLInputElement) => { + this.input = c; + }; + + renderSuggestion = (suggestion: AutoSuggestion, i: number) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (this.props.renderSuggestion && typeof suggestion === 'string') { + const RenderSuggestion = this.props.renderSuggestion; + inner = ; + key = suggestion; + } else if (typeof suggestion === 'object') { + inner = ; + key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; + } else { + inner = ; + key = suggestion; + } + + return ( +
+ {inner} +
+ ); + }; + + handleMenuItemAction = (item: MenuItem | null, e: React.MouseEvent | React.KeyboardEvent) => { + this.onBlur(); + if (item?.action) { + item.action(e); + } + }; + + handleMenuItemClick = (item: MenuItem | null): React.MouseEventHandler => { + return e => { + e.preventDefault(); + this.handleMenuItemAction(item, e); + }; + }; + + renderMenu = () => { + const { menu, suggestions } = this.props; + const { selectedSuggestion } = this.state; + + if (!menu) { + return null; + } + + return menu.map((item, i) => ( + + {item?.icon && ( + + )} + + {item?.text} + + )); + }; + + setPortalPosition() { + if (!this.input) { + return {}; + } + + const { top, height, left, width } = this.input.getBoundingClientRect(); + + return { left, width, top: top + height }; + } + + render() { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props; + const { suggestionsHidden } = this.state; + + const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value)); + + return [ +
+ + + +
, + +
+
+ {suggestions.map(this.renderSuggestion)} +
+ + {this.renderMenu()} +
+
, + ]; + } + +} diff --git a/src/components/autosuggest-location.tsx b/src/components/autosuggest-location.tsx new file mode 100644 index 0000000..f6df0a1 --- /dev/null +++ b/src/components/autosuggest-location.tsx @@ -0,0 +1,42 @@ +import buildingCommunityIcon from '@tabler/icons/outline/building-community.svg'; +import homeIcon from '@tabler/icons/outline/home-2.svg'; +import mapPinIcon from '@tabler/icons/outline/map-pin.svg'; +import roadIcon from '@tabler/icons/outline/road.svg'; + + +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; + +export const ADDRESS_ICONS: Record = { + house: homeIcon, + street: roadIcon, + secondary: roadIcon, + zone: buildingCommunityIcon, + city: buildingCommunityIcon, + administrative: buildingCommunityIcon, +}; + +interface IAutosuggestLocation { + id: string; +} + +const AutosuggestLocation: React.FC = ({ id }) => { + const location = useAppSelector((state) => state.locations.get(id)); + + if (!location) return null; + + return ( + + + + {location.description} + {[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')} + + + ); +}; + +export default AutosuggestLocation; diff --git a/src/components/avatar-stack.tsx b/src/components/avatar-stack.tsx new file mode 100644 index 0000000..0163bc3 --- /dev/null +++ b/src/components/avatar-stack.tsx @@ -0,0 +1,40 @@ +import clsx from 'clsx'; +import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import Avatar from 'soapbox/components/ui/avatar.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { makeGetAccount } from 'soapbox/selectors/index.ts'; + +import type { Account } from 'soapbox/types/entities.ts'; + +const getAccount = makeGetAccount(); + +interface IAvatarStack { + accountIds: ImmutableOrderedSet; + limit?: number; +} + +const AvatarStack: React.FC = ({ accountIds, limit = 3 }) => { + const accounts = useAppSelector(state => ImmutableList(accountIds.slice(0, limit).map(accountId => getAccount(state, accountId)).filter(account => account))) as ImmutableList; + + return ( + + {accounts.map((account, i) => ( +
+ +
+ ))} +
+ ); +}; + +export default AvatarStack; diff --git a/src/components/badge.test.tsx b/src/components/badge.test.tsx new file mode 100644 index 0000000..b0f0cbe --- /dev/null +++ b/src/components/badge.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'soapbox/jest/test-helpers.tsx'; + +import Badge from './badge.tsx'; + +describe('', () => { + it('renders correctly', () => { + render(); + + expect(screen.getByTestId('badge')).toHaveTextContent('Patron'); + }); +}); diff --git a/src/components/badge.tsx b/src/components/badge.tsx new file mode 100644 index 0000000..a1ff9d5 --- /dev/null +++ b/src/components/badge.tsx @@ -0,0 +1,46 @@ +import clsx from 'clsx'; +import { useMemo } from 'react'; + +import { hexToHsl } from 'soapbox/utils/theme.ts'; + +interface IBadge { + title: React.ReactNode; + slug: string; + color?: string; +} +/** Badge to display on a user's profile. */ +const Badge: React.FC = ({ title, slug, color }) => { + const fallback = !['patron', 'admin', 'moderator', 'opaque', 'badge:donor'].includes(slug); + + const isDark = useMemo(() => { + if (!color) return false; + + const hsl = hexToHsl(color); + + if (hsl && hsl.l > 50) return false; + + return true; + }, [color]); + + return ( + + {title} + + ); +}; + +export default Badge; diff --git a/src/components/big-card.tsx b/src/components/big-card.tsx new file mode 100644 index 0000000..d14ec38 --- /dev/null +++ b/src/components/big-card.tsx @@ -0,0 +1,39 @@ +import xIcon from '@tabler/icons/outline/x.svg'; + +import { Card, CardBody } from 'soapbox/components/ui/card.tsx'; +import IconButton from 'soapbox/components/ui/icon-button.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; + +const closeIcon = xIcon; + +interface IBigCard { + title: React.ReactNode; + subtitle?: React.ReactNode; + children: React.ReactNode; + onClose?(): void; +} + +const BigCard: React.FC = ({ title, subtitle, children, onClose }) => { + return ( + + +
+ + {onClose && ()} + {title} + {subtitle && {subtitle}} + +
+ + +
+ {children} +
+
+
+
+ ); +}; + +export { BigCard }; \ No newline at end of file diff --git a/src/components/birthday-input.tsx b/src/components/birthday-input.tsx new file mode 100644 index 0000000..b5737f6 --- /dev/null +++ b/src/components/birthday-input.tsx @@ -0,0 +1,64 @@ +import { useMemo } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { useInstance } from 'soapbox/hooks/useInstance.ts'; + +import { Datetime } from './ui/datetime.tsx'; + +const messages = defineMessages({ + birthdayPlaceholder: { id: 'edit_profile.fields.birthday_placeholder', defaultMessage: 'Your birthday' }, + previousMonth: { id: 'datepicker.previous_month', defaultMessage: 'Previous month' }, + nextMonth: { id: 'datepicker.next_month', defaultMessage: 'Next month' }, + previousYear: { id: 'datepicker.previous_year', defaultMessage: 'Previous year' }, + nextYear: { id: 'datepicker.next_year', defaultMessage: 'Next year' }, +}); + +interface IBirthdayInput { + value?: string; + onChange: (value: string) => void; + required?: boolean; +} + +const BirthdayInput: React.FC = ({ value, onChange, required }) => { + const intl = useIntl(); + const features = useFeatures(); + const { instance } = useInstance(); + + const supportsBirthdays = features.birthdays; + const minAge = instance.pleroma.metadata.birthday_min_age; + + const maxDate = useMemo(() => { + if (!supportsBirthdays) return; + + let maxDate = new Date(); + maxDate = new Date(maxDate.getTime() - minAge * 1000 * 60 * 60 * 24 + maxDate.getTimezoneOffset() * 1000 * 60); + return maxDate; + }, [minAge]); + + const selected = useMemo(() => { + if (!supportsBirthdays || !value) return; + + const date = new Date(value); + return new Date(date.getTime() + (date.getTimezoneOffset() * 60000)); + }, [value]); + + if (!supportsBirthdays) return null; + + const handleChange = (date: Date) => onChange(date ? new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) : ''); + + return ( +
+ +
+ ); +}; + +export default BirthdayInput; diff --git a/src/components/birthday-panel.tsx b/src/components/birthday-panel.tsx new file mode 100644 index 0000000..9235752 --- /dev/null +++ b/src/components/birthday-panel.tsx @@ -0,0 +1,69 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { useEffect, useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { fetchBirthdayReminders } from 'soapbox/actions/accounts.ts'; +import Widget from 'soapbox/components/ui/widget.tsx'; +import AccountContainer from 'soapbox/containers/account-container.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; + +const timeToMidnight = () => { + const now = new Date(); + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0); + + return midnight.getTime() - now.getTime(); +}; + +interface IBirthdayPanel { + limit: number; +} + +const BirthdayPanel = ({ limit }: IBirthdayPanel) => { + const dispatch = useAppDispatch(); + + const birthdays: ImmutableOrderedSet = useAppSelector(state => state.user_lists.birthday_reminders.get(state.me as string)?.items || ImmutableOrderedSet()); + const birthdaysToRender = birthdays.slice(0, limit); + + const timeout = useRef(); + + const handleFetchBirthdayReminders = () => { + const date = new Date(); + + const day = date.getDate(); + const month = date.getMonth() + 1; + + dispatch(fetchBirthdayReminders(month, day))?.then(() => { + timeout.current = setTimeout(() => handleFetchBirthdayReminders(), timeToMidnight()); + }); + }; + + useEffect(() => { + handleFetchBirthdayReminders(); + + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; + }, []); + + if (birthdaysToRender.isEmpty()) { + return null; + } + + return ( + }> + {birthdaysToRender.map(accountId => ( + , but it isn't + id={accountId} + withRelationship={false} + /> + ))} + + ); +}; + +export default BirthdayPanel; diff --git a/src/components/blurhash.tsx b/src/components/blurhash.tsx new file mode 100644 index 0000000..7cfa6e9 --- /dev/null +++ b/src/components/blurhash.tsx @@ -0,0 +1,59 @@ +import { decode } from 'blurhash'; +import { useRef, useEffect, memo } from 'react'; + +interface IBlurhash { + /** Hash to render */ + hash: string | null | undefined; + /** Width of the blurred region in pixels. Defaults to 32. */ + width?: number; + /** Height of the blurred region in pixels. Defaults to width. */ + height?: number; + /** + * Whether dummy mode is enabled. If enabled, nothing is rendered + * and canvas left untouched. + */ + dummy?: boolean; + /** className of the canvas element. */ + className?: string; +} + +/** + * Renders a blurhash in a canvas element. + * @see {@link https://blurha.sh/} + */ +const Blurhash: React.FC = ({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) => { + const canvasRef = useRef(null); + + useEffect(() => { + const { current: canvas } = canvasRef; + if (!canvas) return; + + // resets canvas + canvas.width = canvas.width; // eslint-disable-line no-self-assign + + if (dummy || !hash) return; + + try { + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); + + if (!ctx) return; + ctx.putImageData(imageData, 0, 0); + } catch (err) { + console.error('Blurhash decoding failure', { err, hash }); + } + }, [dummy, hash, width, height]); + + return ( + + ); +}; + +export default memo(Blurhash); diff --git a/src/components/copyable-input.tsx b/src/components/copyable-input.tsx new file mode 100644 index 0000000..2259504 --- /dev/null +++ b/src/components/copyable-input.tsx @@ -0,0 +1,56 @@ +import { useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Button from 'soapbox/components/ui/button.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Input from 'soapbox/components/ui/input.tsx'; + +interface ICopyableInput { + /** Text to be copied. */ + value: string; + /** Input type. */ + type?: 'text' | 'password'; + /** Callback after the value has been copied. */ + onCopy?(): void; +} + +/** An input with copy abilities. */ +const CopyableInput: React.FC = ({ value, type = 'text', onCopy }) => { + const input = useRef(null); + + const selectInput = () => { + input.current?.select(); + + if (navigator.clipboard) { + navigator.clipboard.writeText(value); + } else { + document.execCommand('copy'); + } + + onCopy?.(); + }; + + return ( + + + + + + ); +}; + +export default CopyableInput; diff --git a/src/components/display-name-inline.tsx b/src/components/display-name-inline.tsx new file mode 100644 index 0000000..86af69f --- /dev/null +++ b/src/components/display-name-inline.tsx @@ -0,0 +1,48 @@ +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; +import { emojifyText } from 'soapbox/utils/emojify.tsx'; + +import { getAcct } from '../utils/accounts.ts'; + + +import VerificationBadge from './verification-badge.tsx'; + +import type { Account } from 'soapbox/schemas/index.ts'; + +interface IDisplayName { + account: Pick; + withSuffix?: boolean; +} + +const DisplayNameInline: React.FC = ({ account, withSuffix = true }) => { + const { displayFqn = false } = useSoapboxConfig(); + const { verified } = account; + + const displayName = ( + + + {emojifyText(account.display_name, account.emojis)} + + + {verified && } + + ); + + // eslint-disable-next-line formatjs/no-literal-string-in-jsx + const suffix = (@{getAcct(account, displayFqn)}); + + return ( +
+ {displayName} + - {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + {withSuffix && suffix} +
+ ); +}; + +export default DisplayNameInline; diff --git a/src/components/display-name.test.tsx b/src/components/display-name.test.tsx new file mode 100644 index 0000000..a9ce14e --- /dev/null +++ b/src/components/display-name.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAccount } from 'soapbox/jest/factory.ts'; +import { render, screen } from 'soapbox/jest/test-helpers.tsx'; + +import DisplayName from './display-name.tsx'; + +describe('', () => { + it('renders display name + account name', () => { + const account = buildAccount({ acct: 'bar@baz' }); + render(); + + expect(screen.getByTestId('display-name')).toHaveTextContent('bar@baz'); + }); +}); diff --git a/src/components/display-name.tsx b/src/components/display-name.tsx new file mode 100644 index 0000000..a2d46fb --- /dev/null +++ b/src/components/display-name.tsx @@ -0,0 +1,49 @@ +import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; +import { getAcct } from 'soapbox/utils/accounts.ts'; +import { emojifyText } from 'soapbox/utils/emojify.tsx'; + +import VerificationBadge from './verification-badge.tsx'; + +import type { Account } from 'soapbox/schemas/index.ts'; + +interface IDisplayName { + account: Pick; + withSuffix?: boolean; + children?: React.ReactNode; +} + +const DisplayName: React.FC = ({ account, children, withSuffix = true }) => { + const { displayFqn = false } = useSoapboxConfig(); + const { verified } = account; + + const displayName = ( + + + {emojifyText(account.display_name, account.emojis)} + + + {verified && } + + ); + + const suffix = (@{getAcct(account, displayFqn)}); // eslint-disable-line formatjs/no-literal-string-in-jsx + + return ( + + + {displayName} + + {withSuffix && suffix} + {children} + + ); +}; + +export default DisplayName; diff --git a/src/components/domain.tsx b/src/components/domain.tsx new file mode 100644 index 0000000..81a9f59 --- /dev/null +++ b/src/components/domain.tsx @@ -0,0 +1,47 @@ +import lockOpenIcon from '@tabler/icons/outline/lock-open.svg'; +import { defineMessages, useIntl } from 'react-intl'; + +import { unblockDomain } from 'soapbox/actions/domain-blocks.ts'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import IconButton from 'soapbox/components/ui/icon-button.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; + +const messages = defineMessages({ + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, +}); + +interface IDomain { + domain: string; +} + +const Domain: React.FC = ({ domain }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + // const onBlockDomain = () => { + // dispatch(openModal('CONFIRM', { + // icon: banIcon, + // heading: , + // message: {domain} }} />, + // confirm: intl.formatMessage(messages.blockDomainConfirm), + // onConfirm: () => dispatch(blockDomain(domain)), + // })); + // } + + const handleDomainUnblock = () => { + dispatch(unblockDomain(domain)); + }; + + return ( + + + {domain} + + + + ); +}; + +export default Domain; diff --git a/src/components/dropdown-menu/dropdown-menu-item.tsx b/src/components/dropdown-menu/dropdown-menu-item.tsx new file mode 100644 index 0000000..7090fd8 --- /dev/null +++ b/src/components/dropdown-menu/dropdown-menu-item.tsx @@ -0,0 +1,110 @@ +import clsx from 'clsx'; +import { useEffect, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; + +import Counter from 'soapbox/components/ui/counter.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; + +export interface MenuItem { + action?: React.EventHandler; + active?: boolean; + count?: number; + destructive?: boolean; + href?: string; + icon?: string; + meta?: string; + middleClick?(event: React.MouseEvent): void; + target?: React.HTMLAttributeAnchorTarget; + text: string; + to?: string; +} + +interface IDropdownMenuItem { + index: number; + item: MenuItem | null; + onClick?(): void; +} + +const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => { + const history = useHistory(); + + const itemRef = useRef(null); + + const handleClick: React.EventHandler = (event) => { + event.stopPropagation(); + + if (!item) return; + + if (onClick) onClick(); + + if (item.to) { + event.preventDefault(); + history.push(item.to); + } else if (typeof item.action === 'function') { + event.preventDefault(); + item.action(event); + } + }; + + const handleAuxClick: React.EventHandler = (event) => { + if (!item) return; + if (onClick) onClick(); + + if (event.button === 1 && item.middleClick) { + item.middleClick(event); + } + }; + + const handleItemKeyPress: React.EventHandler = (event) => { + if (event.key === 'Enter' || event.key === ' ') { + handleClick(event); + } + }; + + useEffect(() => { + const firstItem = index === 0; + + if (itemRef.current && firstItem) { + itemRef.current.focus({ preventScroll: true }); + } + }, [itemRef.current, index]); + + if (item === null) { + return
  • ; + } + + return ( +
  • + + {item.icon && } + + {item.text} + + {item.count ? ( + + + + ) : null} + +
  • + ); +}; + +export default DropdownMenuItem; diff --git a/src/components/dropdown-menu/dropdown-menu.tsx b/src/components/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 0000000..0818970 --- /dev/null +++ b/src/components/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,332 @@ +import { offset, Placement, useFloating, flip, arrow, shift } from '@floating-ui/react'; +import dotsIcon from '@tabler/icons/outline/dots.svg'; +import clsx from 'clsx'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import { cloneElement, useEffect, useMemo, useRef, useState } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'soapbox/actions/dropdown-menu.ts'; +import { closeModal, openModal } from 'soapbox/actions/modals.ts'; +import IconButton from 'soapbox/components/ui/icon-button.tsx'; +import Portal from 'soapbox/components/ui/portal.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { userTouching } from 'soapbox/is-mobile.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; + +import DropdownMenuItem, { MenuItem } from './dropdown-menu-item.tsx'; + +export type Menu = Array; + +interface IDropdownMenu { + children?: React.ReactElement; + modal?: boolean; + disabled?: boolean; + items: Menu; + onClose?: () => void; + onOpen?: () => void; + onShiftClick?: React.EventHandler; + placement?: Placement; + src?: string; + status?: StatusEntity; + title?: string; +} + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +const DropdownMenu = (props: IDropdownMenu) => { + const { + children, + modal = false, + disabled, + items, + onClose, + onOpen, + onShiftClick, + placement: initialPlacement = 'top', + src = dotsIcon, + title = 'Menu', + ...filteredProps + } = props; + + const dispatch = useAppDispatch(); + const history = useHistory(); + + const [isOpen, setIsOpen] = useState(false); + + const arrowRef = useRef(null); + + const { x, y, strategy, refs, middlewareData, placement } = useFloating({ + placement: initialPlacement, + middleware: [ + offset(12), + flip(), + shift({ + padding: 8, + }), + arrow({ + element: arrowRef, + }), + ], + }); + + const handleClick: React.EventHandler< + React.MouseEvent | React.KeyboardEvent + > = (event) => { + event.stopPropagation(); + + if (onShiftClick && event.shiftKey) { + event.preventDefault(); + + onShiftClick(event); + return; + } + + if (isOpen) { + handleClose(); + } else { + handleOpen(); + } + }; + + /** + * On mobile screens, let's replace the Popper dropdown with a Modal. + */ + const handleOpen = () => { + if (userTouching.matches || modal) { + dispatch( + openModal('ACTIONS', { + status: filteredProps.status, + actions: items, + onClick: handleItemClick, + }), + ); + } else { + dispatch(openDropdownMenu()); + setIsOpen(true); + } + + if (onOpen) { + onOpen(); + } + }; + + const handleClose = () => { + (refs.reference.current as HTMLButtonElement)?.focus(); + + if (userTouching.matches || modal) { + dispatch(closeModal('ACTIONS')); + } else { + closeDropdownMenu(); + setIsOpen(false); + } + + if (onClose) { + onClose(); + } + }; + + const closeDropdownMenu = () => { + dispatch((dispatch, getState) => { + const isOpenRedux = getState().dropdown_menu.isOpen; + + if (isOpenRedux) { + dispatch(closeDropdownMenuRedux()); + } + }); + }; + + const handleKeyPress: React.EventHandler> = (event) => { + switch (event.key) { + case ' ': + case 'Enter': + event.stopPropagation(); + event.preventDefault(); + handleClick(event); + break; + } + }; + + const handleItemClick: React.EventHandler = (event) => { + event.preventDefault(); + event.stopPropagation(); + + const i = Number(event.currentTarget.getAttribute('data-index')); + const item = items[i]; + if (!item) return; + + const { action, to } = item; + + handleClose(); + + if (typeof action === 'function') { + action(event); + } else if (to) { + history.push(to); + } + }; + + const handleDocumentClick = (event: Event) => { + if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) { + handleClose(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (!refs.floating.current) return; + + const items = Array.from(refs.floating.current.getElementsByTagName('a')); + const index = items.indexOf(document.activeElement as any); + + let element = null; + + switch (e.key) { + case 'ArrowDown': + element = items[index + 1] || items[0]; + break; + case 'ArrowUp': + element = items[index - 1] || items[items.length - 1]; + break; + case 'Tab': + if (e.shiftKey) { + element = items[index - 1] || items[items.length - 1]; + } else { + element = items[index + 1] || items[0]; + } + break; + case 'Home': + element = items[0]; + break; + case 'End': + element = items[items.length - 1]; + break; + case 'Escape': + handleClose(); + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + }; + + const arrowProps: React.CSSProperties = useMemo(() => { + if (middlewareData.arrow) { + const { x, y } = middlewareData.arrow; + + const staticPlacement = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[placement.split('-')[0]]; + + return { + left: x !== null ? `${x}px` : '', + top: y !== null ? `${y}px` : '', + // Ensure the static side gets unset when + // flipping to other placements' axes. + right: '', + bottom: '', + [staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`, + transform: 'rotate(45deg)', + }; + } + + return {}; + }, [middlewareData.arrow, placement]); + + useEffect(() => { + return () => { + closeDropdownMenu(); + }; + }, []); + + useEffect(() => { + if (isOpen) { + if (refs.floating.current) { + (refs.floating.current?.querySelector('li a[role=\'button\']') as HTMLAnchorElement)?.focus(); + } + + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('keydown', handleKeyDown, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + return () => { + document.removeEventListener('click', handleDocumentClick); + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('touchend', handleDocumentClick); + }; + } + }, [isOpen, refs.floating.current]); + + if (items.length === 0) { + return null; + } + + return ( + <> + {children ? ( + cloneElement(children, { + disabled, + onClick: handleClick, + onKeyPress: handleKeyPress, + ref: refs.setReference, + }) + ) : ( + + )} + + {isOpen ? ( + +
    +
      + {items.map((item, idx) => ( + + ))} +
    + + {/* Arrow */} +
    +
    + + ) : null} + + ); +}; + +export default DropdownMenu; diff --git a/src/components/dropdown-menu/index.ts b/src/components/dropdown-menu/index.ts new file mode 100644 index 0000000..780e5b9 --- /dev/null +++ b/src/components/dropdown-menu/index.ts @@ -0,0 +1,3 @@ +export { default } from './dropdown-menu.tsx'; +export type { Menu } from './dropdown-menu.tsx'; +export type { MenuItem } from './dropdown-menu-item.tsx'; \ No newline at end of file diff --git a/src/components/emoji-graphic.tsx b/src/components/emoji-graphic.tsx new file mode 100644 index 0000000..207fb40 --- /dev/null +++ b/src/components/emoji-graphic.tsx @@ -0,0 +1,18 @@ +import Emoji from 'soapbox/components/ui/emoji.tsx'; + +interface IEmojiGraphic { + emoji: string; +} + +/** Large emoji with a background for display purposes (eg breaking up a page). */ +const EmojiGraphic: React.FC = ({ emoji }) => { + return ( +
    +
    + +
    +
    + ); +}; + +export default EmojiGraphic; \ No newline at end of file diff --git a/src/components/event-preview.tsx b/src/components/event-preview.tsx new file mode 100644 index 0000000..42be289 --- /dev/null +++ b/src/components/event-preview.tsx @@ -0,0 +1,97 @@ +import mapPinIcon from '@tabler/icons/outline/map-pin.svg'; +import userIcon from '@tabler/icons/outline/user.svg'; +import clsx from 'clsx'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import Button from 'soapbox/components/ui/button.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import EventActionButton from 'soapbox/features/event/components/event-action-button.tsx'; +import EventDate from 'soapbox/features/event/components/event-date.tsx'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; + +import Icon from './icon.tsx'; +import VerificationBadge from './verification-badge.tsx'; + +import type { Status as StatusEntity } from 'soapbox/types/entities.ts'; + +const messages = defineMessages({ + eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' }, + leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' }, + leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' }, +}); + +interface IEventPreview { + status: StatusEntity; + className?: string; + hideAction?: boolean; + floatingAction?: boolean; +} + +const EventPreview: React.FC = ({ status, className, hideAction, floatingAction = true }) => { + const intl = useIntl(); + + const me = useAppSelector((state) => state.me); + + const account = status.account; + const event = status.event!; + + const banner = event.banner; + + const action = !hideAction && (account.id === me ? ( + + ) : ( + + )); + + return ( +
    +
    + {floatingAction && action} +
    +
    + {banner && {intl.formatMessage(messages.eventBanner)}} +
    + + + {event.name} + + {!floatingAction && action} + + +
    + + + + {account.display_name} + {account.verified && } + + + + + + {event.location && ( + + + + {event.location.get('name')} + + + )} +
    +
    +
    + ); +}; + +export default EventPreview; diff --git a/src/components/extended-video-player.tsx b/src/components/extended-video-player.tsx new file mode 100644 index 0000000..e5567aa --- /dev/null +++ b/src/components/extended-video-player.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react'; + +import { isIOS } from 'soapbox/is-mobile.ts'; + +interface IExtendedVideoPlayer { + src: string; + alt?: string; + width?: number; + height?: number; + time?: number; + controls?: boolean; + muted?: boolean; + onClick?: () => void; +} + +const ExtendedVideoPlayer: React.FC = ({ src, alt, time, controls, muted, onClick }) => { + const video = useRef(null); + + useEffect(() => { + const handleLoadedData = () => { + if (time) { + video.current!.currentTime = time; + } + }; + + video.current?.addEventListener('loadeddata', handleLoadedData); + + return () => { + video.current?.removeEventListener('loadeddata', handleLoadedData); + }; + }, [video.current]); + + const handleClick: React.MouseEventHandler = e => { + e.stopPropagation(); + const handler = onClick; + if (handler) handler(); + }; + + const conditionalAttributes: React.VideoHTMLAttributes = {}; + if (isIOS()) { + conditionalAttributes.playsInline = true; + } + + return ( +
    +
    + ); +}; + +export default ExtendedVideoPlayer; diff --git a/src/components/fork-awesome-icon.tsx b/src/components/fork-awesome-icon.tsx new file mode 100644 index 0000000..2f3e46b --- /dev/null +++ b/src/components/fork-awesome-icon.tsx @@ -0,0 +1,33 @@ +/** + * ForkAwesomeIcon: renders a ForkAwesome icon. + * Full list: https://forkaweso.me/Fork-Awesome/icons/ + * @module soapbox/components/fork_awesome_icon + * @see soapbox/components/icon + */ + +import clsx from 'clsx'; + +export interface IForkAwesomeIcon extends React.HTMLAttributes { + id: string; + className?: string; + fixedWidth?: boolean; +} + +const ForkAwesomeIcon: React.FC = ({ id, className, fixedWidth, ...rest }) => { + // Use the Fork Awesome retweet icon, but change its alt + // tag. There is a common adblocker rule which hides elements with + // alt='retweet' unless the domain is twitter.com. This should + // change what screenreaders call it as well. + // const alt = (id === 'retweet') ? 'repost' : id; + + return ( + + ); +}; + +export default ForkAwesomeIcon; diff --git a/src/components/gdpr-banner.tsx b/src/components/gdpr-banner.tsx new file mode 100644 index 0000000..8e723b9 --- /dev/null +++ b/src/components/gdpr-banner.tsx @@ -0,0 +1,69 @@ +import clsx from 'clsx'; +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Banner from 'soapbox/components/ui/banner.tsx'; +import Button from 'soapbox/components/ui/button.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useInstance } from 'soapbox/hooks/useInstance.ts'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; + +const acceptedGdpr = !!localStorage.getItem('soapbox:gdpr'); + +/** Displays a cookie consent banner. */ +const GdprBanner: React.FC = () => { + /** Track whether the banner has already been displayed once. */ + const [shown, setShown] = useState(acceptedGdpr); + const [slideout, setSlideout] = useState(false); + + const { instance } = useInstance(); + const { gdprUrl } = useSoapboxConfig(); + + const handleAccept = () => { + localStorage.setItem('soapbox:gdpr', 'true'); + setSlideout(true); + setTimeout(() => setShown(true), 200); + }; + + if (shown) { + return null; + } + + return ( + +
    + + + + + + + + + + + + {gdprUrl && ( + + + + )} + + + +
    +
    + ); +}; + +export default GdprBanner; diff --git a/src/components/group-card.tsx b/src/components/group-card.tsx new file mode 100644 index 0000000..3cfee38 --- /dev/null +++ b/src/components/group-card.tsx @@ -0,0 +1,58 @@ +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import GroupHeaderImage from 'soapbox/features/group/components/group-header-image.tsx'; +import GroupMemberCount from 'soapbox/features/group/components/group-member-count.tsx'; +import GroupPrivacy from 'soapbox/features/group/components/group-privacy.tsx'; +import GroupRelationship from 'soapbox/features/group/components/group-relationship.tsx'; + +import GroupAvatar from './groups/group-avatar.tsx'; + +import type { Group as GroupEntity } from 'soapbox/types/entities.ts'; + +interface IGroupCard { + group: GroupEntity; +} + +const GroupCard: React.FC = ({ group }) => { + return ( + + {/* Group Cover Image */} + + + + + {/* Group Avatar */} +
    + +
    + + {/* Group Info */} + + + + {group.display_name} + + + {group.relationship?.pending_requests && ( +
    + )} + + + + + + + + + + ); +}; + +export default GroupCard; diff --git a/src/components/groups/group-avatar.tsx b/src/components/groups/group-avatar.tsx new file mode 100644 index 0000000..0f8f399 --- /dev/null +++ b/src/components/groups/group-avatar.tsx @@ -0,0 +1,35 @@ +import clsx from 'clsx'; + +import Avatar from 'soapbox/components/ui/avatar.tsx'; +import { GroupRoles } from 'soapbox/schemas/group-member.ts'; + +import type { Group } from 'soapbox/schemas/index.ts'; + +interface IGroupAvatar { + group: Group; + size: number; + withRing?: boolean; +} + +const GroupAvatar = (props: IGroupAvatar) => { + const { group, size, withRing = false } = props; + + const isOwner = group.relationship?.role === GroupRoles.OWNER; + + return ( + + ); +}; + +export default GroupAvatar; \ No newline at end of file diff --git a/src/components/groups/popover/group-popover.tsx b/src/components/groups/popover/group-popover.tsx new file mode 100644 index 0000000..ea5c4e9 --- /dev/null +++ b/src/components/groups/popover/group-popover.tsx @@ -0,0 +1,114 @@ +import { defineMessages, useIntl } from 'react-intl'; +import { Link, matchPath, useHistory } from 'react-router-dom'; + +import Button from 'soapbox/components/ui/button.tsx'; +import Divider from 'soapbox/components/ui/divider.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Popover from 'soapbox/components/ui/popover.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import GroupMemberCount from 'soapbox/features/group/components/group-member-count.tsx'; +import GroupPrivacy from 'soapbox/features/group/components/group-privacy.tsx'; + +import GroupAvatar from '../group-avatar.tsx'; + +import type { Group } from 'soapbox/schemas/index.ts'; + +interface IGroupPopoverContainer { + children: React.ReactElement>; + isEnabled: boolean; + group: Group; +} + +const messages = defineMessages({ + title: { id: 'group.popover.title', defaultMessage: 'Membership required' }, + summary: { id: 'group.popover.summary', defaultMessage: 'You must be a member of the group in order to reply to this status.' }, + action: { id: 'group.popover.action', defaultMessage: 'View Group' }, +}); + +const GroupPopover = (props: IGroupPopoverContainer) => { + const { children, group, isEnabled } = props; + + const intl = useIntl(); + const history = useHistory(); + + const path = history.location.pathname; + const shouldHideAction = matchPath(path, { + path: ['/group/:groupSlug'], + exact: true, + }); + + if (!isEnabled) { + return children; + } + + return ( + + + {/* Group Cover Image */} + + {group.header && ( + + )} + + + {/* Group Avatar */} +
    + +
    + + {/* Group Info */} + + + {group.display_name} + + + + + + + +
    + + + + + + {intl.formatMessage(messages.title)} + + + {intl.formatMessage(messages.summary)} + + + + {!shouldHideAction && ( +
    + + + +
    + )} + + } + isFlush + children={ +
    {children}
    + } + /> + ); +}; + +export default GroupPopover; \ No newline at end of file diff --git a/src/components/hashtag-link.tsx b/src/components/hashtag-link.tsx new file mode 100644 index 0000000..9f1437e --- /dev/null +++ b/src/components/hashtag-link.tsx @@ -0,0 +1,14 @@ +import Link from './link.tsx'; + +interface IHashtagLink { + hashtag: string; +} + +const HashtagLink: React.FC = ({ hashtag }) => ( + // eslint-disable-next-line formatjs/no-literal-string-in-jsx + e.stopPropagation()}> + #{hashtag} + +); + +export default HashtagLink; \ No newline at end of file diff --git a/src/components/hashtag.tsx b/src/components/hashtag.tsx new file mode 100644 index 0000000..b86aefe --- /dev/null +++ b/src/components/hashtag.tsx @@ -0,0 +1,57 @@ +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { Sparklines, SparklinesCurve } from 'react-sparklines'; + + +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; + +import { shortNumberFormat } from '../utils/numbers.tsx'; + +import type { Tag } from 'soapbox/types/entities.ts'; + +interface IHashtag { + hashtag: Tag; +} + +const Hashtag: React.FC = ({ hashtag }) => { + const count = Number(hashtag.history?.get(0)?.accounts); + + return ( + + + + #{hashtag.name} {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + + + {Boolean(count) && ( + + {shortNumberFormat(count)}, + }} + /> + + )} + + + {hashtag.history && ( +
    + +day.uses).toArray()} + > + + +
    + )} +
    + ); +}; + +export default Hashtag; diff --git a/src/components/helmet.tsx b/src/components/helmet.tsx new file mode 100644 index 0000000..1a664ba --- /dev/null +++ b/src/components/helmet.tsx @@ -0,0 +1,59 @@ +import { useMemo, useEffect } from 'react'; +import { Helmet as ReactHelmet } from 'react-helmet'; + +import { useStatContext } from 'soapbox/contexts/stat-context.tsx'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { useInstance } from 'soapbox/hooks/useInstance.ts'; +import { useSettings } from 'soapbox/hooks/useSettings.ts'; +import { RootState } from 'soapbox/store.ts'; +import FaviconService from 'soapbox/utils/favicon-service.ts'; + +FaviconService.initFaviconService(); + +const getNotifTotals = (state: RootState): number => { + const notifications = state.notifications.unread || 0; + const reports = state.admin.openReports.count(); + const approvals = state.admin.awaitingApproval.count(); + return notifications + reports + approvals; +}; + +interface IHelmet { + children: React.ReactNode; +} + +const Helmet: React.FC = ({ children }) => { + const { instance } = useInstance(); + const { unreadChatsCount } = useStatContext(); + const unreadCount = useAppSelector((state) => getNotifTotals(state) + unreadChatsCount); + const { demetricator } = useSettings(); + + const hasUnreadNotifications = useMemo(() => !(unreadCount < 1 || demetricator), [unreadCount, demetricator]); + + const addCounter = (string: string) => { + return hasUnreadNotifications ? `(${unreadCount}) ${string}` : string; + }; + + const updateFaviconBadge = () => { + if (hasUnreadNotifications) { + FaviconService.drawFaviconBadge(); + } else { + FaviconService.clearFaviconBadge(); + } + }; + + useEffect(() => { + updateFaviconBadge(); + }, [unreadCount, demetricator]); + + return ( + + {children} + + ); +}; + +export default Helmet; diff --git a/src/components/hover-ref-wrapper.tsx b/src/components/hover-ref-wrapper.tsx new file mode 100644 index 0000000..a6ce153 --- /dev/null +++ b/src/components/hover-ref-wrapper.tsx @@ -0,0 +1,60 @@ +import clsx from 'clsx'; +import { debounce } from 'es-toolkit'; +import { useRef } from 'react'; + +import { fetchAccount } from 'soapbox/actions/accounts.ts'; +import { + openProfileHoverCard, + closeProfileHoverCard, +} from 'soapbox/actions/profile-hover-card.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { isMobile } from 'soapbox/is-mobile.ts'; + +const showProfileHoverCard = debounce((dispatch, ref, accountId) => { + dispatch(openProfileHoverCard(ref, accountId)); +}, 600); + +interface IHoverRefWrapper { + accountId: string; + inline?: boolean; + className?: string; + children: React.ReactNode; +} + +/** Makes a profile hover card appear when the wrapped element is hovered. */ +export const HoverRefWrapper: React.FC = ({ accountId, children, inline = false, className }) => { + const dispatch = useAppDispatch(); + const ref = useRef(null); + const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div'; + + const handleMouseEnter = () => { + if (!isMobile(window.innerWidth)) { + dispatch(fetchAccount(accountId)); + showProfileHoverCard(dispatch, ref, accountId); + } + }; + + const handleMouseLeave = () => { + showProfileHoverCard.cancel(); + setTimeout(() => dispatch(closeProfileHoverCard()), 300); + }; + + const handleClick = () => { + showProfileHoverCard.cancel(); + dispatch(closeProfileHoverCard(true)); + }; + + return ( + + {children} + + ); +}; + +export { HoverRefWrapper as default, showProfileHoverCard }; diff --git a/src/components/hover-status-wrapper.tsx b/src/components/hover-status-wrapper.tsx new file mode 100644 index 0000000..d477f20 --- /dev/null +++ b/src/components/hover-status-wrapper.tsx @@ -0,0 +1,57 @@ +import { debounce } from 'es-toolkit'; +import { useRef } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + openStatusHoverCard, + closeStatusHoverCard, +} from 'soapbox/actions/status-hover-card.ts'; +import { isMobile } from 'soapbox/is-mobile.ts'; + +const showStatusHoverCard = debounce((dispatch, ref, statusId) => { + dispatch(openStatusHoverCard(ref, statusId)); +}, 300); + +interface IHoverStatusWrapper { + statusId: any; + inline: boolean; + className?: string; + children: React.ReactNode; +} + +/** Makes a status hover card appear when the wrapped element is hovered. */ +export const HoverStatusWrapper: React.FC = ({ statusId, children, inline = false, className }) => { + const dispatch = useDispatch(); + const ref = useRef(null); + const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div'; + + const handleMouseEnter = () => { + if (!isMobile(window.innerWidth)) { + showStatusHoverCard(dispatch, ref, statusId); + } + }; + + const handleMouseLeave = () => { + showStatusHoverCard.cancel(); + setTimeout(() => dispatch(closeStatusHoverCard()), 200); + }; + + const handleClick = () => { + showStatusHoverCard.cancel(); + dispatch(closeStatusHoverCard(true)); + }; + + return ( + + {children} + + ); +}; + +export { HoverStatusWrapper as default, showStatusHoverCard }; diff --git a/src/components/icon-button.tsx b/src/components/icon-button.tsx new file mode 100644 index 0000000..1b0a676 --- /dev/null +++ b/src/components/icon-button.tsx @@ -0,0 +1,99 @@ +import clsx from 'clsx'; + +import Icon from 'soapbox/components/icon.tsx'; + +interface IIconButton extends Pick, 'className' | 'disabled' | 'onClick' | 'onKeyDown' | 'onKeyPress' | 'onKeyUp' | 'onMouseDown' | 'onMouseEnter' | 'onMouseLeave' | 'tabIndex' | 'title'> { + active?: boolean; + expanded?: boolean; + iconClassName?: string; + pressed?: boolean; + size?: number; + src: string; + text?: React.ReactNode; +} + +const IconButton: React.FC = ({ + active, + className, + disabled, + expanded, + iconClassName, + onClick, + onKeyDown, + onKeyUp, + onKeyPress, + onMouseDown, + onMouseEnter, + onMouseLeave, + pressed, + size = 18, + src, + tabIndex = 0, + text, + title, +}) => { + + const handleClick: React.MouseEventHandler = (e) => { + e.preventDefault(); + + if (!disabled && onClick) { + onClick(e); + } + }; + + const handleMouseDown: React.MouseEventHandler = (e) => { + if (!disabled && onMouseDown) { + onMouseDown(e); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (!disabled && onKeyDown) { + onKeyDown(e); + } + }; + + const handleKeyUp: React.KeyboardEventHandler = (e) => { + if (!disabled && onKeyUp) { + onKeyUp(e); + } + }; + + const handleKeyPress: React.KeyboardEventHandler = (e) => { + if (onKeyPress && !disabled) { + onKeyPress(e); + } + }; + + const classes = clsx(className, 'inline-flex cursor-pointer items-center border-0 bg-transparent p-0 text-black opacity-40 transition-opacity duration-100 ease-in hover:opacity-60 hover:transition-colors hover:duration-200 focus:opacity-60 focus:outline-none focus:transition-colors focus:duration-200 dark:text-white', { + 'opacity-60 outline-none transition-colors duration-200': active, + 'opacity-20 cursor-default': disabled, + }); + + return ( + + ); +}; + +export default IconButton; diff --git a/src/components/icon-with-counter.tsx b/src/components/icon-with-counter.tsx new file mode 100644 index 0000000..072e216 --- /dev/null +++ b/src/components/icon-with-counter.tsx @@ -0,0 +1,25 @@ +import Icon, { IIcon } from 'soapbox/components/icon.tsx'; +import Counter from 'soapbox/components/ui/counter.tsx'; + +interface IIconWithCounter extends React.HTMLAttributes { + count: number; + countMax?: number; + icon?: string; + src?: string; +} + +const IconWithCounter: React.FC = ({ icon, count, countMax, ...rest }) => { + return ( +
    + + + {count > 0 && ( + + + + )} +
    + ); +}; + +export default IconWithCounter; diff --git a/src/components/icon.tsx b/src/components/icon.tsx new file mode 100644 index 0000000..c463b08 --- /dev/null +++ b/src/components/icon.tsx @@ -0,0 +1,30 @@ +/** + * Icon: abstract component to render SVG icons. + * @module soapbox/components/icon + */ + +import clsx from 'clsx'; +import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports + +export interface IIcon extends React.HTMLAttributes { + src: string; + id?: string; + alt?: string; + className?: string; +} + +/** + * @deprecated Use the UI Icon component directly. + */ +const Icon: React.FC = ({ src, alt, className, ...rest }) => { + return ( +
    + } /> +
    + ); +}; + +export default Icon; diff --git a/src/components/landing-gradient.tsx b/src/components/landing-gradient.tsx new file mode 100644 index 0000000..d7d22dd --- /dev/null +++ b/src/components/landing-gradient.tsx @@ -0,0 +1,6 @@ +/** Fullscreen gradient used as a backdrop to public pages. */ +const LandingGradient: React.FC = () => ( +
    +); + +export default LandingGradient; diff --git a/src/components/link.tsx b/src/components/link.tsx new file mode 100644 index 0000000..8e9c11b --- /dev/null +++ b/src/components/link.tsx @@ -0,0 +1,10 @@ +import { Link as Comp, LinkProps } from 'react-router-dom'; + +const Link = (props: LinkProps) => ( + +); + +export default Link; \ No newline at end of file diff --git a/src/components/list.tsx b/src/components/list.tsx new file mode 100644 index 0000000..cdf9dc6 --- /dev/null +++ b/src/components/list.tsx @@ -0,0 +1,134 @@ +import checkIcon from '@tabler/icons/outline/check.svg'; +import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg'; +import clsx from 'clsx'; +import { Children, cloneElement, isValidElement, useCallback } from 'react'; +import { Link } from 'react-router-dom'; + + +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Select from 'soapbox/components/ui/select.tsx'; + +import { SelectDropdown } from '../features/forms/index.tsx'; + +interface IList { + children: React.ReactNode; +} + +const List: React.FC = ({ children }) => ( +
    {children}
    +); + +interface IListItem { + label: React.ReactNode; + hint?: React.ReactNode; + to?: string; + onClick?(): void; + onSelect?(): void; + isSelected?: boolean; + children?: React.ReactNode; +} + +const ListItem: React.FC = ({ label, hint, children, to, onClick, onSelect, isSelected }) => { + const id = crypto.randomUUID(); + const domId = `list-group-${id}`; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onClick!(); + } + }; + + const LabelComp = to || onClick || onSelect ? 'span' : 'label'; + + const renderChildren = useCallback(() => { + return Children.map(children, (child) => { + if (isValidElement(child)) { + const isSelect = child.type === SelectDropdown || child.type === Select; + + return cloneElement(child, { + // @ts-ignore + id: domId, + className: clsx({ + 'w-auto': isSelect, + }, child.props.className), + }); + } + + return null; + }); + }, [children, domId]); + + const className = clsx('flex items-center justify-between overflow-hidden bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 px-4 py-2 first:rounded-t-lg last:rounded-b-lg dark:from-gradient-start/10 dark:to-gradient-end/10', { + 'cursor-pointer hover:from-gradient-start/30 hover:to-gradient-end/30 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof to !== 'undefined' || typeof onClick !== 'undefined' || typeof onSelect !== 'undefined', + }); + + const body = ( + <> +
    + {label} + + {hint ? ( + {hint} + ) : null} +
    + + {(to || onClick) ? ( + + {children} + + + + ) : null} + + {onSelect ? ( +
    + {children} + +
    + +
    +
    + ) : null} + + {typeof to === 'undefined' && typeof onClick === 'undefined' && typeof onSelect === 'undefined' ? renderChildren() : null} + + ); + + if (to) return ( + + {body} + + ); + + const Comp = onClick ? 'a' : 'div'; + const linkProps = onClick || onSelect ? { onClick: onClick || onSelect, onKeyDown, tabIndex: 0, role: 'link' } : {}; + + return ( + + {body} + + ); +}; + +export { List as default, ListItem }; diff --git a/src/components/load-gap.tsx b/src/components/load-gap.tsx new file mode 100644 index 0000000..fbd074a --- /dev/null +++ b/src/components/load-gap.tsx @@ -0,0 +1,28 @@ +import dotsIcon from '@tabler/icons/outline/dots.svg'; +import { defineMessages, useIntl } from 'react-intl'; + +import SvgIcon from 'soapbox/components/ui/svg-icon.tsx'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +interface ILoadGap { + disabled?: boolean; + maxId: string; + onClick: (id: string) => void; +} + +const LoadGap: React.FC = ({ disabled, maxId, onClick }) => { + const intl = useIntl(); + + const handleClick = () => onClick(maxId); + + return ( + + ); +}; + +export default LoadGap; diff --git a/src/components/load-more.tsx b/src/components/load-more.tsx new file mode 100644 index 0000000..67a393a --- /dev/null +++ b/src/components/load-more.tsx @@ -0,0 +1,24 @@ +import { FormattedMessage } from 'react-intl'; + +import Button from 'soapbox/components/ui/button.tsx'; + +interface ILoadMore { + onClick: React.MouseEventHandler; + disabled?: boolean; + visible?: boolean; + className?: string; +} + +const LoadMore: React.FC = ({ onClick, disabled, visible = true, className }) => { + if (!visible) { + return null; + } + + return ( + + ); +}; + +export default LoadMore; diff --git a/src/components/loading-screen.tsx b/src/components/loading-screen.tsx new file mode 100644 index 0000000..3a8511b --- /dev/null +++ b/src/components/loading-screen.tsx @@ -0,0 +1,19 @@ +import LandingGradient from 'soapbox/components/landing-gradient.tsx'; +import Spinner from 'soapbox/components/ui/spinner.tsx'; + +/** Fullscreen loading indicator. */ +const LoadingScreen: React.FC = () => { + return ( +
    + + +
    +
    + +
    +
    +
    + ); +}; + +export default LoadingScreen; diff --git a/src/components/location-search.tsx b/src/components/location-search.tsx new file mode 100644 index 0000000..9867a52 --- /dev/null +++ b/src/components/location-search.tsx @@ -0,0 +1,112 @@ +import backspaceIcon from '@tabler/icons/outline/backspace.svg'; +import searchIcon from '@tabler/icons/outline/search.svg'; +import clsx from 'clsx'; +import { throttle } from 'es-toolkit'; +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { locationSearch } from 'soapbox/actions/events.ts'; +import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input.tsx'; +import SvgIcon from 'soapbox/components/ui/svg-icon.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; + +import AutosuggestLocation from './autosuggest-location.tsx'; + +const noOp = () => {}; + +const messages = defineMessages({ + placeholder: { id: 'location_search.placeholder', defaultMessage: 'Find an address' }, +}); + +interface ILocationSearch { + onSelected: (locationId: string) => void; +} + +const LocationSearch: React.FC = ({ onSelected }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const [locationIds, setLocationIds] = useState(ImmutableOrderedSet()); + const controller = useRef(new AbortController()); + + const [value, setValue] = useState(''); + + const isEmpty = (): boolean => { + return !(value.length > 0); + }; + + const handleChange: React.ChangeEventHandler = ({ target }) => { + refreshCancelToken(); + handleLocationSearch(target.value); + setValue(target.value); + }; + + const handleSelected = (_tokenStart: number, _lastToken: string | null, suggestion: AutoSuggestion) => { + if (typeof suggestion === 'string') { + onSelected(suggestion); + } + }; + + const handleClear: React.MouseEventHandler = e => { + e.preventDefault(); + + if (!isEmpty()) { + setValue(''); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + if (e.key === 'Escape') { + document.querySelector('.ui')?.parentElement?.focus(); + } + }; + + const refreshCancelToken = () => { + controller.current.abort(); + controller.current = new AbortController(); + }; + + const clearResults = () => { + setLocationIds(ImmutableOrderedSet()); + }; + + const handleLocationSearch = useCallback(throttle(q => { + dispatch(locationSearch(q, controller.current.signal)) + .then((locations: { origin_id: string }[]) => { + const locationIds = locations.map(location => location.origin_id); + setLocationIds(ImmutableOrderedSet(locationIds)); + }) + .catch(noOp); + + }, 900, { edges: ['leading', 'trailing'] }), []); + + useEffect(() => { + if (value === '') { + clearResults(); + } + }, [value]); + + return ( +
    + +
    + + +
    +
    + ); +}; + +export default LocationSearch; diff --git a/src/components/markup.css b/src/components/markup.css new file mode 100644 index 0000000..4c7d6fb --- /dev/null +++ b/src/components/markup.css @@ -0,0 +1,125 @@ +[data-markup] { + @apply whitespace-pre-wrap; +} + +[data-markup] h1 { + @apply text-3xl font-semibold; +} + +[data-markup] h2 { + @apply text-2xl font-semibold; +} + +[data-markup] h3 { + @apply text-xl font-black; +} + +[data-markup] p { + @apply mb-4 whitespace-pre-wrap; +} + +[data-markup] p:last-child { + @apply mb-0; +} + +[data-markup] a { + @apply text-primary-600 dark:text-accent-blue hover:underline; +} + +[data-markup] strong { + @apply font-bold; +} + +[data-markup] em { + @apply italic; +} + +[data-markup] ul, +[data-markup] ol { + @apply pl-10 mb-4; +} + +[data-markup] ul { + @apply list-disc list-outside; +} + +[data-markup] ol { + @apply list-decimal list-outside; +} + +[data-markup] blockquote { + @apply py-1 pl-4 mb-4 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400; +} + +[data-markup] table { + @apply table-auto w-full bg-gray-200 dark:bg-gray-900 my-4 rounded-md; +} + +[data-markup] table th, table td { + @apply text-center px-2; +} + +[data-markup] table th { + @apply border-b-2 border-gray-600; +} + +[data-markup] code, +[data-markup] pre { + @apply cursor-text font-mono; +} + +[data-markup] p > code, +[data-markup] pre { + @apply bg-gray-100 dark:bg-primary-800; +} + +/* Inline code */ +[data-markup] p > code { + @apply py-0.5 px-1 rounded-sm; +} + +/* Code block */ +[data-markup] pre { + @apply py-2 px-3 mb-4 leading-6 overflow-x-auto rounded-md break-all; +} + +[data-markup] pre:last-child { + @apply mb-0; +} + +/* Emojis */ +[data-markup] img.emojione { + @apply w-5 h-5 m-0; +} + +/* Markdown inline images (Pleroma) */ +[data-markup] img:not(.emojione) { + @apply max-h-[500px] mx-auto rounded-sm; +} + +/* User setting to underline links */ +body.underline-links [data-markup] a { + @apply underline; +} + +[data-markup] .status-link { + @apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue; +} + +[data-markup] .invisible { + font-size: 0 !important; + line-height: 0 !important; + display: inline-block; + width: 0; + height: 0; + position: absolute; +} + +[data-markup] .invisible img, +[data-markup] .invisible svg { + margin: 0 !important; + border: 0 !important; + padding: 0 !important; + width: 0 !important; + height: 0 !important; +} diff --git a/src/components/markup.tsx b/src/components/markup.tsx new file mode 100644 index 0000000..d43c1e3 --- /dev/null +++ b/src/components/markup.tsx @@ -0,0 +1,87 @@ +import parse, { HTMLReactParserOptions, Text as DOMText, DOMNode, Element, domToReact } from 'html-react-parser'; +import { forwardRef } from 'react'; + +import HashtagLink from 'soapbox/components/hashtag-link.tsx'; +import Mention from 'soapbox/components/mention.tsx'; +import { CustomEmoji } from 'soapbox/schemas/custom-emoji.ts'; +import { Mention as MentionEntity } from 'soapbox/schemas/mention.ts'; +import { emojifyText } from 'soapbox/utils/emojify.tsx'; + +import Text, { IText } from './ui/text.tsx'; +import './markup.css'; + +interface IMarkup extends Omit { + html: { __html: string }; + mentions?: MentionEntity[]; + emojis?: CustomEmoji[]; +} + +/** Styles HTML markup returned by the API, such as in account bios and statuses. */ +const Markup = forwardRef(({ html, emojis, mentions, ...props }, ref) => { + const options: HTMLReactParserOptions = { + replace(domNode) { + if (domNode instanceof Element && ['script', 'iframe'].includes(domNode.name)) { + return null; + } + + if (domNode instanceof DOMText && emojis) { + return emojifyText(domNode.data, emojis); + } + + if (domNode instanceof Element && domNode.name === 'a') { + const classes = domNode.attribs.class?.split(' '); + + if (classes?.includes('hashtag')) { + const child = domToReact(domNode.children as DOMNode[]); + + const hashtag: string | undefined = (() => { + // Mastodon wraps the hashtag in a span, with a sibling text node containing the hashtag. + if (Array.isArray(child) && child.length) { + if (child[0]?.props?.children === '#' && typeof child[1] === 'string') { + return child[1]; + } + } + // Pleroma renders a string directly inside the hashtag link. + if (typeof child === 'string') { + return child.replace(/^#/, ''); + } + })(); + + if (hashtag) { + return ; + } + } + + if (classes?.includes('mention')) { + const mention = mentions?.find(({ url }) => domNode.attribs.href === url); + if (mention) { + return ; + } + } + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + e.stopPropagation()} + rel='nofollow noopener' + target='_blank' + title={domNode.attribs.href} + > + {domToReact(domNode.children as DOMNode[], options)} + + ); + } + }, + }; + + const content = parse(html.__html, options); + + return ( + + {content} + + ); +}); + +export default Markup; \ No newline at end of file diff --git a/src/components/media-gallery.tsx b/src/components/media-gallery.tsx new file mode 100644 index 0000000..dfa8245 --- /dev/null +++ b/src/components/media-gallery.tsx @@ -0,0 +1,592 @@ +import paperclipIcon from '@tabler/icons/outline/paperclip.svg'; +import volumeIcon from '@tabler/icons/outline/volume.svg'; +import clsx from 'clsx'; +import { useState, useRef, useLayoutEffect, CSSProperties } from 'react'; + +import Blurhash from 'soapbox/components/blurhash.tsx'; +import StillImage from 'soapbox/components/still-image.tsx'; +import { MIMETYPE_ICONS } from 'soapbox/components/upload.tsx'; +import { useSettings } from 'soapbox/hooks/useSettings.ts'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; +import { Attachment } from 'soapbox/schemas/index.ts'; +import { truncateFilename } from 'soapbox/utils/media.ts'; + +import { isIOS } from '../is-mobile.ts'; +import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio.ts'; + +import SvgIcon from './ui/svg-icon.tsx'; + +// const Gameboy = lazy(() => import('./gameboy')); + +const ATTACHMENT_LIMIT = 4; +const MAX_FILENAME_LENGTH = 45; + +interface Dimensions { + w: CSSProperties['width']; + h: CSSProperties['height']; + t?: CSSProperties['top']; + r?: CSSProperties['right']; + b?: CSSProperties['bottom']; + l?: CSSProperties['left']; + float?: CSSProperties['float']; + pos?: CSSProperties['position']; +} + +interface SizeData { + style: CSSProperties; + itemsDimensions: Dimensions[]; + size: number; + width: number; +} + +const withinLimits = (aspectRatio: number) => { + return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio; +}; + +const shouldLetterbox = (attachment: Attachment): boolean => { + const aspectRatio = 'meta' in attachment && 'original' in attachment.meta && (attachment).meta.original?.aspect; + + if (!aspectRatio) return true; + + return !withinLimits(aspectRatio); +}; + +interface IItem { + attachment: Attachment; + standalone?: boolean; + index: number; + size: number; + onClick: (index: number) => void; + displayWidth?: number; + visible: boolean; + dimensions: Dimensions; + last?: boolean; + total: number; + compact?: boolean; +} + +const Item: React.FC = ({ + attachment, + index, + onClick, + standalone = false, + visible, + dimensions, + last, + total, + compact, +}) => { + const { autoPlayGif } = useSettings(); + const { mediaPreview } = useSoapboxConfig(); + + const handleMouseEnter: React.MouseEventHandler = ({ currentTarget: video }) => { + if (hoverToPlay()) { + video.play(); + } + }; + + const handleMouseLeave: React.MouseEventHandler = ({ currentTarget: video }) => { + if (hoverToPlay()) { + video.pause(); + video.currentTime = 0; + } + }; + + const hoverToPlay = () => { + return !autoPlayGif && attachment.type === 'gifv'; + }; + + // FIXME: wtf? + const handleClick: React.MouseEventHandler = (e: any) => { + if (isIOS() && !e.target.autoPlay) { + e.target.autoPlay = true; + e.preventDefault(); + } else { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + e.preventDefault(); + onClick(index); + } + } + + e.stopPropagation(); + }; + + const handleVideoHover = (event: React.SyntheticEvent) => { + const video = event.currentTarget; + video.playbackRate = 3.0; + video.play(); + }; + + const handleVideoLeave = (event: React.SyntheticEvent) => { + const video = event.currentTarget; + video.pause(); + video.currentTime = 0; + }; + + const handleFocus: React.FocusEventHandler = handleVideoHover; + const handleBlur: React.FocusEventHandler = handleVideoLeave; + + let width: Dimensions['w'] = 100; + let height: Dimensions['h'] = '100%'; + let top: Dimensions['t'] = 'auto'; + let left: Dimensions['l'] = 'auto'; + let bottom: Dimensions['b'] = 'auto'; + let right: Dimensions['r'] = 'auto'; + let float: Dimensions['float'] = 'left'; + let position: Dimensions['pos'] = 'relative'; + + if (dimensions) { + width = dimensions.w; + height = dimensions.h; + top = dimensions.t || 'auto'; + right = dimensions.r || 'auto'; + bottom = dimensions.b || 'auto'; + left = dimensions.l || 'auto'; + float = dimensions.float || 'left'; + position = dimensions.pos || 'relative'; + } + + let thumbnail: React.ReactNode = ''; + const ext = attachment.url.split('.').pop()?.toLowerCase(); + + if (attachment.type === 'unknown') { + const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH); + const attachmentIcon = ( + + ); + + return ( +
    1, + '!size-[50px] !inset-auto !float-left !mr-[50px]': compact, + })} + key={attachment.id} + style={{ position, float, left, top, right, bottom, height, width: `${width}%` }} + > + + + {attachmentIcon} + {filename} + +
    + ); + } else if (attachment.type === 'image') { + const letterboxed = total === 1 && shouldLetterbox(attachment); + + thumbnail = ( + + + + ); + } else if (attachment.type === 'gifv') { + const conditionalAttributes: React.VideoHTMLAttributes = {}; + if (isIOS()) { + conditionalAttributes.playsInline = true; + } + if (autoPlayGif) { + conditionalAttributes.autoPlay = true; + } + + thumbnail = ( +
    +
    + ); + } else if (attachment.type === 'audio') { + thumbnail = ( + + + {ext} + + ); + } else if (attachment.type === 'video') { + thumbnail = ( + + + {ext} + + ); + } + + return ( +
    1, + '!size-[50px] !inset-auto !float-left !mr-[50px]': compact, + })} + key={attachment.id} + style={{ position, float, left, top, right, bottom, height, width: `${width}%` }} + > + {last && total > ATTACHMENT_LIMIT && ( +
    {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + +{total - ATTACHMENT_LIMIT + 1} +
    + )} + + {visible && thumbnail} +
    + ); +}; + +export interface IMediaGallery { + sensitive?: boolean; + media: readonly Attachment[]; + height?: number; + onOpenMedia: (media: readonly Attachment[], index: number) => void; + defaultWidth?: number; + cacheWidth?: (width: number) => void; + visible?: boolean; + onToggleVisibility?: () => void; + displayMedia?: string; + compact?: boolean; + className?: string; +} + +const MediaGallery: React.FC = (props) => { + const { + media, + defaultWidth = 0, + className, + onOpenMedia, + cacheWidth, + compact, + height, + } = props; + const [width, setWidth] = useState(defaultWidth); + + const node = useRef(null); + + const handleClick = (index: number) => { + onOpenMedia(media, index); + }; + + const getSizeDataSingle = (): SizeData => { + const w = width || defaultWidth; + const aspectRatio = 'meta' in media[0] && 'original' in media[0].meta && (media[0])?.meta.original?.aspect; + + const getHeight = () => { + if (!aspectRatio) return w * 9 / 16; + if (isPanoramic(aspectRatio)) return Math.floor(w / maximumAspectRatio); + if (isPortrait(aspectRatio)) return Math.floor(w / minimumAspectRatio); + return Math.floor(w / aspectRatio); + }; + + return { + style: { height: getHeight() }, + itemsDimensions: [], + size: 1, + width, + }; + }; + + const getSizeDataMultiple = (size: number): SizeData => { + const w = width || defaultWidth; + const panoSize = Math.floor(w / maximumAspectRatio); + const panoSize_px = `${Math.floor(w / maximumAspectRatio)}px`; + + const style: React.CSSProperties = {}; + let itemsDimensions: Dimensions[] = []; + + const ratios = Array(size).fill(null).map((_, i) => + 'meta' in media[i] && 'original' in media[i].meta && typeof media[i].meta.original?.aspect === 'number' + ? media[i].meta.original.aspect + : undefined as unknown as number, // NOTE: the old logic returned undefined anyways, and the implementation of the functions below call 'isNaN', such as the 'isPortrait' function + ); + + const [ar1, ar2, ar3, ar4] = ratios; + + if (size === 2) { + if (isPortrait(ar1) && isPortrait(ar2)) { + style.height = w - (w / maximumAspectRatio); + } else if (isPanoramic(ar1) && isPanoramic(ar2)) { + style.height = panoSize * 2; + } else if ( + (isPanoramic(ar1) && isPortrait(ar2)) || + (isPortrait(ar1) && isPanoramic(ar2)) || + (isPanoramic(ar1) && isNonConformingRatio(ar2)) || + (isNonConformingRatio(ar1) && isPanoramic(ar2)) + ) { + style.height = (w * 0.6) + (w / maximumAspectRatio); + } else { + style.height = w / 2; + } + + if (isPortrait(ar1) && isPortrait(ar2)) { + itemsDimensions = [ + { w: 50, h: '100%', r: '2px' }, + { w: 50, h: '100%', l: '2px' }, + ]; + } else if (isPanoramic(ar1) && isPanoramic(ar2)) { + itemsDimensions = [ + { w: 100, h: panoSize_px, b: '2px' }, + { w: 100, h: panoSize_px, t: '2px' }, + ]; + } else if ( + (isPanoramic(ar1) && isPortrait(ar2)) || + (isPanoramic(ar1) && isNonConformingRatio(ar2)) + ) { + itemsDimensions = [ + { w: 100, h: `${(w / maximumAspectRatio)}px`, b: '2px' }, + { w: 100, h: `${(w * 0.6)}px`, t: '2px' }, + ]; + } else if ( + (isPortrait(ar1) && isPanoramic(ar2)) || + (isNonConformingRatio(ar1) && isPanoramic(ar2)) + ) { + itemsDimensions = [ + { w: 100, h: `${(w * 0.6)}px`, b: '2px' }, + { w: 100, h: `${(w / maximumAspectRatio)}px`, t: '2px' }, + ]; + } else { + itemsDimensions = [ + { w: 50, h: '100%', r: '2px' }, + { w: 50, h: '100%', l: '2px' }, + ]; + } + } else if (size === 3) { + if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) { + style.height = panoSize * 3; + } else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) { + style.height = Math.floor(w / minimumAspectRatio); + } else { + style.height = w; + } + + if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) { + itemsDimensions = [ + { w: 100, h: '50%', b: '2px' }, + { w: 50, h: '50%', t: '2px', r: '2px' }, + { w: 50, h: '50%', t: '2px', l: '2px' }, + ]; + } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) { + itemsDimensions = [ + { w: 100, h: panoSize_px, b: '4px' }, + { w: 100, h: panoSize_px }, + { w: 100, h: panoSize_px, t: '4px' }, + ]; + } else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) { + itemsDimensions = [ + { w: 50, h: '100%', r: '2px' }, + { w: 50, h: '50%', b: '2px', l: '2px' }, + { w: 50, h: '50%', t: '2px', l: '2px' }, + ]; + } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) { + itemsDimensions = [ + { w: 50, h: '50%', b: '2px', r: '2px' }, + { w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' }, + { w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' }, + ]; + } else if ( + (isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) || + (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) + ) { + itemsDimensions = [ + { w: 50, h: '50%', b: '2px', r: '2px' }, + { w: 50, h: '100%', l: '2px', float: 'right' }, + { w: 50, h: '50%', t: '2px', r: '2px' }, + ]; + } else if ( + (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) || + (isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3)) + ) { + itemsDimensions = [ + { w: 50, h: panoSize_px, b: '2px', r: '2px' }, + { w: 50, h: panoSize_px, b: '2px', l: '2px' }, + { w: 100, h: `${w - panoSize}px`, t: '2px' }, + ]; + } else if ( + (isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) || + (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) + ) { + itemsDimensions = [ + { w: 100, h: `${w - panoSize}px`, b: '2px' }, + { w: 50, h: panoSize_px, t: '2px', r: '2px' }, + { w: 50, h: panoSize_px, t: '2px', l: '2px' }, + ]; + } else { + itemsDimensions = [ + { w: 50, h: '50%', b: '2px', r: '2px' }, + { w: 50, h: '50%', b: '2px', l: '2px' }, + { w: 100, h: '50%', t: '2px' }, + ]; + } + } else if (size >= 4) { + if ( + (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) || + (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) || + (isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) || + (isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) || + (isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) + ) { + style.height = Math.floor(w / minimumAspectRatio); + } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) { + style.height = panoSize * 2; + } else if ( + (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) || + (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) + ) { + style.height = panoSize + (w / 2); + } else { + style.height = w; + } + + if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) { + itemsDimensions = [ + { w: 50, h: panoSize_px, b: '2px', r: '2px' }, + { w: 50, h: panoSize_px, b: '2px', l: '2px' }, + { w: 50, h: `${(w / 2)}px`, t: '2px', r: '2px' }, + { w: 50, h: `${(w / 2)}px`, t: '2px', l: '2px' }, + ]; + } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) { + itemsDimensions = [ + { w: 50, h: `${(w / 2)}px`, b: '2px', r: '2px' }, + { w: 50, h: `${(w / 2)}px`, b: '2px', l: '2px' }, + { w: 50, h: panoSize_px, t: '2px', r: '2px' }, + { w: 50, h: panoSize_px, t: '2px', l: '2px' }, + ]; + } else if ( + (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) || + (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) + ) { + itemsDimensions = [ + { w: 67, h: '100%', r: '2px' }, + { w: 33, h: '33%', b: '4px', l: '2px' }, + { w: 33, h: '33%', l: '2px' }, + { w: 33, h: '33%', t: '4px', l: '2px' }, + ]; + } else { + itemsDimensions = [ + { w: 50, h: '50%', b: '2px', r: '2px' }, + { w: 50, h: '50%', b: '2px', l: '2px' }, + { w: 50, h: '50%', t: '2px', r: '2px' }, + { w: 50, h: '50%', t: '2px', l: '2px' }, + ]; + } + } + + return { + style, + itemsDimensions, + size, + width: w, + }; + }; + + const getSizeData = (size: number): Readonly => { + const w = width || defaultWidth; + + if (w) { + if (size === 1) return getSizeDataSingle(); + if (size > 1) return getSizeDataMultiple(size); + } + + return { + style: { height }, + itemsDimensions: [], + size, + width: w, + }; + }; + + const sizeData: SizeData = getSizeData(media.length); + + const children = media.slice(0, ATTACHMENT_LIMIT).map((attachment, i) => ( + + )); + + useLayoutEffect(() => { + if (node.current) { + const { offsetWidth } = node.current; + + if (cacheWidth) { + cacheWidth(offsetWidth); + } + + setWidth(offsetWidth); + } + }, [node.current]); + + return ( +
    + {children} +
    + ); +}; + +export default MediaGallery; diff --git a/src/components/mention.tsx b/src/components/mention.tsx new file mode 100644 index 0000000..c2be2e5 --- /dev/null +++ b/src/components/mention.tsx @@ -0,0 +1,38 @@ +import { Link } from 'react-router-dom'; + +import Tooltip from 'soapbox/components/ui/tooltip.tsx'; +import { shortenNostr } from 'soapbox/utils/nostr.ts'; + + +import type { Mention as MentionEntity } from 'soapbox/schemas/index.ts'; + +interface IMention { + mention: Pick; + disabled?: boolean; +} + +/** Mention for display in post content and the composer. */ +const Mention: React.FC = ({ mention: { acct, username }, disabled }) => { + const handleClick: React.MouseEventHandler = (e) => { + if (disabled) { + e.preventDefault(); + } + e.stopPropagation(); + }; + + return ( + + + @{shortenNostr(username)} + + + ); +}; + +export default Mention; \ No newline at end of file diff --git a/src/components/missing-indicator.tsx b/src/components/missing-indicator.tsx new file mode 100644 index 0000000..42b0b27 --- /dev/null +++ b/src/components/missing-indicator.tsx @@ -0,0 +1,27 @@ +import { FormattedMessage } from 'react-intl'; + +import { Card, CardBody } from 'soapbox/components/ui/card.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; + +interface MissingIndicatorProps { + nested?: boolean; +} + +const MissingIndicator = ({ nested = false }: MissingIndicatorProps): JSX.Element => ( + + + + + + + + + + + + + +); + +export default MissingIndicator; diff --git a/src/components/modal-root.tsx b/src/components/modal-root.tsx new file mode 100644 index 0000000..c2f077c --- /dev/null +++ b/src/components/modal-root.tsx @@ -0,0 +1,256 @@ +import trashIcon from '@tabler/icons/outline/trash.svg'; +import clsx from 'clsx'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import { cancelReplyCompose } from 'soapbox/actions/compose.ts'; +import { cancelEventCompose } from 'soapbox/actions/events.ts'; +import { openModal, closeModal } from 'soapbox/actions/modals.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { usePrevious } from 'soapbox/hooks/usePrevious.ts'; + +import type { ModalType } from 'soapbox/features/ui/components/modal-root.tsx'; +import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/compose-event.ts'; +import type { ReducerCompose } from 'soapbox/reducers/compose.ts'; + +const messages = defineMessages({ + confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' }, + cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' }, +}); + +export const checkComposeContent = (compose?: ReturnType) => { + return !!compose && [ + compose.editorState && compose.editorState.length > 0, + compose.spoiler_text.length > 0, + compose.media_attachments.size > 0, + compose.poll !== null, + ].some(check => check === true); +}; + +export const checkEventComposeContent = (compose?: ReturnType) => { + return !!compose && [ + compose.name.length > 0, + compose.status.length > 0, + compose.location !== null, + compose.banner !== null, + ].some(check => check === true); +}; + +interface IModalRoot { + onCancel?: () => void; + onClose: (type?: ModalType) => void; + type: ModalType; + children: React.ReactNode; +} + +const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + + const [revealed, setRevealed] = useState(!!children); + + const ref = useRef(null); + const activeElement = useRef(revealed ? document.activeElement as HTMLDivElement | null : null); + const modalHistoryKey = useRef(); + const unlistenHistory = useRef>(); + + const prevChildren = usePrevious(children); + const prevType = usePrevious(type); + + const visible = !!children; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) { + handleOnClose(); + } + }; + + const handleOnClose = () => { + dispatch((_, getState) => { + const compose = getState().compose.get('compose-modal'); + const hasComposeContent = checkComposeContent(compose); + const hasEventComposeContent = checkEventComposeContent(getState().compose_event); + + if (hasComposeContent && type === 'COMPOSE') { + const isEditing = compose!.id !== null; + dispatch(openModal('CONFIRM', { + icon: trashIcon, + heading: isEditing + ? + : , + message: isEditing + ? + : , + confirm: intl.formatMessage(messages.confirm), + onConfirm: () => { + dispatch(closeModal('COMPOSE')); + dispatch(cancelReplyCompose()); + }, + onCancel: () => { + dispatch(closeModal('CONFIRM')); + }, + })); + } else if (hasEventComposeContent && type === 'COMPOSE_EVENT') { + const isEditing = getState().compose_event.id !== null; + dispatch(openModal('CONFIRM', { + icon: trashIcon, + heading: isEditing + ? + : , + message: isEditing + ? + : , + confirm: intl.formatMessage(isEditing ? messages.cancelEditing : messages.confirm), + onConfirm: () => { + dispatch(closeModal('COMPOSE_EVENT')); + dispatch(cancelEventCompose()); + }, + onCancel: () => { + dispatch(closeModal('CONFIRM')); + }, + })); + } else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') { + dispatch(closeModal('CONFIRM')); + } else if (type === 'CAPTCHA') { + return; + } else { + onClose(); + } + }); + }; + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Tab') { + const focusable = Array.from(ref.current!.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); + const index = focusable.indexOf(e.target as Element); + + let element; + + if (e.shiftKey) { + element = focusable[index - 1] || focusable[focusable.length - 1]; + } else { + element = focusable[index + 1] || focusable[0]; + } + + if (element) { + (element as HTMLDivElement).focus(); + e.stopPropagation(); + e.preventDefault(); + } + } + }, []); + + const handleModalOpen = () => { + modalHistoryKey.current = Date.now(); + unlistenHistory.current = history.listen(({ state }, action) => { + if (!(state as any)?.soapboxModalKey) { + onClose(); + } else if (action === 'POP') { + handleOnClose(); + + if (onCancel) onCancel(); + } + }); + }; + + const handleModalClose = (type: string) => { + if (unlistenHistory.current) { + unlistenHistory.current(); + } + const { state } = history.location; + if (state && (state as any).soapboxModalKey === modalHistoryKey.current) { + history.goBack(); + } + }; + + const ensureHistoryBuffer = () => { + const { pathname, state } = history.location; + if (!state || (state as any).soapboxModalKey !== modalHistoryKey.current) { + history.push(pathname, { ...(state as any), soapboxModalKey: modalHistoryKey.current }); + } + }; + + const getSiblings = () => { + return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])) + .filter(node => (node as HTMLDivElement).id !== 'toaster') + .filter(node => node !== ref.current); + }; + + useEffect(() => { + if (!visible) return; + + window.addEventListener('keyup', handleKeyUp, false); + window.addEventListener('keydown', handleKeyDown, false); + + return () => { + window.removeEventListener('keyup', handleKeyUp); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [visible]); + + useEffect(() => { + if (!!children && !prevChildren) { + activeElement.current = document.activeElement as HTMLDivElement; + getSiblings().forEach(sibling => (sibling as HTMLDivElement).setAttribute('inert', 'true')); + + handleModalOpen(); + } else if (!prevChildren) { + setRevealed(false); + } + + if (!children && !!prevChildren) { + activeElement.current?.focus(); + activeElement.current = null; + getSiblings().forEach(sibling => (sibling as HTMLDivElement).removeAttribute('inert')); + + handleModalClose(prevType!); + } + + if (children) { + requestAnimationFrame(() => { + setRevealed(true); + }); + + ensureHistoryBuffer(); + } + }, [children]); + + if (!visible) { + return ( +
    + ); + } + + return ( +
    + + ); +}; + +export default ModalRoot; diff --git a/src/components/navlinks.tsx b/src/components/navlinks.tsx new file mode 100644 index 0000000..d21dea8 --- /dev/null +++ b/src/components/navlinks.tsx @@ -0,0 +1,43 @@ +import { Link } from 'react-router-dom'; + +import Text from 'soapbox/components/ui/text.tsx'; +import { useSettings } from 'soapbox/hooks/useSettings.ts'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; + +interface INavlinks { + type: string; +} + +const Navlinks: React.FC = ({ type }) => { + const { locale } = useSettings(); + const { copyright, navlinks } = useSoapboxConfig(); + + return ( +
    +
    + {navlinks.get(type)?.map((link, idx) => { + const url = link.url; + const isExternal = url.startsWith('http'); + const Comp = (isExternal ? 'a' : Link) as 'a'; + const compProps = isExternal ? { href: url, target: '_blank' } : { to: url }; + + return ( +
    + + + {(link.getIn(['titleLocales', locale]) || link.get('title')) as string} + + +
    + ); + })} +
    + +
    + {copyright} +
    +
    + ); +}; + +export { Navlinks }; diff --git a/src/components/outline-box.tsx b/src/components/outline-box.tsx new file mode 100644 index 0000000..4e599be --- /dev/null +++ b/src/components/outline-box.tsx @@ -0,0 +1,20 @@ +import clsx from 'clsx'; + +interface IOutlineBox extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; +} + +/** Wraps children in a container with an outline. */ +const OutlineBox: React.FC = ({ children, className, ...rest }) => { + return ( +
    + {children} +
    + ); +}; + +export default OutlineBox; diff --git a/src/components/pending-items-row.tsx b/src/components/pending-items-row.tsx new file mode 100644 index 0000000..4b0c7b1 --- /dev/null +++ b/src/components/pending-items-row.tsx @@ -0,0 +1,57 @@ +import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg'; +import exclamationCircleIcon from '@tabler/icons/outline/exclamation-circle.svg'; +import clsx from 'clsx'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; + +interface IPendingItemsRow { + /** Path to navigate the user when clicked. */ + to: string; + /** Number of pending items. */ + count: number; + /** Size of the icon. */ + size?: 'md' | 'lg'; +} + +const PendingItemsRow: React.FC = ({ to, count, size = 'md' }) => { + return ( + + + +
    + +
    + + + + +
    + + +
    + + ); +}; + +export { PendingItemsRow }; \ No newline at end of file diff --git a/src/components/polls/poll-footer.tsx b/src/components/polls/poll-footer.tsx new file mode 100644 index 0000000..4722a3e --- /dev/null +++ b/src/components/polls/poll-footer.tsx @@ -0,0 +1,99 @@ +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { fetchPoll, vote } from 'soapbox/actions/polls.ts'; +import RelativeTimestamp from 'soapbox/components/relative-timestamp.tsx'; +import Button from 'soapbox/components/ui/button.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import Tooltip from 'soapbox/components/ui/tooltip.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; + +import type { Poll as PollEntity } from 'soapbox/types/entities.ts'; + +const messages = defineMessages({ + closed: { id: 'poll.closed', defaultMessage: 'Closed' }, + nonAnonymous: { id: 'poll.non_anonymous.label', defaultMessage: 'Other instances may display the options you voted for' }, +}); + +interface IPollFooter { + poll: PollEntity; + showResults: boolean; + selected: Record; +} + +const PollFooter: React.FC = ({ poll, showResults, selected }): JSX.Element => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const handleVote = () => dispatch(vote(poll.id, Object.keys(selected))); + + const handleRefresh: React.EventHandler = (e) => { + dispatch(fetchPoll(poll.id)); + e.stopPropagation(); + e.preventDefault(); + }; + + const timeRemaining = poll.expires_at && ( + poll.expired ? + intl.formatMessage(messages.closed) : + + ); + + let votesCount = null; + + if (poll.voters_count !== null && poll.voters_count !== undefined) { + votesCount = ; + } else { + votesCount = ; + } + + return ( + + {(!showResults && poll.multiple) && ( + + )} + + + {poll.pleroma?.non_anonymous && ( + <> + + + + + + + · {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + + )} + + {showResults && ( + <> + + + · {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + + )} + + + {votesCount} + + + {poll.expires_at !== null && ( + <> + · {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + {timeRemaining} + + )} + + + ); +}; + +export default PollFooter; diff --git a/src/components/polls/poll-option.tsx b/src/components/polls/poll-option.tsx new file mode 100644 index 0000000..919e2bc --- /dev/null +++ b/src/components/polls/poll-option.tsx @@ -0,0 +1,165 @@ +import checkIcon from '@tabler/icons/outline/check.svg'; +import circleCheckIcon from '@tabler/icons/outline/circle-check.svg'; +import clsx from 'clsx'; +import { defineMessages, useIntl } from 'react-intl'; +import { Motion, presets, spring } from 'react-motion'; + +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { emojifyText } from 'soapbox/utils/emojify.tsx'; + +import type { + Poll as PollEntity, + PollOption as PollOptionEntity, +} from 'soapbox/types/entities'; + +const messages = defineMessages({ + voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer' }, + votes: { id: 'poll.votes', defaultMessage: '{votes, plural, one {# vote} other {# votes}}' }, +}); + +const PollPercentageBar: React.FC<{ percent: number; leading: boolean }> = ({ percent, leading }): JSX.Element => { + return ( + + {({ width }) => ( + + )} + + ); +}; + +interface IPollOptionText extends IPollOption { + percent: number; +} + +const PollOptionText: React.FC = ({ poll, option, index, active, onToggle }) => { + const handleOptionChange: React.EventHandler = () => onToggle(index); + + const handleOptionKeyPress: React.EventHandler = e => { + if (e.key === 'Enter' || e.key === ' ') { + onToggle(index); + e.stopPropagation(); + e.preventDefault(); + } + }; + + return ( + + ); +}; + +interface IPollOption { + poll: PollEntity; + option: PollOptionEntity; + index: number; + showResults?: boolean; + active: boolean; + onToggle: (value: number) => void; +} + +const PollOption: React.FC = (props): JSX.Element | null => { + const { index, poll, option, showResults } = props; + + const intl = useIntl(); + + if (!poll) return null; + + const pollVotesCount = poll.voters_count || poll.votes_count; + const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100; + const voted = poll.own_votes?.includes(index); + const message = intl.formatMessage(messages.votes, { votes: option.votes_count }); + + const leading = poll.options + .filter(other => other.title !== option.title) + .every(other => option.votes_count >= other.votes_count); + + return ( +
    + {showResults ? ( +
    + + + +
    + + {emojifyText(option.title, poll.emojis)} + +
    + + + {voted ? ( + + ) : ( +
    + )} + +
    + {Math.round(percent)}% {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} +
    + + +
    + ) : ( + + )} +
    + ); +}; + +export default PollOption; diff --git a/src/components/polls/poll.tsx b/src/components/polls/poll.tsx new file mode 100644 index 0000000..382f4b3 --- /dev/null +++ b/src/components/polls/poll.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { openModal } from 'soapbox/actions/modals.ts'; +import { vote } from 'soapbox/actions/polls.ts'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; + +import PollFooter from './poll-footer.tsx'; +import PollOption from './poll-option.tsx'; + +interface IPoll { + id: string; + status?: string; +} + +const messages = defineMessages({ + multiple: { id: 'poll.choose_multiple', defaultMessage: 'Choose as many as you\'d like.' }, +}); + +const Poll: React.FC = ({ id, status }): JSX.Element | null => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const isLoggedIn = useAppSelector((state) => state.me); + const poll = useAppSelector((state) => state.polls.get(id)); + + const [selected, setSelected] = useState>({}); + + const openUnauthorizedModal = () => + dispatch(openModal('UNAUTHORIZED', { + action: 'POLL_VOTE', + ap_id: status, + })); + + const handleVote = (selectedId: number) => dispatch(vote(id, [String(selectedId)])); + + const toggleOption = (value: number) => { + if (isLoggedIn) { + if (poll?.multiple) { + const tmp = { ...selected }; + if (tmp[value]) { + delete tmp[value]; + } else { + tmp[value] = true; + } + setSelected(tmp); + } else { + const tmp: Record = {}; + tmp[value] = true; + setSelected(tmp); + handleVote(value); + } + } else { + openUnauthorizedModal(); + } + }; + + if (!poll) return null; + + const showResults = poll.voted || poll.expired; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
    e.stopPropagation()}> + {!showResults && poll.multiple && ( + + {intl.formatMessage(messages.multiple)} + + )} + + + + {poll.options.map((option, i) => ( + + ))} + + + + +
    + ); +}; + +export default Poll; diff --git a/src/components/preview-card.tsx b/src/components/preview-card.tsx new file mode 100644 index 0000000..3e82823 --- /dev/null +++ b/src/components/preview-card.tsx @@ -0,0 +1,268 @@ +import externalLinkIcon from '@tabler/icons/outline/external-link.svg'; +import linkIcon from '@tabler/icons/outline/link.svg'; +import playerPlayIcon from '@tabler/icons/outline/player-play.svg'; +import zoomInIcon from '@tabler/icons/outline/zoom-in.svg'; +import clsx from 'clsx'; +import { useState, useEffect, useRef } from 'react'; + +import Blurhash from 'soapbox/components/blurhash.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import SvgIcon from 'soapbox/components/ui/svg-icon.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { normalizeAttachment } from 'soapbox/normalizers/index.ts'; +import { Attachment } from 'soapbox/schemas/index.ts'; +import { addAutoPlay } from 'soapbox/utils/media.ts'; +import { getTextDirection } from 'soapbox/utils/rtl.ts'; + +import type { Card as CardEntity } from 'soapbox/types/entities.ts'; + +/** Props for `PreviewCard`. */ +interface IPreviewCard { + card: CardEntity; + maxTitle?: number; + maxDescription?: number; + onOpenMedia: (attachments: readonly Attachment[], index: number) => void; + compact?: boolean; + defaultWidth?: number; + cacheWidth?: (width: number) => void; + horizontal?: boolean; +} + +/** Displays a Mastodon link preview. Similar to OEmbed. */ +const PreviewCard: React.FC = ({ + card, + defaultWidth = 467, + maxTitle = 120, + maxDescription = 200, + compact = false, + cacheWidth, + onOpenMedia, + horizontal, +}): JSX.Element => { + const ref = useRef(null); + + const [width, setWidth] = useState(defaultWidth); + const [embedded, setEmbedded] = useState(false); + + useEffect(() => { + setEmbedded(false); + }, [card.url]); + + const direction = getTextDirection(card.title + card.description); + + const trimmedTitle = trim(card.title, maxTitle); + const trimmedDescription = trim(card.description, maxDescription); + + useEffect(() => { + if (ref.current) { + const { offsetWidth } = ref.current; + cacheWidth?.(offsetWidth); + setWidth(offsetWidth); + } + }, [ref.current]); + + const handlePhotoClick = () => { + const attachment = normalizeAttachment({ + type: 'image', + url: card.embed_url, + description: trimmedTitle, + meta: { + original: { + width: card.width, + height: card.height, + }, + }, + }).toJS(); + + onOpenMedia([{ ...attachment, blurhash: attachment.blurhash === undefined ? null : attachment.blurhash } as Attachment], 0); + }; + + const handleEmbedClick: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + if (card.type === 'photo') { + handlePhotoClick(); + } else { + setEmbedded(true); + } + }; + + const renderVideo = () => { + const content = { __html: addAutoPlay(card.html) }; + const ratio = getRatio(card); + const height = width / ratio; + + return ( +
    + ); + }; + + const getRatio = (card: CardEntity): number => { + const ratio = (card.width / card.height) || 16 / 9; + + // Constrain to a sane limit + // https://en.wikipedia.org/wiki/Aspect_ratio_(image) + return Math.min(Math.max(9 / 16, ratio), 4); + }; + + const interactive = card.type !== 'link'; + horizontal = typeof horizontal === 'boolean' ? horizontal : interactive || embedded; + const className = clsx('flex overflow-hidden rounded-lg border border-solid border-gray-200 text-sm text-gray-800 no-underline dark:border-gray-800 dark:text-gray-200', { '!block': horizontal, 'border-gray-200 dark:border-gray-800': compact, interactive, 'flex flex-col md:flex-row': card.type === 'link' }); + const ratio = getRatio(card); + const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); + + const title = interactive ? ( + e.stopPropagation()} + href={card.url} + title={trimmedTitle} + rel='noopener' + target='_blank' + dir={direction} + > + {trimmedTitle} + + ) : ( + {trimmedTitle} + ); + + const description = ( + + {trimmedTitle && ( + {title} + )} + {trimmedDescription && ( + {trimmedDescription} + )} + + + + + + {card.provider_name} + + + + ); + + let embed: React.ReactNode = null; + + const canvas = ( + + ); + + const thumbnail = ( +
    + ); + + if (interactive) { + if (embedded) { + embed = renderVideo(); + } else { + let iconVariant = playerPlayIcon; + + if (card.type === 'photo') { + iconVariant = zoomInIcon; + } + + embed = ( + + ); + } + + return ( +
    }> + {embed} + {description} +
    + ); + } else if (card.image) { + embed = ( +
    + {canvas} + {thumbnail} +
    + ); + } + + return ( + } + onClick={e => e.stopPropagation()} + > + {embed} + {description} + + ); +}; + +/** Trim the text, adding ellipses if it's too long. */ +function trim(text: string, len: number): string { + const cut = text.indexOf(' ', len); + + if (cut === -1) { + return text; + } + + return text.substring(0, cut) + (text.length > len ? '…' : ''); +} + +export default PreviewCard; diff --git a/src/components/profile-hover-card.tsx b/src/components/profile-hover-card.tsx new file mode 100644 index 0000000..d0a43c3 --- /dev/null +++ b/src/components/profile-hover-card.tsx @@ -0,0 +1,166 @@ +import { useFloating } from '@floating-ui/react'; +import calendarIcon from '@tabler/icons/outline/calendar.svg'; +import clsx from 'clsx'; +import { useEffect, useState } from 'react'; +import { useIntl, FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + + +import { fetchRelationships } from 'soapbox/actions/accounts.ts'; +import { + closeProfileHoverCard, + updateProfileHoverCard, +} from 'soapbox/actions/profile-hover-card.ts'; +import { useAccount, usePatronUser } from 'soapbox/api/hooks/index.ts'; +import Badge from 'soapbox/components/badge.tsx'; +import Markup from 'soapbox/components/markup.tsx'; +import { Card, CardBody } from 'soapbox/components/ui/card.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import ActionButton from 'soapbox/features/ui/components/action-button.tsx'; +import { UserPanel } from 'soapbox/features/ui/util/async-components.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; + +import { showProfileHoverCard } from './hover-ref-wrapper.tsx'; +import { dateFormatOptions } from './relative-timestamp.tsx'; + +import type { Account, PatronUser } from 'soapbox/schemas/index.ts'; +import type { AppDispatch } from 'soapbox/store.ts'; + +const getBadges = ( + account?: Pick, + patronUser?: Pick, +): JSX.Element[] => { + const badges = []; + + if (account?.admin) { + badges.push(} />); + } else if (account?.moderator) { + badges.push(} />); + } + + if (patronUser?.is_patron) { + badges.push(} />); + } + + return badges; +}; + +const handleMouseEnter = (dispatch: AppDispatch): React.MouseEventHandler => { + return () => { + dispatch(updateProfileHoverCard()); + }; +}; + +const handleMouseLeave = (dispatch: AppDispatch): React.MouseEventHandler => { + return () => { + dispatch(closeProfileHoverCard(true)); + }; +}; + +interface IProfileHoverCard { + visible?: boolean; +} + +/** Popup profile preview that appears when hovering avatars and display names. */ +export const ProfileHoverCard: React.FC = ({ visible = true }) => { + const dispatch = useAppDispatch(); + const history = useHistory(); + const intl = useIntl(); + + const [popperElement, setPopperElement] = useState(null); + + const me = useAppSelector(state => state.me); + const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.accountId || undefined); + const { account } = useAccount(accountId, { withRelationship: true }); + const { patronUser } = usePatronUser(account?.url); + const targetRef = useAppSelector(state => state.profile_hover_card.ref?.current); + const badges = getBadges(account, patronUser); + + useEffect(() => { + if (accountId) dispatch(fetchRelationships([accountId])); + }, [dispatch, accountId]); + + useEffect(() => { + const unlisten = history.listen(() => { + showProfileHoverCard.cancel(); + dispatch(closeProfileHoverCard()); + }); + + return () => { + unlisten(); + }; + }, []); + + const { floatingStyles } = useFloating({ + elements: { + floating: popperElement, + reference: targetRef, + }, + }); + + if (!account) return null; + const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' }); + const followedBy = me !== account.id && account.relationship?.followed_by === true; + + return ( +
    + + + + } + badges={badges} + /> + + {account.local ? ( + + + + + + + + ) : null} + + {account.note.length > 0 && ( + + )} + + + {followedBy && ( +
    + } + /> +
    + )} +
    +
    +
    + ); +}; + +export default ProfileHoverCard; diff --git a/src/components/progress-circle.tsx b/src/components/progress-circle.tsx new file mode 100644 index 0000000..b2f78e1 --- /dev/null +++ b/src/components/progress-circle.tsx @@ -0,0 +1,51 @@ +import clsx from 'clsx'; + +interface IProgressCircle { + progress: number; + radius?: number; + stroke?: number; + title?: string; +} + +const ProgressCircle: React.FC = ({ progress, radius = 12, stroke = 4, title }) => { + const progressStroke = stroke + 0.5; + const actualRadius = radius + progressStroke; + const circumference = 2 * Math.PI * radius; + const dashoffset = circumference * (1 - Math.min(progress, 1)); + + return ( +
    + + + 1, + })} + style={{ + strokeDashoffset: dashoffset, + strokeDasharray: circumference, + }} + cx={actualRadius} + cy={actualRadius} + r={radius} + fill='none' + strokeWidth={progressStroke} + strokeLinecap='round' + /> + +
    + ); +}; + +export default ProgressCircle; diff --git a/src/components/pull-to-refresh.css b/src/components/pull-to-refresh.css new file mode 100644 index 0000000..68b2d9b --- /dev/null +++ b/src/components/pull-to-refresh.css @@ -0,0 +1,4 @@ +.ptr, +.ptr__children { + @apply !overflow-visible; +} \ No newline at end of file diff --git a/src/components/pull-to-refresh.tsx b/src/components/pull-to-refresh.tsx new file mode 100644 index 0000000..7ea8ca5 --- /dev/null +++ b/src/components/pull-to-refresh.tsx @@ -0,0 +1,45 @@ +import PTRComponent from 'react-simple-pull-to-refresh'; + +import Spinner from 'soapbox/components/ui/spinner.tsx'; + +import './pull-to-refresh.css'; + +interface IPullToRefresh { + onRefresh?: () => Promise; + refreshingContent?: JSX.Element | string; + pullingContent?: JSX.Element | string; + children: React.ReactNode; +} + +/** + * PullToRefresh: + * Wrapper around a third-party PTR component with Soapbox defaults. + */ +const PullToRefresh: React.FC = ({ children, onRefresh, ...rest }): JSX.Element => { + const handleRefresh = () => { + if (onRefresh) { + return onRefresh(); + } else { + // If not provided, do nothing + return Promise.resolve(); + } + }; + + return ( + } + // `undefined` will fallback to the default, while `<>` will render nothing + refreshingContent={onRefresh ? : <>} + pullDownThreshold={67} + maxPullDownDistance={95} + resistance={2} + {...rest} + > + {/* This thing really wants a single JSX element as its child (TypeScript), so wrap it in one */} + <>{children} + + ); +}; + +export default PullToRefresh; diff --git a/src/components/pullable.tsx b/src/components/pullable.tsx new file mode 100644 index 0000000..d98a315 --- /dev/null +++ b/src/components/pullable.tsx @@ -0,0 +1,21 @@ +import PullToRefresh from './pull-to-refresh.tsx'; + +interface IPullable { + children: React.ReactNode; +} + +/** + * Pullable: + * Basic "pull to refresh" without the refresh. + * Just visual feedback. + */ +const Pullable: React.FC = ({ children }) =>( + + {children} + +); + +export default Pullable; diff --git a/src/components/pure-event-preview.tsx b/src/components/pure-event-preview.tsx new file mode 100644 index 0000000..57915e3 --- /dev/null +++ b/src/components/pure-event-preview.tsx @@ -0,0 +1,97 @@ +import mapPinIcon from '@tabler/icons/outline/map-pin.svg'; +import userIcon from '@tabler/icons/outline/user.svg'; +import clsx from 'clsx'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import Button from 'soapbox/components/ui/button.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import PureEventActionButton from 'soapbox/features/event/components/pure-event-action-button.tsx'; +import PureEventDate from 'soapbox/features/event/components/pure-event-date.tsx'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; + +import Icon from './icon.tsx'; +import VerificationBadge from './verification-badge.tsx'; + +import type { Status as StatusEntity } from 'soapbox/schemas/index.ts'; + +const messages = defineMessages({ + eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' }, + leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' }, + leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' }, +}); + +interface IPureEventPreview { + status: StatusEntity; + className?: string; + hideAction?: boolean; + floatingAction?: boolean; +} + +const PureEventPreview: React.FC = ({ status, className, hideAction, floatingAction = true }) => { + const intl = useIntl(); + + const me = useAppSelector((state) => state.me); + + const account = status.account; + const event = status.event!; + + const banner = event.banner; + + const action = !hideAction && (account.id === me ? ( + + ) : ( + + )); + + return ( +
    +
    + {floatingAction && action} +
    +
    + {banner && {intl.formatMessage(messages.eventBanner)}} +
    + + + {event.name} + + {!floatingAction && action} + + +
    + + + + {account.display_name} + {account.verified && } + + + + + + {event.location && ( + + + + {event.location?.name} + + + )} +
    +
    +
    + ); +}; + +export default PureEventPreview; diff --git a/src/components/pure-status-action-bar.tsx b/src/components/pure-status-action-bar.tsx new file mode 100644 index 0000000..d3aa214 --- /dev/null +++ b/src/components/pure-status-action-bar.tsx @@ -0,0 +1,879 @@ +import alertTriangleIcon from '@tabler/icons/outline/alert-triangle.svg'; +import arrowsVerticalIcon from '@tabler/icons/outline/arrows-vertical.svg'; +import atIcon from '@tabler/icons/outline/at.svg'; +import banIcon from '@tabler/icons/outline/ban.svg'; +import bellOffIcon from '@tabler/icons/outline/bell-off.svg'; +import bellIcon from '@tabler/icons/outline/bell.svg'; +import boltIcon from '@tabler/icons/outline/bolt.svg'; +import bookmarkOffIcon from '@tabler/icons/outline/bookmark-off.svg'; +import bookmarkIcon from '@tabler/icons/outline/bookmark.svg'; +import clipboardCopyIcon from '@tabler/icons/outline/clipboard-copy.svg'; +import dotsIcon from '@tabler/icons/outline/dots.svg'; +import editIcon from '@tabler/icons/outline/edit.svg'; +import externalLinkIcon from '@tabler/icons/outline/external-link.svg'; +import flagIcon from '@tabler/icons/outline/flag.svg'; +import gavelIcon from '@tabler/icons/outline/gavel.svg'; +import heartIcon from '@tabler/icons/outline/heart.svg'; +import lockIcon from '@tabler/icons/outline/lock.svg'; +import mailIcon from '@tabler/icons/outline/mail.svg'; +import messageCircleIcon from '@tabler/icons/outline/message-circle.svg'; +import messagesIcon from '@tabler/icons/outline/messages.svg'; +import pencilIcon from '@tabler/icons/outline/pencil.svg'; +import pinIcon from '@tabler/icons/outline/pin.svg'; +import pinnedOffIcon from '@tabler/icons/outline/pinned-off.svg'; +import quoteIcon from '@tabler/icons/outline/quote.svg'; +import repeatIcon from '@tabler/icons/outline/repeat.svg'; +import shareIcon from '@tabler/icons/outline/share.svg'; +import thumbDownIcon from '@tabler/icons/outline/thumb-down.svg'; +import thumbUpIcon from '@tabler/icons/outline/thumb-up.svg'; +import trashIcon from '@tabler/icons/outline/trash.svg'; +import uploadIcon from '@tabler/icons/outline/upload.svg'; +import volume3Icon from '@tabler/icons/outline/volume-3.svg'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useHistory, useRouteMatch } from 'react-router-dom'; + +import { blockAccount } from 'soapbox/actions/accounts.ts'; +import { launchChat } from 'soapbox/actions/chats.ts'; +import { directCompose, mentionCompose } from 'soapbox/actions/compose.ts'; +import { editEvent } from 'soapbox/actions/events.ts'; +import { openModal } from 'soapbox/actions/modals.ts'; +import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation.tsx'; +import { initMuteModal } from 'soapbox/actions/mutes.ts'; +import { ReportableEntities } from 'soapbox/actions/reports.ts'; +import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses.ts'; +import { deleteFromTimelines } from 'soapbox/actions/timelines.ts'; +import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus.ts'; +import { useBlockGroupMember, useBookmark, useGroup, useGroupRelationship, useMuteGroup, useUnmuteGroup, useFavourite } from 'soapbox/api/hooks/index.ts'; +import DropdownMenu from 'soapbox/components/dropdown-menu/index.ts'; +import PureStatusReactionWrapper from 'soapbox/components/pure-status-reaction-wrapper.tsx'; +import StatusActionButton from 'soapbox/components/status-action-button.tsx'; +import HStack from 'soapbox/components/ui/hstack.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { useDislike } from 'soapbox/hooks/useDislike.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { useInitReport } from 'soapbox/hooks/useInitReport.ts'; +import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; +import { usePin } from 'soapbox/hooks/usePin.ts'; +import { usePinGroup } from 'soapbox/hooks/usePinGroup.ts'; +import { useQuoteCompose } from 'soapbox/hooks/useQuoteCompose.ts'; +import { useReblog } from 'soapbox/hooks/useReblog.ts'; +import { useReplyCompose } from 'soapbox/hooks/useReplyCompose.ts'; +import { useSettings } from 'soapbox/hooks/useSettings.ts'; +import { GroupRoles } from 'soapbox/schemas/group-member.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; +import toast from 'soapbox/toast.tsx'; +import copy from 'soapbox/utils/copy.ts'; + +import GroupPopover from './groups/popover/group-popover.tsx'; + +import type { Menu } from 'soapbox/components/dropdown-menu/index.ts'; +import type { Group } from 'soapbox/types/entities.ts'; + +const messages = defineMessages({ + adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' }, + admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + blocked: { id: 'group.group_mod_block.success', defaultMessage: '@{name} is banned' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, + bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, + chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, + copy: { id: 'status.copy', defaultMessage: 'Copy Link to Post' }, + deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' }, + deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, + deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, + deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, + deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, + embed: { id: 'status.embed', defaultMessage: 'Embed post' }, + external: { id: 'status.external', defaultMessage: 'View post on {domain}' }, + favourite: { id: 'status.favourite', defaultMessage: 'Like' }, + groupBlockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban User' }, + groupBlockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' }, + groupBlockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' }, + groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' }, + group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, + group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, + markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, + markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + more: { id: 'status.more', defaultMessage: 'More' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + muteConfirm: { id: 'confirmations.mute_group.confirm', defaultMessage: 'Mute' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute Conversation' }, + muteGroup: { id: 'group.mute.long_label', defaultMessage: 'Mute Group' }, + muteHeading: { id: 'confirmations.mute_group.heading', defaultMessage: 'Mute Group' }, + muteMessage: { id: 'confirmations.mute_group.message', defaultMessage: 'You are about to mute the group. Do you want to continue?' }, + muteSuccess: { id: 'group.mute.success', defaultMessage: 'Muted the group' }, + open: { id: 'status.open', defaultMessage: 'Show Post Details' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + pinToGroup: { id: 'status.pin_to_group', defaultMessage: 'Pin to Group' }, + pinToGroupSuccess: { id: 'status.pin_to_group.success', defaultMessage: 'Pinned to Group!' }, + quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, + reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, + reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, + reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, + reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, + reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, + reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, + redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, + replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.reply_all', defaultMessage: 'Reply to thread' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute Conversation' }, + unmuteGroup: { id: 'group.unmute.long_label', defaultMessage: 'Unmute Group' }, + unmuteSuccess: { id: 'group.unmute.success', defaultMessage: 'Unmuted the group' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' }, + view: { id: 'toast.view', defaultMessage: 'View' }, + zap: { id: 'status.zap', defaultMessage: 'Zap' }, +}); + +interface IPureStatusActionBar { + status: StatusEntity; + expandable?: boolean; + space?: 'sm' | 'md' | 'lg'; + statusActionButtonTheme?: 'default' | 'inverse'; +} + +const PureStatusActionBar: React.FC = ({ + status, + expandable = true, + space = 'sm', + statusActionButtonTheme = 'default', +}) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug'); + + const { group } = useGroup((status.group as Group)?.id as string); + const muteGroup = useMuteGroup(group as Group); + const unmuteGroup = useUnmuteGroup(group as Group); + const isMutingGroup = !!group?.relationship?.muting; + const deleteGroupStatus = useDeleteGroupStatus(group as Group, status.id); + const blockGroupMember = useBlockGroupMember(group as Group, status.account); + + const me = useAppSelector(state => state.me); + const { groupRelationship } = useGroupRelationship(status.group?.id); + const features = useFeatures(); + const { boostModal, deleteModal } = useSettings(); + + const { account } = useOwnAccount(); + const isStaff = account ? account.staff : false; + const isAdmin = account ? account.admin : false; + + const { replyCompose } = useReplyCompose(); + const { toggleFavourite } = useFavourite(); + const { toggleDislike } = useDislike(); + const { bookmark, unbookmark } = useBookmark(); + const { toggleReblog } = useReblog(); + const { quoteCompose } = useQuoteCompose(); + const { togglePin } = usePin(); + const { unpinFromGroup, pinToGroup } = usePinGroup(); + const { initReport } = useInitReport(); + + if (!status) { + return null; + } + + const onOpenUnauthorizedModal = (action?: string) => { + dispatch(openModal('UNAUTHORIZED', { + action, + ap_id: status.url, + })); + }; + + const handleReplyClick: React.MouseEventHandler = (e) => { + if (me) { + replyCompose(status.id); + } else { + onOpenUnauthorizedModal('REPLY'); + } + }; + + const handleShareClick = () => { + navigator.share({ + text: status.search_index, + url: status.uri, + }).catch((e) => { + if (e.name !== 'AbortError') console.error(e); + }); + }; + + const handleFavouriteClick: React.EventHandler = (e) => { + if (me) { + toggleFavourite(status.id); + } else { + onOpenUnauthorizedModal('FAVOURITE'); + } + }; + + const handleDislikeClick: React.EventHandler = (e) => { + if (me) { + toggleDislike(status.id); + } else { + onOpenUnauthorizedModal('DISLIKE'); + } + }; + + const handleZapClick: React.EventHandler = (e) => { + if (me) { + dispatch(openModal('ZAP_PAY_REQUEST', { status, account: status.account })); + } else { + onOpenUnauthorizedModal('ZAP_PAY_REQUEST'); + } + }; + + const handleBookmarkClick: React.EventHandler = (e) => { + if (status.bookmarked) { + unbookmark(status.id).then(({ success }) => { + if (success) { + toast.success(messages.bookmarkRemoved); + } + }).catch(null); + } else { + bookmark(status.id).then(({ success }) => { + if (success) { + toast.success(messages.bookmarkAdded, { + actionLink: '/bookmarks/all', actionLabel: messages.view, + }); + } + }).catch(null); + } + }; + + const handleReblogClick: React.EventHandler = e => { + if (me) { + const modalReblog = () => toggleReblog(status.id); + if ((e && e.shiftKey) || !boostModal) { + modalReblog(); + } else { + dispatch(openModal('BOOST', { status, onReblog: modalReblog })); + } + } else { + onOpenUnauthorizedModal('REBLOG'); + } + }; + + const handleQuoteClick: React.EventHandler = (e) => { + if (me) { + quoteCompose(status.id); + } else { + onOpenUnauthorizedModal('REBLOG'); + } + }; + + const doDeleteStatus = (withRedraft = false) => { + dispatch((_, getState) => { + if (!deleteModal) { + dispatch(deleteStatus(status.id, withRedraft)); + } else { + dispatch(openModal('CONFIRM', { + icon: withRedraft ? editIcon : trashIcon, + heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), + message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), + confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.id, withRedraft)), + })); + } + }); + }; + + const handleDeleteClick: React.EventHandler = (e) => { + doDeleteStatus(); + }; + + const handleRedraftClick: React.EventHandler = (e) => { + doDeleteStatus(true); + }; + + const handleEditClick: React.EventHandler = () => { + if (status.event) dispatch(editEvent(status.id)); + else dispatch(editStatus(status.id)); + }; + + const handlePinClick: React.EventHandler = (e) => { + togglePin(status.id); + }; + + const handleGroupPinClick: React.EventHandler = () => { + if (status.pinned) { + unpinFromGroup(status.id); + } else { + pinToGroup(status.id) + ?.then(() => toast.success(intl.formatMessage(messages.pinToGroupSuccess))) + .catch(() => null); + } + }; + + const handleMentionClick: React.EventHandler = (e) => { + dispatch(mentionCompose(status.account)); + }; + + const handleDirectClick: React.EventHandler = (e) => { + dispatch(directCompose(status.account)); + }; + + const handleChatClick: React.EventHandler = (e) => { + const account = status.account; + dispatch(launchChat(account.id, history)); + }; + + const handleMuteClick: React.EventHandler = (e) => { + dispatch(initMuteModal(status.account)); + }; + + const handleMuteGroupClick: React.EventHandler = () => + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.muteHeading), + message: intl.formatMessage(messages.muteMessage), + confirm: intl.formatMessage(messages.muteConfirm), + confirmationTheme: 'primary', + onConfirm: () => muteGroup.mutate(undefined, { + onSuccess() { + toast.success(intl.formatMessage(messages.muteSuccess)); + }, + }), + })); + + const handleUnmuteGroupClick: React.EventHandler = () => { + unmuteGroup.mutate(undefined, { + onSuccess() { + toast.success(intl.formatMessage(messages.unmuteSuccess)); + }, + }); + }; + + const handleBlockClick: React.EventHandler = (e) => { + const account = status.account; + + dispatch(openModal('CONFIRM', { + icon: banIcon, + heading: , + message: @{account.acct} }} />, // eslint-disable-line formatjs/no-literal-string-in-jsx + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.id)), + secondary: intl.formatMessage(messages.blockAndReport), + onSecondary: () => { + dispatch(blockAccount(account.id)); + initReport(ReportableEntities.STATUS, account, { statusId: status.id }); + }, + })); + }; + + const handleEmbed = () => { + dispatch(openModal('EMBED', { + url: status.url, + onError: (error: any) => toast.showAlertForError(error), + })); + }; + + const handleReport: React.EventHandler = (e) => { + initReport(ReportableEntities.STATUS, status.account, { statusId: status.id }); + }; + + const handleConversationMuteClick: React.EventHandler = (e) => { + dispatch(toggleMuteStatus(status)); + }; + + const handleCopy: React.EventHandler = (e) => { + const { uri } = status; + + copy(uri); + }; + + const onModerate: React.MouseEventHandler = (e) => { + const account = status.account; + dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id })); + }; + + const handleDeleteStatus: React.EventHandler = (e) => { + dispatch(deleteStatusModal(intl, status.id)); + }; + + const handleToggleStatusSensitivity: React.EventHandler = (e) => { + dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive)); + }; + + const handleDeleteFromGroup: React.EventHandler = () => { + const account = status.account; + + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.deleteHeading), + message: intl.formatMessage(messages.deleteFromGroupMessage, { name: {account.username} }), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + deleteGroupStatus.mutate(status.id, { + onSuccess() { + dispatch(deleteFromTimelines(status.id)); + }, + }); + }, + })); + }; + + const handleBlockFromGroup = () => { + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.groupBlockFromGroupHeading), + message: intl.formatMessage(messages.groupBlockFromGroupMessage, { name: status.account.username }), + confirm: intl.formatMessage(messages.groupBlockConfirm), + onConfirm: () => { + blockGroupMember({ account_ids: [status.account.id] }, { + onSuccess() { + toast.success(intl.formatMessage(messages.blocked, { name: account?.acct })); + }, + }); + }, + })); + }; + + const _makeMenu = (publicStatus: boolean) => { + const mutingConversation = status.muted; + const ownAccount = status.account.id === me; + const username = status.account.username; + const account = status.account; + + const menu: Menu = []; + + if (expandable) { + menu.push({ + text: intl.formatMessage(messages.open), + icon: arrowsVerticalIcon, + to: `/@${status.account.acct}/posts/${status.id}`, + }); + } + + if (publicStatus) { + menu.push({ + text: intl.formatMessage(messages.copy), + action: handleCopy, + icon: clipboardCopyIcon, + }); + + if (features.embeds && account.local) { + menu.push({ + text: intl.formatMessage(messages.embed), + action: handleEmbed, + icon: shareIcon, + }); + } + } + + if (features.federating && (status.ditto?.external_url || !account.local)) { + const externalNostrUrl: string | undefined = status.ditto?.external_url; + const { hostname: domain } = new URL(externalNostrUrl || status.uri); + + menu.push({ + text: intl.formatMessage(messages.external, { domain }), + icon: externalLinkIcon, + href: externalNostrUrl || status.uri, + target: '_blank', + }); + } + + if (!me) { + return menu; + } + + const isGroupStatus = typeof status.group === 'object'; + if (isGroupStatus && !!status.group) { + const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER; + + if (isGroupOwner) { + menu.push({ + text: intl.formatMessage(status.pinned ? messages.unpinFromGroup : messages.pinToGroup), + action: handleGroupPinClick, + icon: status.pinned ? pinnedOffIcon : pinIcon, + }); + } + } + + if (features.bookmarks) { + menu.push({ + text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), + action: handleBookmarkClick, + icon: status.bookmarked ? bookmarkOffIcon : bookmarkIcon, + }); + } + + menu.push(null); + + menu.push({ + text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), + action: handleConversationMuteClick, + icon: mutingConversation ? bellIcon : bellOffIcon, + }); + + menu.push(null); + + if (ownAccount) { + if (publicStatus) { + menu.push({ + text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), + action: handlePinClick, + icon: status.pinned ? pinnedOffIcon : pinIcon, + }); + } else { + if (status.visibility === 'private') { + menu.push({ + text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private), + action: handleReblogClick, + icon: repeatIcon, + }); + } + } + + menu.push({ + text: intl.formatMessage(messages.delete), + action: handleDeleteClick, + icon: trashIcon, + destructive: true, + }); + if (features.editStatuses) { + menu.push({ + text: intl.formatMessage(messages.edit), + action: handleEditClick, + icon: editIcon, + }); + } else { + menu.push({ + text: intl.formatMessage(messages.redraft), + action: handleRedraftClick, + icon: editIcon, + destructive: true, + }); + } + } else { + menu.push({ + text: intl.formatMessage(messages.mention, { name: username }), + action: handleMentionClick, + icon: atIcon, + }); + + if (status.account.pleroma?.accepts_chat_messages === true) { + menu.push({ + text: intl.formatMessage(messages.chat, { name: username }), + action: handleChatClick, + icon: messagesIcon, + }); + } else if (features.privacyScopes) { + menu.push({ + text: intl.formatMessage(messages.direct, { name: username }), + action: handleDirectClick, + icon: mailIcon, + }); + } + + menu.push(null); + if (features.groupsMuting && status.group) { + menu.push({ + text: isMutingGroup ? intl.formatMessage(messages.unmuteGroup) : intl.formatMessage(messages.muteGroup), + icon: volume3Icon, + action: isMutingGroup ? handleUnmuteGroupClick : handleMuteGroupClick, + }); + menu.push(null); + } + + menu.push({ + text: intl.formatMessage(messages.mute, { name: username }), + action: handleMuteClick, + icon: volume3Icon, + }); + if (features.blocks) { + menu.push({ + text: intl.formatMessage(messages.block, { name: username }), + action: handleBlockClick, + icon: banIcon, + }); + } + menu.push({ + text: intl.formatMessage(messages.report, { name: username }), + action: handleReport, + icon: flagIcon, + }); + } + + if (isGroupStatus && !!status.group) { + const group = status.group as Group; + const account = status.account; + const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER; + const isGroupAdmin = groupRelationship?.role === GroupRoles.ADMIN; + const isStatusFromOwner = group.owner.id === account.id; + + const canBanUser = match?.isExact && (isGroupOwner || isGroupAdmin) && !isStatusFromOwner && !ownAccount; + const canDeleteStatus = !ownAccount && (isGroupOwner || (isGroupAdmin && !isStatusFromOwner)); + + if (canBanUser || canDeleteStatus) { + menu.push(null); + } + + if (canBanUser) { + menu.push({ + text: 'Ban from Group', + action: handleBlockFromGroup, + icon: banIcon, + destructive: true, + }); + } + + if (canDeleteStatus) { + menu.push({ + text: intl.formatMessage(messages.groupModDelete), + action: handleDeleteFromGroup, + icon: trashIcon, + destructive: true, + }); + } + } + + if (isStaff) { + menu.push(null); + + menu.push({ + text: intl.formatMessage(messages.adminAccount, { name: username }), + action: onModerate, + icon: gavelIcon, + }); + + if (isAdmin) { + menu.push({ + text: intl.formatMessage(messages.admin_status), + href: `/pleroma/admin/#/statuses/${status.id}/`, + icon: pencilIcon, + }); + } + + menu.push({ + text: intl.formatMessage(status.sensitive === false ? messages.markStatusSensitive : messages.markStatusNotSensitive), + action: handleToggleStatusSensitivity, + icon: alertTriangleIcon, + }); + + if (!ownAccount) { + menu.push({ + text: intl.formatMessage(messages.deleteStatus), + action: handleDeleteStatus, + icon: trashIcon, + destructive: true, + }); + } + } + + return menu; + }; + + const publicStatus = ['public', 'unlisted', 'group'].includes(status.visibility); + + const replyCount = status.replies_count; + const reblogCount = status.reblogs_count; + const favouriteCount = status.favourites_count; + + const emojiReactCount = status.reactions?.reduce((acc, reaction) => acc + (reaction.count ?? 0), 0) ?? 0; // allow all emojis + + const meEmojiReact = status.reactions?.find((emojiReact) => emojiReact.me) // allow all emojis + ?? ( + status.favourited && account + ? { count: 1, me: status.account.id === account.id, name: '👍' } + : undefined + ); + + const meEmojiName = meEmojiReact?.name as keyof typeof reactMessages | undefined; + + const reactMessages = { + '👍': messages.reactionLike, + '❤️': messages.reactionHeart, + '😆': messages.reactionLaughing, + '😮': messages.reactionOpenMouth, + '😢': messages.reactionCry, + '😩': messages.reactionWeary, + '': messages.favourite, + }; + + const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiName || ''] || messages.favourite); + + const menu = _makeMenu(publicStatus); + let reblogIcon = repeatIcon; + let replyTitle; + let replyDisabled = false; + + if (status.visibility === 'direct') { + reblogIcon = mailIcon; + } else if (status.visibility === 'private') { + reblogIcon = lockIcon; + } + + if ((status.group as Group)?.membership_required && !groupRelationship?.member) { + replyDisabled = true; + replyTitle = intl.formatMessage(messages.replies_disabled_group); + } + + const reblogMenu = [{ + text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog), + action: handleReblogClick, + icon: repeatIcon, + }, { + text: intl.formatMessage(messages.quotePost), + action: handleQuoteClick, + icon: quoteIcon, + }]; + + const reblogButton = ( + + ); + + if (!status.in_reply_to_id) { + replyTitle = intl.formatMessage(messages.reply); + } else { + replyTitle = intl.formatMessage(messages.replyAll); + } + + const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group'); + const acceptsZaps = status.account.ditto.accepts_zaps === true; + + const spacing: { + [key: string]: React.ComponentProps['space']; + } = { + 'sm': 2, + 'md': 8, + 'lg': 0, // using justifyContent instead on the HStack + }; + + return ( + + e.stopPropagation()} + alignItems='center' + > + + + + + {(features.quotePosts && me) ? ( + + {reblogButton} + + ) : ( + reblogButton + )} + + {features.emojiReacts ? ( + + + + ) : ( + + )} + + {features.dislikes && ( + + )} + + {(acceptsZaps) && ( + + )} + + {canShare && ( + + )} + + + + + + + ); +}; + +export default PureStatusActionBar; diff --git a/src/components/pure-status-content.tsx b/src/components/pure-status-content.tsx new file mode 100644 index 0000000..b28d27c --- /dev/null +++ b/src/components/pure-status-content.tsx @@ -0,0 +1,137 @@ +import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg'; +import clsx from 'clsx'; +import { useState, useRef, useLayoutEffect, useMemo, memo } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Icon from 'soapbox/components/icon.tsx'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; +import { isOnlyEmoji as _isOnlyEmoji } from 'soapbox/utils/only-emoji.ts'; +import { getTextDirection } from 'soapbox/utils/rtl.ts'; + +import Markup from './markup.tsx'; +import Poll from './polls/poll.tsx'; + +import type { Sizes } from 'soapbox/components/ui/text.tsx'; + +const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) + +interface IReadMoreButton { + onClick: React.MouseEventHandler; +} + +/** Button to expand a truncated status (due to too much content) */ +const ReadMoreButton: React.FC = ({ onClick }) => ( + +); + +interface IPureStatusContent { + status: StatusEntity; + onClick?: () => void; + collapsable?: boolean; + translatable?: boolean; + textSize?: Sizes; +} + +/** Renders the text content of a status */ +const PureStatusContent: React.FC = ({ + status, + onClick, + collapsable = false, + translatable, + textSize = 'md', +}) => { + const [collapsed, setCollapsed] = useState(false); + + const node = useRef(null); + const isOnlyEmoji = useMemo(() => _isOnlyEmoji(status.content, status.emojis, 10), [status.content]); + + const maybeSetCollapsed = (): void => { + if (!node.current) return; + + if (collapsable && onClick && !collapsed) { + if (node.current.clientHeight > MAX_HEIGHT) { + setCollapsed(true); + } + } + }; + + useLayoutEffect(() => { + maybeSetCollapsed(); + }); + + const parsedHtml = useMemo((): string => { + return translatable && status.translation ? status.translation.content : status.content; + }, [status.content, status.translation]); + + if (status.content.length === 0) { + return null; + } + + const withSpoiler = status.spoiler_text.length > 0; + + const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; + + const direction = getTextDirection(status.search_index); + const className = clsx(baseClassName, { + 'cursor-pointer': onClick, + 'whitespace-normal': withSpoiler, + 'max-h-[300px]': collapsed, + 'leading-normal !text-4xl': isOnlyEmoji, + }); + + if (onClick) { + const output = [ + , + ]; + + if (collapsed) { + output.push(); + } + + const hasPoll = (!!status.poll) && typeof status.poll.id === 'string'; + if (hasPoll) { + output.push(); + } + + return
    {output}
    ; + } else { + const output = [ + , + ]; + + if (status.poll && typeof status.poll === 'string') { + output.push(); + } + + return <>{output}; + } +}; + +export default memo(PureStatusContent); diff --git a/src/components/pure-status-list.tsx b/src/components/pure-status-list.tsx new file mode 100644 index 0000000..be3ed89 --- /dev/null +++ b/src/components/pure-status-list.tsx @@ -0,0 +1,255 @@ +import clsx from 'clsx'; +import { debounce } from 'es-toolkit'; +import { useRef, useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import LoadGap from 'soapbox/components/load-gap.tsx'; +import PureStatus from 'soapbox/components/pure-status.tsx'; +import ScrollableList from 'soapbox/components/scrollable-list.tsx'; +import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions.tsx'; +import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status.tsx'; +import PendingStatus from 'soapbox/features/ui/components/pending-status.tsx'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; + +import type { VirtuosoHandle } from 'react-virtuoso'; +import type { IScrollableList } from 'soapbox/components/scrollable-list.tsx'; + +interface IPureStatusList extends Omit{ + /** Unique key to preserve the scroll position when navigating back. */ + scrollKey: string; + /** List of statuses to display. */ + statuses: readonly StatusEntity[]; + /** Last _unfiltered_ status ID (maxId) for pagination. */ + lastStatusId?: string; + /** Pinned statuses to show at the top of the feed. */ + featuredStatuses?: readonly StatusEntity[]; + /** Pagination callback when the end of the list is reached. */ + onLoadMore?: (lastStatusId: string) => void; + /** Whether the data is currently being fetched. */ + isLoading: boolean; + /** Whether the server did not return a complete page. */ + isPartial?: boolean; + /** Whether we expect an additional page of data. */ + hasMore: boolean; + /** Message to display when the list is loaded but empty. */ + emptyMessage: React.ReactNode; + /** ID of the timeline in Redux. */ + timelineId?: string; + /** Whether to display a gap or border between statuses in the list. */ + divideType?: 'space' | 'border'; + /** Whether to display ads. */ + showAds?: boolean; + /** Whether to show group information. */ + showGroup?: boolean; +} + +/** + * Feed of statuses, built atop ScrollableList. + */ +const PureStatusList: React.FC = ({ + statuses, + lastStatusId, + featuredStatuses, + divideType = 'border', + onLoadMore, + timelineId, + isLoading, + isPartial, + showAds = false, + showGroup = true, + className, + ...other +}) => { + const soapboxConfig = useSoapboxConfig(); + const node = useRef(null); + + const getFeaturedStatusCount = () => { + return featuredStatuses?.length || 0; + }; + + const getCurrentStatusIndex = (id: string, featured: boolean): number => { + if (featured) { + return (featuredStatuses ?? []).findIndex(key => key.id === id) || 0; + } else { + return ( + (statuses?.map(status => status.id) ?? []).findIndex(key => key === id) + + getFeaturedStatusCount() + ); + } + }; + + const handleMoveUp = (id: string, featured: boolean = false) => { + const elementIndex = getCurrentStatusIndex(id, featured) - 1; + selectChild(elementIndex); + }; + + const handleMoveDown = (id: string, featured: boolean = false) => { + const elementIndex = getCurrentStatusIndex(id, featured) + 1; + selectChild(elementIndex); + }; + + const handleLoadOlder = useCallback(debounce(() => { + const maxId = lastStatusId || statuses.slice(-1)?.[0]?.id; + if (onLoadMore && maxId) { + onLoadMore(maxId.replace('末suggestions-', '')); + } + }, 300, { edges: ['leading'] }), [onLoadMore, lastStatusId, statuses.slice(-1)?.[0]?.id]); + + const selectChild = (index: number) => { + node.current?.scrollIntoView({ + index, + behavior: 'smooth', + done: () => { + const element = document.querySelector(`#status-list [data-index="${index}"] .focusable`); + element?.focus(); + }, + }); + }; + + const renderLoadGap = (index: number) => { + const ids = statuses?.map(status => status.id) ?? []; + const nextId = ids[index + 1]; + const prevId = ids[index - 1]; + + if (index < 1 || !nextId || !prevId || !onLoadMore) return null; + + return ( + + ); + }; + + const renderStatus = (status: StatusEntity) => { + return ( + + ); + }; + + const renderPendingStatus = (statusId: string) => { + const idempotencyKey = statusId.replace(/^末pending-/, ''); + + return ( + + ); + }; + + const renderFeaturedStatuses = (): React.ReactNode[] => { + if (!featuredStatuses) return []; + + return (featuredStatuses ?? []).map(status => ( + + )); + }; + + const renderFeedSuggestions = (statusId: string): React.ReactNode => { + return ( + + ); + }; + + const renderStatuses = (): React.ReactNode[] => { + if (isLoading || (statuses?.length ?? 0) > 0) { + return (statuses ?? []).reduce((acc, status, index) => { + if (status.id === null) { + const gap = renderLoadGap(index); + // one does not simply push a null item to Virtuoso: https://github.com/petyosi/react-virtuoso/issues/206#issuecomment-747363793 + if (gap) { + acc.push(gap); + } + } else if (status.id.startsWith('末suggestions-')) { + if (soapboxConfig.feedInjection) { + acc.push(renderFeedSuggestions(status.id)); + } + } else if (status.id.startsWith('末pending-')) { + acc.push(renderPendingStatus(status.id)); + } else { + acc.push(renderStatus(status)); + } + + return acc; + }, [] as React.ReactNode[]); + } else { + return []; + } + }; + + const renderScrollableContent = () => { + const featuredStatuses = renderFeaturedStatuses(); + const statuses = renderStatuses(); + + if (featuredStatuses && statuses) { + return featuredStatuses.concat(statuses); + } else { + return statuses; + } + }; + + if (isPartial) { + return ( +
    +
    +
    + + + + +
    +
    +
    + ); + } + + return ( + } + placeholderCount={20} + ref={node} + listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', { + 'divide-none': divideType !== 'border', + }, className)} + itemClassName={clsx({ + 'pb-3': divideType !== 'border', + })} + {...other} + > + {renderScrollableContent()} + + ); +}; + +export default PureStatusList; \ No newline at end of file diff --git a/src/components/pure-status-reaction-wrapper.tsx b/src/components/pure-status-reaction-wrapper.tsx new file mode 100644 index 0000000..c242d57 --- /dev/null +++ b/src/components/pure-status-reaction-wrapper.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect, useRef, cloneElement } from 'react'; + +import { openModal } from 'soapbox/actions/modals.ts'; +import { useReaction } from 'soapbox/api/hooks/index.ts'; +import EmojiSelector from 'soapbox/components/ui/emoji-selector.tsx'; +import Portal from 'soapbox/components/ui/portal.tsx'; +import { Entities } from 'soapbox/entity-store/entities.ts'; +import { selectEntity } from 'soapbox/entity-store/selectors.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useGetState } from 'soapbox/hooks/useGetState.ts'; +import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; +import { userTouching } from 'soapbox/is-mobile.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; + +interface IPureStatusReactionWrapper { + statusId: string; + children: JSX.Element; +} + +/** Provides emoji reaction functionality to the underlying button component */ +const PureStatusReactionWrapper: React.FC = ({ statusId, children }): JSX.Element | null => { + const dispatch = useAppDispatch(); + const { account: ownAccount } = useOwnAccount(); + const getState = useGetState(); + + const status = selectEntity(getState(), Entities.STATUSES, statusId); + const { simpleEmojiReact } = useReaction(); + + const timeout = useRef(); + const [visible, setVisible] = useState(false); + + const [referenceElement, setReferenceElement] = useState(null); + + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; + }, []); + + if (!status) return null; + + const handleMouseEnter = () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + + if (!userTouching.matches) { + setVisible(true); + } + }; + + const handleMouseLeave = () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + + // Unless the user is touching, delay closing the emoji selector briefly + // so the user can move the mouse diagonally to make a selection. + if (userTouching.matches) { + setVisible(false); + } else { + timeout.current = setTimeout(() => { + setVisible(false); + }, 500); + } + }; + + const handleReact = (emoji: string, custom?: string): void => { + if (ownAccount) { + simpleEmojiReact(status, emoji); + } else { + handleUnauthorized(); + } + + setVisible(false); + }; + + const handleClick: React.EventHandler = e => { + const meEmojiReact = status.reactions?.find((emojiReact) => emojiReact.me)?.name ?? '👍' ; // allow all emojis + + if (userTouching.matches) { + if (ownAccount) { + if (visible) { + handleReact(meEmojiReact); + } else { + setVisible(true); + } + } else { + handleUnauthorized(); + } + } else { + handleReact(meEmojiReact); + } + + e.preventDefault(); + e.stopPropagation(); + }; + + const handleUnauthorized = () => { + dispatch(openModal('UNAUTHORIZED', { + action: 'FAVOURITE', + ap_id: status.url, + })); + }; + + return ( +
    + {cloneElement(children, { + onClick: handleClick, + ref: setReferenceElement, + })} + + {visible && ( + + setVisible(false)} + /> + + )} +
    + ); +}; + +export default PureStatusReactionWrapper; diff --git a/src/components/pure-status-reply-mentions.tsx b/src/components/pure-status-reply-mentions.tsx new file mode 100644 index 0000000..33b3247 --- /dev/null +++ b/src/components/pure-status-reply-mentions.tsx @@ -0,0 +1,112 @@ +import { FormattedList, FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { openModal } from 'soapbox/actions/modals.ts'; +import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper.tsx'; +import HoverStatusWrapper from 'soapbox/components/hover-status-wrapper.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; +import { shortenNostr } from 'soapbox/utils/nostr.ts'; + +interface IPureStatusReplyMentions { + status: StatusEntity; + hoverable?: boolean; +} + +const PureStatusReplyMentions: React.FC = ({ status, hoverable = true }) => { + const dispatch = useAppDispatch(); + + const handleOpenMentionsModal: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + const account = status.account; + + dispatch(openModal('MENTIONS', { + username: account.acct, + statusId: status.id, + })); + }; + + if (!status.in_reply_to_id) { + return null; + } + + const to = status.mentions; + + // The post is a reply, but it has no mentions. + // Rare, but it can happen. + if (to.length === 0) { + return ( +
    + +
    + ); + } + + // The typical case with a reply-to and a list of mentions. + const accounts = to.slice(0, 2).map(account => { + const link = ( + e.stopPropagation()} + > {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + @{shortenNostr(account.username)} + + ); + + if (hoverable) { + return ( + + {link} + + ); + } else { + return link; + } + }); + + if (to.length > 2) { + accounts.push( + + + , + ); + } + + return ( +
    + , + // @ts-ignore wtf? + hover: (children: React.ReactNode) => { + if (hoverable) { + return ( + + + {children} + + + ); + } else { + return children; + } + }, + }} + /> +
    + ); +}; + +export default PureStatusReplyMentions; diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx new file mode 100644 index 0000000..31e9e5d --- /dev/null +++ b/src/components/pure-status.tsx @@ -0,0 +1,501 @@ +import circlesIcon from '@tabler/icons/outline/circles.svg'; +import pinnedIcon from '@tabler/icons/outline/pinned.svg'; +import repeatIcon from '@tabler/icons/outline/repeat.svg'; +import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; +import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; +import { Link, useHistory } from 'react-router-dom'; + +import { openModal } from 'soapbox/actions/modals.ts'; +import { unfilterStatus } from 'soapbox/actions/statuses.ts'; +import { useFavourite } from 'soapbox/api/hooks/index.ts'; +import PureEventPreview from 'soapbox/components/pure-event-preview.tsx'; +import PureStatusActionBar from 'soapbox/components/pure-status-action-bar.tsx'; +import PureStatusContent from 'soapbox/components/pure-status-content.tsx'; +import PureStatusReplyMentions from 'soapbox/components/pure-status-reply-mentions.tsx'; +import PureTranslateButton from 'soapbox/components/pure-translate-button.tsx'; +import PureSensitiveContentOverlay from 'soapbox/components/statuses/pure-sensitive-content-overlay.tsx'; +import { Card } from 'soapbox/components/ui/card.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import AccountContainer from 'soapbox/containers/account-container.tsx'; +import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container.tsx'; +import { HotKeys } from 'soapbox/features/ui/components/hotkeys.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useMentionCompose } from 'soapbox/hooks/useMentionCompose.ts'; +import { useReblog } from 'soapbox/hooks/useReblog.ts'; +import { useReplyCompose } from 'soapbox/hooks/useReplyCompose.ts'; +import { useSettings } from 'soapbox/hooks/useSettings.ts'; +import { useStatusHidden } from 'soapbox/hooks/useStatusHidden.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; +import { emojifyText } from 'soapbox/utils/emojify.tsx'; +import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status.ts'; + +import StatusMedia from './status-media.tsx'; +import StatusInfo from './statuses/status-info.tsx'; +import Tombstone from './tombstone.tsx'; + +// Defined in components/scrollable-list +export type ScrollPosition = { height: number; top: number }; + +const messages = defineMessages({ + reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, +}); + +export interface IPureStatus { + id?: string; + avatarSize?: number; + status: StatusEntity; + onClick?: () => void; + muted?: boolean; + hidden?: boolean; + unread?: boolean; + onMoveUp?: (statusId: string, featured?: boolean) => void; + onMoveDown?: (statusId: string, featured?: boolean) => void; + focusable?: boolean; + featured?: boolean; + hideActionBar?: boolean; + hoverable?: boolean; + variant?: 'default' | 'rounded' | 'slim'; + showGroup?: boolean; + accountAction?: React.ReactElement; +} + +/** + * Status accepting the full status entity in pure format. + */ +const PureStatus: React.FC = (props) => { + const { + status, + accountAction, + avatarSize = 42, + focusable = true, + hoverable = true, + onClick, + onMoveUp, + onMoveDown, + muted, + hidden, + featured, + unread, + hideActionBar, + variant = 'rounded', + showGroup = true, + } = props; + + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + + const { displayMedia, boostModal } = useSettings(); + const didShowCard = useRef(false); + const node = useRef(null); + const overlay = useRef(null); + + const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + const [minHeight, setMinHeight] = useState(208); + + const actualStatus = getActualStatus(status); + const isReblog = status.reblog && typeof status.reblog === 'object'; + const statusUrl = `/@${actualStatus.account.acct}/posts/${actualStatus.id}`; + const group = actualStatus.group; + + const filtered = (status.filtered.length || actualStatus.filtered.length) > 0; + + const { replyCompose } = useReplyCompose(); + const { mentionCompose } = useMentionCompose(); + const { toggleFavourite } = useFavourite(); + const { toggleReblog } = useReblog(); + const { toggleStatusHidden } = useStatusHidden(); + + // Track height changes we know about to compensate scrolling. + useEffect(() => { + didShowCard.current = Boolean(!muted && !hidden && status?.card); + }, []); + + useEffect(() => { + setShowMedia(defaultMediaVisibility(status, displayMedia)); + }, [status.id]); + + useEffect(() => { + if (overlay.current) { + setMinHeight(overlay.current.getBoundingClientRect().height); + } + }, [overlay.current]); + + const handleToggleMediaVisibility = (): void => { + setShowMedia(!showMedia); + }; + + const handleClick = (e?: React.MouseEvent): void => { + e?.stopPropagation(); + + // If the user is selecting text, don't focus the status. + if (getSelection()?.toString().length) { + return; + } + + if (!e || !(e.ctrlKey || e.metaKey)) { + if (onClick) { + onClick(); + } else { + history.push(statusUrl); + } + } else { + window.open(statusUrl, '_blank'); + } + }; + + const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { + const status = actualStatus; + const firstAttachment = status.media_attachments[0]; + + e?.preventDefault(); + + if (firstAttachment) { + if (firstAttachment.type === 'video') { + dispatch(openModal('VIDEO', { status, media: firstAttachment, time: 0 })); + } else { + dispatch(openModal('MEDIA', { status, media: status.media_attachments, index: 0 })); + } + } + }; + + const handleHotkeyReply = (e?: KeyboardEvent): void => { + e?.preventDefault(); + replyCompose(status.id); + }; + + const handleHotkeyFavourite = (): void => { + toggleFavourite(status.id); + }; + + const handleHotkeyBoost = (e?: KeyboardEvent): void => { + const modalReblog = () => toggleReblog(status.id); + if ((e && e.shiftKey) || !boostModal) { + modalReblog(); + } else { + dispatch(openModal('BOOST', { status: status, onReblog: modalReblog })); + } + }; + + const handleHotkeyMention = (e?: KeyboardEvent): void => { + e?.preventDefault(); + mentionCompose(actualStatus.account); + }; + + const handleHotkeyOpen = (): void => { + history.push(statusUrl); + }; + + const handleHotkeyOpenProfile = (): void => { + history.push(`/@${actualStatus.account.acct}`); + }; + + const handleHotkeyMoveUp = (e?: KeyboardEvent): void => { + if (onMoveUp) { + onMoveUp(status.id, featured); + } + }; + + const handleHotkeyMoveDown = (e?: KeyboardEvent): void => { + if (onMoveDown) { + onMoveDown(status.id, featured); + } + }; + + const handleHotkeyToggleHidden = (): void => { + toggleStatusHidden(status.id); + }; + + const handleHotkeyToggleSensitive = (): void => { + handleToggleMediaVisibility(); + }; + + const handleHotkeyReact = (): void => { + _expandEmojiSelector(); + }; + + const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.length ? status.id : actualStatus.id)); + + const _expandEmojiSelector = (): void => { + const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji?.focus(); + }; + + const renderStatusInfo = () => { + if (isReblog && showGroup && group) { + return ( + } + text={ + + + + {emojifyText(status.account.display_name, status.account.emojis)} + + + + ), + group: ( + + + {group.display_name} + + + ), + }} + /> + } + /> + ); + } else if (isReblog) { + return ( + } + text={ + + + + {emojifyText(status.account.display_name, status.account.emojis)} + + + + ), + }} + /> + } + /> + ); + } else if (featured) { + return ( + } + text={ + + } + /> + ); + } else if (showGroup && group) { + return ( + } + text={ + + + + {group.display_name} + + + + ), + }} + /> + } + /> + ); + } + }; + + if (!status) return null; + + if (hidden) { + return ( +
    + <> + {actualStatus.account.display_name || actualStatus.account.username} + {actualStatus.content} + +
    + ); + } + + if (filtered && status.showFiltered) { + const minHandlers = muted ? undefined : { + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + }; + + return ( + +
    + {/* eslint-disable formatjs/no-literal-string-in-jsx */} + + : {status.filtered.join(', ')}. + {' '} + + + {/* eslint-enable formatjs/no-literal-string-in-jsx */} +
    +
    + ); + } + + let rebloggedByText; + if (status.reblog && typeof status.reblog === 'object') { + rebloggedByText = intl.formatMessage( + messages.reblogged_by, + { name: status.account.acct }, + ); + } + + let quote; + + if (actualStatus.quote) { + if ((actualStatus?.pleroma?.quote_visible ?? true) === false) { + quote = ( +
    +

    +
    + ); + } else { + quote = ; + } + } + + const handlers = muted ? undefined : { + reply: handleHotkeyReply, + favourite: handleHotkeyFavourite, + boost: handleHotkeyBoost, + mention: handleHotkeyMention, + open: handleHotkeyOpen, + openProfile: handleHotkeyOpenProfile, + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + toggleHidden: handleHotkeyToggleHidden, + toggleSensitive: handleHotkeyToggleSensitive, + openMedia: handleHotkeyOpenMedia, + react: handleHotkeyReact, + }; + + const isUnderReview = actualStatus.visibility === 'self'; + const isSensitive = actualStatus.hidden; + const isSoftDeleted = status.tombstone?.reason === 'deleted'; + + if (isSoftDeleted) { + return ( + onMoveUp ? onMoveUp(id) : null} + onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null} + /> + ); + } + + return ( + + {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} +
    + + {renderStatusInfo()} + + + +
    + + + + {(isUnderReview || isSensitive) && ( + + )} + + {actualStatus.event ? : ( + + + + + + {(quote || actualStatus.card || actualStatus.media_attachments.length > 0) && ( + + + + {quote} + + )} + + )} + + + {(!hideActionBar && !isUnderReview) && ( +
    + +
    + )} +
    +
    +
    +
    + ); +}; + +export default PureStatus; \ No newline at end of file diff --git a/src/components/pure-translate-button.tsx b/src/components/pure-translate-button.tsx new file mode 100644 index 0000000..3408da4 --- /dev/null +++ b/src/components/pure-translate-button.tsx @@ -0,0 +1,82 @@ +import languageIcon from '@tabler/icons/outline/language.svg'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { translateStatus, undoStatusTranslation } from 'soapbox/actions/statuses.ts'; +import Button from 'soapbox/components/ui/button.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; +import { useInstance } from 'soapbox/hooks/useInstance.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; + +interface IPureTranslateButton { + status: StatusEntity; +} + +const PureTranslateButton: React.FC = ({ status }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const features = useFeatures(); + const { instance } = useInstance(); + + const me = useAppSelector((state) => state.me); + + const { + allow_remote: allowRemote, + allow_unauthenticated: allowUnauthenticated, + source_languages: sourceLanguages, + target_languages: targetLanguages, + } = instance.pleroma.metadata.translation; + + const renderTranslate = (me || allowUnauthenticated) && (allowRemote || status.account.local) && ['public', 'unlisted'].includes(status.visibility) && status.content.length > 0 && status.language !== null && intl.locale !== status.language; + + const supportsLanguages = (!sourceLanguages || sourceLanguages.includes(status.language!)) && (!targetLanguages || targetLanguages.includes(intl.locale)); + + const handleTranslate: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + if (status.translation) { + dispatch(undoStatusTranslation(status.id)); + } else { + dispatch(translateStatus(status.id, intl.locale)); + } + }; + + if (!features.translations || !renderTranslate || !supportsLanguages) return null; + + if (status.translation) { + const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' }); + const languageName = languageNames.of(status.language!); + const provider = status.translation.provider; + + return ( + +
    + + ); +}; + +export default PureTranslateButton; diff --git a/src/components/quoted-status-indicator.tsx b/src/components/quoted-status-indicator.tsx new file mode 100644 index 0000000..0444c9a --- /dev/null +++ b/src/components/quoted-status-indicator.tsx @@ -0,0 +1,30 @@ +import quoteIcon from '@tabler/icons/outline/quote.svg'; +import { useCallback } from 'react'; + +import HStack from 'soapbox/components/ui/hstack.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { makeGetStatus } from 'soapbox/selectors/index.ts'; + +interface IQuotedStatusIndicator { + /** The quoted status id. */ + statusId: string; +} + +const QuotedStatusIndicator: React.FC = ({ statusId }) => { + const getStatus = useCallback(makeGetStatus(), []); + + const status = useAppSelector(state => getStatus(state, { id: statusId })); + + if (!status) return null; + + return ( + + + {status.url} + + ); +}; + +export default QuotedStatusIndicator; diff --git a/src/components/quoted-status.test.tsx b/src/components/quoted-status.test.tsx new file mode 100644 index 0000000..49dde1a --- /dev/null +++ b/src/components/quoted-status.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen, rootState } from 'soapbox/jest/test-helpers.tsx'; +import { normalizeStatus, normalizeAccount } from 'soapbox/normalizers/index.ts'; + +import QuotedStatus from './quoted-status.tsx'; + +import type { ReducerStatus } from 'soapbox/reducers/statuses.ts'; + +describe('', () => { + it('renders content', () => { + const account = normalizeAccount({ + id: '1', + acct: 'alex', + url: 'https://soapbox.test/users/alex', + }); + + const status = normalizeStatus({ + id: '1', + account, + content: 'hello world', + contentHtml: 'hello world', + }) as ReducerStatus; + + const state = rootState/*.accounts.set('1', account)*/; + + render(, undefined, state); + screen.getByText(/hello world/i); + expect(screen.getByTestId('quoted-status')).toHaveTextContent(/hello world/i); + }); +}); diff --git a/src/components/quoted-status.tsx b/src/components/quoted-status.tsx new file mode 100644 index 0000000..e509222 --- /dev/null +++ b/src/components/quoted-status.tsx @@ -0,0 +1,156 @@ +import xIcon from '@tabler/icons/outline/x.svg'; +import clsx from 'clsx'; +import { MouseEventHandler, useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import StatusMedia from 'soapbox/components/status-media.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import AccountContainer from 'soapbox/containers/account-container.tsx'; +import { useSettings } from 'soapbox/hooks/useSettings.ts'; +import { Status as StatusEntity } from 'soapbox/schemas/index.ts'; +import { defaultMediaVisibility } from 'soapbox/utils/status.ts'; + +import EventPreview from './event-preview.tsx'; +import OutlineBox from './outline-box.tsx'; +import QuotedStatusIndicator from './quoted-status-indicator.tsx'; +import StatusContent from './status-content.tsx'; +import StatusReplyMentions from './status-reply-mentions.tsx'; +import SensitiveContentOverlay from './statuses/sensitive-content-overlay.tsx'; + +import type { Status as LegacyStatus } from 'soapbox/types/entities.ts'; + +const messages = defineMessages({ + cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, +}); + +interface IQuotedStatus { + /** The quoted status entity. */ + status?: LegacyStatus; + /** Callback when cancelled (during compose). */ + onCancel?: Function; + /** Whether the status is shown in the post composer. */ + compose?: boolean; +} + +/** Status embedded in a quote post. */ +const QuotedStatus: React.FC = ({ status, onCancel, compose }) => { + const intl = useIntl(); + const history = useHistory(); + + const { displayMedia } = useSettings(); + + const overlay = useRef(null); + + const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + const [minHeight, setMinHeight] = useState(208); + + useEffect(() => { + if (overlay.current) { + setMinHeight(overlay.current.getBoundingClientRect().height); + } + }, [overlay.current]); + + const handleExpandClick: MouseEventHandler = (e) => { + if (!status) return; + const account = status.account; + + if (!compose && e.button === 0) { + const statusUrl = `/@${account.acct}/posts/${status.id}`; + if (!(e.ctrlKey || e.metaKey)) { + history.push(statusUrl); + } else { + window.open(statusUrl, '_blank'); + } + e.stopPropagation(); + e.preventDefault(); + } + }; + + const handleClose = () => { + if (onCancel) { + onCancel(); + } + }; + + const handleToggleMediaVisibility = () => { + setShowMedia(!showMedia); + }; + + if (!status) { + return null; + } + + const account = status.account; + + let actions = {}; + if (onCancel) { + actions = { + onActionClick: handleClose, + actionIcon: xIcon, + actionAlignment: 'top', + actionTitle: intl.formatMessage(messages.cancel), + }; + } + + return ( + + + + + + + {status.event ? : ( + + {(status.hidden) && ( + + )} + + + + + {status.quote && } + + {status.media_attachments.size > 0 && ( + + )} + + + )} + + + ); +}; + +export default QuotedStatus; diff --git a/src/components/radio.tsx b/src/components/radio.tsx new file mode 100644 index 0000000..806bc8a --- /dev/null +++ b/src/components/radio.tsx @@ -0,0 +1,43 @@ +import { Children, cloneElement } from 'react'; + +import List, { ListItem } from './list.tsx'; + +interface IRadioGroup { + onChange: React.ChangeEventHandler; + children: React.ReactElement<{ onChange: React.ChangeEventHandler }>[]; +} + +const RadioGroup = ({ onChange, children }: IRadioGroup) => { + const childrenWithProps = Children.map(children, child => + cloneElement(child, { onChange }), + ); + + return {childrenWithProps}; +}; + +interface IRadioItem { + label: React.ReactNode; + hint?: React.ReactNode; + value: string; + checked: boolean; + onChange?: React.ChangeEventHandler; +} + +const RadioItem: React.FC = ({ label, hint, checked = false, onChange, value }) => { + return ( + + + + ); +}; + +export { + RadioGroup, + RadioItem, +}; \ No newline at end of file diff --git a/src/components/relative-timestamp.tsx b/src/components/relative-timestamp.tsx new file mode 100644 index 0000000..59b4751 --- /dev/null +++ b/src/components/relative-timestamp.tsx @@ -0,0 +1,200 @@ +import { Component } from 'react'; +import { injectIntl, defineMessages, IntlShape, FormatDateOptions } from 'react-intl'; + +import Text, { IText } from './ui/text.tsx'; + +const messages = defineMessages({ + just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, + seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, + minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, + hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, + days: { id: 'relative_time.days', defaultMessage: '{number}d' }, + moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, + seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, + minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, + hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, + days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, +}); + +export const dateFormatOptions: FormatDateOptions = { + hour12: true, + year: 'numeric', + month: 'short', + day: '2-digit', + hour: 'numeric', + minute: '2-digit', +}; + +const shortDateFormatOptions: FormatDateOptions = { + month: 'short', + day: 'numeric', +}; + +const SECOND = 1000; +const MINUTE = 1000 * 60; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; + +const MAX_DELAY = 2147483647; + +const selectUnits = (delta: number) => { + const absDelta = Math.abs(delta); + + if (absDelta < MINUTE) { + return 'second'; + } else if (absDelta < HOUR) { + return 'minute'; + } else if (absDelta < DAY) { + return 'hour'; + } + + return 'day'; +}; + +const getUnitDelay = (units: string) => { + switch (units) { + case 'second': + return SECOND; + case 'minute': + return MINUTE; + case 'hour': + return HOUR; + case 'day': + return DAY; + default: + return MAX_DELAY; + } +}; + +export const timeAgoString = (intl: IntlShape, date: Date, now: number, year: number) => { + const delta = now - date.getTime(); + + let relativeTime; + + if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.just_now); + } else if (delta < 7 * DAY) { + if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); + } + } else if (date.getFullYear() === year) { + relativeTime = intl.formatDate(date, shortDateFormatOptions); + } else { + relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' }); + } + + return relativeTime; +}; + +const timeRemainingString = (intl: IntlShape, date: Date, now: number) => { + const delta = date.getTime() - now; + + let relativeTime; + + if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.moments_remaining); + } else if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) }); + } + + return relativeTime; +}; + +interface RelativeTimestampProps extends IText { + intl: IntlShape; + timestamp: string; + year?: number; + futureDate?: boolean; +} + +interface RelativeTimestampState { + now: number; +} + +/** Displays a timestamp compared to the current time, eg "1m" for one minute ago. */ +class RelativeTimestamp extends Component { + + _timer: NodeJS.Timeout | undefined; + + state = { + now: Date.now(), + }; + + static defaultProps = { + year: (new Date()).getFullYear(), + theme: 'inherit' as const, + }; + + shouldComponentUpdate(nextProps: RelativeTimestampProps, nextState: RelativeTimestampState) { + // As of right now the locale doesn't change without a new page load, + // but we might as well check in case that ever changes. + return this.props.timestamp !== nextProps.timestamp || + this.props.intl.locale !== nextProps.intl.locale || + this.state.now !== nextState.now; + } + + UNSAFE_componentWillReceiveProps(prevProps: RelativeTimestampProps) { + if (this.props.timestamp !== prevProps.timestamp) { + this.setState({ now: Date.now() }); + } + } + + componentDidMount() { + this._scheduleNextUpdate(); + } + + UNSAFE_componentWillUpdate() { + this._scheduleNextUpdate(); + } + + componentWillUnmount() { + if (this._timer) { + clearTimeout(this._timer); + } + } + + _scheduleNextUpdate() { + if (this._timer) { + clearTimeout(this._timer); + } + + const { timestamp } = this.props; + const delta = (new Date(timestamp)).getTime() - this.state.now; + const unitDelay = getUnitDelay(selectUnits(delta)); + const unitRemainder = Math.abs(delta % unitDelay); + const updateInterval = 1000 * 10; + const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); + + this._timer = setTimeout(() => { + this.setState({ now: Date.now() }); + }, delay); + } + + render() { + const { timestamp, intl, year, futureDate, theme, ...textProps } = this.props; + + const date = new Date(timestamp); + const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year!); + + return ( + + {relativeTime} + + ); + } + +} + +export default injectIntl(RelativeTimestamp); diff --git a/src/components/safe-embed.tsx b/src/components/safe-embed.tsx new file mode 100644 index 0000000..acb93d6 --- /dev/null +++ b/src/components/safe-embed.tsx @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface ISafeEmbed { + /** Styles for the outer frame element. */ + className?: string; + /** Space-separate list of restrictions to ALLOW for the iframe. */ + sandbox?: string; + /** Unique title for the iframe. */ + title: string; + /** HTML body to embed. */ + html?: string; +} + +/** Safely embeds arbitrary HTML content on the page (by putting it in an iframe). */ +const SafeEmbed: React.FC = ({ + className, + sandbox, + title, + html, +}) => { + const iframe = useRef(null); + const [height, setHeight] = useState(undefined); + + const handleMessage = useCallback((e: MessageEvent) => { + if (e.data?.type === 'setHeight') { + setHeight(e.data?.height); + } + }, []); + + useEffect(() => { + const iframeDocument = iframe.current?.contentWindow?.document; + + if (iframeDocument && html) { + iframeDocument.open(); + iframeDocument.write(html); + iframeDocument.close(); + iframeDocument.body.style.margin = '0'; + + iframe.current?.contentWindow?.addEventListener('message', handleMessage); + + const innerFrame = iframeDocument.querySelector('iframe'); + if (innerFrame) { + innerFrame.width = '100%'; + } + } + + return () => { + iframe.current?.contentWindow?.removeEventListener('message', handleMessage); + }; + }, [iframe.current, html]); + + return ( + '; + expect(addAutoPlay(html)).toEqual(''); + }); + + describe('when the iframe src already has params', () => { + it('adds the correct query parameters to the src', () => { + const html = ''; + expect(addAutoPlay(html)).toEqual(''); + }); + }); + }); + + describe('when the provider is not Rumble', () => { + it('adds the correct query parameters to the src', () => { + const html = ''; + expect(addAutoPlay(html)).toEqual(''); + }); + + describe('when the iframe src already has params', () => { + it('adds the correct query parameters to the src', () => { + const html = ''; + expect(addAutoPlay(html)).toEqual(''); + }); + }); + }); +}); diff --git a/src/utils/media.ts b/src/utils/media.ts new file mode 100644 index 0000000..b44f1eb --- /dev/null +++ b/src/utils/media.ts @@ -0,0 +1,97 @@ +const truncateFilename = (url: string, maxLength: number) => { + const filename = url.split('/').pop(); + + if (!filename) { + return filename; + } + + if (filename.length <= maxLength) return filename; + + return [ + filename.substr(0, maxLength / 2), + filename.substr(filename.length - maxLength / 2), + ].join('…'); +}; + +const formatBytes = (bytes: number, decimals: number = 2) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}; + +const getVideoDuration = (file: File): Promise => { + const video = document.createElement('video'); + + const promise = new Promise((resolve, reject) => { + video.addEventListener('loadedmetadata', () => { + // Chrome bug: https://bugs.chromium.org/p/chromium/issues/detail?id=642012 + if (video.duration === Infinity) { + video.currentTime = Number.MAX_SAFE_INTEGER; + video.ontimeupdate = () => { + video.ontimeupdate = null; + resolve(video.duration); + video.currentTime = 0; + }; + } else { + resolve(video.duration); + } + }); + + video.onerror = (event: any) => reject(event.target.error); + }); + + video.src = window.URL.createObjectURL(file); + + return promise; +}; + +const domParser = new DOMParser(); + +enum VideoProviders { + RUMBLE = 'rumble.com' +} + +/** Try adding autoplay to an iframe embed for platforms such as YouTube. */ +const addAutoPlay = (html: string): string => { + try { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + + if (iframe) { + iframe.style.width = '100%'; + iframe.style.height = '100%'; + } + + if (iframe) { + const url = new URL(iframe.src); + const provider = new URL(iframe.src).host; + + if (provider === VideoProviders.RUMBLE) { + url.searchParams.append('pub', '7a20'); + url.searchParams.append('autoplay', '2'); + } else { + url.searchParams.append('autoplay', '1'); + url.searchParams.append('auto_play', '1'); + iframe.allow = 'autoplay'; + } + + iframe.src = url.toString(); + + // DOM parser creates html/body elements around original HTML fragment, + // so we need to get innerHTML out of the body and not the entire document + return (document.querySelector('body') as HTMLBodyElement).innerHTML; + } + } catch (e) { + return html; + } + + return html; +}; + +export { getVideoDuration, formatBytes, truncateFilename, addAutoPlay }; diff --git a/src/utils/normalizers.ts b/src/utils/normalizers.ts new file mode 100644 index 0000000..f46d66c --- /dev/null +++ b/src/utils/normalizers.ts @@ -0,0 +1,33 @@ +import z from 'zod'; + +/** Use new value only if old value is undefined */ +export const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal; + +/** Normalize entity ID */ +export const normalizeId = (id: unknown): string | null => { + return z.string().nullable().catch(null).parse(id); +}; + +export type Normalizer = (value: V) => R; + +/** + * Allows using any legacy normalizer function as a zod schema. + * + * @example + * ```ts + * const statusSchema = toSchema(normalizeStatus); + * statusSchema.parse(status); + * ``` + */ +export const toSchema = (normalizer: Normalizer) => { + return z.custom().transform(normalizer); +}; + +/** Legacy normalizer transition helper function. */ +export const maybeFromJS = (value: any): unknown => { + if ('toJS' in value) { + return value.toJS(); + } else { + return value; + } +}; \ No newline at end of file diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts new file mode 100644 index 0000000..68b59e0 --- /dev/null +++ b/src/utils/nostr.ts @@ -0,0 +1,12 @@ +import { BECH32_REGEX } from 'nostr-tools/nip19'; + +/** Check whether the given input is a valid Nostr hexadecimal pubkey. */ +const isPubkey = (value: string) => /^[0-9a-f]{64}$/i.test(value); + +/** If the value is a Nostr pubkey or bech32, shorten it. */ +export function shortenNostr(value: string): string { + if (isPubkey(value) || BECH32_REGEX.test(value)) { + return value.slice(0, 8); + } + return value; +} \ No newline at end of file diff --git a/src/utils/notification.ts b/src/utils/notification.ts new file mode 100644 index 0000000..5b2c943 --- /dev/null +++ b/src/utils/notification.ts @@ -0,0 +1,40 @@ +/** Notification types known to Soapbox. */ +const NOTIFICATION_TYPES = [ + 'follow', + 'follow_request', + 'mention', + 'reblog', + 'favourite', + 'group_favourite', + 'group_reblog', + 'poll', + 'status', + 'move', + 'pleroma:chat_mention', + 'pleroma:emoji_reaction', + 'user_approved', + 'update', + 'pleroma:event_reminder', + 'pleroma:participation_request', + 'pleroma:participation_accepted', + 'ditto:name_grant', + 'ditto:zap', +] as const; + +/** Notification types to exclude from the "All" filter by default. */ +const EXCLUDE_TYPES = [ + 'pleroma:chat_mention', + 'chat', // TruthSocial +] as const; + +type NotificationType = typeof NOTIFICATION_TYPES[number]; + +/** Ensure the Notification is a valid, known type. */ +const validType = (type: string): type is NotificationType => NOTIFICATION_TYPES.includes(type as any); + +export { + NOTIFICATION_TYPES, + EXCLUDE_TYPES, + type NotificationType, + validType, +}; diff --git a/src/utils/numbers.test.tsx b/src/utils/numbers.test.tsx new file mode 100644 index 0000000..0f7eba9 --- /dev/null +++ b/src/utils/numbers.test.tsx @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'vitest'; + +import { render, screen } from 'soapbox/jest/test-helpers.tsx'; + +import { isIntegerId, secondsToDays, shortNumberFormat } from './numbers.tsx'; + +test('isIntegerId()', () => { + expect(isIntegerId('0')).toBe(true); + expect(isIntegerId('1')).toBe(true); + expect(isIntegerId('508107650')).toBe(true); + expect(isIntegerId('-1764036199')).toBe(true); + expect(isIntegerId('106801667066418367')).toBe(true); + expect(isIntegerId('9v5bmRalQvjOy0ECcC')).toBe(false); + expect(isIntegerId(null as any)).toBe(false); + expect(isIntegerId(undefined as any)).toBe(false); +}); + +test('secondsToDays', () => { + expect(secondsToDays(604800)).toEqual(7); + expect(secondsToDays(1209600)).toEqual(14); + expect(secondsToDays(2592000)).toEqual(30); + expect(secondsToDays(7776000)).toEqual(90); +}); + +describe('shortNumberFormat', () => { + test('handles non-numbers', () => { + render(
    {shortNumberFormat('not-number')}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('•'); + }); + + test('handles max argument', () => { + render(
    {shortNumberFormat(25, 20)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('20+'); + }); + + test('formats numbers under 1,000', () => { + render(
    {shortNumberFormat(555)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('555'); + }); + + test('formats numbers under 1,000,000', () => { + render(
    {shortNumberFormat(5555)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('5.55k'); + }); + + test('formats numbers over 1,000,000', () => { + render(
    {shortNumberFormat(5555555)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('5.55M'); + }); + + test('formats a multitude of numbers', () => { + let result = render(
    {shortNumberFormat(0)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('0'); + result.unmount(); + + result = render(
    {shortNumberFormat(1)}
    ); + expect(screen.getByTestId('num')).toHaveTextContent('1'); + result.unmount(); + + result = render(
    {shortNumberFormat(999)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('999'); + result.unmount(); + + result = render(
    {shortNumberFormat(1000)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('1k'); + result.unmount(); + + result = render(
    {shortNumberFormat(1001)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('1k'); + result.unmount(); + + result = render(
    {shortNumberFormat(1005)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('1k'); + result.unmount(); + + result = render(
    {shortNumberFormat(1006)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('1k'); + result.unmount(); + + result = render(
    {shortNumberFormat(1010)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('1.01k'); + result.unmount(); + + result = render(
    {shortNumberFormat(1530)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('1.53k'); + result.unmount(); + + result = render(
    {shortNumberFormat(10530)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('10.5k'); + result.unmount(); + + result = render(
    {shortNumberFormat(999500)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('999k'); + result.unmount(); + + result = render(
    {shortNumberFormat(999999)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('999k'); + result.unmount(); + + result = render(
    {shortNumberFormat(999499)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('999k'); + result.unmount(); + + result = render(
    {shortNumberFormat(1000000)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('1M'); + result.unmount(); + + result = render(
    {shortNumberFormat(3905558)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('3.9M'); + result.unmount(); + + result = render(
    {shortNumberFormat(1031511)}
    , undefined, null); + expect(screen.getByTestId('num')).toHaveTextContent('1.03M'); + result.unmount(); + }); +}); diff --git a/src/utils/numbers.tsx b/src/utils/numbers.tsx new file mode 100644 index 0000000..60f0329 --- /dev/null +++ b/src/utils/numbers.tsx @@ -0,0 +1,56 @@ +import { FormattedNumber } from 'react-intl'; +import { z } from 'zod'; + +/** Check if a value is REALLY a number. */ +export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value); + +/** The input is a number and is not NaN. */ +export const realNumberSchema = z.coerce.number().refine(n => !isNaN(n)); + +export const secondsToDays = (seconds: number) => Math.floor(seconds / (3600 * 24)); + +const roundDown = (num: number) => { + if (num >= 100 && num < 1000) { + num = Math.floor(num); + } + + const n = Number(num.toFixed(2)); + return (n > num) ? n - (1 / (Math.pow(10, 2))) : n; +}; + +/** Display a number nicely for the UI, eg 1000 becomes 1K. */ +export const shortNumberFormat = (number: any, max?: number): React.ReactNode => { + if (!isNumber(number)) return '•'; + + let value = number; + let factor: string = ''; + if (number >= 1000 && number < 1000000) { + factor = 'k'; + value = roundDown(value / 1000); + } else if (number >= 1000000) { + factor = 'M'; + value = roundDown(value / 1000000); + } + + if (max && value > max) { + // eslint-disable-next-line formatjs/no-literal-string-in-jsx + return {max}+; + } + + return ( + + + {factor} + + ); +}; + +/** Check if an entity ID is an integer (eg not a FlakeId). */ +export const isIntegerId = (id: string): boolean => new RegExp(/^-?[0-9]+$/g).test(id); diff --git a/src/utils/only-emoji.ts b/src/utils/only-emoji.ts new file mode 100644 index 0000000..ab0b33d --- /dev/null +++ b/src/utils/only-emoji.ts @@ -0,0 +1,21 @@ +import graphemesplit from 'graphemesplit'; + +import { CustomEmoji } from 'soapbox/schemas/custom-emoji.ts'; +import { htmlToPlaintext } from 'soapbox/utils/html.ts'; + +/** Given the HTML string, determine whether the plaintext contains only emojis (native or custom), not exceeding the max. */ +export function isOnlyEmoji(html: string, emojis: CustomEmoji[], max: number): boolean { + let plain = htmlToPlaintext(html).replaceAll(/\s/g, ''); + + const native = graphemesplit(plain).filter((char) => /^\p{Extended_Pictographic}+$/u.test(char)); + const custom = [...plain.matchAll(/:(\w+):/g)].map(([, shortcode]) => shortcode).filter((shortcode) => emojis.some((emoji) => emoji.shortcode === shortcode)); + + for (const emoji of native) { + plain = plain.replaceAll(emoji, ''); + } + for (const shortcode of custom) { + plain = plain.replaceAll(`:${shortcode}:`, ''); + } + + return plain.length === 0 && native.length + custom.length <= max; +} \ No newline at end of file diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 0000000..e84f904 --- /dev/null +++ b/src/utils/permissions.ts @@ -0,0 +1,19 @@ +import type { RootState } from 'soapbox/store.ts'; + +export const PERMISSION_CREATE_GROUPS = 0x0000000000100000; +export const PERMISSION_INVITE_USERS = 0x0000000000010000; +export const PERMISSION_MANAGE_USERS = 0x0000000000000400; +export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010; + +type Permission = typeof PERMISSION_CREATE_GROUPS | typeof PERMISSION_INVITE_USERS | typeof PERMISSION_MANAGE_USERS | typeof PERMISSION_MANAGE_REPORTS + +export const hasPermission = (state: RootState, permission: Permission) => { + return true; + // const role = state.accounts_meta[state.me as string]?.role; + + // if (!role) return true; + // const { permissions } = role; + + // if (!permission) return true; + // return (permissions & permission) === permission; +}; diff --git a/src/utils/phone.ts b/src/utils/phone.ts new file mode 100644 index 0000000..c1e4179 --- /dev/null +++ b/src/utils/phone.ts @@ -0,0 +1,19 @@ +/** List of supported E164 country codes. */ +const COUNTRY_CODES = [ + '1', + '351', + '44', + '55', +] as const; + +/** Supported E164 country code. */ +type CountryCode = typeof COUNTRY_CODES[number]; + +/** Check whether a given value is a country code. */ +const isCountryCode = (value: any): value is CountryCode => COUNTRY_CODES.includes(value); + +export { + COUNTRY_CODES, + type CountryCode, + isCountryCode, +}; diff --git a/src/utils/queries.test.ts b/src/utils/queries.test.ts new file mode 100644 index 0000000..39e5f34 --- /dev/null +++ b/src/utils/queries.test.ts @@ -0,0 +1,110 @@ +import { InfiniteData } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { queryClient } from 'soapbox/queries/client.ts'; + +import { PaginatedResult, sortQueryData, updatePageItem } from './queries.ts'; + +interface Item { + id: number; + text: string; +} + +const buildItem = (id: number): Item => ({ id, text: `item ${id}` }); + +const queryKey = ['test', 'query']; + +describe('updatePageItem()', () => { + beforeEach(() => { + queryClient.clear(); + }); + + describe('without cached data', () => { + it('safely returns undefined', () => { + updatePageItem(queryKey, buildItem(1), (o, n) => o.id === n.id); + const nextQueryData = queryClient.getQueryData>>(queryKey); + expect(nextQueryData).toBeUndefined(); + }); + }); + + describe('with cached data', () => { + const cachedQueryData = { + pages: [ + { + result: [buildItem(1), buildItem(2), buildItem(3)], + hasMore: false, + link: undefined, + }, + ], + pageParams: [undefined], + }; + + beforeEach(() => { + queryClient.setQueryData(queryKey, cachedQueryData); + }); + + it('updates the correct item in the cached data', () => { + const initialQueryData = queryClient.getQueryData>>(queryKey); + expect(initialQueryData?.pages[0].result.map((r) => r.id)).toEqual([1, 2, 3]); + expect(initialQueryData?.pages[0].result.find((r) => r.id === 2)?.text).toEqual('item 2'); + updatePageItem(queryKey, { id: 2, text: 'new text' }, (o, n) => o.id === n.id); + const nextQueryData = queryClient.getQueryData>>(queryKey); + expect(nextQueryData?.pages[0].result.map((r) => r.id)).toEqual([1, 2, 3]); + expect(nextQueryData?.pages[0].result.find((r) => r.id === 2)?.text).toEqual('new text'); + }); + }); +}); + +describe('sortQueryData()', () => { + beforeEach(() => { + queryClient.clear(); + }); + + describe('without cached data', () => { + it('safely returns undefined', () => { + sortQueryData(queryKey, (a, b) => b.id - a.id); + const nextQueryData = queryClient.getQueryData>>(queryKey); + expect(nextQueryData).toBeUndefined(); + }); + }); + + describe('with cached data', () => { + const cachedQueryData = { + pages: [ + { + result: [...Array(20).fill(0).map((_, idx) => buildItem(idx))], + hasMore: false, + link: undefined, + }, + { + result: [...Array(4).fill(0).map((_, idx) => buildItem(idx + 20))], + hasMore: true, + link: 'my-link', + }, + ], + pageParams: [undefined], + }; + + beforeEach(() => { + queryClient.setQueryData(queryKey, cachedQueryData); + }); + + it('sorts the cached data', () => { + const initialQueryData = queryClient.getQueryData>>(queryKey); + expect(initialQueryData?.pages[0].result[0].id === 0); // first id is 0 + sortQueryData(queryKey, (a, b) => b.id - a.id); // sort descending + const nextQueryData = queryClient.getQueryData>>(queryKey); + expect(nextQueryData?.pages[0].result[0].id === 0); // first id is now 23 + }); + + it('persists the metadata', () => { + const initialQueryData = queryClient.getQueryData>>(queryKey); + const initialMetaData = initialQueryData?.pages.map((page) => page.link); + sortQueryData(queryKey, (a, b) => b.id - a.id); + const nextQueryData = queryClient.getQueryData>>(queryKey); + const nextMetaData = nextQueryData?.pages.map((page) => page.link); + + expect(initialMetaData).toEqual(nextMetaData); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/queries.ts b/src/utils/queries.ts new file mode 100644 index 0000000..0c25cc8 --- /dev/null +++ b/src/utils/queries.ts @@ -0,0 +1,117 @@ +import { queryClient } from 'soapbox/queries/client.ts'; + +import type { InfiniteData, QueryKey } from '@tanstack/react-query'; + +export interface PaginatedResult { + result: T[]; + hasMore: boolean; + link?: string; +} + +interface Entity { + id: string; +} + +const isEntity = (object: T): object is T & Entity => { + return object && typeof object === 'object' && 'id' in object; +}; + +/** Deduplicate an array of entities by their ID. */ +const deduplicateById = (entities: T[]): T[] => { + const map = entities.reduce>((result, entity) => { + return result.set(entity.id, entity); + }, new Map()); + + return Array.from(map.values()); +}; + +/** Flatten paginated results into a single array. */ +const flattenPages = (queryData: InfiniteData> | undefined) => { + const data = queryData?.pages.reduce( + (prev: T[], curr) => [...prev, ...curr.result], + [], + ); + + if (data && data.every(isEntity)) { + return deduplicateById(data); + } else if (data) { + return data; + } +}; + +/** Traverse pages and update the item inside if found. */ +const updatePageItem = (queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => { + queryClient.setQueriesData>>({ queryKey }, (data) => { + if (data) { + const pages = data.pages.map(page => { + const result = page.result.map(item => isItem(item, newItem) ? newItem : item); + return { ...page, result }; + }); + return { ...data, pages }; + } + }); +}; + +/** Insert the new item at the beginning of the first page. */ +const appendPageItem = (queryKey: QueryKey, newItem: T) => { + queryClient.setQueryData>>(queryKey, (data) => { + if (data) { + const pages = [...data.pages]; + pages[0] = { ...pages[0], result: [newItem, ...pages[0].result] }; + return { ...data, pages }; + } + }); +}; + +/** Remove an item inside if found. */ +const removePageItem = (queryKey: QueryKey, itemToRemove: T, isItem: (item: T, newItem: T) => boolean) => { + queryClient.setQueriesData>>({ queryKey }, (data) => { + if (data) { + const pages = data.pages.map(page => { + const result = page.result.filter(item => !isItem(item, itemToRemove)); + return { ...page, result }; + }); + return { ...data, pages }; + } + }); +}; + +const paginateQueryData = (array: T[] | undefined) => { + return array?.reduce((resultArray: any, item: any, index: any) => { + const chunkIndex = Math.floor(index / 20); + + if (!resultArray[chunkIndex]) { + resultArray[chunkIndex] = []; // start a new chunk + } + + resultArray[chunkIndex].push(item); + + return resultArray; + }, []); +}; + +const sortQueryData = (queryKey: QueryKey, comparator: (a: T, b: T) => number) => { + queryClient.setQueryData>>(queryKey, (prevResult) => { + if (prevResult) { + const nextResult = { ...prevResult }; + const flattenedQueryData = flattenPages(nextResult); + const sortedQueryData = flattenedQueryData?.sort(comparator); + const paginatedPages = paginateQueryData(sortedQueryData); + const newPages = paginatedPages.map((page: T, idx: number) => ({ + ...prevResult.pages[idx], + result: page, + })); + + nextResult.pages = newPages; + return nextResult; + } + }); +}; + +export { + flattenPages, + updatePageItem, + appendPageItem, + removePageItem, + sortQueryData, +}; diff --git a/src/utils/redirect.ts b/src/utils/redirect.ts new file mode 100644 index 0000000..6f42c12 --- /dev/null +++ b/src/utils/redirect.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; + +import type { Location } from 'soapbox/types/history.ts'; + +const LOCAL_STORAGE_REDIRECT_KEY = 'soapbox:redirect-uri'; + +const cacheCurrentUrl = (location: Location) => { + const actualUrl = encodeURIComponent(`${location.pathname}${location.search}`); + localStorage.setItem(LOCAL_STORAGE_REDIRECT_KEY, actualUrl); + return actualUrl; +}; + +const getRedirectUrl = () => { + let redirectUri = localStorage.getItem(LOCAL_STORAGE_REDIRECT_KEY); + if (redirectUri) { + redirectUri = decodeURIComponent(redirectUri); + } + + localStorage.removeItem(LOCAL_STORAGE_REDIRECT_KEY); + return redirectUri || '/'; +}; + +const useCachedLocationHandler = () => { + const removeCachedRedirectUri = () => localStorage.removeItem(LOCAL_STORAGE_REDIRECT_KEY); + + useEffect(() => { + window.addEventListener('beforeunload', removeCachedRedirectUri); + + return () => { + window.removeEventListener('beforeunload', removeCachedRedirectUri); + }; + }, []); + + return null; +}; + +export { cacheCurrentUrl, getRedirectUrl, useCachedLocationHandler }; diff --git a/src/utils/resize-image.ts b/src/utils/resize-image.ts new file mode 100644 index 0000000..f25e66c --- /dev/null +++ b/src/utils/resize-image.ts @@ -0,0 +1,241 @@ +/* eslint-disable no-case-declarations */ +const DEFAULT_MAX_PIXELS = 1920 * 1080; + +interface BrowserCanvasQuirks { + 'image-orientation-automatic'?: boolean; + 'canvas-read-unreliable'?: boolean; +} + +const _browser_quirks: BrowserCanvasQuirks = {}; + +// Some browsers will automatically draw images respecting their EXIF orientation +// while others won't, and the safest way to detect that is to examine how it +// is done on a known image. +// See https://github.com/w3c/csswg-drafts/issues/4666 +// and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881 +const dropOrientationIfNeeded = (orientation: number) => new Promise(resolve => { + switch (_browser_quirks['image-orientation-automatic']) { + case true: + resolve(1); + break; + case false: + resolve(orientation); + break; + default: + // black 2x1 JPEG, with the following meta information set: + // - EXIF Orientation: 6 (Rotated 90° CCW) + const testImageURL = + 'data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' + + 'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' + + 'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' + + 'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' + + 'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' + + 'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q=='; + const img = new Image(); + img.onload = () => { + const automatic = (img.width === 1 && img.height === 2); + _browser_quirks['image-orientation-automatic'] = automatic; + resolve(automatic ? 1 : orientation); + }; + img.onerror = () => { + _browser_quirks['image-orientation-automatic'] = false; + resolve(orientation); + }; + img.src = testImageURL; + } +}); + +// /** +// *Some browsers don't allow reading from a canvas and instead return all-white +// * or randomized data. Use a pre-defined image to check if reading the canvas +// * works. +// */ +// const checkCanvasReliability = () => new Promise((resolve, reject) => { +// switch(_browser_quirks['canvas-read-unreliable']) { +// case true: +// reject('Canvas reading unreliable'); +// break; +// case false: +// resolve(); +// break; +// default: +// // 2×2 GIF with white, red, green and blue pixels +// const testImageURL = +// 'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7'; +// const refData = +// [255, 255, 255, 255, 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]; +// const img = new Image(); +// img.onload = () => { +// const canvas = document.createElement('canvas'); +// const context = canvas.getContext('2d'); +// context?.drawImage(img, 0, 0, 2, 2); +// const imageData = context?.getImageData(0, 0, 2, 2); +// if (imageData?.data.every((x, i) => refData[i] === x)) { +// _browser_quirks['canvas-read-unreliable'] = false; +// resolve(); +// } else { +// _browser_quirks['canvas-read-unreliable'] = true; +// reject('Canvas reading unreliable'); +// } +// }; +// img.onerror = () => { +// _browser_quirks['canvas-read-unreliable'] = true; +// reject('Failed to load test image'); +// }; +// img.src = testImageURL; +// } +// }); + +/** Convert the file into a local blob URL. */ +const getImageUrl = (inputFile: File) => new Promise((resolve, reject) => { + // @ts-ignore: This is a browser capabilities check. + if (window.URL?.createObjectURL) { + try { + resolve(URL.createObjectURL(inputFile)); + } catch (error) { + reject(error); + } + return; + } + + const reader = new FileReader(); + reader.onerror = (...args) => reject(...args); + reader.onload = ({ target }) => resolve((target?.result || '') as string); + + reader.readAsDataURL(inputFile); +}); + +/** Get an image element from a file. */ +const loadImage = (inputFile: File) => new Promise((resolve, reject) => { + getImageUrl(inputFile).then(url => { + const img = new Image(); + + img.onerror = (...args) => reject([...args]); + img.onload = () => resolve(img); + + img.src = url; + }).catch(reject); +}); + +/** Get the exif orientation for the image. */ +async function getOrientation(img: HTMLImageElement, type = 'image/png'): Promise { + if (!['image/jpeg', 'image/webp'].includes(type)) { + return 1; + } + + try { + const exifr = await import('exifr'); + const orientation = await exifr.orientation(img) ?? 1; + + if (orientation !== 1) { + return await dropOrientationIfNeeded(orientation); + } else { + return orientation; + } + } catch (error) { + console.error('Failed to get orientation:', error); + return 1; + } +} + +const processImage = ( + img: HTMLImageElement, + { + width, + height, + orientation, + type = 'image/png', + name = 'resized.png', + } : { + width: number; + height: number; + orientation: number; + type?: string; + name?: string; + }, +) => new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + + if (4 < orientation && orientation < 9) { + canvas.width = height; + canvas.height = width; + } else { + canvas.width = width; + canvas.height = height; + } + + const context = canvas.getContext('2d'); + + if (!context) { + reject(context); + return; + } + + switch (orientation) { + case 2: context.transform(-1, 0, 0, 1, width, 0); break; + case 3: context.transform(-1, 0, 0, -1, width, height); break; + case 4: context.transform(1, 0, 0, -1, 0, height); break; + case 5: context.transform(0, 1, 1, 0, 0, 0); break; + case 6: context.transform(0, 1, -1, 0, height, 0); break; + case 7: context.transform(0, -1, -1, 0, height, width); break; + case 8: context.transform(0, -1, 1, 0, 0, width); break; + } + + context.drawImage(img, 0, 0, width, height); + + canvas.toBlob((blob) => { + if (!blob) { + reject(blob); + return; + } + resolve(new File([blob], name, { type, lastModified: new Date().getTime() })); + }, type); +}); + +const resizeImage = ( + img: HTMLImageElement, + inputFile: File, + maxPixels: number, +) => new Promise((resolve, reject) => { + const { width, height } = img; + const type = inputFile.type || 'image/png'; + + const newWidth = Math.round(Math.sqrt(maxPixels * (width / height))); + const newHeight = Math.round(Math.sqrt(maxPixels * (height / width))); + + // Skip canvas reliability check for now (it's unreliable) + // checkCanvasReliability() + // .then(getOrientation(img, type)) + getOrientation(img, type) + .then(orientation => processImage(img, { + width: newWidth, + height: newHeight, + name: inputFile.name, + orientation, + type, + })) + .then(resolve) + .catch(reject); +}); + +/** Resize an image to the maximum number of pixels. */ +export default (inputFile: File, maxPixels = DEFAULT_MAX_PIXELS) => new Promise((resolve) => { + if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') { + resolve(inputFile); + return; + } + + loadImage(inputFile).then(img => { + if (img.width * img.height < maxPixels) { + resolve(inputFile); + return; + } + + resizeImage(img, inputFile, maxPixels) + .then(resolve) + .catch(error => { + console.error(error); + resolve(inputFile); + }); + }).catch(() => resolve(inputFile)); +}); diff --git a/src/utils/rtl.ts b/src/utils/rtl.ts new file mode 100644 index 0000000..a231348 --- /dev/null +++ b/src/utils/rtl.ts @@ -0,0 +1,59 @@ +/** Unicode character ranges for RTL characters. */ +const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; + +/** + * Check if text is right-to-left (eg Arabic). + * + * - U+0590 to U+05FF - Hebrew + * - U+0600 to U+06FF - Arabic + * - U+0700 to U+074F - Syriac + * - U+0750 to U+077F - Arabic Supplement + * - U+0780 to U+07BF - Thaana + * - U+07C0 to U+07FF - N'Ko + * - U+0800 to U+083F - Samaritan + * - U+08A0 to U+08FF - Arabic Extended-A + * - U+FB1D to U+FB4F - Hebrew presentation forms + * - U+FB50 to U+FDFF - Arabic presentation forms A + * - U+FE70 to U+FEFF - Arabic presentation forms B + */ +function isRtl(text: string, confidence = 0.3): boolean { + if (text.length === 0) { + return false; + } + + // Remove http(s), (s)ftp, ws(s), blob and smtp(s) links + text = text.replace(/(?:https?|ftp|sftp|ws|wss|blob|smtp|smtps):\/\/[\S]+/g, ''); + // Remove email address links + text = text.replace(/(mailto:)([^\s@]+@[^\s@]+\.[^\s@]+)/g, ''); + // Remove phone number links + text = text.replace(/(tel:)([+\d\s()-]+)/g, ''); + // Remove mentions + text = text.replace(/(?:^|[^/\w])@([a-z0-9_]+(@[a-z0-9.-]+)?)/ig, ''); + // Remove hashtags + text = text.replace(/(?:^|[^/\w])#([\S]+)/ig, ''); + // Remove all non-word characters + text = text.replace(/\s+/g, ''); + + const matches = text.match(rtlChars); + + if (!matches) { + return false; + } + + return matches.length / text.length > confidence; +} + +interface GetTextDirectionOpts { + /** The default direction to return if the text is empty. */ + fallback?: 'ltr' | 'rtl' | undefined; + /** The confidence threshold (0-1) to use when determining the direction. */ + confidence?: number; +} + +/** Get the direction of the text. */ +function getTextDirection(text: string, { fallback = 'ltr', confidence }: GetTextDirectionOpts = {}): 'ltr' | 'rtl' { + if (!text) return fallback; + return isRtl(text, confidence) ? 'rtl' : 'ltr'; +} + +export { getTextDirection, isRtl }; \ No newline at end of file diff --git a/src/utils/scopes.ts b/src/utils/scopes.ts new file mode 100644 index 0000000..bd77ee9 --- /dev/null +++ b/src/utils/scopes.ts @@ -0,0 +1,26 @@ +import { PLEROMA, parseVersion } from './features.ts'; + +import type { RootState } from 'soapbox/store.ts'; + +/** + * Get the OAuth scopes to use for login & signup. + * Mastodon will refuse scopes it doesn't know, so care is needed. + */ +const getInstanceScopes = (version: string) => { + const v = parseVersion(version); + + switch (v.software) { + case PLEROMA: + return 'read write follow push admin'; + default: + return 'read write follow push'; + } +}; + +/** Convenience function to get scopes from instance in store. */ +const getScopes = (state: RootState) => getInstanceScopes(state.instance.version); + +export { + getInstanceScopes, + getScopes, +}; diff --git a/src/utils/sounds.ts b/src/utils/sounds.ts new file mode 100644 index 0000000..6774862 --- /dev/null +++ b/src/utils/sounds.ts @@ -0,0 +1,59 @@ +import boopMp3 from 'soapbox/assets/sounds/boop.mp3'; +import boopOgg from 'soapbox/assets/sounds/boop.ogg'; +import chatMp3 from 'soapbox/assets/sounds/chat.mp3'; +import chatOgg from 'soapbox/assets/sounds/chat.ogg'; + +/** Soapbox audio clip. */ +interface Sound { + src: string; + type: string; +} + +type Sounds = 'boop' | 'chat'; + +/** Produce HTML5 audio from sound data. */ +const createAudio = (sources: Sound[]): HTMLAudioElement => { + const audio = new Audio(); + sources.forEach(({ type, src }) => { + const source = document.createElement('source'); + source.type = type; + source.src = src; + audio.appendChild(source); + }); + return audio; +}; + +/** Play HTML5 sound. */ +const play = (audio: HTMLAudioElement): Promise => { + if (!audio.paused) { + audio.pause(); + if (typeof audio.fastSeek === 'function') { + audio.fastSeek(0); + } else { + audio.currentTime = 0; + } + } + + return audio.play().catch((error: Error) => { + if (error.name === 'NotAllowedError') { + // User has disabled autoplay. + // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide + return; + } else { + throw error; + } + }); +}; + +const soundCache: Record = { + boop: createAudio([ + { src: boopOgg, type: 'audio/ogg' }, + { src: boopMp3, type: 'audio/mpeg' }, + ]), + chat: createAudio([ + { src: chatOgg, type: 'audio/ogg' }, + { src: chatMp3, type: 'audio/mpeg' }, + ]), +}; + +export { soundCache, play, type Sounds }; diff --git a/src/utils/state.ts b/src/utils/state.ts new file mode 100644 index 0000000..9fda7a6 --- /dev/null +++ b/src/utils/state.ts @@ -0,0 +1,35 @@ +/** + * State: general Redux state utility functions. + * @module soapbox/utils/state + */ + +import { getSoapboxConfig } from 'soapbox/actions/soapbox.ts'; +import * as BuildConfig from 'soapbox/build-config.ts'; +import { selectOwnAccount } from 'soapbox/selectors/index.ts'; +import { isURL } from 'soapbox/utils/auth.ts'; + +import type { RootState } from 'soapbox/store.ts'; + +/** Whether to display the fqn instead of the acct. */ +export const displayFqn = (state: RootState): boolean => { + return getSoapboxConfig(state).displayFqn; +}; + +/** Whether the instance exposes instance blocks through the API. */ +export const federationRestrictionsDisclosed = (state: RootState): boolean => { + return !!state.instance.pleroma.metadata.federation.mrf_policies; +}; + +const getHost = (url: any): string => { + try { + return new URL(url).origin; + } catch { + return ''; + } +}; + +/** Get the baseURL of the instance. */ +export const getBaseURL = (state: RootState): string => { + const account = selectOwnAccount(state); + return isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : getHost(account?.url); +}; diff --git a/src/utils/status.test.ts b/src/utils/status.test.ts new file mode 100644 index 0000000..168e295 --- /dev/null +++ b/src/utils/status.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { buildStatus } from 'soapbox/jest/factory.ts'; + +import { + hasIntegerMediaIds, + defaultMediaVisibility, +} from './status.ts'; + +describe('hasIntegerMediaIds()', () => { + it('returns true for a Pleroma deleted status', async () => { + const status = buildStatus(await import('soapbox/__fixtures__/pleroma-status-deleted.json') as any); + expect(hasIntegerMediaIds(status)).toBe(true); + }); +}); + +describe('defaultMediaVisibility()', () => { + it('returns false with no status', () => { + expect(defaultMediaVisibility(undefined, 'default')).toBe(false); + }); + + it('hides sensitive media by default', () => { + const status = buildStatus({ sensitive: true }); + expect(defaultMediaVisibility(status, 'default')).toBe(false); + }); + + it('hides media when displayMedia is hide_all', () => { + const status = buildStatus({}); + expect(defaultMediaVisibility(status, 'hide_all')).toBe(false); + }); + + it('shows sensitive media when displayMedia is show_all', () => { + const status = buildStatus({ sensitive: true }); + expect(defaultMediaVisibility(status, 'show_all')).toBe(true); + }); +}); diff --git a/src/utils/status.ts b/src/utils/status.ts new file mode 100644 index 0000000..78c6171 --- /dev/null +++ b/src/utils/status.ts @@ -0,0 +1,79 @@ +import { isIntegerId } from 'soapbox/utils/numbers.tsx'; + +import type { IntlShape } from 'react-intl'; +import type { Status } from 'soapbox/schemas/index.ts'; + +/** Get the initial visibility of media attachments from user settings. */ +export const defaultMediaVisibility = >( + status: T | undefined | null, + displayMedia: string, +): boolean => { + if (!status) return false; + status = getActualStatus(status); + + const isUnderReview = status.visibility === 'self'; + + if (isUnderReview) { + return false; + } + + return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all'); +}; + +/** Grab the first external link from a status. */ +export const getFirstExternalLink = (status: Pick): HTMLAnchorElement | null => { + try { + // Pulled from Pleroma's media parser + const selector = 'a:not(.mention,.hashtag,.attachment,[rel~="tag"])'; + const element = document.createElement('div'); + element.innerHTML = status.content; + return element.querySelector(selector); + } catch { + return null; + } +}; + +/** Whether the status is expected to have a Card after it loads. */ +export const shouldHaveCard = (status: Pick): boolean => { + return Boolean(getFirstExternalLink(status)); +}; + +/** Whether the media IDs on this status have integer IDs (opposed to FlakeIds). */ +// https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/1087 +export const hasIntegerMediaIds = (status: Pick): boolean => { + return status.media_attachments.some(({ id }) => isIntegerId(id)); +}; + +/** Sanitize status text for use with screen readers. */ +export const textForScreenReader = ( + intl: IntlShape, + status: Pick, + rebloggedByText?: string, +): string => { + const { account } = status; + if (!account || typeof account !== 'object') return ''; + + const displayName = account.display_name; + + const values = [ + displayName.length === 0 ? account.acct.split('@')[0] : displayName, + status.spoiler_text && status.hidden ? status.spoiler_text : status.search_index.slice(status.spoiler_text.length), + intl.formatDate(status.created_at, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), + account.acct, + ]; + + if (rebloggedByText) { + values.push(rebloggedByText); + } + + return values.join(', '); +}; + +/** Get reblogged status if any, otherwise return the original status. */ +export const getActualStatus = (status: T): T => { + if (status?.reblog && typeof status?.reblog === 'object') { + return status.reblog; + } else { + return status; + } +}; diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 0000000..c1c8e08 --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,7 @@ +/** Capitalize the first letter of a string. */ +// https://stackoverflow.com/a/1026087 +function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export { capitalize }; diff --git a/src/utils/suggestions.ts b/src/utils/suggestions.ts new file mode 100644 index 0000000..b09f53e --- /dev/null +++ b/src/utils/suggestions.ts @@ -0,0 +1,35 @@ +type CursorMatch = [ + tokenStart: number | null, + token: string | null, +]; + +const textAtCursorMatchesToken = ( + str: string, + caretPosition: number, + searchTokens: string[], +): CursorMatch => { + let word; + + const left = str.slice(0, caretPosition).search(/\S+$/); + const right = str.slice(caretPosition).search(/\s/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || !searchTokens.includes(word[0])) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left + 1, word]; + } else { + return [null, null]; + } +}; + +export { textAtCursorMatchesToken }; diff --git a/src/utils/sw.ts b/src/utils/sw.ts new file mode 100644 index 0000000..b5e49c8 --- /dev/null +++ b/src/utils/sw.ts @@ -0,0 +1,33 @@ +/** Register the ServiceWorker. */ +function registerSW(path: string) { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register(path, { scope: '/' }); + } +} + +/** Prevent a new ServiceWorker from being installed. */ +function lockSW() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register = () => { + throw new Error('ServiceWorker already registered.'); + }; + } +} + +/** Unregister the ServiceWorker */ +// https://stackoverflow.com/a/49771828/8811886 +const unregisterSW = async(): Promise => { + if (navigator.serviceWorker) { + // FIXME: this only works if using a single tab. + // Send a message to sw.js instead to refresh all tabs. + const registrations = await navigator.serviceWorker.getRegistrations(); + const unregisterAll = registrations.map(r => r.unregister()); + await Promise.all(unregisterAll); + } +}; + +export { + registerSW, + unregisterSW, + lockSW, +}; \ No newline at end of file diff --git a/src/utils/tailwind.test.ts b/src/utils/tailwind.test.ts new file mode 100644 index 0000000..bdd7ba7 --- /dev/null +++ b/src/utils/tailwind.test.ts @@ -0,0 +1,264 @@ +import { Map as ImmutableMap } from 'immutable'; +import { describe, expect, it } from 'vitest'; + +import { toTailwind, fromLegacyColors, expandPalette } from './tailwind.ts'; + +describe('toTailwind()', () => { + it('handles empty Soapbox config', () => { + const soapboxConfig = ImmutableMap(); + const result = toTailwind(soapboxConfig); + const expected = ImmutableMap({ colors: ImmutableMap() }); + expect(result).toEqual(expected); + }); + + it('converts brandColor into a Tailwind color palette', () => { + const soapboxConfig = ImmutableMap({ brandColor: '#0482d8' }); + + const expected = { + brandColor: '#0482d8', + colors: { + primary: { + 50: '#f2f9fd', + 100: '#e6f3fb', + 200: '#c0e0f5', + 300: '#4fa8e4', + 400: '#369be0', + 500: '#0482d8', + 600: '#0475c2', + 700: '#0362a2', + 800: '#012741', + 900: '#011929', + }, + }, + }; + + const result = toTailwind(soapboxConfig); + expect(result.toJS()).toMatchObject(expected); + }); + + it('prefers Tailwind colors object over legacy colors', () => { + const soapboxConfig = ImmutableMap({ + brandColor: '#0482d8', + colors: ImmutableMap({ + primary: ImmutableMap({ + 300: '#ff0000', + }), + }), + }); + + const expected = { + brandColor: '#0482d8', + colors: { + primary: { + 50: '#f2f9fd', + 100: '#e6f3fb', + 200: '#c0e0f5', + 300: '#ff0000', // <-- + 400: '#369be0', + 500: '#0482d8', + 600: '#0475c2', + 700: '#0362a2', + 800: '#012741', + 900: '#011929', + }, + }, + }; + + const result = toTailwind(soapboxConfig); + expect(result.toJS()).toMatchObject(expected); + }); +}); + +describe('fromLegacyColors()', () => { + it('converts only brandColor', () => { + const soapboxConfig = ImmutableMap({ brandColor: '#0482d8' }); + + const expected = { + primary: { + 50: '#f2f9fd', + 100: '#e6f3fb', + 200: '#c0e0f5', + 300: '#4fa8e4', + 400: '#369be0', + 500: '#0482d8', + 600: '#0475c2', + 700: '#0362a2', + 800: '#012741', + 900: '#011929', + }, + // Accent color is generated from brandColor + accent: { + 50: '#f3fbfd', + 100: '#e7f7fa', + 200: '#c3ecf4', + 300: '#58cadf', + 400: '#40c2da', + 500: '#10b3d1', + 600: '#0ea1bc', + 700: '#0c869d', + 800: '#05363f', + 900: '#032228', + }, + secondary: { + 50: '#f3fbfd', + 100: '#e7f7fa', + 200: '#c3ecf4', + 300: '#58cadf', + 400: '#40c2da', + 500: '#10b3d1', + 600: '#0ea1bc', + 700: '#0c869d', + 800: '#05363f', + 900: '#032228', + }, + gray: { + 50: '#f8fafa', + 100: '#f1f4f6', + 200: '#dde4e8', + 300: '#9eb2bf', + 400: '#91a7b5', + 500: '#7591a3', + 600: '#698393', + 700: '#586d7a', + 800: '#232c31', + 900: '#161c1f', + }, + }; + + const result = fromLegacyColors(soapboxConfig); + expect(result).toEqual(expected); + }); + + it('converts both legacy colors', () => { + const soapboxConfig = ImmutableMap({ + brandColor: '#0482d8', + accentColor: '#2bd110', + }); + + const expected = { + primary: { + 50: '#f2f9fd', + 100: '#e6f3fb', + 200: '#c0e0f5', + 300: '#4fa8e4', + 400: '#369be0', + 500: '#0482d8', + 600: '#0475c2', + 700: '#0362a2', + 800: '#012741', + 900: '#011929', + }, + accent: { + 50: '#f4fdf3', + 100: '#eafae7', + 200: '#caf4c3', + 300: '#6bdf58', + 400: '#55da40', + 500: '#2bd110', + 600: '#27bc0e', + 700: '#209d0c', + 800: '#0d3f05', + 900: '#082803', + }, + secondary: { + 50: '#f4fdf3', + 100: '#eafae7', + 200: '#caf4c3', + 300: '#6bdf58', + 400: '#55da40', + 500: '#2bd110', + 600: '#27bc0e', + 700: '#209d0c', + 800: '#0d3f05', + 900: '#082803', + }, + gray: { + 50: '#f8fafa', + 100: '#f1f4f6', + 200: '#dde4e8', + 300: '#9eb2bf', + 400: '#91a7b5', + 500: '#7591a3', + 600: '#698393', + 700: '#586d7a', + 800: '#232c31', + 900: '#161c1f', + }, + }; + + const result = fromLegacyColors(soapboxConfig); + expect(result).toEqual(expected); + }); +}); + +describe('expandPalette()', () => { + it('expands one color', () => { + const palette = { primary: '#0482d8' }; + + const expected = { + primary: { + 50: '#f2f9fd', + 100: '#e6f3fb', + 200: '#c0e0f5', + 300: '#4fa8e4', + 400: '#369be0', + 500: '#0482d8', + 600: '#0475c2', + 700: '#0362a2', + 800: '#012741', + 900: '#011929', + }, + }; + + const result = expandPalette(palette); + expect(result).toEqual(expected); + }); + + it('expands mixed palette', () => { + const palette = { + primary: { + 50: '#f2f9fd', + 100: '#e6f3fb', + 200: '#c0e0f5', + 300: '#4fa8e4', + 400: '#369be0', + 500: '#0482d8', + 600: '#0475c2', + 700: '#0362a2', + 800: '#012741', + 900: '#011929', + }, + accent: '#2bd110', + }; + + const expected = { + primary: { + 50: '#f2f9fd', + 100: '#e6f3fb', + 200: '#c0e0f5', + 300: '#4fa8e4', + 400: '#369be0', + 500: '#0482d8', + 600: '#0475c2', + 700: '#0362a2', + 800: '#012741', + 900: '#011929', + }, + accent: { + 50: '#f4fdf3', + 100: '#eafae7', + 200: '#caf4c3', + 300: '#6bdf58', + 400: '#55da40', + 500: '#2bd110', + 600: '#27bc0e', + 700: '#209d0c', + 800: '#0d3f05', + 900: '#082803', + }, + }; + + const result = expandPalette(palette); + expect(result).toEqual(expected); + }); +}); diff --git a/src/utils/tailwind.ts b/src/utils/tailwind.ts new file mode 100644 index 0000000..b95e2a8 --- /dev/null +++ b/src/utils/tailwind.ts @@ -0,0 +1,56 @@ +import { Map as ImmutableMap, fromJS } from 'immutable'; + +import tintify from 'soapbox/utils/colors.ts'; +import { generateAccent, generateNeutral } from 'soapbox/utils/theme.ts'; + +import type { TailwindColorPalette } from 'soapbox/types/colors.ts'; + +type SoapboxConfig = ImmutableMap; +type SoapboxColors = ImmutableMap; + +/** Check if the value is a valid hex color */ +const isHex = (value: any): boolean => /^#([0-9A-F]{3}){1,2}$/i.test(value); + +/** Expand hex colors into tints */ +export const expandPalette = (palette: TailwindColorPalette): TailwindColorPalette => { + // Generate palette only for present colors + return Object.entries(palette).reduce((result: TailwindColorPalette, colorData) => { + const [colorName, color] = colorData; + + // Conditionally handle hex color and Tailwind color object + if (typeof color === 'string' && isHex(color)) { + result[colorName] = tintify(color); + } else if (color && typeof color === 'object') { + result[colorName] = color; + } + + return result; + }, {}); +}; + +// Generate accent color only if brandColor is present +const maybeGenerateAccentColor = (brandColor: any): string | null => { + return isHex(brandColor) ? generateAccent(brandColor) : null; +}; + +/** Build a color object from legacy colors */ +export const fromLegacyColors = (soapboxConfig: SoapboxConfig): TailwindColorPalette => { + const brandColor = soapboxConfig.get('brandColor'); + const accentColor = soapboxConfig.get('accentColor'); + const accent = isHex(accentColor) ? accentColor : maybeGenerateAccentColor(brandColor); + + return expandPalette({ + primary: isHex(brandColor) ? brandColor : null, + secondary: accent, + accent, + gray: (isHex(brandColor) ? generateNeutral(brandColor) : null) as any, + }); +}; + +/** Convert Soapbox Config into Tailwind colors */ +export const toTailwind = (soapboxConfig: SoapboxConfig): SoapboxConfig => { + const colors: SoapboxColors = ImmutableMap(soapboxConfig.get('colors')); + const legacyColors = ImmutableMap(fromJS(fromLegacyColors(soapboxConfig))) as SoapboxColors; + + return soapboxConfig.set('colors', legacyColors.mergeDeep(colors)); +}; diff --git a/src/utils/theme.ts b/src/utils/theme.ts new file mode 100644 index 0000000..6c4c4e1 --- /dev/null +++ b/src/utils/theme.ts @@ -0,0 +1,133 @@ +import { hexToRgb } from './colors.ts'; + +import type { Rgb, Hsl, TailwindColorPalette, TailwindColorObject } from 'soapbox/types/colors.ts'; +import type { SoapboxConfig } from 'soapbox/types/soapbox.ts'; + +// Taken from chromatism.js +// https://github.com/graypegg/chromatism/blob/master/src/conversions/rgb.js +const rgbToHsl = (value: Rgb): Hsl => { + const r = value.r / 255; + const g = value.g / 255; + const b = value.b / 255; + const rgbOrdered = [ r, g, b ].sort(); + const l = ((rgbOrdered[0] + rgbOrdered[2]) / 2) * 100; + let s, h; + if (rgbOrdered[0] === rgbOrdered[2]) { + s = 0; + h = 0; + } else { + if (l >= 50) { + s = ((rgbOrdered[2] - rgbOrdered[0]) / ((2.0 - rgbOrdered[2]) - rgbOrdered[0])) * 100; + } else { + s = ((rgbOrdered[2] - rgbOrdered[0]) / (rgbOrdered[2] + rgbOrdered[0])) * 100; + } + if (rgbOrdered[2] === r) { + h = ((g - b) / (rgbOrdered[2] - rgbOrdered[0])) * 60; + } else if (rgbOrdered[2] === g) { + h = (2 + ((b - r) / (rgbOrdered[2] - rgbOrdered[0]))) * 60; + } else { + h = (4 + ((r - g) / (rgbOrdered[2] - rgbOrdered[0]))) * 60; + } + if (h < 0) { + h += 360; + } else if (h > 360) { + h = h % 360; + } + } + + return { + h: h, + s: s, + l: l, + }; +}; + +// https://stackoverflow.com/a/44134328 +function hslToHex(color: Hsl): string { + const { h, s } = color; + let { l } = color; + + l /= 100; + const a = s * Math.min(l, 1 - l) / 100; + + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color).toString(16).padStart(2, '0'); // convert to Hex and prefix "0" if needed + }; + + return `#${f(0)}${f(8)}${f(4)}`; +} + +/** Generate accent color from brand color. */ +export const generateAccent = (brandColor: string): string | null => { + const rgb = hexToRgb(brandColor); + if (!rgb) return null; + + const { h } = rgbToHsl(rgb); + return hslToHex({ h: h - 15, s: 86, l: 44 }); +}; + +/** Generate neutral color from brand color. */ +export const generateNeutral = (brandColor: string): string | null => { + const rgb = hexToRgb(brandColor); + if (!rgb) return null; + + const { h } = rgbToHsl(rgb); + return hslToHex({ h, s: 20, l: 55 }); +}; + +const parseShades = (obj: Record, color: string, shades: Record): void => { + if (!shades) return; + + if (typeof shades === 'string') { + const rgb = hexToRgb(shades); + if (!rgb) return; + + const { r, g, b } = rgb; + obj[`--color-${color}`] = `${r} ${g} ${b}`; + return; + } + + Object.keys(shades).forEach(shade => { + const rgb = hexToRgb(shades[shade]); + if (!rgb) return; + + const { r, g, b } = rgb; + obj[`--color-${color}-${shade}`] = `${r} ${g} ${b}`; + }); +}; + +// Convert colors as CSS variables +const parseColors = (colors: TailwindColorPalette): TailwindColorPalette => { + return Object.keys(colors).reduce((obj, color) => { + parseShades(obj, color, colors[color] as TailwindColorObject); + return obj; + }, {}); +}; + +export const colorsToCss = (colors: TailwindColorPalette): string => { + const parsed = parseColors(colors); + return Object.keys(parsed).reduce((css, variable) => { + return css + `${variable}:${parsed[variable]};`; + }, ''); +}; + +export const generateThemeCss = (soapboxConfig: SoapboxConfig): string => { + return colorsToCss(soapboxConfig.colors.toJS() as TailwindColorPalette); +}; + +export const hexToHsl = (hex: string): Hsl | null => { + const rgb = hexToRgb(hex); + return rgb ? rgbToHsl(rgb) : null; +}; + +export const hueShift = (hex: string, delta: number): string => { + const { h, s, l } = hexToHsl(hex)!; + + return hslToHex({ + h: (h + delta) % 360, + s, + l, + }); +}; \ No newline at end of file diff --git a/src/utils/timelines.test.ts b/src/utils/timelines.test.ts new file mode 100644 index 0000000..9e163b8 --- /dev/null +++ b/src/utils/timelines.test.ts @@ -0,0 +1,74 @@ +import { fromJS } from 'immutable'; +import { describe, expect, it } from 'vitest'; + +import { buildStatus } from 'soapbox/jest/factory.ts'; + +import { shouldFilter } from './timelines.ts'; + +describe('shouldFilter', () => { + it('returns false under normal circumstances', () => { + const columnSettings = fromJS({}); + const status = buildStatus({}); + expect(shouldFilter(status, columnSettings)).toBe(false); + }); + + it('reblog: returns true when `shows.reblog == false`', () => { + const columnSettings = fromJS({ shows: { reblog: false } }); + const status = buildStatus({ reblog: buildStatus() as any }); + expect(shouldFilter(status, columnSettings)).toBe(true); + }); + + it('reblog: returns false when `shows.reblog == true`', () => { + const columnSettings = fromJS({ shows: { reblog: true } }); + const status = buildStatus({ reblog: buildStatus() as any }); + expect(shouldFilter(status, columnSettings)).toBe(false); + }); + + it('reply: returns true when `shows.reply == false`', () => { + const columnSettings = fromJS({ shows: { reply: false } }); + const status = buildStatus({ in_reply_to_id: '1234' }); + expect(shouldFilter(status, columnSettings)).toBe(true); + }); + + it('reply: returns false when `shows.reply == true`', () => { + const columnSettings = fromJS({ shows: { reply: true } }); + const status = buildStatus({ in_reply_to_id: '1234' }); + expect(shouldFilter(status, columnSettings)).toBe(false); + }); + + it('direct: returns true when `shows.direct == false`', () => { + const columnSettings = fromJS({ shows: { direct: false } }); + const status = buildStatus({ visibility: 'direct' }); + expect(shouldFilter(status, columnSettings)).toBe(true); + }); + + it('direct: returns false when `shows.direct == true`', () => { + const columnSettings = fromJS({ shows: { direct: true } }); + const status = buildStatus({ visibility: 'direct' }); + expect(shouldFilter(status, columnSettings)).toBe(false); + }); + + it('direct: returns false for a public post when `shows.direct == false`', () => { + const columnSettings = fromJS({ shows: { direct: false } }); + const status = buildStatus({ visibility: 'public' }); + expect(shouldFilter(status, columnSettings)).toBe(false); + }); + + it('multiple settings', () => { + const columnSettings = fromJS({ shows: { reblog: false, reply: false, direct: false } }); + const status = buildStatus({ reblog: null, in_reply_to_id: null, visibility: 'direct' }); + expect(shouldFilter(status, columnSettings)).toBe(true); + }); + + it('multiple settings', () => { + const columnSettings = fromJS({ shows: { reblog: false, reply: true, direct: false } }); + const status = buildStatus({ reblog: null, in_reply_to_id: '1234', visibility: 'public' }); + expect(shouldFilter(status, columnSettings)).toBe(false); + }); + + it('multiple settings', () => { + const columnSettings = fromJS({ shows: { reblog: true, reply: false, direct: true } }); + const status = buildStatus({ reblog: {}, in_reply_to_id: '1234', visibility: 'direct' }); + expect(shouldFilter(status, columnSettings)).toBe(true); + }); +}); diff --git a/src/utils/timelines.ts b/src/utils/timelines.ts new file mode 100644 index 0000000..166604f --- /dev/null +++ b/src/utils/timelines.ts @@ -0,0 +1,18 @@ +import { Map as ImmutableMap, type Collection } from 'immutable'; + +import type { Status } from 'soapbox/schemas/index.ts'; + +export const shouldFilter = ( + status: Pick & { reblog: unknown }, + columnSettings: Collection, +) => { + const shows = ImmutableMap({ + reblog: status.reblog !== null, + reply: status.in_reply_to_id !== null, + direct: status.visibility === 'direct', + }); + + return shows.some((value, key) => { + return columnSettings.getIn(['shows', key]) === false && value; + }); +}; diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..31eacd4 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,7 @@ +/** + * Resolve a type into a flat POJO interface if it's been wrapped by generics. + * https://gleasonator.com/@alex/posts/AWfK4hyppMDCqrT2y8 + */ +type Resolve = Pick; + +export type { Resolve }; \ No newline at end of file diff --git a/src/workers.ts b/src/workers.ts new file mode 100644 index 0000000..24bd2b3 --- /dev/null +++ b/src/workers.ts @@ -0,0 +1,9 @@ +import * as Comlink from 'comlink'; + +import type { PowWorker } from './workers/pow.worker.ts'; + +const powWorker = Comlink.wrap( + new Worker(new URL('./workers/pow.worker.ts', import.meta.url), { type: 'module' }), +); + +export { powWorker }; \ No newline at end of file diff --git a/src/workers/pow.worker.ts b/src/workers/pow.worker.ts new file mode 100644 index 0000000..24c3420 --- /dev/null +++ b/src/workers/pow.worker.ts @@ -0,0 +1,10 @@ +import * as Comlink from 'comlink'; +import { nip13, type UnsignedEvent } from 'nostr-tools'; + +export const PowWorker = { + mine(event: UnsignedEvent, difficulty: number) { + return nip13.minePow(event, difficulty); + }, +}; + +Comlink.expose(PowWorker); \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..2220c55 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,173 @@ +import aspectRatioPlugin from '@tailwindcss/aspect-ratio'; +import formsPlugin from '@tailwindcss/forms'; +import typographyPlugin from '@tailwindcss/typography'; +import { type Config } from 'tailwindcss'; +import plugin from 'tailwindcss/plugin'; + +import { parseColorMatrix } from './tailwind/colors.ts'; + +const blackVariantPlugin = plugin(({ addVariant }) => addVariant('black', '.black.black &')); + +const config: Config = { + content: ['./src/**/*.{html,js,ts,tsx}', './custom/instance/**/*.html', './index.html'], + darkMode: 'class', + theme: { + screens: { + sm: '581px', + md: '768px', + lg: '976px', + xl: '1280px', + }, + extend: { + boxShadow: ({ theme }) => ({ + '3xl': '0 25px 75px -15px rgba(0, 0, 0, 0.25)', + 'inset-ring': `inset 0 0 0 2px ${theme('colors.accent-blue')}`, + }), + fontSize: { + base: '0.9375rem', + }, + fontFamily: { + arabic: [ + 'Vazirmatn', + 'Cairo', + 'Amiri', + 'Tajawal', + 'sans-serif', + ], + javanese: [ + 'Noto Sans Javanese', + 'serif', + ], + sans: [ + 'Inter', + 'ui-sans-serif', + 'system-ui', + '-apple-system', + 'BlinkMacSystemFont', + 'Segoe UI', + 'Roboto', + 'Helvetica Neue', + 'Arial', + 'Noto Sans', + 'sans-serif', + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji', + ], + mono: [ + 'Roboto Mono', + 'ui-monospace', + 'mono', + ], + emoji: [ + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Segoe UI', + 'Apple Color Emoji', + 'Twemoji Mozilla', + 'Noto Color Emoji', + 'Android Emoji', + ], + }, + spacing: { + '4.5': '1.125rem', + }, + colors: parseColorMatrix({ + // Define color matrix (of available colors) + // Colors are configured at runtime with CSS variables in soapbox.json + gray: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + primary: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + secondary: [100, 200, 300, 400, 500, 600], + success: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + danger: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + accent: [300, 500], + 'accent-blue': true, + 'gradient-start': true, + 'gradient-end': true, + 'greentext': true, + }), + animation: { + 'loader-figure': 'loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)', + 'loader-label': 'loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)', + fade: 'fade 150ms linear', + 'sonar-scale-4': 'sonar-scale-4 3s linear infinite', + 'sonar-scale-3': 'sonar-scale-3 3s 0.5s linear infinite', + 'sonar-scale-2': 'sonar-scale-2 3s 1s linear infinite', + 'sonar-scale-1': 'sonar-scale-1 3s 1.5s linear infinite', + 'enter': 'enter 200ms ease-out', + 'leave': 'leave 150ms ease-in forwards', + }, + keyframes: { + 'loader-figure': { + '0%': { + backgroundColor: 'rgb(229, 231, 235)', + width: '0px', + height: '0px', + }, + + '29%': { + backgroundColor: 'rgb(229, 231, 235)', + }, + + '30%': { + width: '3rem', + height: '3rem', + backgroundColor: 'transparent', + opacity: '1', + borderWidth: '6px', + }, + + '100%': { + width: '3rem', + height: '3rem', + borderWidth: '0', + opacity: '0', + backgroundColor: 'transparent', + }, + }, + 'loader-label': { + '0%': { opacity: '0.25' }, + '30%': { opacity: '1' }, + '100%': { opacity: '0.25' }, + }, + fade: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + 'sonar-scale-4': { + from: { opacity: '0.4', transform: 'scale(1)' }, + to: { opacity: '0', transform: 'scale(4)' }, + }, + 'sonar-scale-3': { + from: { opacity: '0.4', transform: 'scale(1)' }, + to: { opacity: '0', transform: 'scale(3.5)' }, + }, + 'sonar-scale-2': { + from: { opacity: '0.4', transform: 'scale(1)' }, + to: { opacity: '0', transform: 'scale(3)' }, + }, + 'sonar-scale-1': { + from: { opacity: '0.4', transform: 'scale(1)' }, + to: { opacity: '0', transform: 'scale(2.5)' }, + }, + enter: { + from: { transform: 'scale(0.9)', opacity: '0' }, + to: { transform: 'scale(1)', opacity: '1' }, + }, + leave: { + from: { transform: 'scale(1)', opacity: '1' }, + to: { transform: 'scale(0.9)', opacity: '0' }, + }, + }, + }, + }, + plugins: [ + aspectRatioPlugin, + formsPlugin, + typographyPlugin, + blackVariantPlugin, + ], +}; + +export default config; \ No newline at end of file diff --git a/tailwind/colors.test.ts b/tailwind/colors.test.ts new file mode 100644 index 0000000..bd66e74 --- /dev/null +++ b/tailwind/colors.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { + withOpacityValue, + parseColorMatrix, +} from './colors.ts'; + +describe('withOpacityValue()', () => { + it('returns a Tailwind color function with alpha support', () => { + const result = withOpacityValue('--color-primary-500'); + + // It returns a function + expect(typeof result).toBe('function'); + + // Test calling the function + expect(result).toBe('rgb(var(--color-primary-500) / )'); + }); +}); + +describe('parseColorMatrix()', () => { + it('returns a Tailwind color object', () => { + const colorMatrix = { + gray: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + primary: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + success: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + danger: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + accent: [300, 500], + }; + + const result = parseColorMatrix(colorMatrix); + + // Colors are mapped to functions which return CSS values + // @ts-ignore + expect(result.accent['300']).toEqual('rgb(var(--color-accent-300) / )'); + }); + + it('parses single-tint values', () => { + const colorMatrix = { + gray: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + primary: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + success: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + danger: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], + accent: [300, 500], + 'gradient-start': true, + 'gradient-end': true, + }; + + const result = parseColorMatrix(colorMatrix); + + expect(result['gradient-start']).toEqual('rgb(var(--color-gradient-start) / )'); + }); +}); diff --git a/tailwind/colors.ts b/tailwind/colors.ts new file mode 100644 index 0000000..089d298 --- /dev/null +++ b/tailwind/colors.ts @@ -0,0 +1,47 @@ +import { type RecursiveKeyValuePair } from 'tailwindcss/types/config'; + +/** https://tailwindcss.com/docs/customizing-colors#using-css-variables */ +function withOpacityValue(variable: string): string { + return `rgb(var(${variable}) / )`; +} + +/** Parse a single color as a CSS variable. */ +const toColorVariable = (colorName: string, tint: number | null = null): string => { + const suffix = tint ? `-${tint}` : ''; + const variable = `--color-${colorName}${suffix}`; + + return withOpacityValue(variable); +}; + +/** Parse list of tints into Tailwind function with CSS variables. */ +const parseTints = (colorName: string, tints: number[]): RecursiveKeyValuePair => { + return tints.reduce>((colorObj, tint) => { + colorObj[tint] = toColorVariable(colorName, tint); + return colorObj; + }, {}); +}; + +interface ColorMatrix { + [colorName: string]: number[] | boolean; +} + +/** Parse color matrix into Tailwind color palette. */ +const parseColorMatrix = (colorMatrix: ColorMatrix): RecursiveKeyValuePair => { + return Object.entries(colorMatrix).reduce((palette, colorData) => { + const [colorName, tints] = colorData; + + // Conditionally parse array or single-tint colors + if (Array.isArray(tints)) { + palette[colorName] = parseTints(colorName, tints); + } else if (tints === true) { + palette[colorName] = toColorVariable(colorName); + } + + return palette; + }, {}); +}; + +export { + withOpacityValue, + parseColorMatrix, +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a71aa20 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "outDir": "dist", + "paths": { + "soapbox/*": ["./src/*"], + }, + "types": [ + "vite/client", + "@webbtc/webln-types" + ], + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..d571ca6 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,155 @@ +/// +import fs from 'node:fs'; +import { fileURLToPath, URL } from 'node:url'; + +import react from '@vitejs/plugin-react-swc'; +import { visualizer } from 'rollup-plugin-visualizer'; +import { Connect, defineConfig, Plugin, UserConfig } from 'vite'; +import checker from 'vite-plugin-checker'; +import compileTime from 'vite-plugin-compile-time'; +import { createHtmlPlugin } from 'vite-plugin-html'; +import { VitePWA } from 'vite-plugin-pwa'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; + +const { NODE_ENV, PORT } = process.env; + +export default defineConfig(() => { + const config: UserConfig = { + build: { + assetsDir: 'packs', + assetsInlineLimit: 0, + rollupOptions: { + output: { + assetFileNames: 'packs/assets/[name]-[hash].[ext]', + chunkFileNames: 'packs/js/[name]-[hash].js', + entryFileNames: 'packs/[name]-[hash].js', + }, + }, + sourcemap: true, + }, + assetsInclude: ['**/*.oga'], + server: { + port: Number(PORT ?? 3036), + }, + plugins: [ + checker({ typescript: true }), + compileTime(), + createHtmlPlugin({ + template: 'index.html', + minify: { + collapseWhitespace: true, + removeComments: false, + }, + inject: { + data: { + snippets: readFileContents('custom/snippets.html'), + csp: buildCSP(NODE_ENV), + }, + }, + }), + react(), + VitePWA({ + injectRegister: null, + strategies: 'injectManifest', + injectManifest: { + injectionPoint: undefined, + plugins: [ + // @ts-ignore + compileTime(), + ], + }, + manifest: { + name: 'Soapbox', + short_name: 'Soapbox', + description: 'A social media frontend with a focus on custom branding and ease of use.', + theme_color: '#0482d8', + }, + srcDir: 'src/service-worker', + filename: 'sw.ts', + }), + viteStaticCopy({ + targets: [{ + src: './src/instance', + dest: '.', + }, { + src: './custom/instance', + dest: '.', + }], + }), + visualizer({ + emitFile: true, + filename: 'report.html', + title: 'Soapbox Bundle', + }) as Plugin, + { + // Vite's default behavior is to serve index.html (HTTP 200) for unmatched routes, like a PWA. + // Instead, 404 on known backend routes to more closely match a real server. + name: 'vite-mastodon-dev', + configureServer(server) { + const notFoundHandler: Connect.SimpleHandleFunction = (_req, res) => { + res.statusCode = 404; + res.end(); + }; + + server.middlewares.use('/api/', notFoundHandler); + server.middlewares.use('/oauth/', notFoundHandler); + server.middlewares.use('/nodeinfo/', notFoundHandler); + server.middlewares.use('/.well-known/', notFoundHandler); + }, + }, + ], + resolve: { + alias: [ + { find: 'soapbox', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, + ], + }, + // @ts-ignore + test: { + globals: true, + environment: 'jsdom', + setupFiles: 'src/jest/test-setup.ts', + }, + }; + + return config; +}); + +/** Build a sane default CSP string to embed in index.html in case the server doesn't return one. */ +/* eslint-disable quotes */ +function buildCSP(env: string | undefined): string { + const csp = [ + "default-src 'none'", + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "frame-src 'self' https:", + "font-src 'self'", + "base-uri 'self'", + "manifest-src 'self'", + ]; + + if (env === 'development') { + csp.push( + "connect-src 'self' blob: https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*", + "img-src 'self' data: blob: https: http://localhost:* http://127.0.0.1:*", + "media-src 'self' https: http://localhost:* http://127.0.0.1:*", + ); + } else { + csp.push( + "connect-src 'self' blob: https: wss:", + "img-src 'self' data: blob: https:", + "media-src 'self' https:", + ); + } + + return csp.join('; '); +} +/* eslint-enable quotes */ + +/** Return file as string, or return empty string if the file isn't found. */ +function readFileContents(path: string) { + try { + return fs.readFileSync(path, 'utf8'); + } catch { + return ''; + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..7ab2371 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8972 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@aashutoshrathi/word-wrap@^1.2.3": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" + integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== + +"@adobe/css-tools@^4.3.0": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.1.tgz#abfccb8ca78075a2b6187345c26243c1a0842f28" + integrity sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg== + +"@akryum/flexsearch-es@^0.7.32": + version "0.7.32" + resolved "https://registry.yarnpkg.com/@akryum/flexsearch-es/-/flexsearch-es-0.7.32.tgz#9d00e6bdf2418ae686323a4a9224b7a1568075e9" + integrity sha512-jA9b9oYefRJkuhGAjd9lB2iVkOFzgdzuy1giserUo+usstEjuNSe9Pod6ZfC8S9xvCmPT2ovY09DNFnEQSgpMA== + +"@alloc/quick-lru@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" + integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== + +"@ampproject/remapping@^2.2.0": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" + integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + +"@apideck/better-ajv-errors@^0.3.1": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz#957d4c28e886a64a8141f7522783be65733ff097" + integrity sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA== + dependencies: + json-schema "^0.4.0" + jsonpointer "^5.0.0" + leven "^3.1.0" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" + integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== + dependencies: + "@babel/highlight" "^7.24.7" + picocolors "^1.0.0" + +"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9", "@babel/compat-data@^7.25.2": + version "7.25.4" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.4.tgz#7d2a80ce229890edcf4cc259d4d696cb4dae2fcb" + integrity sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ== + +"@babel/core@^7.24.4": + version "7.25.2" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.25.2.tgz#ed8eec275118d7613e77a352894cd12ded8eba77" + integrity sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.25.0" + "@babel/helper-compilation-targets" "^7.25.2" + "@babel/helper-module-transforms" "^7.25.2" + "@babel/helpers" "^7.25.0" + "@babel/parser" "^7.25.0" + "@babel/template" "^7.25.0" + "@babel/traverse" "^7.25.2" + "@babel/types" "^7.25.2" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.25.0", "@babel/generator@^7.25.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.25.6.tgz#0df1ad8cb32fe4d2b01d8bf437f153d19342a87c" + integrity sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw== + dependencies: + "@babel/types" "^7.25.6" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + +"@babel/helper-annotate-as-pure@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882" + integrity sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-builder-binary-assignment-operator-visitor@^7.22.5": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz#5426b109cf3ad47b91120f8328d8ab1be8b0b956" + integrity sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw== + dependencies: + "@babel/types" "^7.22.15" + +"@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.5", "@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.2": + version "7.25.2" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz#e1d9410a90974a3a5a66e84ff55ef62e3c02d06c" + integrity sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw== + dependencies: + "@babel/compat-data" "^7.25.2" + "@babel/helper-validator-option" "^7.24.8" + browserslist "^4.23.1" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-create-class-features-plugin@^7.22.11", "@babel/helper-create-class-features-plugin@^7.22.5": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz#97a61b385e57fe458496fad19f8e63b63c867de4" + integrity sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.22.5" + "@babel/helper-environment-visitor" "^7.22.5" + "@babel/helper-function-name" "^7.22.5" + "@babel/helper-member-expression-to-functions" "^7.22.15" + "@babel/helper-optimise-call-expression" "^7.22.5" + "@babel/helper-replace-supers" "^7.22.9" + "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.22.6" + semver "^6.3.1" + +"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.22.5": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz#5ee90093914ea09639b01c711db0d6775e558be1" + integrity sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w== + dependencies: + "@babel/helper-annotate-as-pure" "^7.22.5" + regexpu-core "^5.3.1" + semver "^6.3.1" + +"@babel/helper-define-polyfill-provider@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.2.tgz#82c825cadeeeee7aad237618ebbe8fa1710015d7" + integrity sha512-k0qnnOqHn5dK9pZpfD5XXZ9SojAITdCKRn2Lp6rnDGzIbaP0rHyMPk/4wsSxVBVz4RfN0q6VpXWP2pDGIoQ7hw== + dependencies: + "@babel/helper-compilation-targets" "^7.22.6" + "@babel/helper-plugin-utils" "^7.22.5" + debug "^4.1.1" + lodash.debounce "^4.0.8" + resolve "^1.14.2" + +"@babel/helper-environment-visitor@^7.22.5": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" + integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== + +"@babel/helper-function-name@^7.22.5": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" + integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== + dependencies: + "@babel/template" "^7.22.15" + "@babel/types" "^7.23.0" + +"@babel/helper-hoist-variables@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" + integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-member-expression-to-functions@^7.22.15", "@babel/helper-member-expression-to-functions@^7.22.5": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.15.tgz#b95a144896f6d491ca7863576f820f3628818621" + integrity sha512-qLNsZbgrNh0fDQBCPocSL8guki1hcPvltGDv/NxvUoABwFq7GkKSu1nRXeJkVZc+wJvne2E0RKQz+2SQrz6eAA== + dependencies: + "@babel/types" "^7.22.15" + +"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.22.5", "@babel/helper-module-imports@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz#f2f980392de5b84c3328fc71d38bd81bbb83042b" + integrity sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + +"@babel/helper-module-transforms@^7.22.15", "@babel/helper-module-transforms@^7.22.5", "@babel/helper-module-transforms@^7.22.9", "@babel/helper-module-transforms@^7.25.2": + version "7.25.2" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz#ee713c29768100f2776edf04d4eb23b8d27a66e6" + integrity sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ== + dependencies: + "@babel/helper-module-imports" "^7.24.7" + "@babel/helper-simple-access" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" + "@babel/traverse" "^7.25.2" + +"@babel/helper-optimise-call-expression@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz#f21531a9ccbff644fdd156b4077c16ff0c3f609e" + integrity sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz#94ee67e8ec0e5d44ea7baeb51e571bd26af07878" + integrity sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg== + +"@babel/helper-remap-async-to-generator@^7.22.5", "@babel/helper-remap-async-to-generator@^7.22.9": + version "7.22.17" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.17.tgz#dabaa50622b3b4670bd6546fc8db23eb12d89da0" + integrity sha512-bxH77R5gjH3Nkde6/LuncQoLaP16THYPscurp1S8z7S9ZgezCyV3G8Hc+TZiCmY8pz4fp8CvKSgtJMW0FkLAxA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.22.5" + "@babel/helper-environment-visitor" "^7.22.5" + "@babel/helper-wrap-function" "^7.22.17" + +"@babel/helper-replace-supers@^7.22.5", "@babel/helper-replace-supers@^7.22.9": + version "7.22.9" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.22.9.tgz#cbdc27d6d8d18cd22c81ae4293765a5d9afd0779" + integrity sha512-LJIKvvpgPOPUThdYqcX6IXRuIcTkcAub0IaDRGCZH0p5GPUp7PhRU9QVgFcDDd51BaPkk77ZjqFwh6DZTAEmGg== + dependencies: + "@babel/helper-environment-visitor" "^7.22.5" + "@babel/helper-member-expression-to-functions" "^7.22.5" + "@babel/helper-optimise-call-expression" "^7.22.5" + +"@babel/helper-simple-access@^7.22.5", "@babel/helper-simple-access@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz#bcade8da3aec8ed16b9c4953b74e506b51b5edb3" + integrity sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + +"@babel/helper-skip-transparent-expression-wrappers@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz#007f15240b5751c537c40e77abb4e89eeaaa8847" + integrity sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-split-export-declaration@^7.22.6": + version "7.22.6" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c" + integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-string-parser@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz#5b3329c9a58803d5df425e5785865881a81ca48d" + integrity sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ== + +"@babel/helper-validator-identifier@^7.22.5", "@babel/helper-validator-identifier@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" + integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== + +"@babel/helper-validator-option@^7.22.15", "@babel/helper-validator-option@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz#3725cdeea8b480e86d34df15304806a06975e33d" + integrity sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q== + +"@babel/helper-wrap-function@^7.22.17": + version "7.22.17" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.17.tgz#222ac3ff9cc8f9b617cc1e5db75c0b538e722801" + integrity sha512-nAhoheCMlrqU41tAojw9GpVEKDlTS8r3lzFmF0lP52LwblCPbuFSO7nGIZoIcoU5NIm1ABrna0cJExE4Ay6l2Q== + dependencies: + "@babel/helper-function-name" "^7.22.5" + "@babel/template" "^7.22.15" + "@babel/types" "^7.22.17" + +"@babel/helpers@^7.25.0": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.25.6.tgz#57ee60141829ba2e102f30711ffe3afab357cc60" + integrity sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q== + dependencies: + "@babel/template" "^7.25.0" + "@babel/types" "^7.25.6" + +"@babel/highlight@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" + integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.7" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/parser@^7.25.0", "@babel/parser@^7.25.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.25.6.tgz#85660c5ef388cbbf6e3d2a694ee97a38f18afe2f" + integrity sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q== + dependencies: + "@babel/types" "^7.25.6" + +"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.15.tgz#02dc8a03f613ed5fdc29fb2f728397c78146c962" + integrity sha512-FB9iYlz7rURmRJyXRKEnalYPPdn87H5no108cyuQQyMwlpJ2SJtpIUBI27kdTin956pz+LPypkPVPUTlxOmrsg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.15.tgz#2aeb91d337d4e1a1e7ce85b76a37f5301781200f" + integrity sha512-Hyph9LseGvAeeXzikV88bczhsrLrIZqDPxO+sSmAunMPaGrBGhfMWzCPYTtiW9t+HzSE2wtV8e5cc5P6r1xMDQ== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" + "@babel/plugin-transform-optional-chaining" "^7.22.15" + +"@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2": + version "7.21.0-placeholder-for-preset-env.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703" + integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w== + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-dynamic-import@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" + integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-export-namespace-from@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" + integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-syntax-import-assertions@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz#07d252e2aa0bc6125567f742cd58619cb14dce98" + integrity sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-syntax-import-attributes@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz#ab840248d834410b829f569f5262b9e517555ecb" + integrity sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-syntax-import-meta@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-unicode-sets-regex@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357" + integrity sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-arrow-functions@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz#e5ba566d0c58a5b2ba2a8b795450641950b71958" + integrity sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-async-generator-functions@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.15.tgz#3b153af4a6b779f340d5b80d3f634f55820aefa3" + integrity sha512-jBm1Es25Y+tVoTi5rfd5t1KLmL8ogLKpXszboWOTTtGFGz2RKnQe2yn7HbZ+kb/B8N0FVSGQo874NSlOU1T4+w== + dependencies: + "@babel/helper-environment-visitor" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-remap-async-to-generator" "^7.22.9" + "@babel/plugin-syntax-async-generators" "^7.8.4" + +"@babel/plugin-transform-async-to-generator@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz#c7a85f44e46f8952f6d27fe57c2ed3cc084c3775" + integrity sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ== + dependencies: + "@babel/helper-module-imports" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-remap-async-to-generator" "^7.22.5" + +"@babel/plugin-transform-block-scoped-functions@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz#27978075bfaeb9fa586d3cb63a3d30c1de580024" + integrity sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-block-scoping@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.15.tgz#494eb82b87b5f8b1d8f6f28ea74078ec0a10a841" + integrity sha512-G1czpdJBZCtngoK1sJgloLiOHUnkb/bLZwqVZD8kXmq0ZnVfTTWUcs9OWtp0mBtYJ+4LQY1fllqBkOIPhXmFmw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-class-properties@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz#97a56e31ad8c9dc06a0b3710ce7803d5a48cca77" + integrity sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-class-static-block@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.11.tgz#dc8cc6e498f55692ac6b4b89e56d87cec766c974" + integrity sha512-GMM8gGmqI7guS/llMFk1bJDkKfn3v3C4KHK9Yg1ey5qcHcOlKb0QvcMrgzvxo+T03/4szNh5lghY+fEC98Kq9g== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.22.11" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + +"@babel/plugin-transform-classes@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.15.tgz#aaf4753aee262a232bbc95451b4bdf9599c65a0b" + integrity sha512-VbbC3PGjBdE0wAWDdHM9G8Gm977pnYI0XpqMd6LrKISj8/DJXEsWqgRuTYaNE9Bv0JGhTZUzHDlMk18IpOuoqw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.22.5" + "@babel/helper-compilation-targets" "^7.22.15" + "@babel/helper-environment-visitor" "^7.22.5" + "@babel/helper-function-name" "^7.22.5" + "@babel/helper-optimise-call-expression" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-replace-supers" "^7.22.9" + "@babel/helper-split-export-declaration" "^7.22.6" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz#cd1e994bf9f316bd1c2dafcd02063ec261bb3869" + integrity sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/template" "^7.22.5" + +"@babel/plugin-transform-destructuring@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.15.tgz#e7404ea5bb3387073b9754be654eecb578324694" + integrity sha512-HzG8sFl1ZVGTme74Nw+X01XsUTqERVQ6/RLHo3XjGRzm7XD6QTtfS3NJotVgCGy8BzkDqRjRBD8dAyJn5TuvSQ== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-dotall-regex@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz#dbb4f0e45766eb544e193fb00e65a1dd3b2a4165" + integrity sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-duplicate-keys@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz#b6e6428d9416f5f0bba19c70d1e6e7e0b88ab285" + integrity sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-dynamic-import@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.11.tgz#2c7722d2a5c01839eaf31518c6ff96d408e447aa" + integrity sha512-g/21plo58sfteWjaO0ZNVb+uEOkJNjAaHhbejrnBmu011l/eNDScmkbjCC3l4FKb10ViaGU4aOkFznSu2zRHgA== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + +"@babel/plugin-transform-exponentiation-operator@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz#402432ad544a1f9a480da865fda26be653e48f6a" + integrity sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g== + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-export-namespace-from@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.11.tgz#b3c84c8f19880b6c7440108f8929caf6056db26c" + integrity sha512-xa7aad7q7OiT8oNZ1mU7NrISjlSkVdMbNxn9IuLZyL9AJEhs1Apba3I+u5riX1dIkdptP5EKDG5XDPByWxtehw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + +"@babel/plugin-transform-for-of@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.15.tgz#f64b4ccc3a4f131a996388fae7680b472b306b29" + integrity sha512-me6VGeHsx30+xh9fbDLLPi0J1HzmeIIyenoOQHuw2D4m2SAU3NrspX5XxJLBpqn5yrLzrlw2Iy3RA//Bx27iOA== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-function-name@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz#935189af68b01898e0d6d99658db6b164205c143" + integrity sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg== + dependencies: + "@babel/helper-compilation-targets" "^7.22.5" + "@babel/helper-function-name" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-json-strings@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.11.tgz#689a34e1eed1928a40954e37f74509f48af67835" + integrity sha512-CxT5tCqpA9/jXFlme9xIBCc5RPtdDq3JpkkhgHQqtDdiTnTI0jtZ0QzXhr5DILeYifDPp2wvY2ad+7+hLMW5Pw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-json-strings" "^7.8.3" + +"@babel/plugin-transform-literals@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz#e9341f4b5a167952576e23db8d435849b1dd7920" + integrity sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-logical-assignment-operators@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.11.tgz#24c522a61688bde045b7d9bc3c2597a4d948fc9c" + integrity sha512-qQwRTP4+6xFCDV5k7gZBF3C31K34ut0tbEcTKxlX/0KXxm9GLcO14p570aWxFvVzx6QAfPgq7gaeIHXJC8LswQ== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + +"@babel/plugin-transform-member-expression-literals@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz#4fcc9050eded981a468347dd374539ed3e058def" + integrity sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-modules-amd@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz#4e045f55dcf98afd00f85691a68fc0780704f526" + integrity sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ== + dependencies: + "@babel/helper-module-transforms" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-modules-commonjs@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.15.tgz#b11810117ed4ee7691b29bd29fd9f3f98276034f" + integrity sha512-jWL4eh90w0HQOTKP2MoXXUpVxilxsB2Vl4ji69rSjS3EcZ/v4sBmn+A3NpepuJzBhOaEBbR7udonlHHn5DWidg== + dependencies: + "@babel/helper-module-transforms" "^7.22.15" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-simple-access" "^7.22.5" + +"@babel/plugin-transform-modules-systemjs@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.11.tgz#3386be5875d316493b517207e8f1931d93154bb1" + integrity sha512-rIqHmHoMEOhI3VkVf5jQ15l539KrwhzqcBO6wdCNWPWc/JWt9ILNYNUssbRpeq0qWns8svuw8LnMNCvWBIJ8wA== + dependencies: + "@babel/helper-hoist-variables" "^7.22.5" + "@babel/helper-module-transforms" "^7.22.9" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-validator-identifier" "^7.22.5" + +"@babel/plugin-transform-modules-umd@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz#4694ae40a87b1745e3775b6a7fe96400315d4f98" + integrity sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ== + dependencies: + "@babel/helper-module-transforms" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz#67fe18ee8ce02d57c855185e27e3dc959b2e991f" + integrity sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-new-target@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz#1b248acea54ce44ea06dfd37247ba089fcf9758d" + integrity sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-nullish-coalescing-operator@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.11.tgz#debef6c8ba795f5ac67cd861a81b744c5d38d9fc" + integrity sha512-YZWOw4HxXrotb5xsjMJUDlLgcDXSfO9eCmdl1bgW4+/lAGdkjaEvOnQ4p5WKKdUgSzO39dgPl0pTnfxm0OAXcg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + +"@babel/plugin-transform-numeric-separator@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.11.tgz#498d77dc45a6c6db74bb829c02a01c1d719cbfbd" + integrity sha512-3dzU4QGPsILdJbASKhF/V2TVP+gJya1PsueQCxIPCEcerqF21oEcrob4mzjsp2Py/1nLfF5m+xYNMDpmA8vffg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + +"@babel/plugin-transform-object-rest-spread@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.15.tgz#21a95db166be59b91cde48775310c0df6e1da56f" + integrity sha512-fEB+I1+gAmfAyxZcX1+ZUwLeAuuf8VIg67CTznZE0MqVFumWkh8xWtn58I4dxdVf080wn7gzWoF8vndOViJe9Q== + dependencies: + "@babel/compat-data" "^7.22.9" + "@babel/helper-compilation-targets" "^7.22.15" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-transform-parameters" "^7.22.15" + +"@babel/plugin-transform-object-super@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz#794a8d2fcb5d0835af722173c1a9d704f44e218c" + integrity sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-replace-supers" "^7.22.5" + +"@babel/plugin-transform-optional-catch-binding@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.11.tgz#461cc4f578a127bb055527b3e77404cad38c08e0" + integrity sha512-rli0WxesXUeCJnMYhzAglEjLWVDF6ahb45HuprcmQuLidBJFWjNnOzssk2kuc6e33FlLaiZhG/kUIzUMWdBKaQ== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + +"@babel/plugin-transform-optional-chaining@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.15.tgz#d7a5996c2f7ca4ad2ad16dbb74444e5c4385b1ba" + integrity sha512-ngQ2tBhq5vvSJw2Q2Z9i7ealNkpDMU0rGWnHPKqRZO0tzZ5tlaoz4hDvhXioOoaE0X2vfNss1djwg0DXlfu30A== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + +"@babel/plugin-transform-parameters@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.15.tgz#719ca82a01d177af358df64a514d64c2e3edb114" + integrity sha512-hjk7qKIqhyzhhUvRT683TYQOFa/4cQKwQy7ALvTpODswN40MljzNDa0YldevS6tGbxwaEKVn502JmY0dP7qEtQ== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-private-methods@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz#21c8af791f76674420a147ae62e9935d790f8722" + integrity sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-private-property-in-object@^7.22.11": + version "7.22.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.11.tgz#ad45c4fc440e9cb84c718ed0906d96cf40f9a4e1" + integrity sha512-sSCbqZDBKHetvjSwpyWzhuHkmW5RummxJBVbYLkGkaiTOWGxml7SXt0iWa03bzxFIx7wOj3g/ILRd0RcJKBeSQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.22.5" + "@babel/helper-create-class-features-plugin" "^7.22.11" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + +"@babel/plugin-transform-property-literals@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz#b5ddabd73a4f7f26cd0e20f5db48290b88732766" + integrity sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-regenerator@^7.22.10": + version "7.22.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz#8ceef3bd7375c4db7652878b0241b2be5d0c3cca" + integrity sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + regenerator-transform "^0.15.2" + +"@babel/plugin-transform-reserved-words@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz#832cd35b81c287c4bcd09ce03e22199641f964fb" + integrity sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-shorthand-properties@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz#6e277654be82b5559fc4b9f58088507c24f0c624" + integrity sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-spread@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz#6487fd29f229c95e284ba6c98d65eafb893fea6b" + integrity sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" + +"@babel/plugin-transform-sticky-regex@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz#295aba1595bfc8197abd02eae5fc288c0deb26aa" + integrity sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-template-literals@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz#8f38cf291e5f7a8e60e9f733193f0bcc10909bff" + integrity sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-typeof-symbol@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz#5e2ba478da4b603af8673ff7c54f75a97b716b34" + integrity sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-unicode-escapes@^7.22.10": + version "7.22.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz#c723f380f40a2b2f57a62df24c9005834c8616d9" + integrity sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-unicode-property-regex@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz#098898f74d5c1e86660dc112057b2d11227f1c81" + integrity sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-unicode-regex@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz#ce7e7bb3ef208c4ff67e02a22816656256d7a183" + integrity sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-unicode-sets-regex@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz#77788060e511b708ffc7d42fdfbc5b37c3004e91" + integrity sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/preset-env@^7.11.0": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.22.15.tgz#142716f8e00bc030dae5b2ac6a46fbd8b3e18ff8" + integrity sha512-tZFHr54GBkHk6hQuVA8w4Fmq+MSPsfvMG0vPnOYyTnJpyfMqybL8/MbNCPRT9zc2KBO2pe4tq15g6Uno4Jpoag== + dependencies: + "@babel/compat-data" "^7.22.9" + "@babel/helper-compilation-targets" "^7.22.15" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-validator-option" "^7.22.15" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.22.15" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.22.15" + "@babel/plugin-proposal-private-property-in-object" "7.21.0-placeholder-for-preset-env.2" + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/plugin-syntax-import-assertions" "^7.22.5" + "@babel/plugin-syntax-import-attributes" "^7.22.5" + "@babel/plugin-syntax-import-meta" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6" + "@babel/plugin-transform-arrow-functions" "^7.22.5" + "@babel/plugin-transform-async-generator-functions" "^7.22.15" + "@babel/plugin-transform-async-to-generator" "^7.22.5" + "@babel/plugin-transform-block-scoped-functions" "^7.22.5" + "@babel/plugin-transform-block-scoping" "^7.22.15" + "@babel/plugin-transform-class-properties" "^7.22.5" + "@babel/plugin-transform-class-static-block" "^7.22.11" + "@babel/plugin-transform-classes" "^7.22.15" + "@babel/plugin-transform-computed-properties" "^7.22.5" + "@babel/plugin-transform-destructuring" "^7.22.15" + "@babel/plugin-transform-dotall-regex" "^7.22.5" + "@babel/plugin-transform-duplicate-keys" "^7.22.5" + "@babel/plugin-transform-dynamic-import" "^7.22.11" + "@babel/plugin-transform-exponentiation-operator" "^7.22.5" + "@babel/plugin-transform-export-namespace-from" "^7.22.11" + "@babel/plugin-transform-for-of" "^7.22.15" + "@babel/plugin-transform-function-name" "^7.22.5" + "@babel/plugin-transform-json-strings" "^7.22.11" + "@babel/plugin-transform-literals" "^7.22.5" + "@babel/plugin-transform-logical-assignment-operators" "^7.22.11" + "@babel/plugin-transform-member-expression-literals" "^7.22.5" + "@babel/plugin-transform-modules-amd" "^7.22.5" + "@babel/plugin-transform-modules-commonjs" "^7.22.15" + "@babel/plugin-transform-modules-systemjs" "^7.22.11" + "@babel/plugin-transform-modules-umd" "^7.22.5" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.22.5" + "@babel/plugin-transform-new-target" "^7.22.5" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.22.11" + "@babel/plugin-transform-numeric-separator" "^7.22.11" + "@babel/plugin-transform-object-rest-spread" "^7.22.15" + "@babel/plugin-transform-object-super" "^7.22.5" + "@babel/plugin-transform-optional-catch-binding" "^7.22.11" + "@babel/plugin-transform-optional-chaining" "^7.22.15" + "@babel/plugin-transform-parameters" "^7.22.15" + "@babel/plugin-transform-private-methods" "^7.22.5" + "@babel/plugin-transform-private-property-in-object" "^7.22.11" + "@babel/plugin-transform-property-literals" "^7.22.5" + "@babel/plugin-transform-regenerator" "^7.22.10" + "@babel/plugin-transform-reserved-words" "^7.22.5" + "@babel/plugin-transform-shorthand-properties" "^7.22.5" + "@babel/plugin-transform-spread" "^7.22.5" + "@babel/plugin-transform-sticky-regex" "^7.22.5" + "@babel/plugin-transform-template-literals" "^7.22.5" + "@babel/plugin-transform-typeof-symbol" "^7.22.5" + "@babel/plugin-transform-unicode-escapes" "^7.22.10" + "@babel/plugin-transform-unicode-property-regex" "^7.22.5" + "@babel/plugin-transform-unicode-regex" "^7.22.5" + "@babel/plugin-transform-unicode-sets-regex" "^7.22.5" + "@babel/preset-modules" "0.1.6-no-external-plugins" + "@babel/types" "^7.22.15" + babel-plugin-polyfill-corejs2 "^0.4.5" + babel-plugin-polyfill-corejs3 "^0.8.3" + babel-plugin-polyfill-regenerator "^0.5.2" + core-js-compat "^3.31.0" + semver "^6.3.1" + +"@babel/preset-modules@0.1.6-no-external-plugins": + version "0.1.6-no-external-plugins" + resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz#ccb88a2c49c817236861fee7826080573b8a923a" + integrity sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/types" "^7.4.4" + esutils "^2.0.2" + +"@babel/regjsgen@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" + integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== + +"@babel/runtime@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0.tgz#adeb78fedfc855aa05bc041640f3f6f98e85424c" + integrity sha512-7hGhzlcmg01CvH1EHdSPVXYX1aJ8KCEyz6I9xYIi/asDtzBPMyMhVibhM/K6g/5qnKBwjZtp10bNZIEFTRW1MA== + dependencies: + regenerator-runtime "^0.12.0" + +"@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8" + integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/template@^7.22.15", "@babel/template@^7.22.5", "@babel/template@^7.25.0": + version "7.25.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.0.tgz#e733dc3134b4fede528c15bc95e89cb98c52592a" + integrity sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/parser" "^7.25.0" + "@babel/types" "^7.25.0" + +"@babel/traverse@^7.24.7", "@babel/traverse@^7.25.2": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.6.tgz#04fad980e444f182ecf1520504941940a90fea41" + integrity sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.25.6" + "@babel/parser" "^7.25.6" + "@babel/template" "^7.25.0" + "@babel/types" "^7.25.6" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.22.15", "@babel/types@^7.22.17", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.24.7", "@babel/types@^7.25.0", "@babel/types@^7.25.2", "@babel/types@^7.25.6", "@babel/types@^7.4.4": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.6.tgz#893942ddb858f32ae7a004ec9d3a76b3463ef8e6" + integrity sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw== + dependencies: + "@babel/helper-string-parser" "^7.24.8" + "@babel/helper-validator-identifier" "^7.24.7" + to-fast-properties "^2.0.0" + +"@csstools/css-parser-algorithms@^3.0.1": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.2.tgz#be03c710a60b34f95ea62e332c9ca0c2674f6d5f" + integrity sha512-6tC/MnlEvs5suR4Ahef4YlBccJDHZuxGsAlxXmybWjZ5jPxlzLSMlRZ9mVHSRvlD+CmtE7+hJ+UQbfXrws/rUQ== + +"@csstools/css-tokenizer@^3.0.1": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.2.tgz#1c1d7298f6a7b3db94afe53d949b9a7d6a8ebc57" + integrity sha512-IuTRcD53WHsXPCZ6W7ubfGqReTJ9Ra0yRRFmXYP/Re8hFYYfoIYIK4080X5luslVLWimhIeFq0hj09urVMQzTw== + +"@csstools/media-query-list-parser@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@csstools/media-query-list-parser/-/media-query-list-parser-3.0.1.tgz#9474e08e6d7767cf68c56bf1581b59d203360cb0" + integrity sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw== + +"@csstools/selector-specificity@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-4.0.0.tgz#7dfccb9df5499e627e7bfdbb4021a06813a45dba" + integrity sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ== + +"@dual-bundle/import-meta-resolve@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz#519c1549b0e147759e7825701ecffd25e5819f7b" + integrity sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg== + +"@emoji-mart/data@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c" + integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw== + +"@es-joy/jsdoccomment@~0.41.0": + version "0.41.0" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.41.0.tgz#4a2f7db42209c0425c71a1476ef1bdb6dcd836f6" + integrity sha512-aKUhyn1QI5Ksbqcr3fFJj16p99QdjUxXAEuFst1Z47DRyoiMwivIH9MV/ARcJOCXVjPfjITciej8ZD2O/6qUmw== + dependencies: + comment-parser "1.4.1" + esquery "^1.5.0" + jsdoc-type-pratt-parser "~4.0.0" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/aix-ppc64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz#b57697945b50e99007b4c2521507dc613d4a648c" + integrity sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz#1add7e0af67acefd556e407f8497e81fddad79c0" + integrity sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-arm@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.0.tgz#ab7263045fa8e090833a8e3c393b60d59a789810" + integrity sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/android-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.0.tgz#e8f8b196cfdfdd5aeaebbdb0110983460440e705" + integrity sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz#2d0d9414f2acbffd2d86e98253914fca603a53dd" + integrity sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/darwin-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz#33087aab31a1eb64c89daf3d2cf8ce1775656107" + integrity sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz#bb76e5ea9e97fa3c753472f19421075d3a33e8a7" + integrity sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/freebsd-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz#e0e2ce9249fdf6ee29e5dc3d420c7007fa579b93" + integrity sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz#d1b2aa58085f73ecf45533c07c82d81235388e75" + integrity sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-arm@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz#8e4915df8ea3e12b690a057e77a47b1d5935ef6d" + integrity sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-ia32@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz#8200b1110666c39ab316572324b7af63d82013fb" + integrity sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-loong64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz#6ff0c99cf647504df321d0640f0d32e557da745c" + integrity sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-mips64el@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz#3f720ccd4d59bfeb4c2ce276a46b77ad380fa1f3" + integrity sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-ppc64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz#9d6b188b15c25afd2e213474bf5f31e42e3aa09e" + integrity sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-riscv64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz#f989fdc9752dfda286c9cd87c46248e4dfecbc25" + integrity sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-s390x@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz#29ebf87e4132ea659c1489fce63cd8509d1c7319" + integrity sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/linux-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz#4af48c5c0479569b1f359ffbce22d15f261c0cef" + integrity sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/netbsd-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz#1ae73d23cc044a0ebd4f198334416fb26c31366c" + integrity sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg== + +"@esbuild/openbsd-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz#5d904a4f5158c89859fd902c427f96d6a9e632e2" + integrity sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/openbsd-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz#4c8aa88c49187c601bae2971e71c6dc5e0ad1cdf" + integrity sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/sunos-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz#8ddc35a0ea38575fa44eda30a5ee01ae2fa54dd4" + integrity sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz#6e79c8543f282c4539db684a207ae0e174a9007b" + integrity sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-ia32@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz#057af345da256b7192d18b676a02e95d0fa39103" + integrity sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@esbuild/win32-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz#168ab1c7e1c318b922637fad8f339d48b01e1244" + integrity sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA== + +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1": + version "4.8.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.1.tgz#8c4bb756cc2aa7eaf13cfa5e69c83afb3260c20c" + integrity sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ== + +"@eslint/eslintrc@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.2.tgz#c6936b4b328c64496692f76944e755738be62396" + integrity sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333" + integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w== + +"@floating-ui/core@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.4.1.tgz#0d633f4b76052668afb932492ac452f7ebe97f17" + integrity sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ== + dependencies: + "@floating-ui/utils" "^0.1.1" + +"@floating-ui/dom@^1.5.1": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.2.tgz#6812e89d1d4d4ea32f10d15c3b81feb7f9836d89" + integrity sha512-6ArmenS6qJEWmwzczWyhvrXRdI/rI78poBcW0h/456+onlabit+2G+QxHx5xTOX60NBJQXjsCLFbW2CmsXpUog== + dependencies: + "@floating-ui/core" "^1.4.1" + "@floating-ui/utils" "^0.1.1" + +"@floating-ui/react-dom@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.2.tgz#fab244d64db08e6bed7be4b5fcce65315ef44d20" + integrity sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ== + dependencies: + "@floating-ui/dom" "^1.5.1" + +"@floating-ui/react@^0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.0.tgz#f33317652d382f21ce2584c43bb009154e6b7700" + integrity sha512-W70xgicegogSoy+8Hfmpd/NWEuL26vsatIHkpVydmigJ84YYhs5/GlBCkLcETWajCjD9XKwlHUv6ezwbLLiung== + dependencies: + "@floating-ui/react-dom" "^2.0.2" + "@floating-ui/utils" "^0.1.5" + tabbable "^6.0.1" + +"@floating-ui/utils@^0.1.1", "@floating-ui/utils@^0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.5.tgz#f0bada210a75fdf41101c48ddcc291e1b33b3f47" + integrity sha512-3lClsx2F3ei6hup0LYFbbm+NH87qVTX/6T63IllEFCLjT7XCxmbgBM42sXf8LTZx0CE5VpRRUnISUbqSlsxGSA== + +"@fontsource/amiri@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@fontsource/amiri/-/amiri-5.1.0.tgz#86da9f17fbab9ecdb6b23e49d083cc9501abc0dd" + integrity sha512-epfiqkAaDIHyIW7Mz2HVEcSTjpJZLdnvd1AkpnpKnngYWxo+GDl5TrXsQjZp6/CnL/UYZsQZoiugajiL9HGQUA== + +"@fontsource/cairo@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@fontsource/cairo/-/cairo-5.1.0.tgz#89dc3babe15ac02ba02c5a1f32f08ea5d3457ec4" + integrity sha512-gjiQa8YP4ORv20WhWHHymEBgeHEZ2GP6H0VZACWylG5VoqsHXawRn3V+9YVg3nOZWIknUexCqncIrlXbKDXnqQ== + +"@fontsource/inter@^5.0.0": + version "5.0.8" + resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.0.8.tgz#61b50cb0eb72b14ae1938d47c4a9a91546d2a50c" + integrity sha512-28knWH1BfOiRalfLs90U4sge5mpQ8ZH6FS0PTT+IZMKrZ7wNHDHRuKa1kQJg+uHcc6axBppnxll+HXM4c7zo/Q== + +"@fontsource/noto-sans-javanese@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@fontsource/noto-sans-javanese/-/noto-sans-javanese-5.1.0.tgz#bfae45a2324d40e2bc1e5b14479a7d47ae59c7a7" + integrity sha512-N3yvfUucmc5hC5ksxdd8DYHTGgx1rMqfgZm4qZ5T1TNMe+773exqGAKVYgdgzCyLCxdOVHQui9ATEeO8hr1Nqg== + +"@fontsource/roboto-mono@^5.0.0": + version "5.0.8" + resolved "https://registry.yarnpkg.com/@fontsource/roboto-mono/-/roboto-mono-5.0.8.tgz#9b3df61f884f46e12d3eca46e75517fde58da68e" + integrity sha512-vjnNX8zQCSp4HadUJ3gpZiizCsK/ROjgGMpd4bcRxuyiTNGGMaznmKbhqdyVeFVap1sX8h2Qu380awaotey/mQ== + +"@fontsource/tajawal@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@fontsource/tajawal/-/tajawal-5.1.0.tgz#9508e6da41fa6eae59e025c98c791603bd73ad0a" + integrity sha512-CYYxryDbFK4r4ev4xis+SyklUtnGy5O8nlJoDES/zEUdEz/qc7eYn1nVlQnUqWYLPzN5DgyTqHOx/5gWwHS7BA== + +"@fontsource/vazirmatn@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@fontsource/vazirmatn/-/vazirmatn-5.1.0.tgz#bf758d2fcff91a15dd6db8e01d043e5d4707d8dc" + integrity sha512-qoHGF9VDKRX7m92QzznU+Et4cF02HOafuaBmu1igAqYOzJdHVNAsRsWUs86revIIpZcMO0ens4pz3Xzq8KZnNw== + +"@formatjs/cli@^6.3.11": + version "6.3.11" + resolved "https://registry.yarnpkg.com/@formatjs/cli/-/cli-6.3.11.tgz#b14af6121a9afbe7c841e410b6057758952fc82c" + integrity sha512-TonnLTxrSLoD/ZMNz+XrswN8sTwGBxvq0ff7Tmh7Wx3Mw7U0h1p+bXfevHfHp/5ANra8tfHUd9c3InYOOIp4XQ== + +"@formatjs/ecma402-abstract@1.18.2": + version "1.18.2" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.2.tgz#bf103712a406874eb1e387858d5be2371ab3aa14" + integrity sha512-+QoPW4csYALsQIl8GbN14igZzDbuwzcpWrku9nyMXlaqAlwRBgl5V+p0vWMGFqHOw37czNXaP/lEk4wbLgcmtA== + dependencies: + "@formatjs/intl-localematcher" "0.5.4" + tslib "^2.4.0" + +"@formatjs/ecma402-abstract@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.2.3.tgz#dc5a032e1971c709b32b9ab511fa35504a7d3bc9" + integrity sha512-aElGmleuReGnk2wtYOzYFmNWYoiWWmf1pPPCYg0oiIQSJj0mjc4eUfzUXaSOJ4S8WzI/cLqnCTWjqz904FT2OQ== + dependencies: + "@formatjs/fast-memoize" "2.2.3" + "@formatjs/intl-localematcher" "0.5.7" + tslib "2" + +"@formatjs/ecma402-abstract@2.2.4": + version "2.2.4" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.2.4.tgz#355e42d375678229d46dc8ad7a7139520dd03e7b" + integrity sha512-lFyiQDVvSbQOpU+WFd//ILolGj4UgA/qXrKeZxdV14uKiAUiPAtX6XAn7WBCRi7Mx6I7EybM9E5yYn4BIpZWYg== + dependencies: + "@formatjs/fast-memoize" "2.2.3" + "@formatjs/intl-localematcher" "0.5.8" + tslib "2" + +"@formatjs/fast-memoize@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz#33bd616d2e486c3e8ef4e68c99648c196887802b" + integrity sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA== + dependencies: + tslib "^2.4.0" + +"@formatjs/fast-memoize@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.2.3.tgz#74e64109279d5244f9fc281f3ae90c407cece823" + integrity sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA== + dependencies: + tslib "2" + +"@formatjs/icu-messageformat-parser@2.7.6": + version "2.7.6" + resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.6.tgz#3d69806de056d2919d53dad895a5ff4851e4e9ff" + integrity sha512-etVau26po9+eewJKYoiBKP6743I1br0/Ie00Pb/S/PtmYfmjTcOn2YCh2yNkSZI12h6Rg+BOgQYborXk46BvkA== + dependencies: + "@formatjs/ecma402-abstract" "1.18.2" + "@formatjs/icu-skeleton-parser" "1.8.0" + tslib "^2.4.0" + +"@formatjs/icu-messageformat-parser@2.9.3": + version "2.9.3" + resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.9.3.tgz#7785cb48ba980ebcbe67a0a3fe12837032b95518" + integrity sha512-9L99QsH14XjOCIp4TmbT8wxuffJxGK8uLNO1zNhLtcZaVXvv626N0s4A2qgRCKG3dfYWx9psvGlFmvyVBa6u/w== + dependencies: + "@formatjs/ecma402-abstract" "2.2.3" + "@formatjs/icu-skeleton-parser" "1.8.7" + tslib "2" + +"@formatjs/icu-messageformat-parser@2.9.4": + version "2.9.4" + resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.9.4.tgz#52501fbdc122a86097644f03ae1117b9ced00872" + integrity sha512-Tbvp5a9IWuxUcpWNIW6GlMQYEc4rwNHR259uUFoKWNN1jM9obf9Ul0e+7r7MvFOBNcN+13K7NuKCKqQiAn1QEg== + dependencies: + "@formatjs/ecma402-abstract" "2.2.4" + "@formatjs/icu-skeleton-parser" "1.8.8" + tslib "2" + +"@formatjs/icu-skeleton-parser@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.0.tgz#5f3d3a620c687d6f8c180d80d1241e8f213acf79" + integrity sha512-QWLAYvM0n8hv7Nq5BEs4LKIjevpVpbGLAJgOaYzg9wABEoX1j0JO1q2/jVkO6CVlq0dbsxZCngS5aXbysYueqA== + dependencies: + "@formatjs/ecma402-abstract" "1.18.2" + tslib "^2.4.0" + +"@formatjs/icu-skeleton-parser@1.8.7": + version "1.8.7" + resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.7.tgz#c0c21d75428bf7213ac23a0efbf4dbfa868b800d" + integrity sha512-fI+6SmS2g7h3srfAKSWa5dwreU5zNEfon2uFo99OToiLF6yxGE+WikvFSbsvMAYkscucvVmTYNlWlaDPp0n5HA== + dependencies: + "@formatjs/ecma402-abstract" "2.2.3" + tslib "2" + +"@formatjs/icu-skeleton-parser@1.8.8": + version "1.8.8" + resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.8.tgz#a16eff7fd040acf096fb1853c99527181d38cf90" + integrity sha512-vHwK3piXwamFcx5YQdCdJxUQ1WdTl6ANclt5xba5zLGDv5Bsur7qz8AD7BevaKxITwpgDeU0u8My3AIibW9ywA== + dependencies: + "@formatjs/ecma402-abstract" "2.2.4" + tslib "2" + +"@formatjs/intl-localematcher@0.5.4": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz#caa71f2e40d93e37d58be35cfffe57865f2b366f" + integrity sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g== + dependencies: + tslib "^2.4.0" + +"@formatjs/intl-localematcher@0.5.7": + version "0.5.7" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.7.tgz#f889d076881b785d11ff993b966f527d199436d0" + integrity sha512-GGFtfHGQVFe/niOZp24Kal5b2i36eE2bNL0xi9Sg/yd0TR8aLjcteApZdHmismP5QQax1cMnZM9yWySUUjJteA== + dependencies: + tslib "2" + +"@formatjs/intl-localematcher@0.5.8": + version "0.5.8" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.8.tgz#b11bbd04bd3551f7cadcb1ef1e231822d0e3c97e" + integrity sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg== + dependencies: + tslib "2" + +"@formatjs/intl@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-3.0.1.tgz#a2daa3d6268fbd1c42d1f059659e5e796b28f069" + integrity sha512-QzdeMxOnSuGJhF0eWOIXHDtXZgIBwAqBZ4/bgZmPMC+FmYI8X2Akmu/j/ABKKO85GnxPV6KN8hJ8zytLnTJuYQ== + dependencies: + "@formatjs/fast-memoize" "2.2.3" + "@formatjs/icu-messageformat-parser" "2.9.4" + intl-messageformat "10.7.7" + tslib "2" + +"@formatjs/ts-transformer@3.13.22": + version "3.13.22" + resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-3.13.22.tgz#accab986f60039e5fa0e0a8088e5cb9c599c4e8b" + integrity sha512-+Zfz0wZ6wkdQE2zePiIQWIf4dVWeJGFXjkZxoCwzqxXdDrRhyAsQm91kbdFRIcVrjILA6KV0gOz8X7OBbLP4fQ== + dependencies: + "@formatjs/icu-messageformat-parser" "2.9.3" + "@types/json-stable-stringify" "1" + "@types/node" "14 || 16 || 17 || 18 || 20 || 22" + chalk "4" + json-stable-stringify "1" + tslib "2" + typescript "5" + +"@gitbeaker/core@^35.8.0": + version "35.8.0" + resolved "https://registry.yarnpkg.com/@gitbeaker/core/-/core-35.8.0.tgz#8e55950dd6c45e6b48791432a1fa2c13b9460d39" + integrity sha512-l/LgTmPFeUBnqyxU/VbFmqKsanCITBBMp7A0yXVbiTQCvNWSV6JJyUL3ILR3q825RRU/AzRm40FFli0AgBpXTw== + dependencies: + "@gitbeaker/requester-utils" "^35.8.0" + form-data "^4.0.0" + li "^1.3.0" + mime "^3.0.0" + query-string "^7.0.0" + xcase "^2.0.1" + +"@gitbeaker/node@^35.8.0": + version "35.8.0" + resolved "https://registry.yarnpkg.com/@gitbeaker/node/-/node-35.8.0.tgz#cd6d175ffa119ed323251d6e88c7441a18930b07" + integrity sha512-n8xbGemNs3aZb7gaYsEya0FKxemjyAJ4UyaF2MWM6mrj5rCnL3Y9Siko2rT/AuSJwjx82Z7BdKxV9QH/ihqjOQ== + dependencies: + "@gitbeaker/core" "^35.8.0" + "@gitbeaker/requester-utils" "^35.8.0" + delay "^5.0.0" + got "^11.8.3" + xcase "^2.0.1" + +"@gitbeaker/requester-utils@^35.8.0": + version "35.8.0" + resolved "https://registry.yarnpkg.com/@gitbeaker/requester-utils/-/requester-utils-35.8.0.tgz#e4894e2c67e2ae00e5aa5c869a0d87ec190b63d9" + integrity sha512-d/cseQQUvj1V02jXo6HBpuMarf6e6GdrxEaiWrjAiS2nDEQFRGxDGtPHzqgU84aN11nEBFnFa0vaSMqcZG/+9w== + dependencies: + form-data "^4.0.0" + qs "^6.10.1" + xcase "^2.0.1" + +"@humanwhocodes/config-array@^0.11.11": + version "0.11.11" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844" + integrity sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" + debug "^4.1.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== + +"@icons/material@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" + integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw== + +"@jedmao/redux-mock-store@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@jedmao/redux-mock-store/-/redux-mock-store-3.0.5.tgz#015fa4fc96bfc02b61ca221d9ea0476b78c70c97" + integrity sha512-zNcVCd5/ekSMdQWk64CqTPM24D9Lo59st9KvS+fljGpQXV4SliB7Vo0NFQIgvQJWPYeeobdngnrGy0XbCaARNw== + +"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" + integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/source-map@^0.3.3": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.5.tgz#a3bb4d5c6825aab0d281268f47f6ad5853431e91" + integrity sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.9": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@lexical/clipboard@0.18.0", "@lexical/clipboard@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/clipboard/-/clipboard-0.18.0.tgz#584ac4188461d1048717d854cb75ed14813b38f3" + integrity sha512-ybc+hx14wj0n2ZjdOkLcZ02MRB3UprXjpLDXlByFIuVcZpUxVcp3NzA0UBPOKXYKvdt0bmgjnAsFWM5OSbwS0w== + dependencies: + "@lexical/html" "0.18.0" + "@lexical/list" "0.18.0" + "@lexical/selection" "0.18.0" + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/code@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/code/-/code-0.18.0.tgz#990156c059ee78ad4359ae17df03a0840a0f0d27" + integrity sha512-VB8fRHIrB8QTqyZUvGBMVWP2tpKe3ArOjPdWAqgrS8MVFldqUhuTHcW+XJFkVxcEBYCXynNT29YRYtQhfQ+vDQ== + dependencies: + "@lexical/utils" "0.18.0" + lexical "0.18.0" + prismjs "^1.27.0" + +"@lexical/devtools-core@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/devtools-core/-/devtools-core-0.18.0.tgz#436fb1a7489de63114f7c8ee639aa40b5becca3a" + integrity sha512-gVgtEkLwGjz1frOmDpFJzDPFxPgAcC9n5ZaaZWHo5GLcptnQmkuLm1t+UInQWujXhFmcyJzfiqDaMJ8EIcb2Ww== + dependencies: + "@lexical/html" "0.18.0" + "@lexical/link" "0.18.0" + "@lexical/mark" "0.18.0" + "@lexical/table" "0.18.0" + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/dragon@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/dragon/-/dragon-0.18.0.tgz#bec3d6742eff4ac9e70bc448f438cab30bd61081" + integrity sha512-toD/y2/TgtG+eFVKXf65kDk/Mv02FwgmcGH18nyAabZnO1TLBaMYPkGFdTTZ8hVmQxqIu9nZuLWUbdIBMs8UWw== + dependencies: + lexical "0.18.0" + +"@lexical/hashtag@0.18.0", "@lexical/hashtag@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/hashtag/-/hashtag-0.18.0.tgz#40e14a44f7d333be08db32814a30bbb0251bf441" + integrity sha512-bm+Sv7keguVYbUY0ngd+iAv2Owd3dePzdVkzkmw9Al8GPXkE5ll8fjq6Xjw2u3OVhf+9pTnesIo/AS7H+h0exw== + dependencies: + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/history@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/history/-/history-0.18.0.tgz#b0eadd62d4f61907e28093a3eef32823deb910ea" + integrity sha512-c87J4ke1Sae03coElJay2Ikac/4OcA2OmhtNbt2gAi/XBtcsP4mPuz1yZfZf9XIe+weekObgjinvZekQ2AFw0g== + dependencies: + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/html@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/html/-/html-0.18.0.tgz#a4019a4692da23e6f810c21a7202bae5a45428a4" + integrity sha512-8lhba1DFnnobXgYm4Rk5Gr2tZedD4Gl6A/NKCt7whO/CET63vT3UnK2ggcVVgtIJG530Cv0bdZoJbJu5DauI5w== + dependencies: + "@lexical/selection" "0.18.0" + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/link@0.18.0", "@lexical/link@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/link/-/link-0.18.0.tgz#e0b36321e420ae385578bfb2a682b8407b34f2db" + integrity sha512-GCYcbNTSTwJk0lr+GMc8nn6Meq44BZs3QL2d1B0skpZAspd8yI53sRS6HDy5P+jW5P0dzyZr/XJAU4U+7zsEEg== + dependencies: + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/list@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.18.0.tgz#4aece48efa8fde89c25cbd3e27184e73ed130e40" + integrity sha512-DEWs9Scbg3+STZeE2O0OoG8SWnKnxQccObBzyeHRjn4GAN6JA7lgcAzfrdgp0fNWTbMM/ku876MmXKGnqhvg9Q== + dependencies: + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/mark@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/mark/-/mark-0.18.0.tgz#9aa4c7bbaf261141332a12e642942095f76230b4" + integrity sha512-QA4YWfTP5WWnCnoH/RmfcsSZyhhd7oeFWDpfP7S8Bbmhz6kiPwGcsVr+uRQBBT56AqEX167xX2rX8JR6FiYZqA== + dependencies: + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/markdown@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/markdown/-/markdown-0.18.0.tgz#612c2324fbf6294ef956d8a840a4eae677ef8476" + integrity sha512-uSWwcK8eJw5C+waEhU5WoX8W+JxNZbKuFnZwsn5nsp+iQgqMj4qY6g0yJub4sq8vvh6jjl4vVXhXTq2up9aykw== + dependencies: + "@lexical/code" "0.18.0" + "@lexical/link" "0.18.0" + "@lexical/list" "0.18.0" + "@lexical/rich-text" "0.18.0" + "@lexical/text" "0.18.0" + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/offset@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/offset/-/offset-0.18.0.tgz#1d12df8dcdeb0f43447d3109f72fb188e7662a65" + integrity sha512-KGlboyLSxQAH5PMOlJmyvHlbYXZneVnKiHpfyBV5IUX5kuyB/eZbQEYcJP9saekfQ5Xb1FWXWmsZEo+sWtrrZA== + dependencies: + lexical "0.18.0" + +"@lexical/overflow@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/overflow/-/overflow-0.18.0.tgz#18ede63a4f5041778bc21902b08d188620ef6c0a" + integrity sha512-3ATTwttVgZtVLq60ZUWbpbXBbpuMa3PZD5CxSP3nulviL+2I4phvacV4WUN+8wMeq+PGmuarl+cYfrFL02ii3g== + dependencies: + lexical "0.18.0" + +"@lexical/plain-text@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/plain-text/-/plain-text-0.18.0.tgz#f77860bc086c0ab64b691c27a064ec1f38c9edb7" + integrity sha512-L6yQpiwW0ZacY1oNwvRBxSuW2TZaUcveZLheJc8JzGcZoVxzII/CAbLZG8691VbNuKsbOURiNXZIsgwujKmo4Q== + dependencies: + "@lexical/clipboard" "0.18.0" + "@lexical/selection" "0.18.0" + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/react@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/react/-/react-0.18.0.tgz#3d857dacda06969ff7e189383a53ec3767b14fb1" + integrity sha512-DLvIbTsjvFIFqm+9zvAjEwuZHAbSxzZf1AGqf1lLctlL/Ran0f+8EZOv5jttELTe7xISZ2+xSXTLRfyxhNwGXQ== + dependencies: + "@lexical/clipboard" "0.18.0" + "@lexical/code" "0.18.0" + "@lexical/devtools-core" "0.18.0" + "@lexical/dragon" "0.18.0" + "@lexical/hashtag" "0.18.0" + "@lexical/history" "0.18.0" + "@lexical/link" "0.18.0" + "@lexical/list" "0.18.0" + "@lexical/mark" "0.18.0" + "@lexical/markdown" "0.18.0" + "@lexical/overflow" "0.18.0" + "@lexical/plain-text" "0.18.0" + "@lexical/rich-text" "0.18.0" + "@lexical/selection" "0.18.0" + "@lexical/table" "0.18.0" + "@lexical/text" "0.18.0" + "@lexical/utils" "0.18.0" + "@lexical/yjs" "0.18.0" + lexical "0.18.0" + react-error-boundary "^3.1.4" + +"@lexical/rich-text@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/rich-text/-/rich-text-0.18.0.tgz#09a3baaeb497131f50ef1d3e1d808c312a2c48c2" + integrity sha512-xMANCB7WueMsmWK8qxik5FZN4ApyaHWHQILS9r4FTbdv/DlNepsR7Pt8kg2317xZ56NAueQLIdyyKYXG1nBrHw== + dependencies: + "@lexical/clipboard" "0.18.0" + "@lexical/selection" "0.18.0" + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/selection@0.18.0", "@lexical/selection@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.18.0.tgz#5c9660c52b39b5338eb2e847f81b9d43dcb50c4a" + integrity sha512-mJoMhmxeZLfM9K2JMYETs9u179IkHQUlgtYG5GZJHjKx2iUn+9KvJ9RVssq+Lusi7C/N42wWPGNHDPdUvFtxXg== + dependencies: + lexical "0.18.0" + +"@lexical/table@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.18.0.tgz#ece206da2352b54f81e53defa0ac4f96ea30c70a" + integrity sha512-TeTAnuFAAgVjm1QE8adRB3GFWN+DUUiS4vzGq+ynPRCtNdpmW27NmTkRMyxKsetUtt7nIFfj4DvLvor4RwqIpA== + dependencies: + "@lexical/clipboard" "0.18.0" + "@lexical/utils" "0.18.0" + lexical "0.18.0" + +"@lexical/text@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.18.0.tgz#909f4ca08f06f323ff77e76514fcd432f47bb29f" + integrity sha512-MTHSBeq3K0+lqSsP5oysBMnY4tPVhB8kAa2xBnEc3dYgXFxEEvJwZahbHNX93EPObtJkxXfUuI63Al4G3lYK8A== + dependencies: + lexical "0.18.0" + +"@lexical/utils@0.18.0", "@lexical/utils@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.18.0.tgz#7e99feb99ef5ce88f2f16d522cc4501bfbf9165d" + integrity sha512-4s9dVpBZjqIaA/1q2GtfWFjKsv2Wqhjer0Zw2mcl1TIVN0zreXxcTKN316QppAWmSQJxVGvkWHjjaZJwl6/TSw== + dependencies: + "@lexical/list" "0.18.0" + "@lexical/selection" "0.18.0" + "@lexical/table" "0.18.0" + lexical "0.18.0" + +"@lexical/yjs@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@lexical/yjs/-/yjs-0.18.0.tgz#ca35614425b8dc7d162438c0c2b65b6e7788aba4" + integrity sha512-rl7Rl9XIb3ygQEEHOFtACdXs3BE+UUUmdyNqB6kK9A6IRGz+w4Azp+qzt8It/t+c0oaSYHpAtcLNXg1amJz+kA== + dependencies: + "@lexical/offset" "0.18.0" + "@lexical/selection" "0.18.0" + lexical "0.18.0" + +"@mdn/browser-compat-data@^5.2.34", "@mdn/browser-compat-data@^5.3.13": + version "5.3.16" + resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.3.16.tgz#c3b6585c256461fe5e2eac85182b11b36ea2678b" + integrity sha512-b0kKg2weqKDLI+Ai5+tocgUEIidccdSfzUndbS2YnwIp5aVvd3M0D+DCcbrsSOSgMyrV9QKMqogtqMIjKwvDxw== + +"@mkljczk/react-hotkeys@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@mkljczk/react-hotkeys/-/react-hotkeys-1.2.2.tgz#9e27d1f54f6fcc96657643d07534cf71987eba09" + integrity sha512-BGGdSdPtpu+XbqvfO3gxzUNHzMJN0ztmQoIuqEEPBsogH6oAzlGdFnfy5VDxExJ+8LufmLHHr3yZ7eiqYqGvog== + dependencies: + lodash "^4.17.21" + mousetrap "^1.6.5" + +"@noble/ciphers@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.5.1.tgz#292f388b69c9ed80d49dca1a5cbfd4ff06852111" + integrity sha512-aNE06lbe36ifvMbbWvmmF/8jx6EQPu2HVg70V95T+iGjOuYwPpAccwAQc2HlXO2D0aiQ3zavbMga4jjWnrpiPA== + +"@noble/curves@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.2.0.tgz#92d7e12e4e49b23105a2555c6984d41733d65c35" + integrity sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw== + dependencies: + "@noble/hashes" "1.3.2" + +"@noble/curves@~1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d" + integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA== + dependencies: + "@noble/hashes" "1.3.1" + +"@noble/curves@~1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.0.tgz#f05771ef64da724997f69ee1261b2417a49522d6" + integrity sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg== + dependencies: + "@noble/hashes" "1.4.0" + +"@noble/hashes@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" + integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== + +"@noble/hashes@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" + integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== + +"@noble/hashes@1.4.0", "@noble/hashes@^1.4.0", "@noble/hashes@~1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" + integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== + +"@noble/hashes@~1.3.0", "@noble/hashes@~1.3.1": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" + integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@nostrify/nostrify@npm:@jsr/nostrify__nostrify": + version "0.17.1" + resolved "https://npm.jsr.io/~/10/@jsr/nostrify__nostrify/0.17.1.tgz#130487F4CF4715F1036A687E7EA819D3ADEC9B6F" + integrity sha512-+mUwudIJFSsnVD3+7PdFG+s27tOf9F1OROyRv7+cWoPWK9UnWlGFAQ50GIeDGYv9nd+gp8N7nu31r/cmqHmORw== + dependencies: + "@noble/hashes" "^1.4.0" + "@scure/base" "^1.1.6" + "@scure/bip32" "^1.4.0" + "@scure/bip39" "^1.3.0" + kysely "^0.27.3" + lru-cache "^10.2.0" + nostr-tools "^2.5.0" + websocket-ts "^2.1.5" + zod "^3.23.4" + +"@parcel/watcher-android-arm64@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz#c2c19a3c442313ff007d2d7a9c2c1dd3e1c9ca84" + integrity sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg== + +"@parcel/watcher-darwin-arm64@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz#c817c7a3b4f3a79c1535bfe54a1c2818d9ffdc34" + integrity sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA== + +"@parcel/watcher-darwin-x64@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz#1a3f69d9323eae4f1c61a5f480a59c478d2cb020" + integrity sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg== + +"@parcel/watcher-freebsd-x64@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz#0d67fef1609f90ba6a8a662bc76a55fc93706fc8" + integrity sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w== + +"@parcel/watcher-linux-arm-glibc@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz#ce5b340da5829b8e546bd00f752ae5292e1c702d" + integrity sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA== + +"@parcel/watcher-linux-arm64-glibc@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz#6d7c00dde6d40608f9554e73998db11b2b1ff7c7" + integrity sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA== + +"@parcel/watcher-linux-arm64-musl@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz#bd39bc71015f08a4a31a47cd89c236b9d6a7f635" + integrity sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA== + +"@parcel/watcher-linux-x64-glibc@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz#0ce29966b082fb6cdd3de44f2f74057eef2c9e39" + integrity sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg== + +"@parcel/watcher-linux-x64-musl@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz#d2ebbf60e407170bb647cd6e447f4f2bab19ad16" + integrity sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ== + +"@parcel/watcher-win32-arm64@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz#eb4deef37e80f0b5e2f215dd6d7a6d40a85f8adc" + integrity sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg== + +"@parcel/watcher-win32-ia32@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz#94fbd4b497be39fd5c8c71ba05436927842c9df7" + integrity sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw== + +"@parcel/watcher-win32-x64@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz#4bf920912f67cae5f2d264f58df81abfea68dadf" + integrity sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A== + +"@parcel/watcher@^2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.4.1.tgz#a50275151a1bb110879c6123589dba90c19f1bf8" + integrity sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA== + dependencies: + detect-libc "^1.0.3" + is-glob "^4.0.3" + micromatch "^4.0.5" + node-addon-api "^7.0.0" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.4.1" + "@parcel/watcher-darwin-arm64" "2.4.1" + "@parcel/watcher-darwin-x64" "2.4.1" + "@parcel/watcher-freebsd-x64" "2.4.1" + "@parcel/watcher-linux-arm-glibc" "2.4.1" + "@parcel/watcher-linux-arm64-glibc" "2.4.1" + "@parcel/watcher-linux-arm64-musl" "2.4.1" + "@parcel/watcher-linux-x64-glibc" "2.4.1" + "@parcel/watcher-linux-x64-musl" "2.4.1" + "@parcel/watcher-win32-arm64" "2.4.1" + "@parcel/watcher-win32-ia32" "2.4.1" + "@parcel/watcher-win32-x64" "2.4.1" + +"@reach/auto-id@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.18.0.tgz#4b97085cd1cf1360a9bedc6e9c78e97824014f0d" + integrity sha512-XwY1IwhM7mkHZFghhjiqjQ6dstbOdpbFLdggeke75u8/8icT8uEHLbovFUgzKjy9qPvYwZIB87rLiR8WdtOXCg== + dependencies: + "@reach/utils" "0.18.0" + +"@reach/combobox@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/combobox/-/combobox-0.18.0.tgz#8b3879b7c2dc426cddf0941b041d1ddc6d9adee6" + integrity sha512-x60PiPOIB4azeyh+FZ/svh0kXZRCneGCXVLL6htWs1VmaKq+TWR/48V03yQX5cSKjvRM8UFDVn47mpcg5ZSFtg== + dependencies: + "@reach/auto-id" "0.18.0" + "@reach/descendants" "0.18.0" + "@reach/polymorphic" "0.18.0" + "@reach/popover" "0.18.0" + "@reach/portal" "0.18.0" + "@reach/utils" "0.18.0" + +"@reach/descendants@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/descendants/-/descendants-0.18.0.tgz#16fe52a5154da262994b0b8768baff4f670922d1" + integrity sha512-GXUxnM6CfrX5URdnipPIl3Tlc6geuz4xb4n61y4tVWXQX1278Ra9Jz9DMRN8x4wheHAysvrYwnR/SzAlxQzwtA== + dependencies: + "@reach/utils" "0.18.0" + +"@reach/dropdown@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/dropdown/-/dropdown-0.18.0.tgz#c2e2e99df682f2136558851b80dc05b4f9dd92a5" + integrity sha512-LriXdVgxJoUhIQfS2r2DHYv3X6fHyplYxa9FmSwQIMXdESpE/P9Zsb1pVEObcNf3ZQBrl0L1bl/5rk7SpK7qfA== + dependencies: + "@reach/auto-id" "0.18.0" + "@reach/descendants" "0.18.0" + "@reach/polymorphic" "0.18.0" + "@reach/popover" "0.18.0" + "@reach/utils" "0.18.0" + +"@reach/menu-button@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/menu-button/-/menu-button-0.18.0.tgz#ae40dc86e47e7f925599ca720e3ba65263cc56f3" + integrity sha512-v1lj5rYSpavOKI4ipXj8OfvQmvVNAYXCv+UcltRkjOcWEKWADUUKkGX55wiUhsCsTGCJ7lGYz5LqOZrn3LP6PQ== + dependencies: + "@reach/dropdown" "0.18.0" + "@reach/polymorphic" "0.18.0" + "@reach/popover" "0.18.0" + "@reach/utils" "0.18.0" + +"@reach/observe-rect@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2" + integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ== + +"@reach/polymorphic@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/polymorphic/-/polymorphic-0.18.0.tgz#2fe42007a774e06cdbc8e13e0d46f2dc30f2f1ed" + integrity sha512-N9iAjdMbE//6rryZZxAPLRorzDcGBnluf7YQij6XDLiMtfCj1noa7KyLpEc/5XCIB/EwhX3zCluFAwloBKdblA== + +"@reach/popover@0.18.0", "@reach/popover@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.18.0.tgz#1eba3e9ed826ac69dfdf3b01a1dab15ca889b5fc" + integrity sha512-mpnWWn4w74L2U7fcneVdA6Fz3yKWNdZIRMoK8s6H7F8U2dLM/qN7AjzjEBqi6LXKb3Uf1ge4KHSbMixW0BygJQ== + dependencies: + "@reach/polymorphic" "0.18.0" + "@reach/portal" "0.18.0" + "@reach/rect" "0.18.0" + "@reach/utils" "0.18.0" + tabbable "^5.3.3" + +"@reach/portal@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.18.0.tgz#dd466f5110689d14a0e7491b3aa8a449e8cefb40" + integrity sha512-TImozRapd576ofRk30Le2L3lRTFXF1p47B182wnp5eMTdZa74JX138BtNGEPJFOyrMaVmguVF8SSwZ6a0fon1Q== + dependencies: + "@reach/utils" "0.18.0" + +"@reach/rect@0.18.0", "@reach/rect@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.18.0.tgz#d1dc45adc92f80cc54b64498e19de909ced40722" + integrity sha512-Xk8urN4NLn3F70da/DtByMow83qO6DF6vOxpLjuDBqud+kjKgxAU9vZMBSZJyH37+F8mZinRnHyXtlLn5njQOg== + dependencies: + "@reach/observe-rect" "1.2.0" + "@reach/utils" "0.18.0" + +"@reach/tabs@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/tabs/-/tabs-0.18.0.tgz#f2e789d445d61a371eace9415841502729d099c9" + integrity sha512-gTRJzStWJJtgMhn9FDEmKogAJMcqNaGZx0i1SGoTdVM+D29DBhVeRdO8qEg+I2l2k32DkmuZxG/Mrh+GZTjczQ== + dependencies: + "@reach/auto-id" "0.18.0" + "@reach/descendants" "0.18.0" + "@reach/polymorphic" "0.18.0" + "@reach/utils" "0.18.0" + +"@reach/utils@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.18.0.tgz#4f3cebe093dd436eeaff633809bf0f68f4f9d2ee" + integrity sha512-KdVMdpTgDyK8FzdKO9SCpiibuy/kbv3pwgfXshTI6tEcQT1OOwj7BAksnzGC0rPz0UholwC+AgkqEl3EJX3M1A== + +"@reduxjs/toolkit@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.0.1.tgz#0a5233c1e35c1941b03aece39cceade3467a1062" + integrity sha512-fxIjrR9934cmS8YXIGd9e7s1XRsEU++aFc9DVNMFMRTM5Vtsg2DCRMj21eslGtDt43IUf9bJL3h5bwUlZleibA== + dependencies: + immer "^10.0.3" + redux "^5.0.0" + redux-thunk "^3.1.0" + reselect "^5.0.1" + +"@remix-run/router@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.2.1.tgz#812edd4104a15a493dda1ccac0b352270d7a188c" + integrity sha512-XiY0IsyHR+DXYS5vBxpoBe/8veTeoRpMHP+vDosLZxL5bnpetzI0igkxkLZS235ldLzyfkxF+2divEwWHP3vMQ== + +"@rollup/plugin-babel@^5.2.0": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" + integrity sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q== + dependencies: + "@babel/helper-module-imports" "^7.10.4" + "@rollup/pluginutils" "^3.1.0" + +"@rollup/plugin-node-resolve@^15.2.3": + version "15.3.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz#efbb35515c9672e541c08d59caba2eff492a55d5" + integrity sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag== + dependencies: + "@rollup/pluginutils" "^5.0.1" + "@types/resolve" "1.20.2" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.22.1" + +"@rollup/plugin-replace@^2.4.1": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz#a2d539314fbc77c244858faa523012825068510a" + integrity sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + magic-string "^0.25.7" + +"@rollup/plugin-terser@^0.4.3": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz#15dffdb3f73f121aa4fbb37e7ca6be9aeea91962" + integrity sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A== + dependencies: + serialize-javascript "^6.0.1" + smob "^1.0.0" + terser "^5.17.4" + +"@rollup/pluginutils@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" + integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== + dependencies: + "@types/estree" "0.0.39" + estree-walker "^1.0.1" + picomatch "^2.2.2" + +"@rollup/pluginutils@^4.2.0": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" + integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ== + dependencies: + estree-walker "^2.0.1" + picomatch "^2.2.2" + +"@rollup/pluginutils@^5.0.1": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.2.tgz#d3bc9f0fea4fd4086aaac6aa102f3fa587ce8bd9" + integrity sha512-/FIdS3PyZ39bjZlwqFnWqCOVnW7o963LtKMwQOD0NhQqw22gSr2YY1afu3FxRip4ZCZNsD5jq6Aaz6QV3D/Njw== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^2.3.1" + +"@rollup/rollup-android-arm-eabi@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.0.tgz#462e7ecdd60968bc9eb95a20d185e74f8243ec1b" + integrity sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ== + +"@rollup/rollup-android-arm64@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.0.tgz#78a2b8a8a55f71a295eb860a654ae90a2b168f40" + integrity sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA== + +"@rollup/rollup-darwin-arm64@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.0.tgz#5b783af714f434f1e66e3cdfa3817e0b99216d84" + integrity sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q== + +"@rollup/rollup-darwin-x64@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.0.tgz#f72484e842521a5261978034e18e20f778a2850d" + integrity sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w== + +"@rollup/rollup-freebsd-arm64@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.0.tgz#3c919dff72b2fe344811a609c674a8347b033f62" + integrity sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ== + +"@rollup/rollup-freebsd-x64@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.0.tgz#b62a3a8365b363b3fdfa6da11a9188b6ab4dca7c" + integrity sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA== + +"@rollup/rollup-linux-arm-gnueabihf@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.0.tgz#0d02cc55bd229bd8ca5c54f65f916ba5e0591c94" + integrity sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w== + +"@rollup/rollup-linux-arm-musleabihf@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.0.tgz#c51d379263201e88a60e92bd8e90878f0c044425" + integrity sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg== + +"@rollup/rollup-linux-arm64-gnu@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.0.tgz#93ce2addc337b5cfa52b84f8e730d2e36eb4339b" + integrity sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg== + +"@rollup/rollup-linux-arm64-musl@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.0.tgz#730af6ddc091a5ba5baac28a3510691725dc808b" + integrity sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw== + +"@rollup/rollup-linux-powerpc64le-gnu@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.0.tgz#b5565aac20b4de60ca1e557f525e76478b5436af" + integrity sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ== + +"@rollup/rollup-linux-riscv64-gnu@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.0.tgz#d488290bf9338bad4ae9409c4aa8a1728835a20b" + integrity sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g== + +"@rollup/rollup-linux-s390x-gnu@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.0.tgz#eb2e3f3a06acf448115045c11a5a96868c95a556" + integrity sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw== + +"@rollup/rollup-linux-x64-gnu@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.0.tgz#065952ef2aea7e837dc7e02aa500feeaff4fc507" + integrity sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw== + +"@rollup/rollup-linux-x64-musl@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.0.tgz#3435d484d05f5c4d1ffd54541b4facce2887103a" + integrity sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw== + +"@rollup/rollup-win32-arm64-msvc@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.0.tgz#69682a2a10d9fedc334f87583cfca83c39c08077" + integrity sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg== + +"@rollup/rollup-win32-ia32-msvc@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.0.tgz#b64470f9ac79abb386829c56750b9a4711be3332" + integrity sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A== + +"@rollup/rollup-win32-x64-msvc@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.0.tgz#cb313feef9ac6e3737067fdf34f42804ac65a6f2" + integrity sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ== + +"@scure/base@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" + integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== + +"@scure/base@^1.1.6", "@scure/base@~1.1.0", "@scure/base@~1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.6.tgz#8ce5d304b436e4c84f896e0550c83e4d88cb917d" + integrity sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g== + +"@scure/bip32@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.1.tgz#7248aea723667f98160f593d621c47e208ccbb10" + integrity sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A== + dependencies: + "@noble/curves" "~1.1.0" + "@noble/hashes" "~1.3.1" + "@scure/base" "~1.1.0" + +"@scure/bip32@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.4.0.tgz#4e1f1e196abedcef395b33b9674a042524e20d67" + integrity sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg== + dependencies: + "@noble/curves" "~1.4.0" + "@noble/hashes" "~1.4.0" + "@scure/base" "~1.1.6" + +"@scure/bip39@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.1.tgz#5cee8978656b272a917b7871c981e0541ad6ac2a" + integrity sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg== + dependencies: + "@noble/hashes" "~1.3.0" + "@scure/base" "~1.1.0" + +"@scure/bip39@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.3.0.tgz#0f258c16823ddd00739461ac31398b4e7d6a18c3" + integrity sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ== + dependencies: + "@noble/hashes" "~1.4.0" + "@scure/base" "~1.1.6" + +"@sentry-internal/browser-utils@8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.34.0.tgz#36a50d503ad4ad51fce22e80670f8fd6fd195a27" + integrity sha512-4AcYOzPzD1tL5eSRQ/GpKv5enquZf4dMVUez99/Bh3va8qiJrNP55AcM7UzZ7WZLTqKygIYruJTU5Zu2SpEAPQ== + dependencies: + "@sentry/core" "8.34.0" + "@sentry/types" "8.34.0" + "@sentry/utils" "8.34.0" + +"@sentry-internal/feedback@8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.34.0.tgz#ff0db65c36f13665db99e3e22f2032bfdda98731" + integrity sha512-aYSM2KPUs0FLPxxbJCFSwCYG70VMzlT04xepD1Y/tTlPPOja/02tSv2tyOdZbv8Uw7xslZs3/8Lhj74oYcTBxw== + dependencies: + "@sentry/core" "8.34.0" + "@sentry/types" "8.34.0" + "@sentry/utils" "8.34.0" + +"@sentry-internal/replay-canvas@8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.34.0.tgz#10acadaef74e982dee2b9842a3eb6fec73f032ed" + integrity sha512-x8KhZcCDpbKHqFOykYXiamX6x0LRxv6N1OJHoH+XCrMtiDBZr4Yo30d/MaS6rjmKGMtSRij30v+Uq+YWIgxUrg== + dependencies: + "@sentry-internal/replay" "8.34.0" + "@sentry/core" "8.34.0" + "@sentry/types" "8.34.0" + "@sentry/utils" "8.34.0" + +"@sentry-internal/replay@8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.34.0.tgz#b730919a174cc5ae8a77f79fb24a5ffb18e44db5" + integrity sha512-EoMh9NYljNewZK1quY23YILgtNdGgrkzJ9TPsj6jXUG0LZ0Q7N7eFWd0xOEDBvFxrmI3cSXF1i4d1sBb+eyKRw== + dependencies: + "@sentry-internal/browser-utils" "8.34.0" + "@sentry/core" "8.34.0" + "@sentry/types" "8.34.0" + "@sentry/utils" "8.34.0" + +"@sentry/browser@8.34.0", "@sentry/browser@^8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.34.0.tgz#d2dfc2dbbfa9132d5c3e951f0a4b467805bc4c75" + integrity sha512-3HHG2NXxzHq1lVmDy2uRjYjGNf9NsJsTPlOC70vbQdOb+S49EdH/XMPy+J3ruIoyv6Cu0LwvA6bMOM6rHZOgNQ== + dependencies: + "@sentry-internal/browser-utils" "8.34.0" + "@sentry-internal/feedback" "8.34.0" + "@sentry-internal/replay" "8.34.0" + "@sentry-internal/replay-canvas" "8.34.0" + "@sentry/core" "8.34.0" + "@sentry/types" "8.34.0" + "@sentry/utils" "8.34.0" + +"@sentry/core@8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.34.0.tgz#92efe1cc8ced843beee636c344e66086d8915563" + integrity sha512-adrXCTK/zsg5pJ67lgtZqdqHvyx6etMjQW3P82NgWdj83c8fb+zH+K79Z47pD4zQjX0ou2Ws5nwwi4wJbz4bfA== + dependencies: + "@sentry/types" "8.34.0" + "@sentry/utils" "8.34.0" + +"@sentry/react@^8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry/react/-/react-8.34.0.tgz#f131d3b7168469617722474a3465a16cdcd77cb4" + integrity sha512-gIgzhj7h67C+Sdq2ul4fOSK142Gf0uV99bqHRdtIiUlXw9yjzZQY5TKTtzbOaevn7qBJ0xrRKtIRUbOBMl0clw== + dependencies: + "@sentry/browser" "8.34.0" + "@sentry/core" "8.34.0" + "@sentry/types" "8.34.0" + "@sentry/utils" "8.34.0" + hoist-non-react-statics "^3.3.2" + +"@sentry/types@8.34.0", "@sentry/types@^8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.34.0.tgz#b02da72d1be67df5246aa9a97ca661ee71569372" + integrity sha512-zLRc60CzohGCo6zNsNeQ9JF3SiEeRE4aDCP9fDDdIVCOKovS+mn1rtSip0qd0Vp2fidOu0+2yY0ALCz1A3PJSQ== + +"@sentry/utils@8.34.0": + version "8.34.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-8.34.0.tgz#5ba543381a9de0ada1196df1fc5cde3b891de41e" + integrity sha512-W1KoRlFUjprlh3t86DZPFxLfM6mzjRzshVfMY7vRlJFymBelJsnJ3A1lPeBZM9nCraOSiw6GtOWu6k5BAkiGIg== + dependencies: + "@sentry/types" "8.34.0" + +"@sindresorhus/is@^4.0.0": + version "4.6.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" + integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== + +"@soapbox/weblock@npm:@jsr/soapbox__weblock": + version "0.1.0" + resolved "https://npm.jsr.io/~/7/@jsr/soapbox__weblock/0.1.0.tgz#749AEE0872D23CC4E37366D5F0D092B87986C5E1" + integrity sha512-FLLJL6xYk+k7f2bMXJ1nbcn3lhbEZXA0yboKLm8wns0hrcoEDOrWwmxkYF7xpVRndiAzFBctBGVbIAa3sA72ew== + +"@std/semver@npm:@jsr/std__semver": + version "1.0.3" + resolved "https://npm.jsr.io/~/11/@jsr/std__semver/1.0.3.tgz#3CF8010B0635D85DCA83BA9795934F0DDC33E4B7" + integrity sha512-d1uBT0Muxhd3yBIw9ZE1Q/4N1Y0td0EJe1AqwM3hP05IMwaWQV/miksQOPR3rup3bVovuIvqBm7WJcoUripdQA== + +"@surma/rollup-plugin-off-main-thread@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053" + integrity sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ== + dependencies: + ejs "^3.1.6" + json5 "^2.2.0" + magic-string "^0.25.0" + string.prototype.matchall "^4.0.6" + +"@swc/core-darwin-arm64@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.9.1.tgz#0fd83e5febe1044c7b12f128089cb8b213e14d0b" + integrity sha512-2/ncHSCdAh5OHem1fMITrWEzzl97OdMK1PHc9CkxSJnphLjRubfxB5sbc5tDhcO68a5tVy+DxwaBgDec3PXnOg== + +"@swc/core-darwin-x64@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.9.1.tgz#da28fcd37207655d2ad34dcb0d0819f20decb57a" + integrity sha512-4MDOFC5zmNqRJ9RGFOH95oYf27J9HniLVpB1pYm2gGeNHdl2QvDMtx2QTuMHQ6+OTn/3y1BHYuhBGp7d405oLA== + +"@swc/core-linux-arm-gnueabihf@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.9.1.tgz#dde1a15d1b88a6be000bbcecebe301227eb76c57" + integrity sha512-eVW/BjRW8/HpLe3+1jRU7w7PdRLBgnEEYTkHJISU8805/EKT03xNZn6CfaBpKfeAloY4043hbGzE/NP9IahdpQ== + +"@swc/core-linux-arm64-gnu@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.9.1.tgz#8b6d15b56597ba5e097932d3a305e88c3d749cec" + integrity sha512-8m3u1v8R8NgI/9+cHMkzk14w87blSy3OsQPWPfhOL+XPwhyLPvat+ahQJb2nZmltjTgkB4IbzKFSfbuA34LmNA== + +"@swc/core-linux-arm64-musl@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.9.1.tgz#dd822efe61b2bbbd378e6ca8d80b4ba992c34ad8" + integrity sha512-hpT0sQAZnW8l02I289yeyFfT9llGO9PzKDxUq8pocKtioEHiElRqR53juCWoSmzuWi+6KX7zUJ0NKCBrc8pmDg== + +"@swc/core-linux-x64-gnu@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.9.1.tgz#d02c63e96d4137c988e71189ccf1c40deb43b4cc" + integrity sha512-sGFdpdAYusk/ropHiwtXom2JrdaKPxl8MqemRv6dvxZq1Gm/GdmOowxdXIPjCgBGMgoXVcgNviH6CgiO5q+UtA== + +"@swc/core-linux-x64-musl@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.9.1.tgz#a8736ddb8e573aa59ccceb50813badff806b299b" + integrity sha512-YtNLNwIWs0Z2+XgBs6+LrCIGtfCDtNr4S4b6Q5HDOreEIGzSvhkef8eyBI5L+fJ2eGov4b7iEo61C4izDJS5RA== + +"@swc/core-win32-arm64-msvc@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.9.1.tgz#91c75fe95cd7bb7f7ae85c6b3bd405af63dc5984" + integrity sha512-qSxD3uZW2vSiHqUt30vUi0PB92zDh9bjqh5YKpfhhVa7h1vt/xXhlid8yMvSNToTfzhRrTEffOAPUr7WVoyQUA== + +"@swc/core-win32-ia32-msvc@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.9.1.tgz#e2ea0be7ca34b642b3adbb6c1fad34fb7874514b" + integrity sha512-C3fPEwyX/WRPlX6zIToNykJuz1JkZX0sk8H1QH2vpnKuySUkt/Ur5K2FzLgSWzJdbfxstpgS151/es0VGAD+ZA== + +"@swc/core-win32-x64-msvc@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.9.1.tgz#c9e532a791fdf44e3c9635135b1026f140d06483" + integrity sha512-2XZ+U1AyVsOAXeH6WK1syDm7+gwTjA8fShs93WcbxnK7HV+NigDlvr4124CeJLTHyh3fMh1o7+CnQnaBJhlysQ== + +"@swc/core@^1.7.26": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.9.1.tgz#1a1b8378e4b64b74e7f932014ca800ea6133ac27" + integrity sha512-OnPc+Kt5oy3xTvr/KCUOqE9ptJcWbyQgAUr1ydh9EmbBcmJTaO1kfQCxm/axzJi6sKeDTxL9rX5zvLOhoYIaQw== + dependencies: + "@swc/counter" "^0.1.3" + "@swc/types" "^0.1.14" + optionalDependencies: + "@swc/core-darwin-arm64" "1.9.1" + "@swc/core-darwin-x64" "1.9.1" + "@swc/core-linux-arm-gnueabihf" "1.9.1" + "@swc/core-linux-arm64-gnu" "1.9.1" + "@swc/core-linux-arm64-musl" "1.9.1" + "@swc/core-linux-x64-gnu" "1.9.1" + "@swc/core-linux-x64-musl" "1.9.1" + "@swc/core-win32-arm64-msvc" "1.9.1" + "@swc/core-win32-ia32-msvc" "1.9.1" + "@swc/core-win32-x64-msvc" "1.9.1" + +"@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + +"@swc/types@^0.1.14": + version "0.1.14" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.14.tgz#0a0a3f60f801c5d7d52ab02fd5f924d9c6dbcb0d" + integrity sha512-PbSmTiYCN+GMrvfjrMo9bdY+f2COnwbdnoMw7rqU/PI5jXpKjxOGZ0qqZCImxnT81NkNsKnmEpvu+hRXLBeCJg== + dependencies: + "@swc/counter" "^0.1.3" + +"@szmarczak/http-timer@^4.0.5": + version "4.0.6" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807" + integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w== + dependencies: + defer-to-connect "^2.0.0" + +"@tabler/icons@^3.19.0": + version "3.19.0" + resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.19.0.tgz#5998b0557ef34572e003f2d75ac95e7c04f88c81" + integrity sha512-A4WEWqpdbTfnpFEtwXqwAe9qf9sp1yRPvzppqAuwcoF0q5YInqB+JkJtSFToCyBpPVeLxJUxxkapLvt2qQgnag== + +"@tailwindcss/aspect-ratio@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz#9ffd52fee8e3c8b20623ff0dcb29e5c21fb0a9ba" + integrity sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ== + +"@tailwindcss/forms@^0.5.9": + version "0.5.9" + resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.9.tgz#b495c12575d6eae5865b2cbd9876b26d89f16f61" + integrity sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg== + dependencies: + mini-svg-data-uri "^1.2.3" + +"@tailwindcss/typography@^0.5.15": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.15.tgz#007ab9870c86082a1c76e5b3feda9392c7c8d648" + integrity sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA== + dependencies: + lodash.castarray "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.merge "^4.6.2" + postcss-selector-parser "6.0.10" + +"@tanstack/query-core@5.59.13": + version "5.59.13" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.59.13.tgz#8c962980af174bbd446b7e9b9999f7432897df80" + integrity sha512-Oou0bBu/P8+oYjXsJQ11j+gcpLAMpqW42UlokQYEz4dE7+hOtVO9rVuolJKgEccqzvyFzqX4/zZWY+R/v1wVsQ== + +"@tanstack/react-query@^5.59.13": + version "5.59.13" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.59.13.tgz#decac9c15ddc29a54dc3520a41a1dcedb1a9596a" + integrity sha512-GB2ELtiH8tL0rcFiM4sWvnXhazt1xRXX/LolMEV12kfEKu58aNA4lQoieslP61PO4vZO9JJMwm+6lqyS0E1HOA== + dependencies: + "@tanstack/query-core" "5.59.13" + +"@testing-library/dom@^9.0.0": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.0.1.tgz#fb9e3837fe2a662965df1536988f0863f01dbf51" + integrity sha512-fTOVsMY9QLFCCXRHG3Ese6cMH5qIWwSbgxZsgeF5TNsy81HKaZ4kgehnSF8FsR3OF+numlIV2YcU79MzbnhSig== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "^5.0.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@^6.1.3": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.1.3.tgz#443118c9e4043f96396f120de2c7122504a079c5" + integrity sha512-YzpjRHoCBWPzpPNtg6gnhasqtE/5O4qz8WCwDEaxtfnPO6gkaLrnuXusrGSPyhIGPezr1HM7ZH0CFaUTY9PJEQ== + dependencies: + "@adobe/css-tools" "^4.3.0" + "@babel/runtime" "^7.9.2" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + +"@testing-library/react@^14.0.0": + version "14.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.0.0.tgz#59030392a6792450b9ab8e67aea5f3cc18d6347c" + integrity sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^9.0.0" + "@types/react-dom" "^18.0.0" + +"@testing-library/user-event@^14.5.1": + version "14.5.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.1.tgz#27337d72046d5236b32fd977edee3f74c71d332f" + integrity sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg== + +"@trysound/sax@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" + integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== + +"@types/aria-query@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc" + integrity sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q== + +"@types/cacheable-request@^6.0.1": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz#a430b3260466ca7b5ca5bfd735693b36e7a9d183" + integrity sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw== + dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "^3.1.4" + "@types/node" "*" + "@types/responselike" "^1.0.0" + +"@types/dompurify@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7" + integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg== + dependencies: + "@types/trusted-types" "*" + +"@types/escape-html@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-1.0.1.tgz#b19b4646915f0ae2c306bf984dc0a59c5cfc97ba" + integrity sha512-4mI1FuUUZiuT95fSVqvZxp/ssQK9zsa86S43h9x3zPOSU9BBJ+BfDkXwuaU7BfsD+e7U0/cUUfJFk3iW2M4okA== + +"@types/eslint@9": + version "9.6.1" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" + integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*", "@types/estree@1.0.6", "@types/estree@^1.0.0": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== + +"@types/estree@0.0.39": + version "0.0.39" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" + integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== + +"@types/geojson@*": + version "7946.0.10" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" + integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA== + +"@types/history@^4.7.11": + version "4.7.11" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" + integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA== + +"@types/hoist-non-react-statics@3": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" + integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + +"@types/http-cache-semantics@*": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812" + integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ== + +"@types/http-link-header@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types/http-link-header/-/http-link-header-1.0.3.tgz#899adf1d8d2036074514f3dbd148fb901ceff920" + integrity sha512-y8HkoD/vyid+5MrJ3aas0FvU3/BVBGcyG9kgxL0Zn4JwstA8CglFPnrR0RuzOjRCXwqzL5uxWC2IO7Ub0rMU2A== + dependencies: + "@types/node" "*" + +"@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.9": + version "7.0.13" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85" + integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ== + +"@types/json-stable-stringify@1": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz#41393e6b7a9a67221607346af4a79783aeb28aea" + integrity sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg== + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= + +"@types/keyv@^3.1.4": + version "3.1.4" + resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" + integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg== + dependencies: + "@types/node" "*" + +"@types/leaflet@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.8.0.tgz#dc92d3e868fb6d5067b4b59fa08cd4441f84fabe" + integrity sha512-+sXFmiJTFdhaXXIGFlV5re9AdqtAODoXbGAvxx02e5SHXL3ir7ClP5J7pahO8VmzKY3dth4RUS1nf2BTT+DW1A== + dependencies: + "@types/geojson" "*" + +"@types/lodash@^4.14.180": + version "4.14.180" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.180.tgz#4ab7c9ddfc92ec4a887886483bc14c79fb380670" + integrity sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g== + +"@types/node@*", "@types/node@14 || 16 || 17 || 18 || 20 || 22": + version "22.9.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.9.0.tgz#b7f16e5c3384788542c72dc3d561a7ceae2c0365" + integrity sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ== + dependencies: + undici-types "~6.19.8" + +"@types/object-assign@^4.0.30": + version "4.0.30" + resolved "https://registry.yarnpkg.com/@types/object-assign/-/object-assign-4.0.30.tgz#8949371d5a99f4381ee0f1df0a9b7a187e07e652" + integrity sha1-iUk3HVqZ9Dge4PHfCpt6GH4H5lI= + +"@types/parse-json@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" + integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== + +"@types/path-browserify@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/path-browserify/-/path-browserify-1.0.0.tgz#294ec6e88b6b0d340a3897b7120e5b393f16690e" + integrity sha512-XMCcyhSvxcch8b7rZAtFAaierBYdeHXVvg2iYnxOV0MCQHmPuRRmGZPFDRzPayxcGiiSL1Te9UIO+f3cuj0tfw== + +"@types/picomatch@3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/picomatch/-/picomatch-3.0.1.tgz#d2bb932bb24c2f2f97f77c31c3c37ddd60c9a4a5" + integrity sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw== + +"@types/prop-types@*": + version "15.7.4" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" + integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + +"@types/react-color@^3.0.6": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.6.tgz#602fed023802b2424e7cd6ff3594ccd3d5055f9a" + integrity sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w== + dependencies: + "@types/react" "*" + "@types/reactcss" "*" + +"@types/react-dom@^18.0.0", "@types/react-dom@^18.3.0": + version "18.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" + integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== + dependencies: + "@types/react" "*" + +"@types/react-helmet@^6.1.5": + version "6.1.5" + resolved "https://registry.yarnpkg.com/@types/react-helmet/-/react-helmet-6.1.5.tgz#35f89a6b1646ee2bc342a33a9a6c8777933f9083" + integrity sha512-/ICuy7OHZxR0YCAZLNg9r7I9aijWUWvxaPR6uTuyxe8tAj5RL4Sw1+R6NhXUtOsarkGYPmaHdBDvuXh2DIN/uA== + dependencies: + "@types/react" "*" + +"@types/react-motion@^0.0.40": + version "0.0.40" + resolved "https://registry.yarnpkg.com/@types/react-motion/-/react-motion-0.0.40.tgz#e743961e999688d9aa31d6e6b71b65cbb53b3104" + integrity sha512-Bp6i9WTvW6QFN2E/XQPm8HPGMx1SVJ7H1DPsvptwWWh1XBymDZ7N7SK4nSZT/4tP4bTGvp1KHSAsswWZKO/WHA== + dependencies: + "@types/react" "*" + +"@types/react-router-dom@^5.3.3": + version "5.3.3" + resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83" + integrity sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw== + dependencies: + "@types/history" "^4.7.11" + "@types/react" "*" + "@types/react-router" "*" + +"@types/react-router@*": + version "5.1.20" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.20.tgz#88eccaa122a82405ef3efbcaaa5dcdd9f021387c" + integrity sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q== + dependencies: + "@types/history" "^4.7.11" + "@types/react" "*" + +"@types/react-sparklines@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@types/react-sparklines/-/react-sparklines-1.7.2.tgz#c14e80623abd3669a10f18d13f6fb9fbdc322f70" + integrity sha512-N1GwO7Ri5C5fE8+CxhiDntuSw1qYdGytBuedKrCxWpaojXm4WnfygbdBdc5sXGX7feMxDXBy9MNhxoUTwrMl4A== + dependencies: + "@types/react" "*" + +"@types/react-swipeable-views@^0.13.1": + version "0.13.1" + resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c" + integrity sha512-Nuvywkv9CkwcUgItOCBszkc/pc8YSdiKV5E1AzOJ/p32Db50LgwhJFi5b1ANPgyWxB0Q5yn69aMURHyGi3MLyg== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@^18.3.9": + version "18.3.9" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.9.tgz#2cdf5f425ec8a133d67e9e3673909738b783db20" + integrity sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + +"@types/reactcss@*": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.6.tgz#133c1e7e896f2726370d1d5a26bf06a30a038bcc" + integrity sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg== + dependencies: + "@types/react" "*" + +"@types/redux-mock-store@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz#0a03b2655028b7cf62670d41ac1de5ca1b1f5958" + integrity sha512-eg5RDfhJTXuoJjOMyXiJbaDb1B8tfTaJixscmu+jOusj6adGC0Krntz09Tf4gJgXeCqCrM5bBMd+B7ez0izcAQ== + dependencies: + redux "^4.0.5" + +"@types/resolve@1.20.2": + version "1.20.2" + resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" + integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== + +"@types/responselike@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" + integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA== + dependencies: + "@types/node" "*" + +"@types/semver@^7.3.9", "@types/semver@^7.5.0": + version "7.5.2" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.2.tgz#31f6eec1ed7ec23f4f05608d3a2d381df041f564" + integrity sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw== + +"@types/trusted-types@*", "@types/trusted-types@^2.0.2": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + +"@typescript-eslint/eslint-plugin@^7.0.0": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz#407daffe09d964d57aceaf3ac51846359fbe61b0" + integrity sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ== + dependencies: + "@eslint-community/regexpp" "^4.5.1" + "@typescript-eslint/scope-manager" "7.0.1" + "@typescript-eslint/type-utils" "7.0.1" + "@typescript-eslint/utils" "7.0.1" + "@typescript-eslint/visitor-keys" "7.0.1" + debug "^4.3.4" + graphemer "^1.4.0" + ignore "^5.2.4" + natural-compare "^1.4.0" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/parser@^7.0.0": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.0.1.tgz#e9c61d9a5e32242477d92756d36086dc40322eed" + integrity sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ== + dependencies: + "@typescript-eslint/scope-manager" "7.0.1" + "@typescript-eslint/types" "7.0.1" + "@typescript-eslint/typescript-estree" "7.0.1" + "@typescript-eslint/visitor-keys" "7.0.1" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz#611ec8e78c70439b152a805e1b10aaac36de7c00" + integrity sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w== + dependencies: + "@typescript-eslint/types" "7.0.1" + "@typescript-eslint/visitor-keys" "7.0.1" + +"@typescript-eslint/scope-manager@8.13.0": + version "8.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.13.0.tgz#2f4aed0b87d72360e64e4ea194b1fde14a59082e" + integrity sha512-XsGWww0odcUT0gJoBZ1DeulY1+jkaHUciUq4jKNv4cpInbvvrtDoyBH9rE/n2V29wQJPk8iCH1wipra9BhmiMA== + dependencies: + "@typescript-eslint/types" "8.13.0" + "@typescript-eslint/visitor-keys" "8.13.0" + +"@typescript-eslint/type-utils@7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz#0fba92c1f81cad561d7b3adc812aa1cc0e35cdae" + integrity sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw== + dependencies: + "@typescript-eslint/typescript-estree" "7.0.1" + "@typescript-eslint/utils" "7.0.1" + debug "^4.3.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/types@7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.0.1.tgz#dcfabce192db5b8bf77ea3c82cfaabe6e6a3c901" + integrity sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg== + +"@typescript-eslint/types@8.13.0": + version "8.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.13.0.tgz#3f35dead2b2491a04339370dcbcd17bbdfc204d8" + integrity sha512-4cyFErJetFLckcThRUFdReWJjVsPCqyBlJTi6IDEpc1GWCIIZRFxVppjWLIMcQhNGhdWJJRYFHpHoDWvMlDzng== + +"@typescript-eslint/typescript-estree@7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz#1d52ac03da541693fa5bcdc13ad655def5046faf" + integrity sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig== + dependencies: + "@typescript-eslint/types" "7.0.1" + "@typescript-eslint/visitor-keys" "7.0.1" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "9.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/typescript-estree@8.13.0": + version "8.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.13.0.tgz#db8c93dd5437ca3ce417a255fb35ddc3c12c3e95" + integrity sha512-v7SCIGmVsRK2Cy/LTLGN22uea6SaUIlpBcO/gnMGT/7zPtxp90bphcGf4fyrCQl3ZtiBKqVTG32hb668oIYy1g== + dependencies: + "@typescript-eslint/types" "8.13.0" + "@typescript-eslint/visitor-keys" "8.13.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/utils@7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.0.1.tgz#b8ceac0ba5fef362b4a03a33c0e1fedeea3734ed" + integrity sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@types/json-schema" "^7.0.12" + "@types/semver" "^7.5.0" + "@typescript-eslint/scope-manager" "7.0.1" + "@typescript-eslint/types" "7.0.1" + "@typescript-eslint/typescript-estree" "7.0.1" + semver "^7.5.4" + +"@typescript-eslint/utils@8.13.0": + version "8.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.13.0.tgz#f6d40e8b5053dcaeabbd2e26463857abf27d62c0" + integrity sha512-A1EeYOND6Uv250nybnLZapeXpYMl8tkzYUxqmoKAWnI4sei3ihf2XdZVd+vVOmHGcp3t+P7yRrNsyyiXTvShFQ== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "8.13.0" + "@typescript-eslint/types" "8.13.0" + "@typescript-eslint/typescript-estree" "8.13.0" + +"@typescript-eslint/visitor-keys@7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz#864680ac5a8010ec4814f8a818e57595f79f464e" + integrity sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw== + dependencies: + "@typescript-eslint/types" "7.0.1" + eslint-visitor-keys "^3.4.1" + +"@typescript-eslint/visitor-keys@8.13.0": + version "8.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.13.0.tgz#e97b0d92b266ef38a1faf40a74da289b66683a5b" + integrity sha512-7N/+lztJqH4Mrf0lb10R/CbI1EaAMMGyF5y0oJvFoAhafwgiRA7TXyd8TFn8FC8k5y2dTsYogg238qavRGNnlw== + dependencies: + "@typescript-eslint/types" "8.13.0" + eslint-visitor-keys "^3.4.3" + +"@vitejs/plugin-react-swc@^3.7.2": + version "3.7.2" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.2.tgz#b0958dd44c48dbd37b5ef887bdb8b8d1276f24cd" + integrity sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew== + dependencies: + "@swc/core" "^1.7.26" + +"@vitest/expect@2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.1.8.tgz#13fad0e8d5a0bf0feb675dcf1d1f1a36a1773bc1" + integrity sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw== + dependencies: + "@vitest/spy" "2.1.8" + "@vitest/utils" "2.1.8" + chai "^5.1.2" + tinyrainbow "^1.2.0" + +"@vitest/mocker@2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.8.tgz#51dec42ac244e949d20009249e033e274e323f73" + integrity sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA== + dependencies: + "@vitest/spy" "2.1.8" + estree-walker "^3.0.3" + magic-string "^0.30.12" + +"@vitest/pretty-format@2.1.8", "@vitest/pretty-format@^2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.8.tgz#88f47726e5d0cf4ba873d50c135b02e4395e2bca" + integrity sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ== + dependencies: + tinyrainbow "^1.2.0" + +"@vitest/runner@2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.8.tgz#b0e2dd29ca49c25e9323ea2a45a5125d8729759f" + integrity sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg== + dependencies: + "@vitest/utils" "2.1.8" + pathe "^1.1.2" + +"@vitest/snapshot@2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.8.tgz#d5dc204f4b95dc8b5e468b455dfc99000047d2de" + integrity sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg== + dependencies: + "@vitest/pretty-format" "2.1.8" + magic-string "^0.30.12" + pathe "^1.1.2" + +"@vitest/spy@2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.8.tgz#bc41af3e1e6a41ae3b67e51f09724136b88fa447" + integrity sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg== + dependencies: + tinyspy "^3.0.2" + +"@vitest/utils@2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.8.tgz#f8ef85525f3362ebd37fd25d268745108d6ae388" + integrity sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA== + dependencies: + "@vitest/pretty-format" "2.1.8" + loupe "^3.1.2" + tinyrainbow "^1.2.0" + +"@webbtc/webln-types@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@webbtc/webln-types/-/webln-types-3.0.0.tgz#448b2138423865087ba8859e9e6430fc2463b864" + integrity sha512-aXfTHLKz5lysd+6xTeWl+qHNh/p3qVYbeLo+yDN5cUDmhie2ZoGvkppfWxzbGkcFBzb6dJyQ2/i2cbmDHas+zQ== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.8.2, acorn@^8.9.0: + version "8.10.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" + integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== + +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434" + integrity sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg== + dependencies: + debug "^4.3.4" + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + +ajv-keywords@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" + integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== + dependencies: + fast-deep-equal "^3.1.3" + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.0, ajv@^8.0.1, ajv@^8.6.0, ajv@^8.8.0: + version "8.12.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" + integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +ansi-colors@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-escapes@^4.3.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== + +anymatch@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +are-docs-informative@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/are-docs-informative/-/are-docs-informative-0.0.2.tgz#387f0e93f5d45280373d387a59d34c96db321963" + integrity sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig== + +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +aria-query@^5.0.0, aria-query@^5.1.3: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + +array-buffer-byte-length@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz#fabe8bc193fea865f317fe7807085ee0dee5aead" + integrity sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A== + dependencies: + call-bind "^1.0.2" + is-array-buffer "^3.0.1" + +array-includes@^3.1.6: + version "3.1.7" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.7.tgz#8cd2e01b26f7a3086cbc87271593fe921c62abda" + integrity sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + get-intrinsic "^1.2.1" + is-string "^1.0.7" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array.prototype.findlastindex@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz#b37598438f97b579166940814e2c0493a4f50207" + integrity sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + get-intrinsic "^1.2.1" + +array.prototype.flat@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz#1476217df8cff17d72ee8f3ba06738db5b387d18" + integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.flatmap@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz#c9a7c6831db8e719d6ce639190146c24bbd3e527" + integrity sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.tosorted@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.2.tgz#620eff7442503d66c799d95503f82b475745cefd" + integrity sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + get-intrinsic "^1.2.1" + +arraybuffer.prototype.slice@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz#98bd561953e3e74bb34938e77647179dfe6e9f12" + integrity sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw== + dependencies: + array-buffer-byte-length "^1.0.0" + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + get-intrinsic "^1.2.1" + is-array-buffer "^3.0.2" + is-shared-array-buffer "^1.0.2" + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +ast-metadata-inferer@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/ast-metadata-inferer/-/ast-metadata-inferer-0.8.0.tgz#0f94c3425e310d8da45823ab2161142e3f134343" + integrity sha512-jOMKcHht9LxYIEQu+RVd22vtgrPaVCtDRQ/16IGmurdzxvYbDd5ynxjnyrzLnieG96eTcAyaoj/wN/4/1FyyeA== + dependencies: + "@mdn/browser-compat-data" "^5.2.34" + +ast-types-flow@^0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" + integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= + +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + +async@^3.2.3: + version "3.2.4" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" + integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + +asynciterator.prototype@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz#8c5df0514936cdd133604dfcc9d3fb93f09b2b62" + integrity sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg== + dependencies: + has-symbols "^1.0.3" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +autoprefixer@^10.4.15: + version "10.4.15" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.15.tgz#a1230f4aeb3636b89120b34a1f513e2f6834d530" + integrity sha512-KCuPB8ZCIqFdA4HwKXsvz7j6gvSDNhDP7WnUjBleRkKjPdvCmHFuQ77ocavI8FT6NdvlBnE2UFr2H4Mycn8Vew== + dependencies: + browserslist "^4.21.10" + caniuse-lite "^1.0.30001520" + fraction.js "^4.2.0" + normalize-range "^0.1.2" + picocolors "^1.0.0" + postcss-value-parser "^4.2.0" + +available-typed-arrays@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" + integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== + +axe-core@^4.6.2: + version "4.8.1" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.1.tgz#6948854183ee7e7eae336b9877c5bafa027998ea" + integrity sha512-9l850jDDPnKq48nbad8SiEelCv4OrUWrKab/cPj0GScVg6cb6NbCCt/Ulk26QEq5jP9NnGr04Bit1BHyV6r5CQ== + +axobject-query@^3.1.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" + integrity sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg== + dependencies: + dequal "^2.0.3" + +babel-plugin-polyfill-corejs2@^0.4.5: + version "0.4.5" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.5.tgz#8097b4cb4af5b64a1d11332b6fb72ef5e64a054c" + integrity sha512-19hwUH5FKl49JEsvyTcoHakh6BE0wgXLLptIyKZ3PijHc/Ci521wygORCUCCred+E/twuqRyAkE02BAWPmsHOg== + dependencies: + "@babel/compat-data" "^7.22.6" + "@babel/helper-define-polyfill-provider" "^0.4.2" + semver "^6.3.1" + +babel-plugin-polyfill-corejs3@^0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz#b4f719d0ad9bb8e0c23e3e630c0c8ec6dd7a1c52" + integrity sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.4.2" + core-js-compat "^3.31.0" + +babel-plugin-polyfill-regenerator@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.2.tgz#80d0f3e1098c080c8b5a65f41e9427af692dc326" + integrity sha512-tAlOptU0Xj34V1Y2PNTL4Y0FOJMDB6bZmoW39FeCQIhigGLkqu3Fj6uiXpxIf6Ij274ENdYx64y6Au+ZKlb1IA== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.4.2" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +balanced-match@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" + integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== + +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + +blurhash@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.4.tgz#60642a823b50acaaf3732ddb6c7dfd721bdfef2a" + integrity sha512-r/As72u2FbucLoK5NTegM/GucxJc3d8GvHc4ngo13IO/nt2HU4gONxNLq1XPN6EM/V8Y9URIa7PcSz2RZu553A== + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + +bowser@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" + integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.0.0, browserslist@^4.16.6, browserslist@^4.21.10, browserslist@^4.21.4, browserslist@^4.23.1: + version "4.24.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.0.tgz#a1325fe4bc80b64fda169629fc01b3d6cecd38d4" + integrity sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A== + dependencies: + caniuse-lite "^1.0.30001663" + electron-to-chromium "^1.5.28" + node-releases "^2.0.18" + update-browserslist-db "^1.1.0" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +builtin-modules@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" + integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== + +bundle-require@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-5.0.0.tgz#071521bdea6534495cf23e92a83f889f91729e93" + integrity sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w== + dependencies: + load-tsconfig "^0.2.3" + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +cacheable-lookup@^5.0.3: + version "5.0.4" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" + integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== + +cacheable-request@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27" + integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^4.0.0" + lowercase-keys "^2.0.0" + normalize-url "^6.0.1" + responselike "^2.0.0" + +call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camel-case@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" + integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== + dependencies: + pascal-case "^3.1.2" + tslib "^2.0.3" + +camelcase-css@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== + +caniuse-api@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" + integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== + dependencies: + browserslist "^4.0.0" + caniuse-lite "^1.0.0" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001520, caniuse-lite@^1.0.30001524, caniuse-lite@^1.0.30001663: + version "1.0.30001663" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001663.tgz" + integrity sha512-o9C3X27GLKbLeTYZ6HBOLU1tsAcBZsLis28wrVzddShCS16RujjHp9GDHKZqrB3meE0YjhawvMFsGb/igqiPzA== + +chai@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.1.2.tgz#3afbc340b994ae3610ca519a6c70ace77ad4378d" + integrity sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +chalk@4, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + +chokidar@^3.5.1, chokidar@^3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chokidar@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.1.tgz#4a6dff66798fb0f72a94f616abbd7e1a19f31d41" + integrity sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA== + dependencies: + readdirp "^4.0.1" + +classnames@^2.2.5: + version "2.3.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" + integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== + +clean-css@^5.2.2: + version "5.3.0" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.0.tgz#ad3d8238d5f3549e83d5f87205189494bc7cbb59" + integrity sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ== + dependencies: + source-map "~0.6.0" + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-truncate@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" + integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== + dependencies: + slice-ansi "^3.0.0" + string-width "^4.2.0" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +clone-response@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" + integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== + dependencies: + mimic-response "^1.0.0" + +clsx@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" + integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colord@^2.9.1, colord@^2.9.3: + version "2.9.3" + resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" + integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw== + +colorette@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" + integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== + +colorette@^2.0.16: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +comlink@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.1.tgz#e568b8e86410b809e8600eb2cf40c189371ef981" + integrity sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q== + +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +commander@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + +commander@^8.0.0, commander@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + +comment-parser@1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" + integrity sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg== + +common-tags@^1.8.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" + integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +connect-history-api-fallback@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" + integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== + +consola@^2.15.3: + version "2.15.3" + resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" + integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +core-js-compat@^3.31.0: + version "3.32.2" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.32.2.tgz#8047d1a8b3ac4e639f0d4f66d4431aa3b16e004c" + integrity sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ== + dependencies: + browserslist "^4.21.10" + +cosmiconfig@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" + integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" + +cosmiconfig@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz#34c3fc58287b915f3ae905ab6dc3de258b55ad9d" + integrity sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg== + dependencies: + env-paths "^2.2.1" + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + +cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypto-random-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" + integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== + +css-declaration-sorter@^6.3.1: + version "6.4.1" + resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz#28beac7c20bad7f1775be3a7129d7eae409a3a71" + integrity sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g== + +css-functions-list@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/css-functions-list/-/css-functions-list-3.2.3.tgz#95652b0c24f0f59b291a9fc386041a19d4f40dbe" + integrity sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA== + +css-select@^4.2.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" + integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== + dependencies: + boolbase "^1.0.0" + css-what "^6.0.1" + domhandler "^4.3.1" + domutils "^2.8.0" + nth-check "^2.0.1" + +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-tree@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" + integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== + dependencies: + mdn-data "2.0.30" + source-map-js "^1.0.1" + +css-tree@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-3.0.0.tgz#079c7b87e465a28cedbc826502f9a227213db0f3" + integrity sha512-o88DVQ6GzsABn1+6+zo2ct801dBO5OASVyxbbvA2W20ue2puSh/VOuqUj90eUeMSX/xqGqBmOKiRQN7tJOuBXw== + dependencies: + mdn-data "2.10.0" + source-map-js "^1.0.1" + +css-tree@~2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.2.1.tgz#36115d382d60afd271e377f9c5f67d02bd48c032" + integrity sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA== + dependencies: + mdn-data "2.0.28" + source-map-js "^1.0.1" + +css-what@^6.0.1, css-what@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +cssnano-preset-default@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-6.0.1.tgz#2a93247140d214ddb9f46bc6a3562fa9177fe301" + integrity sha512-7VzyFZ5zEB1+l1nToKyrRkuaJIx0zi/1npjvZfbBwbtNTzhLtlvYraK/7/uqmX2Wb2aQtd983uuGw79jAjLSuQ== + dependencies: + css-declaration-sorter "^6.3.1" + cssnano-utils "^4.0.0" + postcss-calc "^9.0.0" + postcss-colormin "^6.0.0" + postcss-convert-values "^6.0.0" + postcss-discard-comments "^6.0.0" + postcss-discard-duplicates "^6.0.0" + postcss-discard-empty "^6.0.0" + postcss-discard-overridden "^6.0.0" + postcss-merge-longhand "^6.0.0" + postcss-merge-rules "^6.0.1" + postcss-minify-font-values "^6.0.0" + postcss-minify-gradients "^6.0.0" + postcss-minify-params "^6.0.0" + postcss-minify-selectors "^6.0.0" + postcss-normalize-charset "^6.0.0" + postcss-normalize-display-values "^6.0.0" + postcss-normalize-positions "^6.0.0" + postcss-normalize-repeat-style "^6.0.0" + postcss-normalize-string "^6.0.0" + postcss-normalize-timing-functions "^6.0.0" + postcss-normalize-unicode "^6.0.0" + postcss-normalize-url "^6.0.0" + postcss-normalize-whitespace "^6.0.0" + postcss-ordered-values "^6.0.0" + postcss-reduce-initial "^6.0.0" + postcss-reduce-transforms "^6.0.0" + postcss-svgo "^6.0.0" + postcss-unique-selectors "^6.0.0" + +cssnano-utils@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-utils/-/cssnano-utils-4.0.0.tgz#d1da885ec04003ab19505ff0e62e029708d36b08" + integrity sha512-Z39TLP+1E0KUcd7LGyF4qMfu8ZufI0rDzhdyAMsa/8UyNUU8wpS0fhdBxbQbv32r64ea00h4878gommRVg2BHw== + +cssnano@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-6.0.1.tgz#87c38c4cd47049c735ab756d7e77ac3ca855c008" + integrity sha512-fVO1JdJ0LSdIGJq68eIxOqFpIJrZqXUsBt8fkrBcztCQqAjQD51OhZp7tc0ImcbwXD4k7ny84QTV90nZhmqbkg== + dependencies: + cssnano-preset-default "^6.0.1" + lilconfig "^2.1.0" + +csso@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/csso/-/csso-5.0.5.tgz#f9b7fe6cc6ac0b7d90781bb16d5e9874303e2ca6" + integrity sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ== + dependencies: + css-tree "~2.2.0" + +cssstyle@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.0.1.tgz#ef29c598a1e90125c870525490ea4f354db0660a" + integrity sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ== + dependencies: + rrweb-cssom "^0.6.0" + +csstype@^3.0.2: + version "3.0.9" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b" + integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw== + +damerau-levenshtein@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" + integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== + +data-urls@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde" + integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg== + dependencies: + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.6, debug@^4.3.7: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + +decode-uri-component@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" + integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" + integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== + +defer-to-connect@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + +define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delay@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" + integrity sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw== + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +detect-it@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/detect-it/-/detect-it-4.0.1.tgz#3f8de6b8330f5086270571251bedf10aec049e18" + integrity sha512-dg5YBTJYvogK1+dA2mBUDKzOWfYZtHVba89SyZUhc4+e3i2tzgjANFg5lDRCd3UOtRcw00vUTMK8LELcMdicug== + +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + +detect-passive-events@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-passive-events/-/detect-passive-events-2.0.3.tgz#1f75ebf80660a66c615d8be23c3241cdda6977e0" + integrity sha512-QN/1X65Axis6a9D8qg8Py9cwY/fkWAmAH/edTbmLMcv4m5dboLJ7LcAi8CfaCON2tjk904KwKX/HTdsHC6yeRg== + dependencies: + detect-it "^4.0.1" + +devalue@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.1.1.tgz#a71887ac0f354652851752654e4bd435a53891ae" + integrity sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw== + +didyoumean@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" + integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +dlv@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" + integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + +dom-helpers@^3.2.1, dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + +dom-serializer@^1.0.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91" + integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.2.0" + entities "^2.0.0" + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@5.0.3, domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domhandler@^4.2.0, domhandler@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" + integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== + dependencies: + domelementtype "^2.2.0" + +dompurify@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.8.tgz#e0021ab1b09184bc8af7e35c7dd9063f43a8a437" + integrity sha512-b7uwreMYL2eZhrSCRC4ahLTeZcPZxSmYfmcQGXGkXiZSNW1X85v+SDM5KsWcpivIiUBH47Ji7NtyUdpLeF5JZQ== + +domutils@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + +domutils@^3.0.1, domutils@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" + integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +dot-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" + integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + +dotenv-expand@^8.0.2: + version "8.0.3" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-8.0.3.tgz#29016757455bcc748469c83a19b36aaf2b83dd6e" + integrity sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg== + +dotenv@^16.0.0: + version "16.0.3" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" + integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== + +ejs@^3.1.6: + version "3.1.9" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.9.tgz#03c9e8777fe12686a9effcef22303ca3d8eeb361" + integrity sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ== + dependencies: + jake "^10.8.5" + +electron-to-chromium@^1.5.28: + version "1.5.28" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.28.tgz#aee074e202c6ee8a0030a9c2ef0b3fe9f967d576" + integrity sha512-VufdJl+rzaKZoYVUijN13QcXVF5dWPZANeFTLNy+OSpHdDL5ynXTF35+60RSBbaQYB1ae723lQXHCrf4pyLsMw== + +emoji-mart@^5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023" + integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow== + +emoji-regex@10: + version "10.4.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4" + integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw== + +emoji-regex@10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.3.0.tgz#76998b9268409eb3dae3de989254d456e70cfe23" + integrity sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +enhanced-resolve@^5.12.0: + version "5.15.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz#1af946c7d93603eb88e9896cee4904dc012e9c35" + integrity sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +enquirer@^2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" + integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== + dependencies: + ansi-colors "^4.1.1" + +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + +entities@^4.2.0, entities@^4.4.0, entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +env-paths@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +es-abstract@^1.22.1: + version "1.22.2" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.2.tgz#90f7282d91d0ad577f505e423e52d4c1d93c1b8a" + integrity sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA== + dependencies: + array-buffer-byte-length "^1.0.0" + arraybuffer.prototype.slice "^1.0.2" + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-set-tostringtag "^2.0.1" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.1" + get-symbol-description "^1.0.0" + globalthis "^1.0.3" + gopd "^1.0.1" + has "^1.0.3" + has-property-descriptors "^1.0.0" + has-proto "^1.0.1" + has-symbols "^1.0.3" + internal-slot "^1.0.5" + is-array-buffer "^3.0.2" + is-callable "^1.2.7" + is-negative-zero "^2.0.2" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.2" + is-string "^1.0.7" + is-typed-array "^1.1.12" + is-weakref "^1.0.2" + object-inspect "^1.12.3" + object-keys "^1.1.1" + object.assign "^4.1.4" + regexp.prototype.flags "^1.5.1" + safe-array-concat "^1.0.1" + safe-regex-test "^1.0.0" + string.prototype.trim "^1.2.8" + string.prototype.trimend "^1.0.7" + string.prototype.trimstart "^1.0.7" + typed-array-buffer "^1.0.0" + typed-array-byte-length "^1.0.0" + typed-array-byte-offset "^1.0.0" + typed-array-length "^1.0.4" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.11" + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-iterator-helpers@^1.0.12: + version "1.0.15" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz#bd81d275ac766431d19305923707c3efd9f1ae40" + integrity sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g== + dependencies: + asynciterator.prototype "^1.0.0" + call-bind "^1.0.2" + define-properties "^1.2.1" + es-abstract "^1.22.1" + es-set-tostringtag "^2.0.1" + function-bind "^1.1.1" + get-intrinsic "^1.2.1" + globalthis "^1.0.3" + has-property-descriptors "^1.0.0" + has-proto "^1.0.1" + has-symbols "^1.0.3" + internal-slot "^1.0.5" + iterator.prototype "^1.1.2" + safe-array-concat "^1.0.1" + +es-module-lexer@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" + integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== + +es-set-tostringtag@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8" + integrity sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg== + dependencies: + get-intrinsic "^1.1.3" + has "^1.0.3" + has-tostringtag "^1.0.0" + +es-shim-unscopables@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" + integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== + dependencies: + has "^1.0.3" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es-toolkit@^1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.27.0.tgz#affc1aaf78d47e42d282c427c14bf8b610923f12" + integrity sha512-ETSFA+ZJArcuSCpzD2TjAy6UHpx4E4uqFsoDg9F/nTLogrLmVVZQ+zNxco5h7cWnA1nNak07IXsLcaSMih+ZPQ== + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +esbuild@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.0.tgz#f2d470596885fcb2e91c21eb3da3b3c89c0b55e7" + integrity sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ== + optionalDependencies: + "@esbuild/aix-ppc64" "0.24.0" + "@esbuild/android-arm" "0.24.0" + "@esbuild/android-arm64" "0.24.0" + "@esbuild/android-x64" "0.24.0" + "@esbuild/darwin-arm64" "0.24.0" + "@esbuild/darwin-x64" "0.24.0" + "@esbuild/freebsd-arm64" "0.24.0" + "@esbuild/freebsd-x64" "0.24.0" + "@esbuild/linux-arm" "0.24.0" + "@esbuild/linux-arm64" "0.24.0" + "@esbuild/linux-ia32" "0.24.0" + "@esbuild/linux-loong64" "0.24.0" + "@esbuild/linux-mips64el" "0.24.0" + "@esbuild/linux-ppc64" "0.24.0" + "@esbuild/linux-riscv64" "0.24.0" + "@esbuild/linux-s390x" "0.24.0" + "@esbuild/linux-x64" "0.24.0" + "@esbuild/netbsd-x64" "0.24.0" + "@esbuild/openbsd-arm64" "0.24.0" + "@esbuild/openbsd-x64" "0.24.0" + "@esbuild/sunos-x64" "0.24.0" + "@esbuild/win32-arm64" "0.24.0" + "@esbuild/win32-ia32" "0.24.0" + "@esbuild/win32-x64" "0.24.0" + +escalade@^3.1.1, escalade@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-import-resolver-node@^0.3.7: + version "0.3.9" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" + integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g== + dependencies: + debug "^3.2.7" + is-core-module "^2.13.0" + resolve "^1.22.4" + +eslint-import-resolver-typescript@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.0.tgz#36f93e1eb65a635e688e16cae4bead54552e3bbd" + integrity sha512-QTHR9ddNnn35RTxlaEnx2gCxqFlF2SEN0SE2d17SqwyM7YOSI2GHWRYp5BiRkObTUNYPupC/3Fq2a0PpT+EKpg== + dependencies: + debug "^4.3.4" + enhanced-resolve "^5.12.0" + eslint-module-utils "^2.7.4" + fast-glob "^3.3.1" + get-tsconfig "^4.5.0" + is-core-module "^2.11.0" + is-glob "^4.0.3" + +eslint-module-utils@^2.7.4, eslint-module-utils@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz#e439fee65fc33f6bba630ff621efc38ec0375c49" + integrity sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw== + dependencies: + debug "^3.2.7" + +eslint-plugin-compat@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-compat/-/eslint-plugin-compat-4.2.0.tgz#eeaf80daa1afe495c88a47e9281295acae45c0aa" + integrity sha512-RDKSYD0maWy5r7zb5cWQS+uSPc26mgOzdORJ8hxILmWM7S/Ncwky7BcAtXVY5iRbKjBdHsWU8Yg7hfoZjtkv7w== + dependencies: + "@mdn/browser-compat-data" "^5.3.13" + ast-metadata-inferer "^0.8.0" + browserslist "^4.21.10" + caniuse-lite "^1.0.30001524" + find-up "^5.0.0" + lodash.memoize "^4.1.2" + semver "^7.5.4" + +eslint-plugin-formatjs@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-formatjs/-/eslint-plugin-formatjs-5.2.2.tgz#51c1b88779aac98b894dd236c04a0ee5633b9300" + integrity sha512-EtLhaz2/6l86s/3cOo5SyNGYnsocStylO3KLIoMJI8Ch2LqejZsd06RR7wMpeHusIN9ogvTlJZV2M54mBlElKg== + dependencies: + "@formatjs/icu-messageformat-parser" "2.9.3" + "@formatjs/ts-transformer" "3.13.22" + "@types/eslint" "9" + "@types/picomatch" "3" + "@typescript-eslint/utils" "8.13.0" + emoji-regex "10" + magic-string "^0.30.0" + picomatch "2 || 3 || 4" + tslib "2" + unicode-emoji-utils "^1.2.0" + +eslint-plugin-import@^2.28.1: + version "2.28.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.28.1.tgz#63b8b5b3c409bfc75ebaf8fb206b07ab435482c4" + integrity sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A== + dependencies: + array-includes "^3.1.6" + array.prototype.findlastindex "^1.2.2" + array.prototype.flat "^1.3.1" + array.prototype.flatmap "^1.3.1" + debug "^3.2.7" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.7" + eslint-module-utils "^2.8.0" + has "^1.0.3" + is-core-module "^2.13.0" + is-glob "^4.0.3" + minimatch "^3.1.2" + object.fromentries "^2.0.6" + object.groupby "^1.0.0" + object.values "^1.1.6" + semver "^6.3.1" + tsconfig-paths "^3.14.2" + +eslint-plugin-jsdoc@^48.0.0: + version "48.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.0.1.tgz#d92339e437b4429de6b8504b94c517c39ec1f705" + integrity sha512-XFrkEyCSFWRz3mrWK4Fa9T/eE2RUg5CRc8VG7habOzl/gu8J5FxqwfpLYsMF2yEvGZ0vSaZeTnh+O2Bw1yXtUQ== + dependencies: + "@es-joy/jsdoccomment" "~0.41.0" + are-docs-informative "^0.0.2" + comment-parser "1.4.1" + debug "^4.3.4" + escape-string-regexp "^4.0.0" + esquery "^1.5.0" + is-builtin-module "^3.2.1" + semver "^7.5.4" + spdx-expression-parse "^4.0.0" + +eslint-plugin-jsx-a11y@^6.7.1: + version "6.7.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz#fca5e02d115f48c9a597a6894d5bcec2f7a76976" + integrity sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA== + dependencies: + "@babel/runtime" "^7.20.7" + aria-query "^5.1.3" + array-includes "^3.1.6" + array.prototype.flatmap "^1.3.1" + ast-types-flow "^0.0.7" + axe-core "^4.6.2" + axobject-query "^3.1.1" + damerau-levenshtein "^1.0.8" + emoji-regex "^9.2.2" + has "^1.0.3" + jsx-ast-utils "^3.3.3" + language-tags "=1.0.5" + minimatch "^3.1.2" + object.entries "^1.1.6" + object.fromentries "^2.0.6" + semver "^6.3.0" + +eslint-plugin-promise@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz#269a3e2772f62875661220631bd4dafcb4083816" + integrity sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig== + +eslint-plugin-react-hooks@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" + integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== + +eslint-plugin-react@^7.33.2: + version "7.33.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz#69ee09443ffc583927eafe86ffebb470ee737608" + integrity sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw== + dependencies: + array-includes "^3.1.6" + array.prototype.flatmap "^1.3.1" + array.prototype.tosorted "^1.1.1" + doctrine "^2.1.0" + es-iterator-helpers "^1.0.12" + estraverse "^5.3.0" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.6" + object.fromentries "^2.0.6" + object.hasown "^1.1.2" + object.values "^1.1.6" + prop-types "^15.8.1" + resolve "^2.0.0-next.4" + semver "^6.3.1" + string.prototype.matchall "^4.0.8" + +eslint-plugin-tailwindcss@^3.17.5: + version "3.17.5" + resolved "https://registry.yarnpkg.com/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.17.5.tgz#6bf9403e77a5f3f930fb3444a3e22b29cd0fee07" + integrity sha512-8Mi7p7dm+mO1dHgRHHFdPu4RDTBk69Cn4P0B40vRQR+MrguUpwmKwhZy1kqYe3Km8/4nb+cyrCF+5SodOEmaow== + dependencies: + fast-glob "^3.2.5" + postcss "^8.4.4" + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint@^8.49.0: + version "8.49.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.49.0.tgz#09d80a89bdb4edee2efcf6964623af1054bf6d42" + integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.2" + "@eslint/js" "8.49.0" + "@humanwhocodes/config-array" "^0.11.11" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esquery@^1.4.2, esquery@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" + integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== + +estree-walker@^2.0.1, estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +execa@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exifr@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/exifr/-/exifr-7.1.3.tgz#f6218012c36dbb7d843222011b27f065fddbab6f" + integrity sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw== + +expect-type@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.1.0.tgz#a146e414250d13dfc49eafcfd1344a4060fa4c75" + integrity sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA== + +fake-indexeddb@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-5.0.0.tgz#c9f394d6d36db62760ad596ebec97ba3d700c95b" + integrity sha512-hGMsl73XgJAk5OtC8hFDSLUVzJ3Z1/C06YpFwI7DzCsEsmH5Mvkxplv3PK6uUL7XCYVBTzayp/4gD+cp7Qi8xQ== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.2.11, fast-glob@^3.2.5, fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.1, fast-glob@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fastest-levenshtein@^1.0.16: + version "1.0.16" + resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" + integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== + +fastq@^1.6.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" + integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw== + dependencies: + reusify "^1.0.4" + +fdir@^6.4.2: + version "6.4.2" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.2.tgz#ddaa7ce1831b161bc3657bb99cb36e1622702689" + integrity sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ== + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +file-entry-cache@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-9.1.0.tgz#2e66ad98ce93f49aed1b178c57b0b5741591e075" + integrity sha512-/pqPFG+FdxWQj+/WSuzXSDaNzxgTLr/OrR1QuqfEZzDakpdYE70PwUxL7BPUa8hpjbvY1+qvCl8k+8Tq34xJgg== + dependencies: + flat-cache "^5.0.0" + +filelist@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" + integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== + dependencies: + minimatch "^5.0.1" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +filter-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" + integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flat-cache@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-5.0.0.tgz#26c4da7b0f288b408bb2b506b2cb66c240ddf062" + integrity sha512-JrqFmyUl2PnPi1OvLyTVHnQvwQ0S+e6lGSwu8OkAZlSaNIZciTY2H/cOOROxsBA1m/LZNHDsqAgDZt6akWcjsQ== + dependencies: + flatted "^3.3.1" + keyv "^4.5.4" + +flatted@^3.2.9, flatted@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" + integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +fraction.js@^4.2.0: + version "4.3.6" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.6.tgz#e9e3acec6c9a28cf7bc36cbe35eea4ceb2c5c92d" + integrity sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg== + +fs-extra@^10.0.1: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-extra@^11.1.0: + version "11.1.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" + integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-extra@^9.0.1: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.1, function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.5, function.prototype.name@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" + integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + functions-have-names "^1.2.3" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@^1.2.1, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-own-enumerable-property-symbols@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" + integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== + +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-symbol-description@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" + integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.1" + +get-tsconfig@^4.5.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.7.0.tgz#06ce112a1463e93196aa90320c35df5039147e34" + integrity sha512-pmjiZ7xtB8URYm74PlGJozDNyhvsVLUcpBa8DZBG3bWHwaHa9bPiRpiSfovw+fjhwONSCWKRyk+JQHEGZmMrzw== + dependencies: + resolve-pkg-maps "^1.0.0" + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.1.3, glob@^7.1.6: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-modules@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" + integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== + dependencies: + global-prefix "^3.0.0" + +global-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" + integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== + dependencies: + ini "^1.3.5" + kind-of "^6.0.2" + which "^1.3.1" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globals@^13.19.0: + version "13.19.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.19.0.tgz#7a42de8e6ad4f7242fbcca27ea5b23aca367b5c8" + integrity sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ== + dependencies: + type-fest "^0.20.2" + +globalthis@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf" + integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA== + dependencies: + define-properties "^1.1.3" + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +globjoin@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43" + integrity sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM= + +goober@^2.1.10: + version "2.1.11" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.11.tgz#bbd71f90d2df725397340f808dbe7acc3118e610" + integrity sha512-5SS2lmxbhqH0u9ABEWq7WPU69a4i2pYcHeCxqaNq6Cw3mnrF0ghWNM4tEGid4dKy8XNIAUbuThuozDHHKJVh3A== + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +got@^11.8.3: + version "11.8.6" + resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a" + integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== + dependencies: + "@sindresorhus/is" "^4.0.0" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.2" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" + +graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +graphemesplit@^2.4.4: + version "2.4.4" + resolved "https://registry.yarnpkg.com/graphemesplit/-/graphemesplit-2.4.4.tgz#6d325c61e928efdaec2189f54a9b87babf89b75a" + integrity sha512-lKrpp1mk1NH26USxC/Asw4OHbhSQf5XfrWZ+CDv/dFVvd1j17kFgMotdJvOesmHkbFX9P9sBfpH8VogxOWLg8w== + dependencies: + js-base64 "^3.6.0" + unicode-trie "^2.0.0" + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" + integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== + +has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" + integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== + dependencies: + has-symbols "^1.0.2" + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@1.2.0, he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +history@^4.9.0: + version "4.10.1" + resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" + integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^3.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^1.0.1" + +history@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" + integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== + dependencies: + "@babel/runtime" "^7.7.6" + +hoist-non-react-statics@3, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + +html-dom-parser@5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/html-dom-parser/-/html-dom-parser-5.0.4.tgz#2941a762317d088e747db31c8cf290987ec30a55" + integrity sha512-azy8THLKd4Ar0OVJpEgX+MSjYvKdNDWlGiRBIlovMqEQYMAnLLXBhhiSwjylDD3RDdcCYT8Utg6uoRDeLHUyHg== + dependencies: + domhandler "5.0.3" + htmlparser2 "9.0.0" + +html-encoding-sniffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448" + integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ== + dependencies: + whatwg-encoding "^3.1.1" + +html-minifier-terser@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#bfc818934cc07918f6b3669f5774ecdfd48f32ab" + integrity sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw== + dependencies: + camel-case "^4.1.2" + clean-css "^5.2.2" + commander "^8.3.0" + he "^1.2.0" + param-case "^3.0.4" + relateurl "^0.2.7" + terser "^5.10.0" + +html-react-parser@^5.0.0: + version "5.0.7" + resolved "https://registry.yarnpkg.com/html-react-parser/-/html-react-parser-5.0.7.tgz#3ada0420c0ae05dce2915fff78aad2b9d678041f" + integrity sha512-00ve/0B7ukLUAcAbmD6Vh74EicB+ktLvAM4APeXJjiBsRiPz2ouochTvyUhOJB8apP2t40xAXvpmd+t50aVnJg== + dependencies: + domhandler "5.0.3" + html-dom-parser "5.0.4" + react-property "2.0.2" + style-to-js "1.1.10" + +html-tags@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" + integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== + +htmlparser2@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.0.0.tgz#e431142b7eeb1d91672742dea48af8ac7140cddb" + integrity sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.1.0" + entities "^4.5.0" + +http-cache-semantics@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" + integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + +http-link-header@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/http-link-header/-/http-link-header-1.0.3.tgz#abbc2cdc5e06dd7e196a4983adac08a2d085ec90" + integrity sha512-nARK1wSKoBBrtcoESlHBx36c1Ln/gnbNQi1eB6MeTUefJIT3NvUOsV15bClga0k38f0q/kN5xxrGSDS3EFnm9w== + +http-proxy-agent@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz#e9096c5afd071a3fce56e6252bb321583c124673" + integrity sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +http2-wrapper@^1.0.0-beta.5.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" + integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.0.0" + +https-proxy-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz#e2645b846b90e96c6e6f347fb5b2e41f1590b09b" + integrity sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA== + dependencies: + agent-base "^7.0.2" + debug "4" + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +husky@^9.0.0: + version "9.0.10" + resolved "https://registry.yarnpkg.com/husky/-/husky-9.0.10.tgz#ddca8908deb5f244e9286865ebc80b54387672c2" + integrity sha512-TQGNknoiy6bURzIO77pPRu+XHi6zI7T93rX+QnJsoYFf3xdjKOur+IlfqzJGMHIK/wXrLg+GsvMs8Op7vI2jVA== + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +idb@^7.0.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" + integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== + +ignore@^5.2.0, ignore@^5.2.4: + version "5.3.0" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" + integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== + +ignore@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-6.0.2.tgz#77cccb72a55796af1b6d2f9eb14fa326d24f4283" + integrity sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A== + +immer@^10.0.0, immer@^10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.3.tgz#a8de42065e964aa3edf6afc282dfc7f7f34ae3c9" + integrity sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A== + +immutable@^4.0.0, immutable@^4.2.1: + version "4.3.4" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f" + integrity sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA== + +import-fresh@^3.2.1, import-fresh@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.5: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +inline-style-parser@0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.2.tgz#d498b4e6de0373458fc610ff793f6b14ebf45633" + integrity sha512-EcKzdTHVe8wFVOGEYXiW9WmJXPjqi1T+234YpJr98RiFYKHV3cdy1+3mkTE+KHTHxFFLH51SfaGOoUdW+v7ViQ== + +internal-slot@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986" + integrity sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ== + dependencies: + get-intrinsic "^1.2.0" + has "^1.0.3" + side-channel "^1.0.4" + +intl-messageformat@10.5.11: + version "10.5.11" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.5.11.tgz#95d6a3b0b303f924d5d8c3f8d3ad057d1dc73c64" + integrity sha512-eYq5fkFBVxc7GIFDzpFQkDOZgNayNTQn4Oufe8jw6YY6OHVw70/4pA3FyCsQ0Gb2DnvEJEMmN2tOaXUGByM+kg== + dependencies: + "@formatjs/ecma402-abstract" "1.18.2" + "@formatjs/fast-memoize" "2.2.0" + "@formatjs/icu-messageformat-parser" "2.7.6" + tslib "^2.4.0" + +intl-messageformat@10.7.7: + version "10.7.7" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.7.7.tgz#42085e1664729d02240a03346e31a2540b1112a0" + integrity sha512-F134jIoeYMro/3I0h08D0Yt4N9o9pjddU/4IIxMMURqbAtI2wu70X8hvG1V48W49zXHXv3RKSF/po+0fDfsGjA== + dependencies: + "@formatjs/ecma402-abstract" "2.2.4" + "@formatjs/fast-memoize" "2.2.3" + "@formatjs/icu-messageformat-parser" "2.9.4" + tslib "2" + +intl-pluralrules@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz#de16c3df1e09437635829725e88ea70c9ad79569" + integrity sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg== + +is-array-buffer@^3.0.1, is-array-buffer@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe" + integrity sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.0" + is-typed-array "^1.1.10" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-async-function@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.0.0.tgz#8e4418efd3e5d3a6ebb0164c05ef5afb69aa9646" + integrity sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA== + dependencies: + has-tostringtag "^1.0.0" + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-builtin-module@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169" + integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A== + dependencies: + builtin-modules "^3.3.0" + +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.11.0, is-core-module@^2.13.0, is-core-module@^2.9.0: + version "2.13.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db" + integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ== + dependencies: + has "^1.0.3" + +is-date-object@^1.0.1, is-date-object@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-finalizationregistry@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz#c8749b65f17c133313e661b1289b95ad3dbd62e6" + integrity sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw== + dependencies: + call-bind "^1.0.2" + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-function@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-map@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" + integrity sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg== + +is-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" + integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== + +is-negative-zero@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" + integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== + +is-number-object@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.6.tgz#6a7aaf838c7f0686a50b4553f7e54a96494e89f0" + integrity sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-obj@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= + +is-set@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.2.tgz#90755fa4c2562dc1c5d4024760d6119b94ca18ec" + integrity sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g== + +is-shared-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" + integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== + dependencies: + call-bind "^1.0.2" + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typed-array@^1.1.10, is-typed-array@^1.1.12, is-typed-array@^1.1.9: + version "1.1.12" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.12.tgz#d0bab5686ef4a76f7a73097b95470ab199c57d4a" + integrity sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg== + dependencies: + which-typed-array "^1.1.11" + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +is-weakmap@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2" + integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA== + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-weakset@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.2.tgz#4569d67a747a1ce5a994dfd4ef6dcea76e7c0a1d" + integrity sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.1" + +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isomorphic-dompurify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-2.3.0.tgz#bc48fbdf52f84cf7e0a63a5e8ec89052e7dbc3c5" + integrity sha512-FCoKY4/mW/jnn/+VgE7wXGC2D/RXzVCAmGYuGWEuZXtyWnwmE2100caciIv+RbHk90q9LA0OW5IBn2f+ywHtww== + dependencies: + "@types/dompurify" "^3.0.5" + dompurify "^3.0.8" + jsdom "^24.0.0" + +iterator.prototype@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0" + integrity sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w== + dependencies: + define-properties "^1.2.1" + get-intrinsic "^1.2.1" + has-symbols "^1.0.3" + reflect.getprototypeof "^1.0.4" + set-function-name "^2.0.1" + +jake@^10.8.5: + version "10.8.7" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.7.tgz#63a32821177940c33f356e0ba44ff9d34e1c7d8f" + integrity sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w== + dependencies: + async "^3.2.3" + chalk "^4.0.2" + filelist "^1.0.4" + minimatch "^3.1.2" + +jiti@^1.21.0: + version "1.21.6" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" + integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w== + +js-base64@^3.6.0: + version "3.7.5" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca" + integrity sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsdoc-type-pratt-parser@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz#136f0571a99c184d84ec84662c45c29ceff71114" + integrity sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ== + +jsdom@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-24.0.0.tgz#e2dc04e4c79da368481659818ee2b0cd7c39007c" + integrity sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A== + dependencies: + cssstyle "^4.0.1" + data-urls "^5.0.0" + decimal.js "^10.4.3" + form-data "^4.0.0" + html-encoding-sniffer "^4.0.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.2" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.7" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.3" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^3.1.1" + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + ws "^8.16.0" + xml-name-validator "^5.0.0" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= + +json-stable-stringify@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz#52d4361b47d49168bcc4e564189a42e5a7439454" + integrity sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg== + dependencies: + call-bind "^1.0.5" + isarray "^2.0.5" + jsonify "^0.0.1" + object-keys "^1.1.1" + +json5@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== + dependencies: + minimist "^1.2.0" + +json5@^2.2.0, json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonify@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" + integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== + +jsonpointer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.0.tgz#f802669a524ec4805fa7389eadbc9921d5dc8072" + integrity sha512-PNYZIdMjVIvVgDSYKTT63Y+KZ6IZvGRNNWcxwD+GNnUz1MKPfv30J8ueCjdwcN0nDx2SlshgyB7Oy0epAzVRRg== + +"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.3: + version "3.3.5" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" + integrity sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ== + dependencies: + array-includes "^3.1.6" + array.prototype.flat "^1.3.1" + object.assign "^4.1.4" + object.values "^1.1.6" + +keycode@^2.1.7: + version "2.2.0" + resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04" + integrity sha1-PQr1bce4uOXLqNCpfxByBO7CKwQ= + +keyv@^4.0.0, keyv@^4.5.3, keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +known-css-properties@^0.29.0: + version "0.29.0" + resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.29.0.tgz#e8ba024fb03886f23cb882e806929f32d814158f" + integrity sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ== + +known-css-properties@^0.34.0: + version "0.34.0" + resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.34.0.tgz#ccd7e9f4388302231b3f174a8b1d5b1f7b576cea" + integrity sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ== + +kysely@^0.27.3: + version "0.27.3" + resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.27.3.tgz#6cc6c757040500b43c4ac596cdbb12be400ee276" + integrity sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA== + +language-subtag-registry@~0.3.2: + version "0.3.21" + resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz#04ac218bea46f04cb039084602c6da9e788dd45a" + integrity sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg== + +language-tags@=1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/language-tags/-/language-tags-1.0.5.tgz#d321dbc4da30ba8bf3024e040fa5c14661f9193a" + integrity sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ== + dependencies: + language-subtag-registry "~0.3.2" + +leaflet@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.8.0.tgz#4615db4a22a304e8e692cae9270b983b38a2055e" + integrity sha512-gwhMjFCQiYs3x/Sf+d49f10ERXaEFCPr+nVTryhAW8DWbMGqJqt9G4XuIaHmFW08zYvhgdzqXGr8AlW8v8dQkA== + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lexical@0.18.0, lexical@^0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/lexical/-/lexical-0.18.0.tgz#70dc89d8baf348b623d223c564616916a86118ce" + integrity sha512-3K/B0RpzjoW+Wj2E455wWXxkqxqK8UgdIiuqkOqdOsoSSo5mCkHOU6eVw7Nlmlr1MFvAMzGmz4RPn8NZaLQ2Mw== + +li@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b" + integrity sha512-z34TU6GlMram52Tss5mt1m//ifRIpKH5Dqm7yUVOdHI+BQCs9qGPHFaCUTIzsWX7edN30aa2WrPwR7IO10FHaw== + +lilconfig@^2.0.5, lilconfig@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" + integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== + +line-awesome@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/line-awesome/-/line-awesome-1.3.0.tgz#51d59fe311ed040d22d8e80d3aa0b9a4b57e6cd3" + integrity sha512-Y0YHksL37ixDsHz+ihCwOtF5jwJgCDxQ3q+zOVgaSW8VugHGTsZZXMacPYZB1/JULBi6BAuTCTek+4ZY/UIwcw== + +lines-and-columns@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" + integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= + +lint-staged@>=10: + version "11.1.2" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-11.1.2.tgz#4dd78782ae43ee6ebf2969cad9af67a46b33cd90" + integrity sha512-6lYpNoA9wGqkL6Hew/4n1H6lRqF3qCsujVT0Oq5Z4hiSAM7S6NksPJ3gnr7A7R52xCtiZMcEUNNQ6d6X5Bvh9w== + dependencies: + chalk "^4.1.1" + cli-truncate "^2.1.0" + commander "^7.2.0" + cosmiconfig "^7.0.0" + debug "^4.3.1" + enquirer "^2.3.6" + execa "^5.0.0" + listr2 "^3.8.2" + log-symbols "^4.1.0" + micromatch "^4.0.4" + normalize-path "^3.0.0" + please-upgrade-node "^3.2.0" + string-argv "0.3.1" + stringify-object "^3.3.0" + +listr2@^3.8.2: + version "3.12.1" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.12.1.tgz#75e515b86c66b60baf253542cc0dced6b60fedaf" + integrity sha512-oB1DlXlCzGPbvWhqYBZUQEPJKqsmebQWofXG6Mpbe3uIvoNl8mctBEojyF13ZyqwQ91clCWXpwsWp+t98K4FOQ== + dependencies: + cli-truncate "^2.1.0" + colorette "^1.4.0" + log-update "^4.0.0" + p-map "^4.0.0" + rxjs "^6.6.7" + through "^2.3.8" + wrap-ansi "^7.0.0" + +load-tsconfig@^0.2.3: + version "0.2.5" + resolved "https://registry.yarnpkg.com/load-tsconfig/-/load-tsconfig-0.2.5.tgz#453b8cd8961bfb912dea77eb6c168fe8cca3d3a1" + integrity sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg== + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash-es@^4.17.15: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + +lodash.castarray@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115" + integrity sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU= + +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== + +lodash.truncate@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" + integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= + +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= + +lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +log-update@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1" + integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg== + dependencies: + ansi-escapes "^4.3.0" + cli-cursor "^3.1.0" + slice-ansi "^4.0.0" + wrap-ansi "^6.2.0" + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +loupe@^3.1.0, loupe@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.2.tgz#c86e0696804a02218f2206124c45d8b15291a240" + integrity sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg== + +lower-case@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" + integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + dependencies: + tslib "^2.0.3" + +lowercase-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" + integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + +lru-cache@^10.2.0: + version "10.2.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.2.tgz#48206bc114c1252940c41b25b41af5b545aca878" + integrity sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + +magic-string@^0.25.0, magic-string@^0.25.7: + version "0.25.9" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" + integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== + dependencies: + sourcemap-codec "^1.4.8" + +magic-string@^0.30.0, magic-string@^0.30.12, magic-string@^0.30.14: + version "0.30.14" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.14.tgz#e9bb29870b81cfc1ec3cc656552f5a7fcbf19077" + integrity sha512-5c99P1WKTed11ZC0HMJOj6CDIue6F8ySu+bJL+85q1zBEIY8IklrJ1eiKC2NDRh3Ct3FcvmJPyQHb9erXMTJNw== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +material-colors@^1.2.1: + version "1.2.6" + resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" + integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg== + +mathml-tag-names@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" + integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== + +mdn-data@2.0.28: + version "2.0.28" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" + integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g== + +mdn-data@2.0.30: + version "2.0.30" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" + integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== + +mdn-data@2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.10.0.tgz#701da407f8fbc7a42aa0ba0c149ec897daef8986" + integrity sha512-qq7C3EtK3yJXMwz1zAab65pjl+UhohqMOctTgcqjLOWABqmwj+me02LSsCuEUxnst9X1lCBpoE0WArGKgdGDzw== + +meow@^13.2.0: + version "13.2.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-13.2.0.tgz#6b7d63f913f984063b3cc261b6e8800c4cd3474f" + integrity sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mime-db@1.49.0: + version "1.49.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed" + integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA== + +mime-types@^2.1.12: + version "2.1.32" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5" + integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A== + dependencies: + mime-db "1.49.0" + +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +mimic-response@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" + integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== + +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + +mini-create-react-context@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz#072171561bfdc922da08a60c2197a497cc2d1d5e" + integrity sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ== + dependencies: + "@babel/runtime" "^7.12.1" + tiny-warning "^1.0.3" + +mini-css-extract-plugin@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.0.tgz#578aebc7fc14d32c0ad304c2c34f08af44673f5e" + integrity sha512-ndG8nxCEnAemsg4FSgS+yNyHKgkTB4nPKqCOgh65j3/30qqC5RaSQQXMm++Y6sb6E1zRSxPkztj9fqxhS1Eo6w== + dependencies: + schema-utils "^4.0.0" + +mini-svg-data-uri@^1.2.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.3.tgz#43177b2e93766ba338931a3e2a84a3dfd3a222b8" + integrity sha512-gSfqpMRC8IxghvMcxzzmMnWpXAChSA+vy4cia33RgerMS8Fex95akUyQZPbxJJmeBGiGmK7n/1OpUX8ksRjIdA== + +minimatch@9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mousetrap@^1.6.5: + version "1.6.5" + resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9" + integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA== + +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mz@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + +no-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" + integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== + dependencies: + lower-case "^2.0.2" + tslib "^2.0.3" + +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + +node-html-parser@^5.3.3: + version "5.4.2" + resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-5.4.2.tgz#93e004038c17af80226c942336990a0eaed8136a" + integrity sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw== + dependencies: + css-select "^4.2.1" + he "1.2.0" + +node-releases@^2.0.18: + version "2.0.18" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" + integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= + +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + +nostr-tools@^2.3.0, nostr-tools@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.5.1.tgz#614d6aaf5c21df6b239d7ed42fdf77616a4621e7" + integrity sha512-bpkhGGAhdiCN0irfV+xoH3YP5CQeOXyXzUq7SYeM6D56xwTXZCPEmBlUGqFVfQidvRsoVeVxeAiOXW2c2HxoRQ== + dependencies: + "@noble/ciphers" "^0.5.1" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.1" + "@scure/base" "1.1.1" + "@scure/bip32" "1.3.1" + "@scure/bip39" "1.2.1" + optionalDependencies: + nostr-wasm v0.1.0 + +nostr-wasm@v0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94" + integrity sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +nwsapi@^2.2.7: + version "2.2.7" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" + integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== + +object-assign@^4.0.1, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-hash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + +object-inspect@^1.12.3, object-inspect@^1.9.0: + version "1.12.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" + integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" + integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +object.entries@^1.1.6: + version "1.1.7" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.7.tgz#2b47760e2a2e3a752f39dd874655c61a7f03c131" + integrity sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + +object.fromentries@^2.0.6: + version "2.0.7" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.7.tgz#71e95f441e9a0ea6baf682ecaaf37fa2a8d7e616" + integrity sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + +object.groupby@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.1.tgz#d41d9f3c8d6c778d9cbac86b4ee9f5af103152ee" + integrity sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + get-intrinsic "^1.2.1" + +object.hasown@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/object.hasown/-/object.hasown-1.1.3.tgz#6a5f2897bb4d3668b8e79364f98ccf971bda55ae" + integrity sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA== + dependencies: + define-properties "^1.2.0" + es-abstract "^1.22.1" + +object.values@^1.1.6: + version "1.1.7" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.7.tgz#617ed13272e7e1071b43973aa1655d9291b8442a" + integrity sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +onetime@^5.1.0, onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +open@^8.4.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" + integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + +optionator@^0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" + integrity sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg== + dependencies: + "@aashutoshrathi/word-wrap" "^1.2.3" + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + +p-cancelable@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" + integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + +pako@^0.2.5: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== + +param-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" + integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.0.0, parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse5@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + +pascal-case@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" + integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pathe@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-0.2.0.tgz#30fd7bbe0a0d91f0e60bae621f5d19e9e225c339" + integrity sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw== + +pathe@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" + integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== + +pathval@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" + integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== + +performance-now@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" + integrity sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU= + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +"picomatch@2 || 3 || 4", picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +pirates@^4.0.1: + version "4.0.5" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" + integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== + +please-upgrade-node@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" + integrity sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg== + dependencies: + semver-compare "^1.0.0" + +postcss-calc@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-9.0.1.tgz#a744fd592438a93d6de0f1434c572670361eb6c6" + integrity sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ== + dependencies: + postcss-selector-parser "^6.0.11" + postcss-value-parser "^4.2.0" + +postcss-colormin@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-6.0.0.tgz#d4250652e952e1c0aca70c66942da93d3cdeaafe" + integrity sha512-EuO+bAUmutWoZYgHn2T1dG1pPqHU6L4TjzPlu4t1wZGXQ/fxV16xg2EJmYi0z+6r+MGV1yvpx1BHkUaRrPa2bw== + dependencies: + browserslist "^4.21.4" + caniuse-api "^3.0.0" + colord "^2.9.1" + postcss-value-parser "^4.2.0" + +postcss-convert-values@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-6.0.0.tgz#ec94a954957e5c3f78f0e8f65dfcda95280b8996" + integrity sha512-U5D8QhVwqT++ecmy8rnTb+RL9n/B806UVaS3m60lqle4YDFcpbS3ae5bTQIh3wOGUSDHSEtMYLs/38dNG7EYFw== + dependencies: + browserslist "^4.21.4" + postcss-value-parser "^4.2.0" + +postcss-discard-comments@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-6.0.0.tgz#9ca335e8b68919f301b24ba47dde226a42e535fe" + integrity sha512-p2skSGqzPMZkEQvJsgnkBhCn8gI7NzRH2683EEjrIkoMiwRELx68yoUJ3q3DGSGuQ8Ug9Gsn+OuDr46yfO+eFw== + +postcss-discard-duplicates@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.0.tgz#c26177a6c33070922e67e9a92c0fd23d443d1355" + integrity sha512-bU1SXIizMLtDW4oSsi5C/xHKbhLlhek/0/yCnoMQany9k3nPBq+Ctsv/9oMmyqbR96HYHxZcHyK2HR5P/mqoGA== + +postcss-discard-empty@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-6.0.0.tgz#06c1c4fce09e22d2a99e667c8550eb8a3a1b9aee" + integrity sha512-b+h1S1VT6dNhpcg+LpyiUrdnEZfICF0my7HAKgJixJLW7BnNmpRH34+uw/etf5AhOlIhIAuXApSzzDzMI9K/gQ== + +postcss-discard-overridden@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-6.0.0.tgz#49c5262db14e975e349692d9024442de7cd8e234" + integrity sha512-4VELwssYXDFigPYAZ8vL4yX4mUepF/oCBeeIT4OXsJPYOtvJumyz9WflmJWTfDwCUcpDR+z0zvCWBXgTx35SVw== + +postcss-import@^15.1.0: + version "15.1.0" + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" + integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-js@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2" + integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw== + dependencies: + camelcase-css "^2.0.1" + +postcss-load-config@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.1.tgz#152383f481c2758274404e4962743191d73875bd" + integrity sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA== + dependencies: + lilconfig "^2.0.5" + yaml "^2.1.1" + +postcss-media-query-parser@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244" + integrity sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ= + +postcss-merge-longhand@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-6.0.0.tgz#6f627b27db939bce316eaa97e22400267e798d69" + integrity sha512-4VSfd1lvGkLTLYcxFuISDtWUfFS4zXe0FpF149AyziftPFQIWxjvFSKhA4MIxMe4XM3yTDgQMbSNgzIVxChbIg== + dependencies: + postcss-value-parser "^4.2.0" + stylehacks "^6.0.0" + +postcss-merge-rules@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-6.0.1.tgz#39f165746404e646c0f5c510222ccde4824a86aa" + integrity sha512-a4tlmJIQo9SCjcfiCcCMg/ZCEe0XTkl/xK0XHBs955GWg9xDX3NwP9pwZ78QUOWB8/0XCjZeJn98Dae0zg6AAw== + dependencies: + browserslist "^4.21.4" + caniuse-api "^3.0.0" + cssnano-utils "^4.0.0" + postcss-selector-parser "^6.0.5" + +postcss-minify-font-values@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-6.0.0.tgz#68d4a028f9fa5f61701974724b2cc9445d8e6070" + integrity sha512-zNRAVtyh5E8ndZEYXA4WS8ZYsAp798HiIQ1V2UF/C/munLp2r1UGHwf1+6JFu7hdEhJFN+W1WJQKBrtjhFgEnA== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-minify-gradients@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-6.0.0.tgz#22b5c88cc63091dadbad34e31ff958404d51d679" + integrity sha512-wO0F6YfVAR+K1xVxF53ueZJza3L+R3E6cp0VwuXJQejnNUH0DjcAFe3JEBeTY1dLwGa0NlDWueCA1VlEfiKgAA== + dependencies: + colord "^2.9.1" + cssnano-utils "^4.0.0" + postcss-value-parser "^4.2.0" + +postcss-minify-params@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-6.0.0.tgz#2b3a85a9e3b990d7a16866f430f5fd1d5961b539" + integrity sha512-Fz/wMQDveiS0n5JPcvsMeyNXOIMrwF88n7196puSuQSWSa+/Ofc1gDOSY2xi8+A4PqB5dlYCKk/WfqKqsI+ReQ== + dependencies: + browserslist "^4.21.4" + cssnano-utils "^4.0.0" + postcss-value-parser "^4.2.0" + +postcss-minify-selectors@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-6.0.0.tgz#5046c5e8680a586e5a0cad52cc9aa36d6be5bda2" + integrity sha512-ec/q9JNCOC2CRDNnypipGfOhbYPuUkewGwLnbv6omue/PSASbHSU7s6uSQ0tcFRVv731oMIx8k0SP4ZX6be/0g== + dependencies: + postcss-selector-parser "^6.0.5" + +postcss-nested@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.1.tgz#f83dc9846ca16d2f4fa864f16e9d9f7d0961662c" + integrity sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ== + dependencies: + postcss-selector-parser "^6.0.11" + +postcss-normalize-charset@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-6.0.0.tgz#36cc12457259064969fb96f84df491652a4b0975" + integrity sha512-cqundwChbu8yO/gSWkuFDmKrCZ2vJzDAocheT2JTd0sFNA4HMGoKMfbk2B+J0OmO0t5GUkiAkSM5yF2rSLUjgQ== + +postcss-normalize-display-values@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.0.tgz#8d2961415078644d8c6bbbdaf9a2fdd60f546cd4" + integrity sha512-Qyt5kMrvy7dJRO3OjF7zkotGfuYALETZE+4lk66sziWSPzlBEt7FrUshV6VLECkI4EN8Z863O6Nci4NXQGNzYw== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-positions@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-6.0.0.tgz#25b96df99a69f8925f730eaee0be74416865e301" + integrity sha512-mPCzhSV8+30FZyWhxi6UoVRYd3ZBJgTRly4hOkaSifo0H+pjDYcii/aVT4YE6QpOil15a5uiv6ftnY3rm0igPg== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-repeat-style@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.0.tgz#ddf30ad8762feb5b1eb97f39f251acd7b8353299" + integrity sha512-50W5JWEBiOOAez2AKBh4kRFm2uhrT3O1Uwdxz7k24aKtbD83vqmcVG7zoIwo6xI2FZ/HDlbrCopXhLeTpQib1A== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-string@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-6.0.0.tgz#948282647a51e409d69dde7910f0ac2ff97cb5d8" + integrity sha512-KWkIB7TrPOiqb8ZZz6homet2KWKJwIlysF5ICPZrXAylGe2hzX/HSf4NTX2rRPJMAtlRsj/yfkrWGavFuB+c0w== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-timing-functions@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.0.tgz#5f13e650b8c43351989fc5de694525cc2539841c" + integrity sha512-tpIXWciXBp5CiFs8sem90IWlw76FV4oi6QEWfQwyeREVwUy39VSeSqjAT7X0Qw650yAimYW5gkl2Gd871N5SQg== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-unicode@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-6.0.0.tgz#741b3310f874616bdcf07764f5503695d3604730" + integrity sha512-ui5crYkb5ubEUDugDc786L/Me+DXp2dLg3fVJbqyAl0VPkAeALyAijF2zOsnZyaS1HyfPuMH0DwyY18VMFVNkg== + dependencies: + browserslist "^4.21.4" + postcss-value-parser "^4.2.0" + +postcss-normalize-url@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-6.0.0.tgz#d0a31e962a16401fb7deb7754b397a323fb650b4" + integrity sha512-98mvh2QzIPbb02YDIrYvAg4OUzGH7s1ZgHlD3fIdTHLgPLRpv1ZTKJDnSAKr4Rt21ZQFzwhGMXxpXlfrUBKFHw== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-whitespace@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.0.tgz#accb961caa42e25ca4179b60855b79b1f7129d4d" + integrity sha512-7cfE1AyLiK0+ZBG6FmLziJzqQCpTQY+8XjMhMAz8WSBSCsCNNUKujgIgjCAmDT3cJ+3zjTXFkoD15ZPsckArVw== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-ordered-values@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-6.0.0.tgz#374704cdff25560d44061d17ba3c6308837a3218" + integrity sha512-K36XzUDpvfG/nWkjs6d1hRBydeIxGpKS2+n+ywlKPzx1nMYDYpoGbcjhj5AwVYJK1qV2/SDoDEnHzlPD6s3nMg== + dependencies: + cssnano-utils "^4.0.0" + postcss-value-parser "^4.2.0" + +postcss-reduce-initial@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-6.0.0.tgz#7d16e83e60e27e2fa42f56ec0b426f1da332eca7" + integrity sha512-s2UOnidpVuXu6JiiI5U+fV2jamAw5YNA9Fdi/GRK0zLDLCfXmSGqQtzpUPtfN66RtCbb9fFHoyZdQaxOB3WxVA== + dependencies: + browserslist "^4.21.4" + caniuse-api "^3.0.0" + +postcss-reduce-transforms@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.0.tgz#28ff2601a6d9b96a2f039b3501526e1f4d584a46" + integrity sha512-FQ9f6xM1homnuy1wLe9lP1wujzxnwt1EwiigtWwuyf8FsqqXUDUp2Ulxf9A5yjlUOTdCJO6lonYjg1mgqIIi2w== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-resolve-nested-selector@^0.1.1, postcss-resolve-nested-selector@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz#3d84dec809f34de020372c41b039956966896686" + integrity sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw== + +postcss-safe-parser@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz#36e4f7e608111a0ca940fd9712ce034718c40ec0" + integrity sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A== + +postcss-scss@^4.0.9: + version "4.0.9" + resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-4.0.9.tgz#a03c773cd4c9623cb04ce142a52afcec74806685" + integrity sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A== + +postcss-selector-parser@6.0.10: + version "6.0.10" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" + integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.13, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" + integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-svgo@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-6.0.0.tgz#7b18742d38d4505a0455bbe70d52b49f00eaf69d" + integrity sha512-r9zvj/wGAoAIodn84dR/kFqwhINp5YsJkLoujybWG59grR/IHx+uQ2Zo+IcOwM0jskfYX3R0mo+1Kip1VSNcvw== + dependencies: + postcss-value-parser "^4.2.0" + svgo "^3.0.2" + +postcss-unique-selectors@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-6.0.0.tgz#c94e9b0f7bffb1203894e42294b5a1b3fb34fbe1" + integrity sha512-EPQzpZNxOxP7777t73RQpZE5e9TrnCrkvp7AH7a0l89JmZiPnS82y216JowHXwpBCQitfyxrof9TK3rYbi7/Yw== + dependencies: + postcss-selector-parser "^6.0.5" + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.4.23, postcss@^8.4.29, postcss@^8.4.4, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.49: + version "8.4.49" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +pretty-bytes@^5.3.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" + integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== + +pretty-bytes@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b" + integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ== + +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + +prismjs@^1.27.0: + version "1.29.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" + integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== + +prop-types-extra@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.1.1.tgz#58c3b74cbfbb95d304625975aa2f0848329a010b" + integrity sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew== + dependencies: + react-is "^16.3.2" + warning "^4.0.0" + +prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +qrcode.react@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8" + integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q== + +qs@^6.10.1: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +query-string@^7.0.0: + version "7.1.3" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328" + integrity sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg== + dependencies: + decode-uri-component "^0.2.2" + filter-obj "^1.1.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + +raf@^3.1.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + dependencies: + performance-now "^2.1.0" + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +react-color@^2.19.3: + version "2.19.3" + resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.19.3.tgz#ec6c6b4568312a3c6a18420ab0472e146aa5683d" + integrity sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA== + dependencies: + "@icons/material" "^0.2.4" + lodash "^4.17.15" + lodash-es "^4.17.15" + material-colors "^1.2.1" + prop-types "^15.5.10" + reactcss "^1.2.0" + tinycolor2 "^1.4.1" + +react-dom@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-error-boundary@^3.1.0, react-error-boundary@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + +react-error-boundary@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.11.tgz#36bf44de7746714725a814630282fee83a7c9a1c" + integrity sha512-U13ul67aP5DOSPNSCWQ/eO0AQEYzEFkVljULQIjMV0KlffTAhxuDoBKdO0pb/JZ8mDhMKFZ9NZi0BmLGUiNphw== + dependencies: + "@babel/runtime" "^7.12.5" + +react-event-listener@^0.6.0: + version "0.6.6" + resolved "https://registry.yarnpkg.com/react-event-listener/-/react-event-listener-0.6.6.tgz#758f7b991cad9086dd39fd29fad72127e1d8962a" + integrity sha512-+hCNqfy7o9wvO6UgjqFmBzARJS7qrNoda0VqzvOuioEpoEXKutiKuv92dSz6kP7rYLmyHPyYNLesi5t/aH1gfw== + dependencies: + "@babel/runtime" "^7.2.0" + prop-types "^15.6.0" + warning "^4.0.1" + +react-fast-compare@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + +react-from-dom@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/react-from-dom/-/react-from-dom-0.6.2.tgz#9da903a508c91c013b55afcd59348b8b0a39bdb4" + integrity sha512-qvWWTL/4xw4k/Dywd41RBpLQUSq97csuv15qrxN+izNeLYlD9wn5W8LspbfYe5CWbaSdkZ72BsaYBPQf2x4VbQ== + +react-helmet@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726" + integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw== + dependencies: + object-assign "^4.1.1" + prop-types "^15.7.2" + react-fast-compare "^3.1.1" + react-side-effect "^2.1.0" + +react-hot-toast@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.0.tgz#b91e7a4c1b6e3068fc599d3d83b4fb48668ae51d" + integrity sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA== + dependencies: + goober "^2.1.10" + +react-inlinesvg@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/react-inlinesvg/-/react-inlinesvg-4.0.3.tgz#69aa4d9c01b037abb800bfa103cb5591c6f3fe76" + integrity sha512-qPSqksbgDc6uVX6w256XY6JmdkLpzA4RiajvHi8u2qszXrhjDl6JwhW8x3VMkO4BxL9ll+/IeKR9ZxgM8wLcKQ== + dependencies: + react-from-dom "^0.6.2" + +react-intl@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-7.0.1.tgz#1d365feebb8e7dc571757486c60217cec37e0d61" + integrity sha512-djq5s6d96mw+84vNh7Zz9/dUa2v2A4VNfpZbQdjvVHrvogGfTRp5WUgacwyWjGNAIrzmcpa8blcjQFps/3gwXw== + dependencies: + "@formatjs/icu-messageformat-parser" "2.9.4" + "@formatjs/intl" "3.0.1" + "@types/hoist-non-react-statics" "3" + "@types/react" "16 || 17 || 18" + hoist-non-react-statics "3" + intl-messageformat "10.7.7" + tslib "2" + +react-is@^16.13.1, react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + +react-motion@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316" + integrity sha512-9q3YAvHoUiWlP3cK0v+w1N5Z23HXMj4IF4YuvjvWegWqNPfLXsOBE/V7UvQGpXxHFKRQQcNcVQE31g9SB/6qgQ== + dependencies: + performance-now "^0.2.0" + prop-types "^15.5.8" + raf "^3.1.0" + +react-overlays@^0.9.0: + version "0.9.3" + resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.9.3.tgz#5bac8c1e9e7e057a125181dee2d784864dd62902" + integrity sha512-u2T7nOLnK+Hrntho4p0Nxh+BsJl0bl4Xuwj/Y0a56xywLMetgAfyjnDVrudLXsNcKGaspoC+t3C1V80W9QQTdQ== + dependencies: + classnames "^2.2.5" + dom-helpers "^3.2.1" + prop-types "^15.5.10" + prop-types-extra "^1.0.1" + react-transition-group "^2.2.1" + warning "^3.0.0" + +react-property@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6" + integrity sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug== + +react-redux@^9.0.4: + version "9.0.4" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.0.4.tgz#6892d465f086507a517d4b53eb589876e6bc8344" + integrity sha512-9J1xh8sWO0vYq2sCxK2My/QO7MzUMRi3rpiILP/+tDr8krBHixC6JMM17fMK88+Oh3e4Ae6/sHIhNBgkUivwFA== + dependencies: + "@types/use-sync-external-store" "^0.0.3" + use-sync-external-store "^1.0.0" + +react-router-dom-v5-compat@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/react-router-dom-v5-compat/-/react-router-dom-v5-compat-6.6.2.tgz#e8a985dd6092885305e0b04aa141f659b282e25f" + integrity sha512-YpumAAZlXfgSOEBnEwvPfI68tAsWvhXHY9grFVprIluff/X7CjNNU0dWHwOmyY7Q+8kFi34NcAhCEYzUCaJbBQ== + dependencies: + history "^5.3.0" + react-router "6.6.2" + +react-router-dom@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.3.0.tgz#da1bfb535a0e89a712a93b97dd76f47ad1f32363" + integrity sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ== + dependencies: + "@babel/runtime" "^7.12.13" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.2.1" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.1.tgz#4d2e4e9d5ae9425091845b8dbc6d9d276239774d" + integrity sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ== + dependencies: + "@babel/runtime" "^7.12.13" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + mini-create-react-context "^0.4.0" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.6.2.tgz#556f7b56cff7fe32c5c02429fef3fcb2ecd08111" + integrity sha512-uJPG55Pek3orClbURDvfljhqFvMgJRo59Pktywkk8hUUkTY2aRfza8Yhl/vZQXs+TNQyr6tu+uqz/fLxPICOGQ== + dependencies: + "@remix-run/router" "1.2.1" + +react-side-effect@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3" + integrity sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ== + +react-simple-pull-to-refresh@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/react-simple-pull-to-refresh/-/react-simple-pull-to-refresh-1.3.3.tgz#118afe0d8ba6cade87094786b3889fb2ffd5b9bc" + integrity sha512-6qXsa5RtNVmKJhLWvDLIX8UK51HFtCEGjdqQGf+M1Qjrcc4qH4fki97sgVpGEFBRwbY7DiVDA5N5p97kF16DTw== + +react-sparklines@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60" + integrity sha512-bJFt9K4c5Z0k44G8KtxIhbG+iyxrKjBZhdW6afP+R7EnIq+iKjbWbEFISrf3WKNFsda+C46XAfnX0StS5fbDcg== + dependencies: + prop-types "^15.5.10" + +react-sticky-box@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/react-sticky-box/-/react-sticky-box-2.0.0.tgz#db3d19f700f2c2fb8405da6973cadd9b74ceea91" + integrity sha512-xTy/46lG6GlfdkGL7DoevLj+p3lqjvULZvGkN36/2oM8FrijvFq/zzfAtK0ZSnDxM9Ct6KOr5ZVuejVdDFqEcw== + +react-swipeable-views-core@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/react-swipeable-views-core/-/react-swipeable-views-core-0.14.0.tgz#6ac443a7cc7bc5ea022fbd549292bb5fff361cce" + integrity sha512-0W/e9uPweNEOSPjmYtuKSC/SvKKg1sfo+WtPdnxeLF3t2L82h7jjszuOHz9C23fzkvLfdgkaOmcbAxE9w2GEjA== + dependencies: + "@babel/runtime" "7.0.0" + warning "^4.0.1" + +react-swipeable-views-utils@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/react-swipeable-views-utils/-/react-swipeable-views-utils-0.14.0.tgz#6b76e251906747482730c22002fe47ab1014ba32" + integrity sha512-W+fXBOsDqgFK1/g7MzRMVcDurp3LqO3ksC8UgInh2P/tKgb5DusuuB1geKHFc6o1wKl+4oyER4Zh3Lxmr8xbXA== + dependencies: + "@babel/runtime" "7.0.0" + keycode "^2.1.7" + prop-types "^15.6.0" + react-event-listener "^0.6.0" + react-swipeable-views-core "^0.14.0" + shallow-equal "^1.2.1" + +react-swipeable-views@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/react-swipeable-views/-/react-swipeable-views-0.14.0.tgz#149c0df3d92220cc89e3f6d5c04a78dfe46f9b54" + integrity sha512-wrTT6bi2nC3JbmyNAsPXffUXLn0DVT9SbbcFr36gKpbaCgEp7rX/OFxsu5hPc/NBsUhHyoSRGvwqJNNrWTwCww== + dependencies: + "@babel/runtime" "7.0.0" + prop-types "^15.5.4" + react-swipeable-views-core "^0.14.0" + react-swipeable-views-utils "^0.14.0" + warning "^4.0.1" + +react-transition-group@^2.2.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + +react-virtuoso@^4.10.4: + version "4.10.4" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.10.4.tgz#856ed415d7071db0c666ce84809bab8bb834f45c" + integrity sha512-G/gprhTbK+lzMxoo/iStcZxVEGph/cIhc3WANEpt92RuMw+LiCZOmBfKoeoZOHlm/iyftTrDJhGaTCpxyucnkQ== + +react@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +reactcss@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd" + integrity sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A== + dependencies: + lodash "^4.0.1" + +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + +readdirp@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.0.2.tgz#388fccb8b75665da3abffe2d8f8ed59fe74c230a" + integrity sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA== + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^4.0.5: + version "4.2.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13" + integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA== + dependencies: + "@babel/runtime" "^7.9.2" + +redux@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.0.tgz#29572e29a439e094ff8fec46883fc45053f6736d" + integrity sha512-blLIYmYetpZMET6Q6uCY7Jtl/Im5OBldy+vNPauA8vvsdqyt66oep4EUpAMWNHauTC6xa9JuRPhRB72rY82QGA== + +reflect.getprototypeof@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz#aaccbf41aca3821b87bb71d9dcbc7ad0ba50a3f3" + integrity sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + get-intrinsic "^1.2.1" + globalthis "^1.0.3" + which-builtin-type "^1.1.3" + +regenerate-unicode-properties@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c" + integrity sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ== + dependencies: + regenerate "^1.4.2" + +regenerate@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" + integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== + +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== + +regenerator-runtime@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45" + integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA== + +regenerator-transform@^0.15.2: + version "0.15.2" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.2.tgz#5bbae58b522098ebdf09bca2f83838929001c7a4" + integrity sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg== + dependencies: + "@babel/runtime" "^7.8.4" + +regexp.prototype.flags@^1.5.0, regexp.prototype.flags@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz#90ce989138db209f81492edd734183ce99f9677e" + integrity sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + set-function-name "^2.0.0" + +regexpu-core@^5.3.1: + version "5.3.2" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.3.2.tgz#11a2b06884f3527aec3e93dbbf4a3b958a95546b" + integrity sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ== + dependencies: + "@babel/regjsgen" "^0.8.0" + regenerate "^1.4.2" + regenerate-unicode-properties "^10.1.0" + regjsparser "^0.9.1" + unicode-match-property-ecmascript "^2.0.0" + unicode-match-property-value-ecmascript "^2.1.0" + +regjsparser@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" + integrity sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ== + dependencies: + jsesc "~0.5.0" + +relateurl@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" + integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +reselect@^5.0.0, reselect@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.0.1.tgz#587cdaaeb4e0e8927cff80ebe2bbef05f74b1648" + integrity sha512-D72j2ubjgHpvuCiORWkOUxndHJrxDaSolheiz5CO+roz8ka97/4msh2E8F5qay4GawR5vzBt5MkbDHT+Rdy/Wg== + +resolve-alpn@^1.0.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" + integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve-pathname@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" + integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + +resolve@^1.1.7, resolve@^1.14.2, resolve@^1.22.1, resolve@^1.22.2, resolve@^1.22.4: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^2.0.0-next.4: + version "2.0.0-next.4" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.4.tgz#3d37a113d6429f496ec4752d2a2e58efb1fd4660" + integrity sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +responselike@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc" + integrity sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw== + dependencies: + lowercase-keys "^2.0.0" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rollup-plugin-visualizer@^5.12.0: + version "5.12.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.12.0.tgz#661542191ce78ee4f378995297260d0c1efb1302" + integrity sha512-8/NU9jXcHRs7Nnj07PF2o4gjxmm9lXIrZ8r175bT9dK8qoLlvKTwRMArRCMgpMGlq8CTLugRvEmyMeMXIU2pNQ== + dependencies: + open "^8.4.0" + picomatch "^2.3.1" + source-map "^0.7.4" + yargs "^17.5.1" + +rollup@^2.43.1: + version "2.79.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" + integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== + optionalDependencies: + fsevents "~2.3.2" + +rollup@^4.20.0, rollup@^4.23.0: + version "4.28.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.28.0.tgz#eb8d28ed43ef60a18f21d0734d230ee79dd0de77" + integrity sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ== + dependencies: + "@types/estree" "1.0.6" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.28.0" + "@rollup/rollup-android-arm64" "4.28.0" + "@rollup/rollup-darwin-arm64" "4.28.0" + "@rollup/rollup-darwin-x64" "4.28.0" + "@rollup/rollup-freebsd-arm64" "4.28.0" + "@rollup/rollup-freebsd-x64" "4.28.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.28.0" + "@rollup/rollup-linux-arm-musleabihf" "4.28.0" + "@rollup/rollup-linux-arm64-gnu" "4.28.0" + "@rollup/rollup-linux-arm64-musl" "4.28.0" + "@rollup/rollup-linux-powerpc64le-gnu" "4.28.0" + "@rollup/rollup-linux-riscv64-gnu" "4.28.0" + "@rollup/rollup-linux-s390x-gnu" "4.28.0" + "@rollup/rollup-linux-x64-gnu" "4.28.0" + "@rollup/rollup-linux-x64-musl" "4.28.0" + "@rollup/rollup-win32-arm64-msvc" "4.28.0" + "@rollup/rollup-win32-ia32-msvc" "4.28.0" + "@rollup/rollup-win32-x64-msvc" "4.28.0" + fsevents "~2.3.2" + +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +rxjs@^6.6.7: + version "6.6.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== + dependencies: + tslib "^1.9.0" + +safe-array-concat@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c" + integrity sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + has-symbols "^1.0.3" + isarray "^2.0.5" + +safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-regex-test@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" + integrity sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + is-regex "^1.1.4" + +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sass@^1.79.5: + version "1.79.5" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.79.5.tgz#646c627601cd5f84c64f7b1485b9292a313efae4" + integrity sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g== + dependencies: + "@parcel/watcher" "^2.4.1" + chokidar "^4.0.0" + immutable "^4.0.0" + source-map-js ">=0.6.2 <2.0.0" + +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +schema-utils@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.0.0.tgz#60331e9e3ae78ec5d16353c467c34b3a0a1d3df7" + integrity sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.8.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.0.0" + +semver-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" + integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w= + +semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.4, semver@^7.5.4, semver@^7.6.0: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + +serialize-javascript@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz#b206efb27c3da0b0ab6b52f48d170b7996458e5c" + integrity sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w== + dependencies: + randombytes "^2.1.0" + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.0, set-function-name@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" + integrity sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA== + dependencies: + define-data-property "^1.0.1" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.0" + +shallow-equal@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.2.1.tgz#4c16abfa56043aa20d050324efa68940b0da79da" + integrity sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +signal-exit@^3.0.2, signal-exit@^3.0.3: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slice-ansi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" + integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + +smob@^1.0.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/smob/-/smob-1.5.0.tgz#85d79a1403abf128d24d3ebc1cdc5e1a9548d3ab" + integrity sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig== + +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map-support@~0.5.20: + version "0.5.20" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.20.tgz#12166089f8f5e5e8c56926b377633392dd2cb6c9" + integrity sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@~0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +source-map@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" + integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== + +source-map@^0.8.0-beta.0: + version "0.8.0-beta.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.8.0-beta.0.tgz#d4c1bb42c3f7ee925f005927ba10709e0d1d1f11" + integrity sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA== + dependencies: + whatwg-url "^7.0.0" + +sourcemap-codec@^1.4.8: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + +spdx-exceptions@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== + +spdx-expression-parse@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz#a23af9f3132115465dac215c099303e4ceac5794" + integrity sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.10" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b" + integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA== + +split-on-first@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" + integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.0.tgz#b56ffc1baf1a29dcc80a3bdf11d7fca7c315e7d5" + integrity sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w== + +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" + integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== + +string-argv@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" + integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string.prototype.matchall@^4.0.6, string.prototype.matchall@^4.0.8: + version "4.0.10" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz#a1553eb532221d4180c51581d6072cd65d1ee100" + integrity sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + get-intrinsic "^1.2.1" + has-symbols "^1.0.3" + internal-slot "^1.0.5" + regexp.prototype.flags "^1.5.0" + set-function-name "^2.0.0" + side-channel "^1.0.4" + +string.prototype.trim@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz#f9ac6f8af4bd55ddfa8895e6aea92a96395393bd" + integrity sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + +string.prototype.trimend@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz#1bb3afc5008661d73e2dc015cd4853732d6c471e" + integrity sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + +string.prototype.trimstart@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz#d4cdb44b83a4737ffbac2d406e405d43d0184298" + integrity sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + +stringify-object@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" + integrity sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== + dependencies: + get-own-enumerable-property-symbols "^3.0.0" + is-obj "^1.0.1" + is-regexp "^1.0.0" + +stringz@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/stringz/-/stringz-2.1.0.tgz#5896b4713eac31157556040fb90258fb02c1630c" + integrity sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A== + dependencies: + char-regex "^1.0.2" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + +strip-comments@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-comments/-/strip-comments-2.0.1.tgz#4ad11c3fbcac177a67a40ac224ca339ca1c1ba9b" + integrity sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw== + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +style-to-js@1.1.10: + version "1.1.10" + resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.10.tgz#ec20e1264ba11dc7f71b94b3a3a05566ed856e54" + integrity sha512-VC7MBJa+y0RZhpnLKDPmVRLRswsASLmixkiZ5R8xZpNT9VyjeRzwnXd2pBzAWdgSGv/pCNNH01gPCCUsB9exYg== + dependencies: + style-to-object "1.0.5" + +style-to-object@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.5.tgz#5e918349bc3a39eee3a804497d97fcbbf2f0d7c0" + integrity sha512-rDRwHtoDD3UMMrmZ6BzOW0naTjMsVZLIjsGleSKS/0Oz+cgCfAPRspaqJuE8rDzpKha/nEvnM0IF4seEAZUTKQ== + dependencies: + inline-style-parser "0.2.2" + +stylehacks@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-6.0.0.tgz#9fdd7c217660dae0f62e14d51c89f6c01b3cb738" + integrity sha512-+UT589qhHPwz6mTlCLSt/vMNTJx8dopeJlZAlBMJPWA3ORqu6wmQY7FBXf+qD+FsqoBJODyqNxOUP3jdntFRdw== + dependencies: + browserslist "^4.21.4" + postcss-selector-parser "^6.0.4" + +stylelint-config-recommended-scss@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-14.0.0.tgz#d3482c9817dada80b5ec01685b38fc8af8f7263f" + integrity sha512-HDvpoOAQ1RpF+sPbDOT2Q2/YrBDEJDnUymmVmZ7mMCeNiFSdhRdyGEimBkz06wsN+HaFwUh249gDR+I9JR7Onw== + dependencies: + postcss-scss "^4.0.9" + stylelint-config-recommended "^14.0.0" + stylelint-scss "^6.0.0" + +stylelint-config-recommended@^14.0.0, stylelint-config-recommended@^14.0.1: + version "14.0.1" + resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz#d25e86409aaf79ee6c6085c2c14b33c7e23c90c6" + integrity sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg== + +stylelint-config-standard-scss@^13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/stylelint-config-standard-scss/-/stylelint-config-standard-scss-13.1.0.tgz#2be36ca13087325a42c1f26df8267808667cc886" + integrity sha512-Eo5w7/XvwGHWkeGLtdm2FZLOMYoZl1omP2/jgFCXyl2x5yNz7/8vv4Tj6slHvMSSUNTaGoam/GAZ0ZhukvalfA== + dependencies: + stylelint-config-recommended-scss "^14.0.0" + stylelint-config-standard "^36.0.0" + +stylelint-config-standard@^36.0.0: + version "36.0.1" + resolved "https://registry.yarnpkg.com/stylelint-config-standard/-/stylelint-config-standard-36.0.1.tgz#727cbb2a1ef3e210f5ce8329cde531129f156609" + integrity sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw== + dependencies: + stylelint-config-recommended "^14.0.1" + +stylelint-scss@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-6.0.0.tgz#bf6be6798d71c898484b7e97007d5ed69a89308d" + integrity sha512-N1xV/Ef5PNRQQt9E45unzGvBUN1KZxCI8B4FgN/pMfmyRYbZGVN4y9qWlvOMdScU17c8VVCnjIHTVn38Bb6qSA== + dependencies: + known-css-properties "^0.29.0" + postcss-media-query-parser "^0.2.3" + postcss-resolve-nested-selector "^0.1.1" + postcss-selector-parser "^6.0.13" + postcss-value-parser "^4.2.0" + +stylelint@^16.10.0: + version "16.10.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-16.10.0.tgz#452b42a5d82f2ad910954eb2ba2b3a2ec583cd75" + integrity sha512-z/8X2rZ52dt2c0stVwI9QL2AFJhLhbPkyfpDFcizs200V/g7v+UYY6SNcB9hKOLcDDX/yGLDsY/pX08sLkz9xQ== + dependencies: + "@csstools/css-parser-algorithms" "^3.0.1" + "@csstools/css-tokenizer" "^3.0.1" + "@csstools/media-query-list-parser" "^3.0.1" + "@csstools/selector-specificity" "^4.0.0" + "@dual-bundle/import-meta-resolve" "^4.1.0" + balanced-match "^2.0.0" + colord "^2.9.3" + cosmiconfig "^9.0.0" + css-functions-list "^3.2.3" + css-tree "^3.0.0" + debug "^4.3.7" + fast-glob "^3.3.2" + fastest-levenshtein "^1.0.16" + file-entry-cache "^9.1.0" + global-modules "^2.0.0" + globby "^11.1.0" + globjoin "^0.1.4" + html-tags "^3.3.1" + ignore "^6.0.2" + imurmurhash "^0.1.4" + is-plain-object "^5.0.0" + known-css-properties "^0.34.0" + mathml-tag-names "^2.1.3" + meow "^13.2.0" + micromatch "^4.0.8" + normalize-path "^3.0.0" + picocolors "^1.0.1" + postcss "^8.4.47" + postcss-resolve-nested-selector "^0.1.6" + postcss-safe-parser "^7.0.1" + postcss-selector-parser "^6.1.2" + postcss-value-parser "^4.2.0" + resolve-from "^5.0.0" + string-width "^4.2.3" + supports-hyperlinks "^3.1.0" + svg-tags "^1.0.0" + table "^6.8.2" + write-file-atomic "^5.0.1" + +sucrase@^3.32.0: + version "3.34.0" + resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.34.0.tgz#1e0e2d8fcf07f8b9c3569067d92fbd8690fb576f" + integrity sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + glob "7.1.6" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + ts-interface-checker "^0.1.9" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.0.0, supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-hyperlinks@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-3.1.0.tgz#b56150ff0173baacc15f21956450b61f2b18d3ac" + integrity sha512-2rn0BZ+/f7puLOHZm1HOJfwBggfaHXUpPUSSG/SWM4TWp5KCfmNYwnC3hruy2rZlMnmWZ+QAGpZfchu3f3695A== + dependencies: + has-flag "^4.0.0" + supports-color "^7.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +svg-tags@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" + integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q= + +svgo@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.0.2.tgz#5e99eeea42c68ee0dc46aa16da093838c262fe0a" + integrity sha512-Z706C1U2pb1+JGP48fbazf3KxHrWOsLme6Rv7imFBn5EnuanDW1GPaA/P1/dvObE670JDePC3mnj0k0B7P0jjQ== + dependencies: + "@trysound/sax" "0.2.0" + commander "^7.2.0" + css-select "^5.1.0" + css-tree "^2.2.1" + csso "^5.0.5" + picocolors "^1.0.0" + +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +tabbable@^5.3.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" + integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== + +tabbable@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.0.1.tgz#427a09b13c83ae41eed3e88abb76a4af28bde1a6" + integrity sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA== + +table@^6.8.2: + version "6.8.2" + resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58" + integrity sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA== + dependencies: + ajv "^8.0.1" + lodash.truncate "^4.4.2" + slice-ansi "^4.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + +tailwindcss@^3.4.13: + version "3.4.13" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.13.tgz#3d11e5510660f99df4f1bfb2d78434666cb8f831" + integrity sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw== + dependencies: + "@alloc/quick-lru" "^5.2.0" + arg "^5.0.2" + chokidar "^3.5.3" + didyoumean "^1.2.2" + dlv "^1.1.3" + fast-glob "^3.3.0" + glob-parent "^6.0.2" + is-glob "^4.0.3" + jiti "^1.21.0" + lilconfig "^2.1.0" + micromatch "^4.0.5" + normalize-path "^3.0.0" + object-hash "^3.0.0" + picocolors "^1.0.0" + postcss "^8.4.23" + postcss-import "^15.1.0" + postcss-js "^4.0.1" + postcss-load-config "^4.0.1" + postcss-nested "^6.0.1" + postcss-selector-parser "^6.0.11" + resolve "^1.22.2" + sucrase "^3.32.0" + +tapable@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" + integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== + +temp-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" + integrity sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg== + +tempy@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tempy/-/tempy-0.6.0.tgz#65e2c35abc06f1124a97f387b08303442bde59f3" + integrity sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw== + dependencies: + is-stream "^2.0.0" + temp-dir "^2.0.0" + type-fest "^0.16.0" + unique-string "^2.0.0" + +terser@^5.10.0, terser@^5.17.4: + version "5.33.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.33.0.tgz#8f9149538c7468ffcb1246cfec603c16720d2db1" + integrity sha512-JuPVaB7s1gdFKPKTelwUyRq5Sid2A3Gko2S0PncwdBq7kN9Ti9HPWDQ06MPsEDGsZeVESjKEnyGy68quBk1w6g== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.8.2" + commander "^2.20.0" + source-map-support "~0.5.20" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +through@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + +tiny-inflate@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" + integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== + +tiny-invariant@^1.0.2, tiny-invariant@^1.1.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" + integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== + +tiny-warning@^1.0.0, tiny-warning@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinycolor2@^1.4.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" + integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== + +tinyexec@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.1.tgz#0ab0daf93b43e2c211212396bdb836b468c97c98" + integrity sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ== + +tinyglobby@^0.2.10: + version "0.2.10" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.10.tgz#e712cf2dc9b95a1f5c5bbd159720e15833977a0f" + integrity sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew== + dependencies: + fdir "^6.4.2" + picomatch "^4.0.2" + +tinypool@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.2.tgz#706193cc532f4c100f66aa00b01c42173d9051b2" + integrity sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== + +tinyrainbow@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz#5c57d2fc0fb3d1afd78465c33ca885d04f02abb5" + integrity sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ== + +tinyspy@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.2.tgz#86dd3cf3d737b15adcf17d7887c84a75201df20a" + integrity sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q== + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tough-cookie@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA== + dependencies: + punycode "^2.1.0" + +tr46@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec" + integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== + dependencies: + punycode "^2.3.1" + +ts-api-utils@^1.0.1, ts-api-utils@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.0.tgz#709c6f2076e511a81557f3d07a0cbd566ae8195c" + integrity sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ== + +ts-interface-checker@^0.1.9: + version "0.1.13" + resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== + +tsconfig-paths@^3.14.2: + version "3.14.2" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz#6e32f1f79412decd261f92d633a9dc1cfa99f088" + integrity sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.2" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@2, tslib@^2.0.3, tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tslib@^1.9.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-fest@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.16.0.tgz#3240b891a78b0deae910dbeb86553e552a148860" + integrity sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^4.0.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.3.1.tgz#5cb58cdab5120f7ab0b40cfdc35073fb9adb651d" + integrity sha512-pphNW/msgOUSkJbH58x8sqpq8uQj6b0ZKGxEsLKMUnGorRcDjrUaLS+39+/ub41JNTwrrMyJcUB8+YZs3mbwqw== + +typed-array-buffer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz#18de3e7ed7974b0a729d3feecb94338d1472cd60" + integrity sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + is-typed-array "^1.1.10" + +typed-array-byte-length@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz#d787a24a995711611fb2b87a4052799517b230d0" + integrity sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA== + dependencies: + call-bind "^1.0.2" + for-each "^0.3.3" + has-proto "^1.0.1" + is-typed-array "^1.1.10" + +typed-array-byte-offset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz#cbbe89b51fdef9cd6aaf07ad4707340abbc4ea0b" + integrity sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + for-each "^0.3.3" + has-proto "^1.0.1" + is-typed-array "^1.1.10" + +typed-array-length@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.4.tgz#89d83785e5c4098bec72e08b319651f0eac9c1bb" + integrity sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng== + dependencies: + call-bind "^1.0.2" + for-each "^0.3.3" + is-typed-array "^1.1.9" + +typescript@5, typescript@^5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" + integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +undici-types@~6.19.8: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + +unicode-canonical-property-names-ecmascript@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" + integrity sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ== + +unicode-emoji-utils@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/unicode-emoji-utils/-/unicode-emoji-utils-1.2.0.tgz#4f9452fcab0e3816ff1d93412d06ee1a1ba2cdc0" + integrity sha512-djUB91p/6oYpgps4W5K/MAvM+UspoAANHSUW495BrxeLRoned3iNPEDQgrKx9LbLq93VhNz0NWvI61vcfrwYoA== + dependencies: + emoji-regex "10.3.0" + +unicode-match-property-ecmascript@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz#54fd16e0ecb167cf04cf1f756bdcc92eba7976c3" + integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q== + dependencies: + unicode-canonical-property-names-ecmascript "^2.0.0" + unicode-property-aliases-ecmascript "^2.0.0" + +unicode-match-property-value-ecmascript@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz#cb5fffdcd16a05124f5a4b0bf7c3770208acbbe0" + integrity sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA== + +unicode-property-aliases-ecmascript@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" + integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== + +unicode-trie@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8" + integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ== + dependencies: + pako "^0.2.5" + tiny-inflate "^1.0.0" + +unique-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" + integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== + dependencies: + crypto-random-string "^2.0.0" + +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +upath@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" + integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== + +update-browserslist-db@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e" + integrity sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ== + dependencies: + escalade "^3.1.2" + picocolors "^1.0.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +use-sync-external-store@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +value-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" + integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + +vite-node@2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.8.tgz#9495ca17652f6f7f95ca7c4b568a235e0c8dbac5" + integrity sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg== + dependencies: + cac "^6.7.14" + debug "^4.3.7" + es-module-lexer "^1.5.4" + pathe "^1.1.2" + vite "^5.0.0" + +vite-plugin-checker@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/vite-plugin-checker/-/vite-plugin-checker-0.8.0.tgz#33419857a623b35c9483e4f603d4ca8b6984acde" + integrity sha512-UA5uzOGm97UvZRTdZHiQVYFnd86AVn8EVaD4L3PoVzxH+IZSfaAw14WGFwX9QS23UW3lV/5bVKZn6l0w+q9P0g== + dependencies: + "@babel/code-frame" "^7.12.13" + ansi-escapes "^4.3.0" + chalk "^4.1.1" + chokidar "^3.5.1" + commander "^8.0.0" + fast-glob "^3.2.7" + fs-extra "^11.1.0" + npm-run-path "^4.0.1" + strip-ansi "^6.0.0" + tiny-invariant "^1.1.0" + vscode-languageclient "^7.0.0" + vscode-languageserver "^7.0.0" + vscode-languageserver-textdocument "^1.0.1" + vscode-uri "^3.0.2" + +vite-plugin-compile-time@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/vite-plugin-compile-time/-/vite-plugin-compile-time-0.3.2.tgz#7b3b2aabdbbaf58d1b0d09c47fc22854c87f1330" + integrity sha512-obNDktew663JQlriX5MJV/l2e8ofPzr1yfZBq0erjIxMuwmmnEfT7SYBGfcb/Y35u17nzQqsAvCvqbsxpbmvwQ== + dependencies: + bundle-require "^5.0.0" + devalue "^5.1.1" + esbuild "^0.24.0" + magic-string "^0.30.14" + +vite-plugin-html@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/vite-plugin-html/-/vite-plugin-html-3.2.2.tgz#661834fa09015d3fda48ba694dbaa809396f5f7a" + integrity sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q== + dependencies: + "@rollup/pluginutils" "^4.2.0" + colorette "^2.0.16" + connect-history-api-fallback "^1.6.0" + consola "^2.15.3" + dotenv "^16.0.0" + dotenv-expand "^8.0.2" + ejs "^3.1.6" + fast-glob "^3.2.11" + fs-extra "^10.0.1" + html-minifier-terser "^6.1.0" + node-html-parser "^5.3.3" + pathe "^0.2.0" + +vite-plugin-pwa@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/vite-plugin-pwa/-/vite-plugin-pwa-0.21.1.tgz#2fb718ce6c7e62729d51e91bce739232e4a8e792" + integrity sha512-rkTbKFbd232WdiRJ9R3u+hZmf5SfQljX1b45NF6oLA6DSktEKpYllgTo1l2lkiZWMWV78pABJtFjNXfBef3/3Q== + dependencies: + debug "^4.3.6" + pretty-bytes "^6.1.1" + tinyglobby "^0.2.10" + workbox-build "^7.3.0" + workbox-window "^7.3.0" + +vite-plugin-static-copy@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-2.2.0.tgz#8eec750f016e508b09234da880e9310cd1367b9b" + integrity sha512-ytMrKdR9iWEYHbUxs6x53m+MRl4SJsOSoMu1U1+Pfg0DjPeMlsRVx3RR5jvoonineDquIue83Oq69JvNsFSU5w== + dependencies: + chokidar "^3.5.3" + fast-glob "^3.2.11" + fs-extra "^11.1.0" + picocolors "^1.0.0" + +vite@^5.0.0: + version "5.4.8" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.8.tgz#af548ce1c211b2785478d3ba3e8da51e39a287e8" + integrity sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vite@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.0.2.tgz#7a22630c73c7b663335ddcdb2390971ffbc14993" + integrity sha512-XdQ+VsY2tJpBsKGs0wf3U/+azx8BBpYRHFAyKm5VeEZNOJZRB63q7Sc8Iup3k0TrN3KO6QgyzFf+opSbfY1y0g== + dependencies: + esbuild "^0.24.0" + postcss "^8.4.49" + rollup "^4.23.0" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.8.tgz#2e6a00bc24833574d535c96d6602fb64163092fa" + integrity sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ== + dependencies: + "@vitest/expect" "2.1.8" + "@vitest/mocker" "2.1.8" + "@vitest/pretty-format" "^2.1.8" + "@vitest/runner" "2.1.8" + "@vitest/snapshot" "2.1.8" + "@vitest/spy" "2.1.8" + "@vitest/utils" "2.1.8" + chai "^5.1.2" + debug "^4.3.7" + expect-type "^1.1.0" + magic-string "^0.30.12" + pathe "^1.1.2" + std-env "^3.8.0" + tinybench "^2.9.0" + tinyexec "^0.3.1" + tinypool "^1.0.1" + tinyrainbow "^1.2.0" + vite "^5.0.0" + vite-node "2.1.8" + why-is-node-running "^2.3.0" + +vscode-jsonrpc@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-6.0.0.tgz#108bdb09b4400705176b957ceca9e0880e9b6d4e" + integrity sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg== + +vscode-languageclient@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/vscode-languageclient/-/vscode-languageclient-7.0.0.tgz#b505c22c21ffcf96e167799757fca07a6bad0fb2" + integrity sha512-P9AXdAPlsCgslpP9pRxYPqkNYV7Xq8300/aZDpO35j1fJm/ncize8iGswzYlcvFw5DQUx4eVk+KvfXdL0rehNg== + dependencies: + minimatch "^3.0.4" + semver "^7.3.4" + vscode-languageserver-protocol "3.16.0" + +vscode-languageserver-protocol@3.16.0: + version "3.16.0" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.16.0.tgz#34135b61a9091db972188a07d337406a3cdbe821" + integrity sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A== + dependencies: + vscode-jsonrpc "6.0.0" + vscode-languageserver-types "3.16.0" + +vscode-languageserver-textdocument@^1.0.1: + version "1.0.8" + resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.8.tgz#9eae94509cbd945ea44bca8dcfe4bb0c15bb3ac0" + integrity sha512-1bonkGqQs5/fxGT5UchTgjGVnfysL0O8v1AYMBjqTbWQTFn721zaPGDYFkOKtfDgFiSgXM3KwaG3FMGfW4Ed9Q== + +vscode-languageserver-types@3.16.0: + version "3.16.0" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz#ecf393fc121ec6974b2da3efb3155644c514e247" + integrity sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA== + +vscode-languageserver@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-7.0.0.tgz#49b068c87cfcca93a356969d20f5d9bdd501c6b0" + integrity sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw== + dependencies: + vscode-languageserver-protocol "3.16.0" + +vscode-uri@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.7.tgz#6d19fef387ee6b46c479e5fb00870e15e58c1eb8" + integrity sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA== + +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + +warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" + integrity sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w= + dependencies: + loose-envify "^1.0.0" + +warning@^4.0.0, warning@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +websocket-ts@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/websocket-ts/-/websocket-ts-2.1.5.tgz#b6b51f0afca89d6bc7ff71c9e74540f19ae0262c" + integrity sha512-rCNl9w6Hsir1azFm/pbjBEFzLD/gi7Th5ZgOxMifB6STUfTSovYAzryWw0TRvSZ1+Qu1Z5Plw4z42UfTNA9idA== + +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + +whatwg-url@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.0.0.tgz#00baaa7fd198744910c4b1ef68378f2200e4ceb6" + integrity sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw== + dependencies: + tr46 "^5.0.0" + webidl-conversions "^7.0.0" + +whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-builtin-type@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.1.3.tgz#b1b8443707cc58b6e9bf98d32110ff0c2cbd029b" + integrity sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw== + dependencies: + function.prototype.name "^1.1.5" + has-tostringtag "^1.0.0" + is-async-function "^2.0.0" + is-date-object "^1.0.5" + is-finalizationregistry "^1.0.2" + is-generator-function "^1.0.10" + is-regex "^1.1.4" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.0.2" + which-collection "^1.0.1" + which-typed-array "^1.1.9" + +which-collection@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.1.tgz#70eab71ebbbd2aefaf32f917082fc62cdcb70906" + integrity sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A== + dependencies: + is-map "^2.0.1" + is-set "^2.0.1" + is-weakmap "^2.0.1" + is-weakset "^2.0.1" + +which-typed-array@^1.1.11, which-typed-array@^1.1.9: + version "1.1.11" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.11.tgz#99d691f23c72aab6768680805a271b69761ed61a" + integrity sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + for-each "^0.3.3" + gopd "^1.0.1" + has-tostringtag "^1.0.0" + +which@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +workbox-background-sync@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz#b6340731a8d5b42b9e75a8a87c8806928e6e6303" + integrity sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg== + dependencies: + idb "^7.0.1" + workbox-core "7.3.0" + +workbox-broadcast-update@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz#bff86b91795c4b9fa46a758d1a7a151828623280" + integrity sha512-T9/F5VEdJVhwmrIAE+E/kq5at2OY6+OXXgOWQevnubal6sO92Gjo24v6dCVwQiclAF5NS3hlmsifRrpQzZCdUA== + dependencies: + workbox-core "7.3.0" + +workbox-build@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-7.3.0.tgz#ab688f3241b32862236aeeb62b240195f1fe4b62" + integrity sha512-JGL6vZTPlxnlqZRhR/K/msqg3wKP+m0wfEUVosK7gsYzSgeIxvZLi1ViJJzVL7CEeI8r7rGFV973RiEqkP3lWQ== + dependencies: + "@apideck/better-ajv-errors" "^0.3.1" + "@babel/core" "^7.24.4" + "@babel/preset-env" "^7.11.0" + "@babel/runtime" "^7.11.2" + "@rollup/plugin-babel" "^5.2.0" + "@rollup/plugin-node-resolve" "^15.2.3" + "@rollup/plugin-replace" "^2.4.1" + "@rollup/plugin-terser" "^0.4.3" + "@surma/rollup-plugin-off-main-thread" "^2.2.3" + ajv "^8.6.0" + common-tags "^1.8.0" + fast-json-stable-stringify "^2.1.0" + fs-extra "^9.0.1" + glob "^7.1.6" + lodash "^4.17.20" + pretty-bytes "^5.3.0" + rollup "^2.43.1" + source-map "^0.8.0-beta.0" + stringify-object "^3.3.0" + strip-comments "^2.0.1" + tempy "^0.6.0" + upath "^1.2.0" + workbox-background-sync "7.3.0" + workbox-broadcast-update "7.3.0" + workbox-cacheable-response "7.3.0" + workbox-core "7.3.0" + workbox-expiration "7.3.0" + workbox-google-analytics "7.3.0" + workbox-navigation-preload "7.3.0" + workbox-precaching "7.3.0" + workbox-range-requests "7.3.0" + workbox-recipes "7.3.0" + workbox-routing "7.3.0" + workbox-strategies "7.3.0" + workbox-streams "7.3.0" + workbox-sw "7.3.0" + workbox-window "7.3.0" + +workbox-cacheable-response@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-7.3.0.tgz#557b0f5fdfceb22fe243e3f19807c76a0ae646e3" + integrity sha512-eAFERIg6J2LuyELhLlmeRcJFa5e16Mj8kL2yCDbhWE+HUun9skRQrGIFVUagqWj4DMaaPSMWfAolM7XZZxNmxA== + dependencies: + workbox-core "7.3.0" + +workbox-core@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.3.0.tgz#f24fb92041a0b7482fe2dd856544aaa9fa105248" + integrity sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw== + +workbox-expiration@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-7.3.0.tgz#2c1ee1fdada34aa7e7474f706d5429c914bd10d2" + integrity sha512-lpnSSLp2BM+K6bgFCWc5bS1LR5pAwDWbcKt1iL87/eTSJRdLdAwGQznZE+1czLgn/X05YChsrEegTNxjM067vQ== + dependencies: + idb "^7.0.1" + workbox-core "7.3.0" + +workbox-google-analytics@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz#3c4d4956c0a9800dfb587d82ec8bc0f9cf963791" + integrity sha512-ii/tSfFdhjLHZ2BrYgFNTrb/yk04pw2hasgbM70jpZfLk0vdJAXgaiMAWsoE+wfJDNWoZmBYY0hMVI0v5wWDbg== + dependencies: + workbox-background-sync "7.3.0" + workbox-core "7.3.0" + workbox-routing "7.3.0" + workbox-strategies "7.3.0" + +workbox-navigation-preload@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-7.3.0.tgz#9d54693b9179d5175e66af5ef9a92d1b7cf3e605" + integrity sha512-fTJzogmFaTv4bShZ6aA7Bfj4Cewaq5rp30qcxl2iYM45YD79rKIhvzNHiFj1P+u5ZZldroqhASXwwoyusnr2cg== + dependencies: + workbox-core "7.3.0" + +workbox-precaching@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.3.0.tgz#a84663d69efdb334f25c04dba0a72ed3391c4da8" + integrity sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw== + dependencies: + workbox-core "7.3.0" + workbox-routing "7.3.0" + workbox-strategies "7.3.0" + +workbox-range-requests@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-7.3.0.tgz#1b3d5c235a0ff5271418c3a7183281dc131ccd0d" + integrity sha512-EyFmM1KpDzzAouNF3+EWa15yDEenwxoeXu9bgxOEYnFfCxns7eAxA9WSSaVd8kujFFt3eIbShNqa4hLQNFvmVQ== + dependencies: + workbox-core "7.3.0" + +workbox-recipes@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-7.3.0.tgz#fa407101e8ce52850dfba8e17a5afccb733a3942" + integrity sha512-BJro/MpuW35I/zjZQBcoxsctgeB+kyb2JAP5EB3EYzePg8wDGoQuUdyYQS+CheTb+GhqJeWmVs3QxLI8EBP1sg== + dependencies: + workbox-cacheable-response "7.3.0" + workbox-core "7.3.0" + workbox-expiration "7.3.0" + workbox-precaching "7.3.0" + workbox-routing "7.3.0" + workbox-strategies "7.3.0" + +workbox-routing@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.3.0.tgz#fc86296bc1155c112ee2c16b3180853586c30208" + integrity sha512-ZUlysUVn5ZUzMOmQN3bqu+gK98vNfgX/gSTZ127izJg/pMMy4LryAthnYtjuqcjkN4HEAx1mdgxNiKJMZQM76A== + dependencies: + workbox-core "7.3.0" + +workbox-strategies@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.3.0.tgz#bb1530f205806895aacdea3639e6cf6bfb3a6cb0" + integrity sha512-tmZydug+qzDFATwX7QiEL5Hdf7FrkhjaF9db1CbB39sDmEZJg3l9ayDvPxy8Y18C3Y66Nrr9kkN1f/RlkDgllg== + dependencies: + workbox-core "7.3.0" + +workbox-streams@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-7.3.0.tgz#a4c0ae51b66121a2aa6f89229e237aca6dc27eb5" + integrity sha512-SZnXucyg8x2Y61VGtDjKPO5EgPUG5NDn/v86WYHX+9ZqvAsGOytP0Jxp1bl663YUuMoXSAtsGLL+byHzEuMRpw== + dependencies: + workbox-core "7.3.0" + workbox-routing "7.3.0" + +workbox-sw@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-7.3.0.tgz#39215017e868d7cfe6835b2961f55369d89b3e73" + integrity sha512-aCUyoAZU9IZtH05mn0ACUpyHzPs0lMeJimAYkQkBsOWiqaJLgusfDCR+yllkPkFRxWpZKF8vSvgHYeG7LwhlmA== + +workbox-window@7.3.0, workbox-window@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-7.3.0.tgz#e71bb0b4d880d2295c96bf1ccadb6cea0df51c07" + integrity sha512-qW8PDy16OV1UBaUNGlTVcepzrlzyzNW/ZJvFQQs2j2TzGsg6IKjcpZC1RSquqQnTOafl5pCj5bGfAHlCjOOjdA== + dependencies: + "@types/trusted-types" "^2.0.2" + workbox-core "7.3.0" + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write-file-atomic@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" + integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^4.0.1" + +ws@^8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" + integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + +xcase@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" + integrity sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw== + +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yaml@^1.10.0: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +yaml@^2.1.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.2.tgz#f522db4313c671a0ca963a75670f1c12ea909144" + integrity sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.5.1: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.23.4, zod@^3.23.5: + version "3.23.5" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.5.tgz#c7b7617d017d4a2f21852f533258d26a9a5ae09f" + integrity sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA== + +zustand@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.0.tgz#71f8aaecf185592a3ba2743d7516607361899da9" + integrity sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==