Merge remote-tracking branch 'origin/main' into instance-v2

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-01-05 00:33:40 +01:00
commit 231a68fb63
96 changed files with 4363 additions and 2729 deletions

View File

@ -5,4 +5,3 @@
/tmp/**
/coverage/**
/custom/**
!.eslintrc.cjs

View File

@ -1,304 +0,0 @@
module.exports = {
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',
],
parserOptions: {
sourceType: 'module',
ecmaFeatures: {
experimentalObjectRestSpread: true,
jsx: true,
},
ecmaVersion: 2018,
},
settings: {
react: {
version: 'detect',
},
'import/extensions': ['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx'],
'import/ignore': [
'node_modules',
'\\.(css|scss|json)$',
],
'import/resolver': {
typescript: true,
node: true,
},
polyfills: [
'es:all', // core-js
'fetch', // not polyfilled, but ignore it
'IntersectionObserver', // npm:intersection-observer
'Promise', // core-js
'ResizeObserver', // npm:resize-observer-polyfill
'URL', // core-js
'URLSearchParams', // core-js
],
tailwindcss: {
config: 'tailwind.config.cjs',
},
},
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, // https://stackoverflow.com/a/53055584/8811886
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': 'warn',
'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-bind': ['error'],
'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',
'always',
{
js: 'never',
mjs: 'ignorePackages',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
'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.cjs',
},
],
'tailwindcss/migration-from-tailwind-2': 'error',
},
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-undef': 'off', // https://stackoverflow.com/a/69155899
'space-before-function-paren': 'off',
},
parser: '@typescript-eslint/parser',
},
{
// Only enforce JSDoc comments on UI components for now.
// https://www.npmjs.com/package/eslint-plugin-jsdoc
files: ['src/components/ui/**/*'],
rules: {
'jsdoc/require-jsdoc': ['error', {
publicOnly: true,
require: {
ArrowFunctionExpression: true,
ClassDeclaration: true,
ClassExpression: true,
FunctionDeclaration: true,
FunctionExpression: true,
MethodDefinition: true,
},
}],
},
},
],
};

350
.eslintrc.json Normal file
View File

@ -0,0 +1,350 @@
{
"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"
],
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
},
"ecmaVersion": 2018
},
"settings": {
"react": {
"version": "detect"
},
"import/extensions": [
".js",
".jsx",
".cjs",
".mjs",
".ts",
".tsx"
],
"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": "warn",
"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",
"always",
{
"js": "never",
"mjs": "ignorePackages",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
],
"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"
},
"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
}
}
]
}
}
]
}

View File

@ -1,4 +1,4 @@
image: node:20
image: node:21
variables:
NODE_ENV: test
@ -45,7 +45,7 @@ lint:
- "**/*.scss"
- "**/*.css"
- ".eslintignore"
- ".eslintrc.cjs"
- ".eslintrc.json"
- ".stylelintrc.json"
build:
@ -111,9 +111,9 @@ pages:
docker:
stage: deploy
image: docker:24.0.6
image: docker:24.0.7
services:
- docker:24.0.6-dind
- docker:24.0.7-dind
tags:
- dind
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df

11
.gitpod.yml Normal file
View File

@ -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

View File

@ -7,10 +7,8 @@
"color-function-notation": null,
"custom-property-pattern": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-colon-newline-after": null,
"declaration-empty-line-before": "never",
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }],
"max-line-length": null,
"no-descending-specificity": null,
"no-duplicate-selectors": null,
"no-invalid-position-at-import-rule": null,

View File

@ -1 +1 @@
nodejs 20.0.0
nodejs 21.4.0

View File

@ -1,4 +1,4 @@
FROM node:20 as build
FROM node:21 as build
WORKDIR /app
COPY package.json .
COPY yarn.lock .

View File

@ -1,4 +1,4 @@
FROM node:20
FROM node:21
RUN apt-get update &&\
apt-get install -y inotify-tools &&\

View File

@ -47,28 +47,30 @@
"@emoji-mart/data": "^1.1.2",
"@floating-ui/react": "^0.26.0",
"@fontsource/inter": "^5.0.0",
"@fontsource/noto-sans-javanese": "^5.0.16",
"@fontsource/roboto-mono": "^5.0.0",
"@fontsource/tajawal": "^5.0.8",
"@gamestdio/websocket": "^0.3.2",
"@lexical/clipboard": "^0.12.2",
"@lexical/hashtag": "^0.12.2",
"@lexical/link": "^0.12.2",
"@lexical/react": "^0.12.2",
"@lexical/selection": "^0.12.2",
"@lexical/utils": "^0.12.2",
"@lexical/clipboard": "^0.12.4",
"@lexical/hashtag": "^0.12.4",
"@lexical/link": "^0.12.4",
"@lexical/react": "^0.12.4",
"@lexical/selection": "^0.12.4",
"@lexical/utils": "^0.12.4",
"@popperjs/core": "^2.11.5",
"@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": "^1.8.1",
"@reduxjs/toolkit": "^2.0.1",
"@sentry/browser": "^7.74.1",
"@sentry/react": "^7.74.1",
"@soapbox.pub/wasmboy": "^0.8.0",
"@tabler/icons": "^2.0.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/react-query": "^5.0.0",
"@types/escape-html": "^1.0.1",
"@types/http-link-header": "^1.0.3",
@ -81,11 +83,11 @@
"@types/react-datepicker": "^4.4.2",
"@types/react-dom": "^18.0.10",
"@types/react-helmet": "^6.1.5",
"@types/react-motion": "^0.0.36",
"@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.3",
"@types/redux-mock-store": "^1.0.6",
"@types/semver": "^7.3.9",
"@types/uuid": "^9.0.0",
"@vitejs/plugin-react": "^4.0.4",
@ -99,6 +101,7 @@
"bowser": "^2.11.0",
"browserslist": "^4.16.6",
"clsx": "^2.0.0",
"comlink": "^4.4.1",
"core-js": "^3.27.2",
"cryptocurrency-icons": "^0.18.1",
"cssnano": "^6.0.0",
@ -109,15 +112,15 @@
"escape-html": "^1.0.3",
"exifr": "^7.1.3",
"graphemesplit": "^2.4.4",
"html-react-parser": "^4.2.2",
"html-react-parser": "^5.0.0",
"http-link-header": "^1.0.2",
"immer": "^10.0.0",
"immutable": "^4.2.1",
"intersection-observer": "^0.12.2",
"intl-messageformat": "10.5.4",
"intl-messageformat": "10.5.8",
"intl-pluralrules": "^2.0.0",
"leaflet": "^1.8.0",
"lexical": "^0.12.2",
"lexical": "^0.12.4",
"line-awesome": "^1.3.0",
"localforage": "^1.10.0",
"lodash": "^4.7.11",
@ -143,7 +146,7 @@
"react-motion": "^0.5.2",
"react-overlays": "^0.9.0",
"react-popper": "^2.3.0",
"react-redux": "^8.0.0",
"react-redux": "^9.0.4",
"react-router-dom": "^5.3.0",
"react-router-dom-v5-compat": "^6.6.2",
"react-router-scroll-4": "^1.0.0-beta.2",
@ -152,12 +155,12 @@
"react-sticky-box": "^2.0.0",
"react-swipeable-views": "^0.14.0",
"react-virtuoso": "^4.3.11",
"redux": "^4.1.1",
"redux": "^5.0.0",
"redux-immutable": "^4.0.0",
"redux-thunk": "^2.2.0",
"reselect": "^4.0.0",
"redux-thunk": "^3.1.0",
"reselect": "^5.0.0",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.66.1",
"sass": "^1.69.5",
"semver": "^7.3.8",
"stringz": "^2.0.0",
"substring-trie": "^1.0.2",
@ -168,11 +171,11 @@
"typescript": "^5.1.3",
"util": "^0.12.4",
"uuid": "^9.0.0",
"vite": "^4.4.9",
"vite": "^5.0.10",
"vite-plugin-compile-time": "^0.2.1",
"vite-plugin-html": "^3.2.0",
"vite-plugin-require": "^1.1.10",
"vite-plugin-static-copy": "^0.17.0",
"vite-plugin-static-copy": "^1.0.0",
"wicg-inert": "^3.1.1",
"zod": "^3.21.4"
},
@ -200,17 +203,17 @@
"eslint-plugin-tailwindcss": "^3.13.0",
"fake-indexeddb": "^5.0.0",
"husky": "^8.0.0",
"jsdom": "^22.1.0",
"jsdom": "^23.0.0",
"lint-staged": ">=10",
"react-intl-translations-manager": "^5.0.3",
"react-refresh": "^0.14.0",
"rollup-plugin-visualizer": "^5.9.2",
"stylelint": "^15.10.3",
"stylelint-config-standard-scss": "^11.0.0",
"tailwindcss": "^3.3.3",
"stylelint": "^16.0.2",
"stylelint-config-standard-scss": "^12.0.0",
"tailwindcss": "^3.4.0",
"vite-plugin-checker": "^0.6.2",
"vite-plugin-pwa": "^0.16.5",
"vitest": "^0.34.4"
"vite-plugin-pwa": "^0.17.0",
"vitest": "^1.0.0"
},
"resolutions": {
"@types/react": "^18.0.26",

View File

@ -1,7 +1,10 @@
module.exports = ({ env }) => ({
/** @type {import('postcss-load-config').ConfigFn} */
const config = ({ env }) => ({
plugins: {
tailwindcss: {},
autoprefixer: {},
cssnano: env === 'production' ? {} : false,
},
});
module.exports = config;

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { isLoggedIn } from 'soapbox/utils/auth';
@ -8,7 +8,7 @@ import { importFetchedAccounts, importFetchedStatus } from './importer';
import { favourite, unfavourite } from './interactions';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status } from 'soapbox/types/entities';
import type { APIEntity, EmojiReaction, Status } from 'soapbox/types/entities';
const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST';
const EMOJI_REACT_SUCCESS = 'EMOJI_REACT_SUCCESS';
@ -26,17 +26,17 @@ const noOp = () => () => new Promise(f => f(undefined));
const simpleEmojiReact = (status: Status, emoji: string, custom?: string) =>
(dispatch: AppDispatch) => {
const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList();
const emojiReacts: ImmutableList<EmojiReaction> = status.reactions || ImmutableList();
if (emoji === '👍' && status.favourited) return dispatch(unfavourite(status));
const undo = emojiReacts.filter(e => e.get('me') === true && e.get('name') === emoji).count() > 0;
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.get('me') === true)
.map(emojiReact => dispatch(unEmojiReact(status, emojiReact.get('name')))).toArray(),
.filter((emojiReact) => emojiReact.me === true)
.map(emojiReact => dispatch(unEmojiReact(status, emojiReact.name))).toArray(),
status.favourited && dispatch(unfavourite(status)),
]).then(() => {
if (emoji === '👍') {

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
import { Set as ImmutableSet } from 'immutable';
import ConfigDB from 'soapbox/utils/config-db';
@ -7,9 +7,9 @@ import { fetchConfig, updateConfig } from './admin';
import type { MRFSimple } from 'soapbox/schemas/pleroma';
import type { AppDispatch, RootState } from 'soapbox/store';
const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions: ImmutableMap<string, any>) => {
const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions: Record<string, any>) => {
const entries = Object.entries(simplePolicy).map(([key, hosts]) => {
const isRestricted = restrictions.get(key);
const isRestricted = restrictions[key];
if (isRestricted) {
return [key, ImmutableSet(hosts).add(host).toJS()];
@ -21,7 +21,7 @@ const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions:
return Object.fromEntries(entries);
};
const updateMrf = (host: string, restrictions: ImmutableMap<string, any>) =>
const updateMrf = (host: string, restrictions: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) =>
dispatch(fetchConfig())
.then(() => {

View File

@ -41,7 +41,7 @@ function useGroupValidation(name: string = '') {
...queryInfo,
data: {
...queryInfo.data,
isValid: !queryInfo.data?.error ?? true,
isValid: !queryInfo.data?.error,
},
};
}

View File

@ -36,7 +36,7 @@ function useSignerStream() {
const respMsg = {
id: reqMsg.data.id,
result: await signEvent(reqMsg.data.params[0]),
result: await signEvent(reqMsg.data.params[0], reqMsg.data.params[1]),
};
const respEvent = await signEvent({

View File

@ -187,7 +187,7 @@ const Account = ({
return (
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems={actionAlignment} space={3} justifyContent='between'>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3} className='overflow-hidden'>
<ProfilePopper
condition={showProfileHoverCard}

View File

@ -12,7 +12,7 @@ const BigCard: React.FC<IBigCard> = ({ title, subtitle, children }) => {
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>{title}</Text>
{subtitle && <Text theme='muted' align='center'>{subtitle}</Text>}

192
src/components/gameboy.tsx Normal file
View File

@ -0,0 +1,192 @@
// @ts-ignore No types available
import { WasmBoy } from '@soapbox.pub/wasmboy';
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { exitFullscreen, isFullscreen, requestFullscreen } from 'soapbox/features/ui/util/fullscreen';
import { HStack, IconButton } from './ui';
let gainNode: GainNode | undefined;
interface IGameboy extends Pick<React.HTMLAttributes<HTMLDivElement>, 'onFocus' | 'onBlur'> {
/** Classname of the outer `<div>`. */
className?: string;
/** URL to the ROM. */
src: string;
/** Aspect ratio of the canvas. */
aspect?: 'normal' | 'stretched';
}
/** Component to display a playable Gameboy emulator. */
const Gameboy: React.FC<IGameboy> = ({ className, src, aspect = 'normal', onFocus, onBlur, ...rest }) => {
const node = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const [paused, setPaused] = useState(false);
const [muted, setMuted] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
const [showControls, setShowControls] = useState(true);
async function init() {
await WasmBoy.config(WasmBoyOptions, canvas.current!);
await WasmBoy.loadROM(src);
await play();
if (document.activeElement === canvas.current) {
await WasmBoy.enableDefaultJoypad();
} else {
await WasmBoy.disableDefaultJoypad();
}
}
const handleFocus: React.FocusEventHandler<HTMLDivElement> = useCallback(() => {
WasmBoy.enableDefaultJoypad();
}, []);
const handleBlur: React.FocusEventHandler<HTMLDivElement> = useCallback(() => {
WasmBoy.disableDefaultJoypad();
}, []);
const handleFullscreenChange = useCallback(() => {
setFullscreen(isFullscreen());
}, []);
const handleCanvasClick = useCallback(() => {
setShowControls(!showControls);
}, [showControls]);
const pause = async () => {
await WasmBoy.pause();
setPaused(true);
};
const play = async () => {
await WasmBoy.play();
setPaused(false);
};
const togglePaused = () => paused ? play() : pause();
const toggleMuted = () => setMuted(!muted);
const toggleFullscreen = () => {
if (isFullscreen()) {
exitFullscreen();
} else if (node.current) {
requestFullscreen(node.current);
}
};
const handleDownload = () => {
window.open(src);
};
useEffect(() => {
init();
return () => {
WasmBoy.pause();
WasmBoy.disableDefaultJoypad();
};
}, []);
useEffect(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange, true);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange, true);
};
}, []);
useEffect(() => {
if (fullscreen) {
node.current?.focus();
}
}, [fullscreen]);
useEffect(() => {
if (gainNode) {
gainNode.gain.value = muted ? 0 : 1;
}
}, [gainNode, muted]);
return (
<div
ref={node}
tabIndex={0}
className={clsx(className, 'relative outline-none')}
onFocus={onFocus ?? handleFocus}
onBlur={onBlur ?? handleBlur}
>
<canvas
ref={canvas}
onClick={handleCanvasClick}
className={clsx('h-full w-full bg-black ', {
'object-contain': aspect === 'normal',
'object-cover': aspect === 'stretched',
})}
{...rest}
/>
<HStack
justifyContent='between'
className={clsx('pointer-events-none absolute inset-x-0 bottom-0 w-full bg-gradient-to-t from-black/50 to-transparent p-2 opacity-0 transition-opacity', {
'pointer-events-auto opacity-100': showControls,
})}
>
<HStack space={2}>
<IconButton
theme='transparent'
className='text-white'
onClick={togglePaused}
src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')}
/>
<IconButton
theme='transparent'
className='text-white'
onClick={toggleMuted}
src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')}
/>
</HStack>
<HStack space={2}>
<IconButton
theme='transparent'
className='text-white'
src={require('@tabler/icons/download.svg')}
onClick={handleDownload}
/>
<IconButton
theme='transparent'
className='text-white'
onClick={toggleFullscreen}
src={fullscreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')}
/>
</HStack>
</HStack>
</div>
);
};
const WasmBoyOptions = {
headless: false,
useGbcWhenOptional: true,
isAudioEnabled: true,
frameSkip: 1,
audioBatchProcessing: true,
timersBatchProcessing: false,
audioAccumulateSamples: true,
graphicsBatchProcessing: false,
graphicsDisableScanlineRendering: false,
tileRendering: true,
tileCaching: true,
gameboyFPSCap: 60,
updateGraphicsCallback: false,
updateAudioCallback: (audioContext: AudioContext, audioBufferSourceNode: AudioBufferSourceNode) => {
gainNode = gainNode ?? audioContext.createGain();
audioBufferSourceNode.connect(gainNode);
return gainNode;
},
saveStateCallback: false,
};
export default Gameboy;

View File

@ -3,7 +3,7 @@ import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Banner, Button, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector, useInstance, useSoapboxConfig } from 'soapbox/hooks';
import { useInstance, useSoapboxConfig } from 'soapbox/hooks';
const acceptedGdpr = !!localStorage.getItem('soapbox:gdpr');
@ -14,8 +14,7 @@ const GdprBanner: React.FC = () => {
const [slideout, setSlideout] = useState(false);
const instance = useInstance();
const soapbox = useSoapboxConfig();
const isLoggedIn = useAppSelector(state => !!state.me);
const { gdprUrl } = useSoapboxConfig();
const handleAccept = () => {
localStorage.setItem('soapbox:gdpr', 'true');
@ -23,15 +22,13 @@ const GdprBanner: React.FC = () => {
setTimeout(() => setShown(true), 200);
};
const showBanner = soapbox.gdpr && !isLoggedIn && !shown;
if (!showBanner) {
if (shown) {
return null;
}
return (
<Banner theme='opaque' className={clsx('transition-transform', { 'translate-y-full': slideout })}>
<div className='flex flex-col space-y-4 rtl:space-x-reverse lg:flex-row lg:items-center lg:justify-between lg:space-x-4 lg:space-y-0'>
<div className='flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-x-4 lg:space-y-0 rtl:space-x-reverse'>
<Stack space={2}>
<Text size='xl' weight='bold'>
<FormattedMessage id='gdpr.title' defaultMessage='{siteTitle} uses cookies' values={{ siteTitle: instance.title }} />
@ -47,8 +44,8 @@ const GdprBanner: React.FC = () => {
</Stack>
<HStack space={2} alignItems='center' className='flex-none'>
{soapbox.gdprUrl && (
<a href={soapbox.gdprUrl} tabIndex={-1} className='inline-flex'>
{gdprUrl && (
<a href={gdprUrl} tabIndex={-1} className='inline-flex'>
<Button theme='secondary'>
<FormattedMessage id='gdpr.learn_more' defaultMessage='Learn more' />
</Button>

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useState, useRef, useLayoutEffect } from 'react';
import React, { useState, useRef, useLayoutEffect, Suspense } from 'react';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
@ -15,6 +15,8 @@ import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maxi
import type { Property } from 'csstype';
import type { List as ImmutableList } from 'immutable';
const Gameboy = React.lazy(() => import('./gameboy'));
const ATTACHMENT_LIMIT = 4;
const MAX_FILENAME_LENGTH = 45;
@ -141,8 +143,24 @@ const Item: React.FC<IItem> = ({
}
let thumbnail: React.ReactNode = '';
const ext = attachment.url.split('.').pop()?.toLowerCase();
if (attachment.type === 'unknown') {
if (attachment.type === 'unknown' && ['gb', 'gbc'].includes(ext!)) {
return (
<div
className={clsx('media-gallery__item', {
standalone,
'rounded-md': total > 1,
})}
key={attachment.id}
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
>
<Suspense fallback={<div className='media-gallery__item-thumbnail' />}>
<Gameboy className='media-gallery__item-thumbnail cursor-default' src={attachment.url} />
</Suspense>
</div>
);
} else if (attachment.type === 'unknown') {
const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH);
const attachmentIcon = (
<Icon
@ -215,7 +233,6 @@ const Item: React.FC<IItem> = ({
</div>
);
} else if (attachment.type === 'audio') {
const ext = attachment.url.split('.').pop()?.toUpperCase();
thumbnail = (
<a
className={clsx('media-gallery__item-thumbnail')}
@ -225,11 +242,10 @@ const Item: React.FC<IItem> = ({
title={attachment.description}
>
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span>
<span className='media-gallery__file-extension__label'>{ext}</span>
<span className='media-gallery__file-extension__label uppercase'>{ext}</span>
</a>
);
} else if (attachment.type === 'video') {
const ext = attachment.url.split('.').pop()?.toUpperCase();
thumbnail = (
<a
className={clsx('media-gallery__item-thumbnail')}
@ -246,7 +262,7 @@ const Item: React.FC<IItem> = ({
>
<source src={attachment.url} />
</video>
<span className='media-gallery__file-extension__label'>{ext}</span>
<span className='media-gallery__file-extension__label uppercase'>{ext}</span>
</a>
);
}

View File

@ -122,7 +122,7 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
return (
<div key={option.title}>
{showResults ? (
<div title={voted ? message : undefined}>
<div title={message}>
<HStack
justifyContent='between'
alignItems='center'

View File

@ -84,7 +84,7 @@ const SiteErrorBoundary: React.FC<ISiteErrorBoundary> = ({ children }) => {
<div className='py-8'>
<div className='mx-auto max-w-xl space-y-2 text-center'>
<h1 className='text-3xl font-extrabold tracking-tight text-gray-900 dark:text-gray-500 sm:text-4xl'>
<h1 className='text-3xl font-extrabold tracking-tight text-gray-900 sm:text-4xl dark:text-gray-500'>
<FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
</h1>
<p className='text-lg text-gray-700 dark:text-gray-600'>

View File

@ -1,4 +1,3 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory, useRouteMatch } from 'react-router-dom';
@ -626,15 +625,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const reblogCount = status.reblogs_count;
const favouriteCount = status.favourites_count;
const emojiReactCount = reduceEmoji(
(status.pleroma.get('emoji_reactions') || ImmutableList()) as ImmutableList<any>,
const emojiReactCount = status.reactions ? reduceEmoji(
status.reactions,
favouriteCount,
status.favourited,
allowedEmoji,
).reduce((acc, cur) => acc + cur.get('count'), 0);
).reduce((acc, cur) => acc + (cur.count || 0), 0) : undefined;
const meEmojiReact = getReactForStatus(status, allowedEmoji);
const meEmojiName = meEmojiReact?.get('name') as keyof typeof reactMessages | undefined;
const meEmojiName = meEmojiReact?.name as keyof typeof reactMessages | undefined;
const reactMessages = {
'👍': messages.reactionLike,

View File

@ -4,7 +4,7 @@ import React from 'react';
import { Text, Icon, Emoji } from 'soapbox/components/ui';
import { shortNumberFormat } from 'soapbox/utils/numbers';
import type { Map as ImmutableMap } from 'immutable';
import type { EmojiReaction } from 'soapbox/schemas';
const COLORS = {
accent: 'accent',
@ -33,7 +33,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
active?: boolean;
color?: Color;
filled?: boolean;
emoji?: ImmutableMap<string, any>;
emoji?: EmojiReaction;
text?: React.ReactNode;
theme?: 'default' | 'inverse';
}
@ -45,7 +45,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
if (emoji) {
return (
<span className='flex h-6 w-6 items-center justify-center'>
<Emoji className='h-full w-full p-0.5' emoji={emoji.get('name')} src={emoji.get('url')} />
<Emoji className='h-full w-full p-0.5' emoji={emoji.name} src={emoji.url} />
</span>
);
} else {

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
import parse, { Element, type HTMLReactParserOptions, domToReact } from 'html-react-parser';
import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode } from 'html-react-parser';
import React, { useState, useRef, useLayoutEffect, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
@ -105,7 +105,7 @@ const StatusContent: React.FC<IStatusContent> = ({
}
if (classes?.includes('hashtag')) {
const child = domToReact(domNode.children);
const child = domToReact(domNode.children as DOMNode[]);
const hashtag = typeof child === 'string' ? child.replace(/^#/, '') : undefined;
if (hashtag) {
return <HashtagLink hashtag={hashtag} />;
@ -121,7 +121,7 @@ const StatusContent: React.FC<IStatusContent> = ({
target='_blank'
title={domNode.attribs.href}
>
{domToReact(domNode.children, options)}
{domToReact(domNode.children as DOMNode[], options)}
</a>
);
}

View File

@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
};
const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍';
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.name || '👍';
if (isUserTouching()) {
if (ownAccount) {

View File

@ -93,7 +93,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
'text-gray-900 dark:text-gray-100': !props.disabled,
'text-gray-600': props.disabled,
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white dark:focus:bg-gray-900': theme === 'search',
'pr-10 rtl:pl-10 rtl:pr-3': isPassword || append,
'pl-8': typeof icon !== 'undefined',
'pl-16': typeof prepend !== 'undefined',

View File

@ -13,7 +13,7 @@ const Select = React.forwardRef<HTMLSelectElement, ISelect>((props, ref) => {
<select
ref={ref}
className={clsx(
'w-full truncate rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-primary-500 focus:outline-none focus:ring-primary-500 disabled:opacity-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:border-primary-500 dark:focus:ring-primary-500 sm:text-sm',
'w-full truncate rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-primary-500 focus:outline-none focus:ring-primary-500 disabled:opacity-50 sm:text-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:border-primary-500 dark:focus:ring-primary-500',
className,
)}
{...filteredProps}

View File

@ -45,7 +45,7 @@ const TagInput: React.FC<ITagInput> = ({ tags, onChange, placeholder }) => {
return (
<div className='relative mt-1 grow shadow-sm'>
<HStack
className='block w-full rounded-md border-gray-400 bg-white p-2 pb-0 text-gray-900 placeholder:text-gray-600 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus:border-primary-500 dark:focus:ring-primary-500 sm:text-sm'
className='block w-full rounded-md border-gray-400 bg-white p-2 pb-0 text-gray-900 placeholder:text-gray-600 focus:border-primary-500 focus:ring-primary-500 sm:text-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus:border-primary-500 dark:focus:ring-primary-500'
space={2}
wrap
>

View File

@ -91,7 +91,7 @@ const Textarea = React.forwardRef(({
ref={ref}
rows={rows}
onChange={handleChange}
className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 dark:text-gray-100 dark:placeholder:text-gray-600 sm:text-sm', {
className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 sm:text-sm dark:text-gray-100 dark:placeholder:text-gray-600', {
'bg-white dark:bg-transparent shadow-sm border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
theme === 'default',
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',

View File

@ -110,7 +110,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
return (
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'>
<div>
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' />
<div className='relative h-32 w-full bg-gray-200 md:rounded-t-xl lg:h-48 dark:bg-gray-900/50' />
</div>
<div className='px-4 sm:px-6'>
@ -620,7 +620,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
)}
<div>
<div className='relative isolate flex h-32 w-full flex-col justify-center overflow-hidden bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48'>
<div className='relative isolate flex h-32 w-full flex-col justify-center overflow-hidden bg-gray-200 md:rounded-t-xl lg:h-48 dark:bg-gray-900/50'>
{renderHeader()}
<div className='absolute left-2 top-2'>

View File

@ -44,7 +44,7 @@ const Search: React.FC = () => {
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='block w-full rounded-full focus:border-primary-500 focus:ring-primary-500 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500 sm:text-sm'
className='block w-full rounded-full focus:border-primary-500 focus:ring-primary-500 sm:text-sm dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500'
type='text'
value={value}
onChange={handleChange}

View File

@ -16,7 +16,7 @@ const ConsumersList: React.FC<IConsumersList> = () => {
if (providers.length > 0) {
return (
<Card className='bg-gray-50 p-4 dark:bg-primary-800 sm:rounded-xl'>
<Card className='bg-gray-50 p-4 sm:rounded-xl dark:bg-primary-800'>
<Text size='xs' theme='muted'>
<FormattedMessage id='oauth_consumers.title' defaultMessage='Other ways to sign in' />
</Text>

View File

@ -60,7 +60,7 @@ const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
<div
ref={containerRef}
style={{ height }}
className='h-screen overflow-hidden bg-white text-gray-900 shadow-lg dark:bg-primary-900 dark:text-gray-100 dark:shadow-none sm:rounded-t-xl'
className='h-screen overflow-hidden bg-white text-gray-900 shadow-lg sm:rounded-t-xl dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'
>
{isOnboarded ? (
<div
@ -68,7 +68,7 @@ const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
data-testid='chat-page'
>
<Stack
className={clsx('dark:inset col-span-9 overflow-hidden bg-gradient-to-r from-white to-gray-100 dark:bg-gray-900 dark:bg-none sm:col-span-3', {
className={clsx('dark:inset col-span-9 overflow-hidden bg-gradient-to-r from-white to-gray-100 sm:col-span-3 dark:bg-gray-900 dark:bg-none', {
'hidden sm:block': isSidebarHidden,
})}
>

View File

@ -121,7 +121,7 @@ const ChatPageMain = () => {
<HStack alignItems='center'>
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden rtl:rotate-180'
onClick={() => history.push('/chats')}
/>

View File

@ -24,7 +24,7 @@ const ChatPageNew: React.FC<IChatPageNew> = () => {
<HStack alignItems='center'>
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden rtl:rotate-180'
onClick={() => history.push('/chats')}
/>

View File

@ -51,7 +51,7 @@ const ChatPageSettings = () => {
<HStack alignItems='center'>
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden rtl:rotate-180'
onClick={() => history.push('/chats')}
/>

View File

@ -39,9 +39,9 @@ const ChatTextarea: React.FC<IChatTextarea> = React.forwardRef(({
bg-white text-gray-900
shadow-sm placeholder:text-gray-600
focus-within:border-primary-500
focus-within:ring-1 focus-within:ring-primary-500 dark:border-gray-800 dark:bg-gray-800
dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus-within:border-primary-500
dark:focus-within:ring-primary-500 sm:text-sm
focus-within:ring-1 focus-within:ring-primary-500 sm:text-sm dark:border-gray-800
dark:bg-gray-800 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600
dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500
`}
>
{(!!attachments?.length || isUploading) && (

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor } from 'lexical';
import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor, $getRoot } from 'lexical';
import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
@ -18,7 +18,6 @@ import { Button, HStack, Stack } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
import { ComposeEditor } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles, useFeatures, useInstance, usePrevious } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is-mobile';
import QuotedStatusContainer from '../containers/quoted-status-container';
import ReplyIndicatorContainer from '../containers/reply-indicator-container';
@ -96,23 +95,25 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const anyMedia = compose.media_attachments.size > 0;
const [composeFocused, setComposeFocused] = useState(false);
const [text, setText] = useState(compose.text);
const firstRender = useRef(true);
const formRef = useRef<HTMLDivElement>(null);
const spoilerTextRef = useRef<AutosuggestInput>(null);
const editorRef = useRef<LexicalEditor>(null);
const { isDraggedOver } = useDraggedFiles(formRef);
const text = editorRef.current?.getEditorState().read(() => $getRoot().getTextContent()) ?? '';
const fulltext = [spoilerText, countableText(text)].join('');
const isEmpty = !(fulltext.trim() || anyMedia);
const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty && !isUploading;
const shouldAutoFocus = autoFocus && !showSearch;
const canSubmit = !!editorRef.current && !isSubmitting && !isUploading && !isChangingUpload && !isEmpty && length(fulltext) <= maxTootChars;
const getClickableArea = () => {
return clickableAreaRef ? clickableAreaRef.current : formRef.current;
};
const isEmpty = () => {
return !(text || spoilerText || anyMedia);
};
const isClickOutside = (e: MouseEvent | React.MouseEvent) => {
return ![
// List of elements that shouldn't collapse the composer when clicked
@ -125,10 +126,10 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
};
const handleClick = useCallback((e: MouseEvent | React.MouseEvent) => {
if (isEmpty() && isClickOutside(e)) {
if (isEmpty && isClickOutside(e)) {
handleClickOutside();
}
}, []);
}, [isEmpty]);
const handleClickOutside = () => {
setComposeFocused(false);
@ -139,20 +140,12 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
};
const handleSubmit = (e?: React.FormEvent<Element>) => {
if (!canSubmit) return;
e?.preventDefault();
dispatch(changeCompose(id, text));
// Submit disabled:
const fulltext = [spoilerText, countableText(text)].join('');
if (e) {
e.preventDefault();
}
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
dispatch(submitCompose(id, { history }));
editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
};
@ -215,12 +208,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</HStack>
), [features, id]);
const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty() && !isUploading;
const disabled = isSubmitting;
const countedText = [spoilerText, countableText(text)].join('');
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
const composeModifiers = !condensed && (
<Stack space={4} className='compose-form__modifiers'>
<UploadForm composeId={id} onSubmit={handleSubmit} />
@ -297,7 +284,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
autoFocus={shouldAutoFocus}
hasPoll={hasPoll}
handleSubmit={handleSubmit}
onChange={setText}
onFocus={handleComposeFocus}
onPaste={onPaste}
/>
@ -324,7 +310,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</HStack>
)}
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={disabledButton} />
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={!canSubmit} />
</HStack>
</div>
</Stack>

View File

@ -11,10 +11,6 @@ export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand();
const FocusPlugin: React.FC<IFocusPlugin> = ({ autoFocus }) => {
const [editor] = useLexicalComposerContext();
const focus = () => {
editor.dispatchCommand(FOCUS_EDITOR_COMMAND, undefined);
};
useEffect(() => editor.registerCommand(FOCUS_EDITOR_COMMAND, () => {
editor.focus(
() => {
@ -29,8 +25,10 @@ const FocusPlugin: React.FC<IFocusPlugin> = ({ autoFocus }) => {
}, COMMAND_PRIORITY_NORMAL));
useEffect(() => {
if (autoFocus) focus();
}, []);
if (autoFocus) {
editor.dispatchCommand(FOCUS_EDITOR_COMMAND, undefined);
}
}, [autoFocus, editor]);
return null;
};

View File

@ -36,7 +36,7 @@ const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onC
<label
ref={picker}
className={clsx(
'dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-36 sm:shadow',
'dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 sm:h-36 sm:shadow dark:bg-gray-800 dark:text-accent-blue',
{
'border-2 border-primary-600 border-dashed !z-[99]': isDragging,
'ring-2 ring-offset-2 ring-primary-600': isDraggedOver,

View File

@ -80,7 +80,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
return (
<>
<div className='-mx-4 -mt-4'>
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' />
<div className='relative h-32 w-full bg-gray-200 md:rounded-t-xl lg:h-48 dark:bg-gray-900/50' />
</div>
<PlaceholderEventHeader />
@ -364,7 +364,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
return (
<>
<div className='-mx-4 -mt-4'>
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48'>
<div className='relative h-32 w-full bg-gray-200 md:rounded-t-xl lg:h-48 dark:bg-gray-900/50'>
{banner && (
<a href={banner.url} onClick={handleHeaderClick} target='_blank'>
<StillImage

View File

@ -24,7 +24,7 @@ const SuggestionItem: React.FC<ISuggestionItem> = ({ accountId }) => {
if (!account) return null;
return (
<Stack space={3} className='w-52 shrink-0 rounded-md border border-solid border-gray-300 p-4 dark:border-gray-800 md:w-full md:shrink md:border-transparent md:p-0 dark:md:border-transparent'>
<Stack space={3} className='w-52 shrink-0 rounded-md border border-solid border-gray-300 p-4 md:w-full md:shrink md:border-transparent md:p-0 dark:border-gray-800 dark:md:border-transparent'>
<Link
to={`/@${account.acct}`}
title={account.acct}

View File

@ -36,7 +36,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
return (
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6' data-testid='group-header-missing'>
<div>
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' />
<div className='relative h-32 w-full bg-gray-200 md:rounded-t-xl lg:h-48 dark:bg-gray-900/50' />
</div>
<div className='px-4 sm:px-6'>
@ -92,7 +92,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
<StillImage
src={group.header}
alt={intl.formatMessage(messages.header)}
className='relative h-32 w-full bg-gray-200 object-center dark:bg-gray-900/50 md:rounded-t-xl lg:h-52'
className='relative h-32 w-full bg-gray-200 object-center md:rounded-t-xl lg:h-52 dark:bg-gray-900/50'
onError={() => setIsHeaderMissing(true)}
/>
);
@ -109,7 +109,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
return (
<div
data-testid='group-header-image'
className='flex h-32 w-full items-center justify-center bg-gray-200 dark:bg-gray-800/30 md:rounded-t-xl lg:h-52'
className='flex h-32 w-full items-center justify-center bg-gray-200 md:rounded-t-xl lg:h-52 dark:bg-gray-800/30'
>
{isHeaderMissing ? (
<Icon src={require('@tabler/icons/photo-off.svg')} className='h-6 w-6 text-gray-500 dark:text-gray-700' />

View File

@ -7,7 +7,7 @@ import { useHashtagStream } from 'soapbox/api/hooks';
import List, { ListItem } from 'soapbox/components/list';
import { Column, Toggle } from 'soapbox/components/ui';
import Timeline from 'soapbox/features/ui/components/timeline';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useFeatures, useLoggedIn } from 'soapbox/hooks';
interface IHashtagTimeline {
params?: {
@ -22,7 +22,7 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
const dispatch = useAppDispatch();
const tag = useAppSelector((state) => state.tags.get(id));
const next = useAppSelector(state => state.timelines.get(`hashtag:${id}`)?.next);
const { isLoggedIn } = useLoggedIn();
const handleLoadMore = (maxId: string) => {
dispatch(expandHashtagTimeline(id, { url: next, maxId }));
@ -50,7 +50,7 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
return (
<Column label={`#${id}`} transparent>
{features.followHashtags && (
{features.followHashtags && isLoggedIn && (
<List>
<ListItem
label={<FormattedMessage id='hashtag.follow' defaultMessage='Follow hashtag' />}

View File

@ -7,6 +7,8 @@ import {
nip04 as _nip04,
} from 'nostr-tools';
import { powWorker } from 'soapbox/workers';
/** localStorage key for the Nostr private key (if not using NIP-07). */
const LOCAL_KEY = 'soapbox:nostr:privateKey';
@ -28,9 +30,18 @@ async function getPublicKey(): Promise<string> {
return window.nostr ? window.nostr.getPublicKey() : _getPublicKey(getPrivateKey());
}
interface SignEventOpts {
pow?: number;
}
/** Sign an event with NIP-07, or the locally generated key. */
async function signEvent<K extends number>(event: EventTemplate<K>): Promise<Event<K>> {
return window.nostr ? window.nostr.signEvent(event) as Promise<Event<K>> : finishEvent(event, getPrivateKey()) ;
async function signEvent<K extends number>(template: EventTemplate<K>, opts: SignEventOpts = {}): Promise<Event<K>> {
if (opts.pow) {
const event = await powWorker.mine({ ...template, pubkey: await getPublicKey() }, opts.pow) as Omit<Event<K>, 'sig'>;
return window.nostr ? window.nostr.signEvent(event) as Promise<Event<K>> : finishEvent(event, getPrivateKey()) ;
} else {
return window.nostr ? window.nostr.signEvent(template) as Promise<Event<K>> : finishEvent(template, getPrivateKey()) ;
}
}
/** Crypto function with NIP-07, or the local key. */

View File

@ -26,7 +26,7 @@ const FediverseStep = ({ onNext }: { onNext: () => void }) => {
</Text>
<Stack space={4}>
<div className='border-b border-solid border-gray-200 pb-2 dark:border-gray-800 sm:pb-5'>
<div className='border-b border-solid border-gray-200 pb-2 sm:pb-5 dark:border-gray-800'>
<Stack space={4}>
<Text theme='muted'>
<FormattedMessage

View File

@ -8,7 +8,7 @@ import PlaceholderStatusContent from './placeholder-status-content';
/** Fake notification to display while data is loading. */
const PlaceholderNotification = () => (
<div className='bg-white px-4 py-6 dark:bg-primary-900 sm:p-6'>
<div className='bg-white px-4 py-6 sm:p-6 dark:bg-primary-900'>
<div className='w-full animate-pulse'>
<div className='mb-2'>
<PlaceholderStatusContent minLength={20} maxLength={20} />

View File

@ -42,6 +42,7 @@ const languages = {
is: 'íslenska',
it: 'Italiano',
ja: '日本語',
jv: 'ꦧꦱꦗꦮ',
ka: 'ქართული',
kk: 'Қазақша',
ko: '한국어',

View File

@ -1,6 +1,4 @@
import clsx from 'clsx';
import { List as ImmutableList } from 'immutable';
import React from 'react';
import clsx from 'clsx';import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
@ -59,7 +57,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
const getNormalizedReacts = () => {
return reduceEmoji(
ImmutableList(status.pleroma.get('emoji_reactions') as any),
status.reactions,
status.favourites_count,
status.favourited,
allowedEmoji,
@ -164,20 +162,22 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
const getEmojiReacts = () => {
const emojiReacts = getNormalizedReacts();
const count = emojiReacts.reduce((acc, cur) => (
acc + cur.get('count')
acc + (cur.count || 0)
), 0);
const handleClick = features.emojiReacts ? handleOpenReactionsModal : handleOpenFavouritesModal;
if (count) {
return (
<InteractionCounter count={count} onClick={features.exposableReactions ? handleOpenReactionsModal : undefined}>
<InteractionCounter count={count} onClick={features.exposableReactions ? handleClick : undefined}>
<HStack space={0.5} alignItems='center'>
{emojiReacts.take(3).map((e, i) => {
return (
<Emoji
key={i}
className='h-4.5 w-4.5 flex-none'
emoji={e.get('name')}
src={e.get('url')}
emoji={e.name}
src={e.url}
/>
);
})}
@ -193,7 +193,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
<HStack space={3}>
{getReposts()}
{getQuotes()}
{features.emojiReacts ? getEmojiReacts() : getFavourites()}
{(features.emojiReacts || features.emojiReactsMastodon) ? getEmojiReacts() : getFavourites()}
{getDislikes()}
</HStack>
);

View File

@ -0,0 +1,45 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Column, Stack, Text, IconButton } from 'soapbox/components/ui';
import { isNetworkError } from 'soapbox/utils/errors';
const messages = defineMessages({
title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this page.' },
retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
});
interface IErrorColumn {
error: Error;
onRetry?: () => void;
}
const ErrorColumn: React.FC<IErrorColumn> = ({ error, onRetry = () => location.reload() }) => {
const intl = useIntl();
const handleRetry = () => {
onRetry?.();
};
if (!isNetworkError(error)) {
throw error;
}
return (
<Column label={intl.formatMessage(messages.title)}>
<Stack space={4} alignItems='center' justifyContent='center' className='min-h-[160px] rounded-lg p-10'>
<IconButton
iconClassName='h-10 w-10'
title={intl.formatMessage(messages.retry)}
src={require('@tabler/icons/refresh.svg')}
onClick={handleRetry}
/>
<Text align='center' theme='muted'>{intl.formatMessage(messages.body)}</Text>
</Stack>
</Column>
);
};
export default ErrorColumn;

View File

@ -209,7 +209,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
<FormGroup
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />}
>
<div className='dark:sm:shadow-inset relative flex h-24 items-center justify-center overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-white sm:h-32 sm:shadow'>
<div className='dark:sm:shadow-inset relative flex h-24 items-center justify-center overflow-hidden rounded-lg bg-primary-100 text-primary-500 sm:h-32 sm:shadow dark:bg-gray-800 dark:text-white'>
{banner ? (
<>
<img className='h-full w-full object-cover' src={banner.url} alt='' />
@ -234,7 +234,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
labelText={<FormattedMessage id='compose_event.fields.description_label' defaultMessage='Event description' />}
>
<ComposeEditor
className='block w-full rounded-md border border-gray-400 bg-white px-3 py-2 text-base text-gray-900 ring-1 placeholder:text-gray-600 focus-within:border-primary-500 focus-within:ring-primary-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500 sm:text-sm'
className='block w-full rounded-md border border-gray-400 bg-white px-3 py-2 text-base text-gray-900 ring-1 placeholder:text-gray-600 focus-within:border-primary-500 focus-within:ring-primary-500 sm:text-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500'
placeholderClassName='pt-2'
composeId='compose-event-modal'
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)}

View File

@ -30,24 +30,25 @@ const EditFederationModal: React.FC<IEditFederationModal> = ({ host, onClose })
const getRemoteInstance = useCallback(makeGetRemoteInstance(), []);
const remoteInstance = useAppSelector(state => getRemoteInstance(state, host));
const [data, setData] = useState({} as any);
const [data, setData] = useState<Record<string, any>>({});
useEffect(() => {
setData(remoteInstance.get('federation'));
setData(remoteInstance.get('federation') as Record<string, any>);
}, [remoteInstance]);
const handleDataChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => {
return ({ target }) => {
setData(data.set(key, target.checked));
setData({ ...data, [key]: target.checked });
};
};
const handleMediaRemoval: React.ChangeEventHandler<HTMLInputElement> = ({ target: { checked } }) => {
const newData = data.merge({
const newData = {
...data,
avatar_removal: checked,
banner_removal: checked,
media_removal: checked,
});
};
setData(newData);
};

View File

@ -42,7 +42,7 @@ const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
<Stack space={3}>
<Stack>
<label
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-36 sm:shadow'
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 sm:h-36 sm:shadow dark:bg-gray-800 dark:text-accent-blue'
>
{group.header && <img className='h-full w-full object-cover' src={group.header} alt='' />}
</label>

View File

@ -76,7 +76,7 @@ const Navbar = () => {
<div className='mx-auto max-w-7xl px-2 sm:px-6 lg:px-8'>
<div className='relative flex h-12 justify-between lg:h-16'>
{account && (
<div className='absolute inset-y-0 left-0 flex items-center rtl:left-auto rtl:right-0 lg:hidden'>
<div className='absolute inset-y-0 left-0 flex items-center lg:hidden rtl:left-auto rtl:right-0'>
<button onClick={onOpenSidebar}>
<Avatar src={account.avatar} size={34} />
</button>
@ -125,7 +125,7 @@ const Navbar = () => {
</Button>
</div>
) : (
<Form className='hidden items-center space-x-2 rtl:space-x-reverse xl:flex' onSubmit={handleSubmit}>
<Form className='hidden items-center space-x-2 xl:flex rtl:space-x-reverse' onSubmit={handleSubmit}>
<Input
required
value={username}

View File

@ -329,7 +329,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/developers/timeline' developerOnly page={DefaultPage} component={TestTimeline} content={children} />
<WrappedRoute path='/developers/sw' developerOnly page={DefaultPage} component={ServiceWorkerInfo} content={children} />
<WrappedRoute path='/developers' page={DefaultPage} component={Developers} content={children} />
<WrappedRoute path='/error/network' developerOnly page={EmptyPage} component={lazy(() => Promise.reject())} content={children} />
<WrappedRoute path='/error/network' developerOnly page={EmptyPage} component={lazy(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module: TEST')))} content={children} />
<WrappedRoute path='/error' developerOnly page={EmptyPage} component={IntentionalError} content={children} />
{hasCrypto && <WrappedRoute path='/donate/crypto' publicRoute page={DefaultPage} component={CryptoDonate} content={children} />}
@ -494,7 +494,7 @@ const UI: React.FC<IUI> = ({ children }) => {
</Layout>
{(me && !shouldHideFAB()) && (
<div className='fixed bottom-24 right-4 z-40 transition-all rtl:left-4 rtl:right-auto lg:hidden'>
<div className='fixed bottom-24 right-4 z-40 transition-all lg:hidden rtl:left-4 rtl:right-auto'>
<FloatingActionButton />
</div>
)}

View File

@ -1,5 +1,6 @@
import React, { Suspense } from 'react';
import { Redirect, Route, useHistory, RouteProps, RouteComponentProps, match as MatchType } from 'react-router-dom';
import React, { Suspense, useEffect, useRef } from 'react';
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
import { Redirect, Route, useHistory, RouteProps, RouteComponentProps, match as MatchType, useLocation } from 'react-router-dom';
import { Layout } from 'soapbox/components/ui';
import { useOwnAccount, useSettings } from 'soapbox/hooks';
@ -7,6 +8,7 @@ import { useOwnAccount, useSettings } from 'soapbox/hooks';
import ColumnForbidden from '../components/column-forbidden';
import ColumnLoading from '../components/column-loading';
import ColumnsArea from '../components/columns-area';
import ErrorColumn from '../components/error-column';
type PageProps = {
params?: MatchType['params'];
@ -46,40 +48,31 @@ const WrappedRoute: React.FC<IWrappedRoute> = ({
const renderComponent = ({ match }: RouteComponentProps) => {
if (Page) {
return (
<Suspense fallback={renderLoading()}>
<Page params={match.params} layout={layout} {...componentParams}>
<Component params={match.params} {...componentParams}>
{content}
</Component>
</Page>
</Suspense>
<ErrorBoundary FallbackComponent={FallbackError}>
<Suspense fallback={<FallbackLoading />}>
<Page params={match.params} layout={layout} {...componentParams}>
<Component params={match.params} {...componentParams}>
{content}
</Component>
</Page>
</Suspense>
</ErrorBoundary>
);
}
return (
<Suspense fallback={renderLoading()}>
<ColumnsArea layout={layout}>
<Component params={match.params} {...componentParams}>
{content}
</Component>
</ColumnsArea>
</Suspense>
<ErrorBoundary FallbackComponent={FallbackError}>
<Suspense fallback={<FallbackLoading />}>
<ColumnsArea layout={layout}>
<Component params={match.params} {...componentParams}>
{content}
</Component>
</ColumnsArea>
</Suspense>
</ErrorBoundary>
);
};
const renderWithLayout = (children: JSX.Element) => (
<>
<Layout.Main>
{children}
</Layout.Main>
<Layout.Aside />
</>
);
const renderLoading = () => renderWithLayout(<ColumnLoading />);
const renderForbidden = () => renderWithLayout(<ColumnForbidden />);
const loginRedirect = () => {
const actualUrl = encodeURIComponent(`${history.location.pathname}${history.location.search}`);
localStorage.setItem('soapbox:redirect_uri', actualUrl);
@ -97,13 +90,58 @@ const WrappedRoute: React.FC<IWrappedRoute> = ({
if (!account) {
return loginRedirect();
} else {
return renderForbidden();
return <FallbackForbidden />;
}
}
return <Route {...rest} render={renderComponent} />;
};
interface IFallbackLayout {
children: JSX.Element;
}
const FallbackLayout: React.FC<IFallbackLayout> = ({ children }) => (
<>
<Layout.Main>
{children}
</Layout.Main>
<Layout.Aside />
</>
);
const FallbackLoading: React.FC = () => (
<FallbackLayout>
<ColumnLoading />
</FallbackLayout>
);
const FallbackForbidden: React.FC = () => (
<FallbackLayout>
<ColumnForbidden />
</FallbackLayout>
);
const FallbackError: React.FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
const location = useLocation();
const firstUpdate = useRef(true);
useEffect(() => {
if (firstUpdate.current) {
firstUpdate.current = false;
} else {
resetErrorBoundary();
}
}, [location]);
return (
<FallbackLayout>
<ErrorColumn error={error} onRetry={resetErrorBoundary} />
</FallbackLayout>
);
};
export {
WrappedRoute,
};

View File

@ -14,6 +14,7 @@ import {
} from 'soapbox/features/ui/util/async-components';
import {
useAppSelector,
useLoggedIn,
useOwnAccount,
useSoapboxConfig,
} from 'soapbox/hooks';
@ -27,13 +28,13 @@ const UI = React.lazy(() => import('soapbox/features/ui'));
const SoapboxMount = () => {
useCachedLocationHandler();
const me = useAppSelector(state => state.me);
const { isLoggedIn } = useLoggedIn();
const { account } = useOwnAccount();
const soapboxConfig = useSoapboxConfig();
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
const showOnboarding = account && needsOnboarding;
const { redirectRootNoLogin } = soapboxConfig;
const { redirectRootNoLogin, gdpr } = soapboxConfig;
// @ts-ignore: I don't actually know what these should be, lol
const shouldUpdateScroll = (prevRouterProps, { location }) => {
@ -46,7 +47,7 @@ const SoapboxMount = () => {
<CompatRouter>
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
<Switch>
{(!me && redirectRootNoLogin) && (
{(!isLoggedIn && redirectRootNoLogin) && (
<Redirect exact from='/' to={redirectRootNoLogin} />
)}
@ -73,9 +74,11 @@ const SoapboxMount = () => {
<ModalContainer />
</Suspense>
<Suspense>
<GdprBanner />
</Suspense>
{(gdpr && !isLoggedIn) && (
<Suspense>
<GdprBanner />
</Suspense>
)}
<div id='toaster'>
<Toaster

View File

@ -9,7 +9,7 @@ import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { Action, applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import { thunk } from 'redux-thunk';
import { ChatProvider } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context';

View File

@ -7,7 +7,7 @@
"account.birthday": "ولد في {date}",
"account.birthday_today": "اليوم يوم ميلاد صاحب الحساب!",
"account.block": "حظر @{name}",
"account.block_domain": "إخفاء كل ما يتعلق بالنطاق {domain}",
"account.block_domain": "إخفاء النطاق {domain}",
"account.blocked": "محظور",
"account.chat": "دردشة مع @{name}",
"account.copy": "نسخ رابط الحساب",
@ -182,7 +182,7 @@
"app_create.results.explanation_title": "أُنشئ التطبيق بنجاح",
"app_create.results.token_label": "رمز OAuth",
"app_create.scopes_label": "آفاق",
"app_create.scopes_placeholder": "مثلاً: (قراءة كتابة متابعة)",
"app_create.scopes_placeholder": "مثلاً: 'read write follow'",
"app_create.submit": "إنشاء تطبيق",
"app_create.website_label": "الموقع",
"auth.awaiting_approval": "حسابك ينتظر الموافقة",
@ -198,6 +198,9 @@
"birthdays_modal.empty": "ليس لأصدقائك يوم ميلاد اليوم.",
"boost_modal.combo": "يمكنك الضغط على {combo} لتخطي هذا في المرة القادمة",
"boost_modal.title": "إعادة نشر؟",
"bundle_column_error.body": "حدث خطأ ما أثناء تحميل هذه الصفحة.",
"bundle_column_error.retry": "حاول مجددًا",
"bundle_column_error.title": "خطأ في الشبكة",
"card.back.label": "العودة",
"chat.actions.send": "إرسال",
"chat.failed_to_send": "فشل ارسال الرسالة.",
@ -378,7 +381,7 @@
"column_forbidden.body": "ليست لديك الصلاحيات للدخول إلى هذه الصفحة.",
"column_forbidden.title": "محظور",
"common.cancel": "إلغاء",
"compare_history_modal.header": "تعديل السِّجل",
"compare_history_modal.header": "سِجلّ التعديلات",
"compose.character_counter.title": "مُستخدم {chars} حرف من أصل {maxChars} {maxChars, plural, one {حروف} other {حروف}}",
"compose.edit_success": "تم تعديل المنشور",
"compose.invalid_schedule": "يجب عليك جدولة منشور بمدة لا تقل عن 5 دقائق.",
@ -391,7 +394,7 @@
"compose_event.fields.description_label": "وصف الحدث",
"compose_event.fields.description_placeholder": "الوصف",
"compose_event.fields.end_time_label": "الحدث والتاريخ",
"compose_event.fields.end_time_placeholder": "الحدث ينتهي في…",
"compose_event.fields.end_time_placeholder": "ينتهي الحدث في…",
"compose_event.fields.has_end_time": "الحدث له تاريخ إنتهاء",
"compose_event.fields.location_label": "موقع الحدث",
"compose_event.fields.name_label": "عنوان الحدث",
@ -399,10 +402,10 @@
"compose_event.fields.start_time_label": "تاريخ بداية الحدث",
"compose_event.fields.start_time_placeholder": "الحدث يبدأ في…",
"compose_event.participation_requests.authorize": "تفويض",
"compose_event.participation_requests.authorize_success": "تم قبول المستخدم",
"compose_event.participation_requests.authorize_success": "قُبِل المستخدم",
"compose_event.participation_requests.reject": "رفض",
"compose_event.participation_requests.reject_success": "تم رفض المستخدم",
"compose_event.reset_location": "حذف الموقع",
"compose_event.participation_requests.reject_success": "رُفِض المستخدم",
"compose_event.reset_location": "إعادة تعيين الموقع",
"compose_event.submit_success": "تم إنشاء الحدث",
"compose_event.tabs.edit": "تعديل التفاصيل",
"compose_event.tabs.pending": "إدارة الطلبات",
@ -496,10 +499,10 @@
"confirmations.kick_from_group.confirm": "طرد",
"confirmations.kick_from_group.message": "هل أنت متأكد أنك تريد طرد @ {name} من هذه المجموعة؟",
"confirmations.leave_event.confirm": "الخروج من الحدث",
"confirmations.leave_event.message": "إذا كنت تريد إعادة الانضمام إلى الحدث ، فستتم مراجعة الطلب يدويًا مرة أخرى. هل انت متأكد انك تريد المتابعة؟",
"confirmations.leave_group.confirm": "ترك",
"confirmations.leave_event.message": "إذا كنت تريد إعادة الانضمام إلى الحدث ، سيتم مراجعة الطلب يدويًا مرة أخرى. متأكد أنك تريد المتابعة؟",
"confirmations.leave_group.confirm": "مغادرة",
"confirmations.leave_group.heading": "مغادرة المجموعة",
"confirmations.leave_group.message": "أنت على وشك مغادرة المجموعة هل تريد الاستمرار؟?",
"confirmations.leave_group.message": "أنت على وشك مغادرة المجموعة. هل تريد الاستمرار؟",
"confirmations.mute.confirm": "كتم",
"confirmations.mute.heading": "كتم @{name}",
"confirmations.mute.message": "هل تود حقًا كتم {name}؟",
@ -617,7 +620,7 @@
"emoji_button.recent": "المُستخدمة حديثا",
"emoji_button.search": "البحث…",
"emoji_button.search_results": "نتائج البحث",
"emoji_button.skins_1": "الإفتراضي",
"emoji_button.skins_1": "الافتراضي",
"emoji_button.skins_2": "فاتح",
"emoji_button.skins_3": "ضوء المتوسط",
"emoji_button.skins_4": "متوسط",
@ -637,7 +640,7 @@
"empty_column.bookmarks": "ليس لديك أي علامات ، ستظهر هنا عند اضافتها.",
"empty_column.community": "لا توجد منشورات في الخط المحلي بعد. أكتب شيئا ما للعامة كبداية!",
"empty_column.direct": "لم تتلقَ أي رسالة خاصة مباشرة بعد. ستعرض الرسائل المباشرة هنا في حال أرسلت أو تلقيت بعضها.",
"empty_column.dislikes": ا أحد قام بعدم إعجاب هذا المنشور حتى الآن. عندما يفعل شخص ما ، سيظهرون هنا.",
"empty_column.dislikes": م يتفاعل أي شخص بعدم الإعجاب على هذا المنشور بعد. عندما يفعل شخص ما ، سيظهرون هنا.",
"empty_column.domain_blocks": "ليس هناك نطاقات مخفية بعد.",
"empty_column.event_participant_requests": "لا توجد طلبات معلقة للمشاركة في الحدث.",
"empty_column.event_participants": "لم ينضم أحد إلى هذا الحدث حتى الآن. عندما يفعل شخص ما ، سوف يظهر هنا.",
@ -686,7 +689,7 @@
"event.quote": "اقتباس الحدث",
"event.reblog": "إعادة نشر الحدث",
"event.show_on_map": "العرض على الخريطة",
"event.unreblog": "حدث لم يُعَدْ نشره",
"event.unreblog": "إلغاء مشاركة الحدث",
"event.website": "روابط خارجية",
"event_map.navigate": "التنقل",
"events.create_event": "إنشاء حدث",
@ -734,7 +737,7 @@
"filters.filters_list_warn": "ترشيح العرض",
"filters.removed": "حُذف المُرشِّح.",
"followRecommendations.heading": "الحسابات المقترحة",
"follow_request.authorize": "ترخيص بالوصول",
"follow_request.authorize": "السماح بالمتابعة",
"follow_request.reject": "رفض",
"gdpr.accept": "الموافقة",
"gdpr.learn_more": "معرفة المزيد",

View File

@ -198,6 +198,9 @@
"birthdays_modal.empty": "None of your friends have birthday today.",
"boost_modal.combo": "You can press {combo} to skip this next time",
"boost_modal.title": "Repost?",
"bundle_column_error.body": "Something went wrong while loading this page.",
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"card.back.label": "Back",
"chat.actions.send": "Send",
"chat.failed_to_send": "Message failed to send.",

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
"account.block_domain": "{domain}全体を非表示",
"account.blocked": "ブロック済み",
"account.chat": "@{name}さんとチャット",
"account.copy": "プロフィールへのリンクをコピー",
"account.deactivated": "非アクティブ化",
"account.direct": "@{name}さんにダイレクトメッセージ",
"account.domain_blocked": "ドメイン非表示",
@ -82,6 +83,11 @@
"account_search.placeholder": "アカウントを検索",
"actualStatus.edited": "{date}に編集済",
"actualStatuses.quote_tombstone": "Post is unavailable.",
"admin.announcements.action": "アナウンスを作成",
"admin.announcements.delete": "削除",
"admin.announcements.edit": "編集",
"admin.announcements.ends_at": "終了日時:",
"admin.announcements.starts_at": "開始日時:",
"admin.awaiting_approval.empty_message": "There is nobody waiting for approval. When a new user signs up, you can review them here.",
"admin.dashboard.registration_mode.approval_hint": "Users can sign up, but their account only gets activated when an admin approves it.",
"admin.dashboard.registration_mode.approval_label": "Approval Required",
@ -98,6 +104,12 @@
"admin.dashcounters.user_count_label": "total users",
"admin.dashwidgets.email_list_header": "Email list",
"admin.dashwidgets.software_header": "Software",
"admin.edit_announcement.created": "アナウンスが作成されました",
"admin.edit_announcement.deleted": "アナウンスを削除しました",
"admin.edit_announcement.fields.all_day_label": "終日",
"admin.edit_announcement.fields.content_label": "内容",
"admin.edit_announcement.fields.content_placeholder": "アナウンスの内容",
"admin.edit_announcement.fields.end_time_label": "終了日",
"admin.latest_accounts_panel.more": "クリックして {count} 人のおすすめユーザーを表示",
"admin.latest_accounts_panel.title": "Latest Accounts",
"admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.",

120
src/locales/jv.json Normal file
View File

@ -0,0 +1,120 @@
{
"about.also_available": "ꦏꦱꦼꦢ꧀ꦪꦲꦶꦁ:",
"accordion.collapse": "ꦲꦩ꧀ꦧꦿꦸꦏ꧀",
"accordion.expand": "ꦗꦼꦩ꧀ꦧꦂꦲꦏꦺ",
"account.add_or_remove_from_list": "ꦠꦩ꧀ꦧꦃꦈꦠꦮꦧꦸꦱꦼꦏ꧀ꦱꦏꦶꦁꦝꦥ꦳꧀ꦠꦂ",
"account.badges.bot": "ꦫꦺꦴꦧꦺꦴꦠ꧀",
"account.birthday": "ꦭꦲꦶꦂ",
"account.birthday_today": "ꦢꦶꦤ꧀ꦠꦼꦤ꧀ꦥꦸꦤꦶꦏꦶꦄꦩ꧀ꦧꦭ꧀ꦮꦂꦱ!",
"account.block": "ꦧꦼꦤ꧀ꦢꦸꦁ @{name}",
"account.block_domain": "ꦱꦩꦸꦧꦫꦁꦱꦏꦶꦁ {domain} ꦣꦼꦭꦶꦏꦏꦺ",
"account.blocked": "ꦢꦶꦧꦼꦤ꧀ꦢꦸꦁ",
"account.chat": "ꦏꦤ꧀ꦝꦃꦏꦭꦶꦃ @{name}",
"account.copy": "ꦱꦭꦶꦤ꧀ꦭꦶꦁꦩꦫꦁꦥꦿꦺꦴꦥ꦳ꦶꦭ꧀",
"account.deactivated": "ꦢꦶꦥꦠꦺꦤꦶ",
"account.direct": "ꦤꦮꦭꦭꦁꦱꦸꦁ @{name}",
"account.domain_blocked": "ꦣꦺꦴꦩꦻꦤ꧀ꦣꦶꦣꦼꦭꦶꦏꦏꦺ",
"account.edit_profile": "ꦎꦮꦲꦶꦥꦿꦺꦴꦥ꦳ꦶꦭ꧀",
"account.endorse": "ꦥ꦳ꦶꦠꦸꦂꦲꦶꦁꦥꦿꦺꦴꦥ꦳ꦶꦭ꧀",
"account.endorse.success": "ꦤ꧀ꦗꦼꦤꦼꦔꦤ꧀ꦱꦏ꧀ꦤꦶꦏꦶꦤꦩ꧀ꦥꦶꦭꦏꦺ @{acct} ꦲꦶꦁꦥꦿꦺꦴꦥ꦳ꦶꦭ꧀ꦤ꧀ꦗꦼꦤꦼꦔꦤ꧀",
"account.familiar_followers": "ꦢꦶꦠꦸꦠ꧀ꦲꦏꦺꦢꦺꦤꦶꦁ {accounts}",
"account.familiar_followers.empty": "ꦧꦺꦴꦠꦼꦤꦮꦺꦴꦤ꧀ꦠꦼꦲꦶꦁꦏꦁꦢꦶꦥꦸꦤ꧀ꦏꦼꦤꦭ꧀ꦏꦁꦔꦼꦠꦸꦠ꧀ꦏꦺ{name}꧉",
"account.familiar_followers.more": "{count, plural, one {# other} ꦭꦪꦤꦺ {# others}} ꦚ꧀ꦗꦼꦤꦼꦔꦤ꧀ꦠꦸꦠ꧀ꦏꦺ",
"account.follow": "ꦠꦸꦠ꧀ꦏꦺ",
"account.followers": "ꦏꦁꦔꦼꦠꦸꦠ꧀ꦏꦺ",
"account.followers.empty": "ꦢꦺꦉꦁꦮꦺꦴꦤ꧀ꦠꦼꦏꦁꦔꦼꦠꦸꦠ꧀ꦏꦺꦥꦔꦒꦼꦩ꧀ꦥꦸꦤꦶꦏꦶ꧉",
"account.follows": "ꦲꦔꦼꦠꦸꦠ꧀ꦏꦺ",
"account.follows.empty": "ꦥꦔꦒꦼꦩ꧀ꦥꦸꦤꦶꦏꦶꦢꦺꦉꦁꦔꦼꦠꦸꦠ꧀ꦏꦺꦱꦶꦤ꧀ꦠꦼꦤ꧀ꦏꦺꦩꦮꦺꦴꦤ꧀꧉",
"account.follows_you": "ꦔꦼꦠꦸꦠ꧀ꦏꦺꦚ꧀ꦗꦼꦤꦼꦔꦤ꧀",
"account.header.alt": "ꦥꦿꦺꦴꦥ꦳ꦶꦭ꧀ꦲꦺꦝꦼꦂ",
"account.hide_reblogs": "ꦣꦼꦭꦶꦏꦺꦏꦶꦫꦶꦩꦤ꧀ꦱꦏꦶꦁ @{name}",
"account.last_status": "ꦠꦿꦑꦶꦂꦄꦏ꧀ꦠꦶꦥ꦳꧀",
"account.link_verified_on": "ꦥꦁꦒꦝꦲꦶꦁꦭꦶꦁꦤꦶꦏꦶꦱꦩꦥꦸꦤ꧀ꦢꦶꦥꦿꦶꦏ꧀ꦰꦶꦁꦲꦶꦁ {date}",
"account.login": "ꦩ꧀ꦊꦧꦸ",
"account.media": "ꦩꦺꦣꦶꦪ",
"account.member_since": "ꦩꦺꦭꦸꦒꦧꦸꦁ {date}",
"account.mention": "ꦚꦼꦧꦸꦠ꧀",
"account.mute": "ꦩ꧀ꦥꦼꦠ꧀",
"account.muted": "ꦢꦶꦩ꧀ꦥꦼꦠ꧀",
"account.never_active": "ꦎꦫꦠꦲꦸ",
"account.patron": "ꦥꦠꦿꦺꦴꦤ꧀",
"account.posts": "ꦥꦺꦴꦱ꧀ꦠꦶꦁꦔꦤ꧀",
"account.posts_with_replies": "ꦥꦺꦴꦱ꧀ꦠꦶꦁꦔꦤ꧀ꦭꦤ꧀ꦧꦊꦱꦤ꧀",
"account.profile": "ꦥꦿꦺꦴꦥ꦳ꦶꦭ꧀",
"account.profile_external": "ꦥꦿꦶꦏ꧀ꦱꦤꦶꦥꦿꦺꦴꦥ꦳ꦶꦭ꧀ꦲꦶꦁ {domain}",
"account.register": "ꦣꦥ꦳꧀ꦠꦂ",
"account.remote_follow": "ꦠꦸꦠ꧀ꦏꦺꦭꦸꦩꦤ꧀ꦠꦂꦫꦺꦩꦺꦴꦠ꧀",
"account.remove_from_followers": "ꦧꦸꦱꦼꦏ꧀ꦱ꧀ꦏꦶꦁꦔꦼꦠꦸꦠ꧀ꦏꦺ",
"account.report": "ꦭꦥꦺꦴꦂꦏꦺ @{name}",
"account.requested": "ꦔꦼꦤ꧀ꦠꦺꦤꦶꦥꦼꦂꦱꦼꦠꦸꦗꦸꦮꦤ꧀꧉ꦏ꧀ꦭꦶꦏ꧀ꦏꦁꦒꦺꦴꦩ꧀ꦧꦠꦭ꧀ꦏꦺꦥꦚꦸꦮꦸꦤ꧀ꦔꦼꦠꦸꦠ꧀ꦏꦺ꧉",
"account.requested_small": "ꦔꦼꦤ꧀ꦠꦺꦤꦶꦥꦼꦂꦱꦼꦠꦸꦗꦸꦮꦤ꧀",
"account.search": "ꦒꦺꦴꦭꦺꦏꦶꦱ꧀ꦏꦶꦁ @{name}",
"account.search_self": "ꦒꦺꦴꦭꦺꦏꦶꦥꦺꦴꦱ꧀ꦠꦶꦁꦔꦤ꧀ꦩꦸ",
"account.share": "ꦥꦿꦺꦴꦥ꦳ꦶꦭꦺ @{name} ꦧꦒꦶꦏꦼꦤ",
"account.show_reblogs": "ꦠꦩ꧀ꦥꦶꦭ꧀ꦏꦺꦭꦥꦺꦴꦫꦤ꧀ꦱ꧀ꦏꦶꦁ @{name}",
"account.subscribe": "ꦭꦁꦒꦤꦤ꧀ꦥꦼꦥꦺꦭꦶꦁꦱ꧀ꦏꦶꦁ @{name}",
"account.subscribe.success": "ꦥꦚ꧀ꦗꦼꦤꦼꦔꦤ꧀ꦱꦩ꧀ꦥꦸꦤ꧀ꦭꦁꦒꦤꦤ꧀ꦩꦿꦶꦁꦄꦏꦸꦤ꧀ꦥꦸꦤꦶꦏꦶ꧉",
"account.unblock": "ꦮꦸꦫꦸꦁꦏꦺꦩ꧀ꦧꦼꦤ꧀ꦢꦸꦁ @{name}",
"account.unblock_domain": "ꦮꦸꦫꦸꦁꦤ꧀ꦝꦼꦭꦶꦏꦺ {domain}",
"account.unendorse": "ꦲꦩ꧀ꦥꦸꦤ꧀ꦒꦤ꧀ꦝꦺꦁꦠꦼꦁꦥꦿꦺꦴꦥ꦳ꦶꦭ꧀",
"account.unendorse.success": "ꦥꦚ꧀ꦗꦼꦤꦼꦔꦤ꧀ꦱꦩ꧀ꦥꦸꦤ꧀ꦧꦺꦴꦠꦼꦤ꧀ꦒꦤ꧀ꦝꦺꦁ",
"account.unfollow": "ꦮꦸꦫꦸꦁꦔꦼꦠꦸꦠ꧀ꦏꦺ",
"account.unmute": "ꦮꦸꦫꦸꦁꦩ꧀ꦧꦸꦁꦏꦼꦩ꧀ @{name}",
"account.unsubscribe": "ꦮꦸꦫꦸꦁꦭꦁꦒꦤꦤ꧀ꦩꦿꦶꦁꦥꦼꦥꦺꦭꦶꦁꦱ꧀ꦏꦶꦁ @{name}",
"account.unsubscribe.success": "ꦥꦚꦗꦼꦤꦼꦔꦤ꧀ꦱꦩ꧀ꦥꦸꦤ꧀ꦧꦺꦴꦠꦼꦭꦁꦒꦤꦤ꧀ꦩꦿꦶꦁꦄꦏꦸꦤ꧀ꦥꦸꦤꦶꦏꦶ",
"account.verified": "ꦄꦏꦸꦤ꧀ꦢꦶꦥ꦳ꦺꦫꦶꦥ꦳ꦶꦏꦱꦶ",
"account_gallery.none": "ꦧꦺꦴꦠꦼꦤ꧀ꦮꦺꦴꦤ꧀ꦠꦼꦤ꧀ꦩꦺꦝꦶꦪꦏꦁꦢꦶꦠꦩ꧀ꦥꦶꦭ꧀ꦏꦺ.",
"account_moderation_modal.admin_fe": "ꦧꦸꦏꦲꦶꦁ AdminFE",
"account_moderation_modal.fields.account_role": "ꦠꦶꦁꦏꦠ꧀ꦱ꧀ꦠꦥ꦳꧀",
"account_moderation_modal.fields.deactivate": "ꦥꦠꦺꦤꦶꦄꦏꦸꦤ꧀",
"account_moderation_modal.fields.delete": "ꦧꦸꦱꦼꦏ꧀ꦄꦏꦸꦤ꧀",
"account_moderation_modal.fields.verified": "ꦥꦠꦺꦤꦶꦄꦏꦸꦤ꧀",
"account_moderation_modal.roles.admin": "ꦄꦝ꧀ꦩꦶꦤ꧀",
"account_moderation_modal.roles.moderator": "ꦩꦺꦴꦝꦼꦫꦠꦺꦴꦂ",
"account_moderation_modal.roles.user": "ꦥꦔꦒꦼꦩ꧀",
"account_moderation_modal.title": "ꦩꦺꦴꦝꦺꦫꦠ꧀ @{acct}",
"account_note.header": "ꦕꦛꦼꦠꦤ꧀",
"account_note.placeholder": "ꦏ꧀ꦭꦶꦏ꧀ꦏꦁꦒꦺꦴꦤꦩ꧀ꦧꦃꦕꦛꦼꦠꦤ꧀",
"account_search.placeholder": "ꦒꦺꦴꦭꦺꦏꦶꦄꦏꦸꦤ꧀",
"actualStatus.edited": "ꦏꦎꦮꦲꦶ {date}",
"actualStatuses.quote_tombstone": "ꦏꦶꦫꦶꦩꦤ꧀ꦲꦺꦴꦫꦏꦱꦼꦝꦶꦪ.",
"admin.announcements.action": "ꦒꦮꦺꦮꦼꦮꦫ",
"admin.announcements.all_day": "ꦱꦧꦤ꧀ꦲꦫꦶ",
"admin.announcements.delete": "ꦧꦸꦱꦼꦏ꧀",
"admin.announcements.edit": "ꦎꦮꦲꦶ",
"admin.announcements.ends_at": "ꦩꦸꦁꦏꦱꦶꦲꦶꦁ:",
"admin.announcements.starts_at": "ꦢꦶꦮꦶꦮꦶꦠꦶꦲꦶꦁ:",
"admin.dashboard.registration_mode.approval_label": "ꦥꦼꦂꦱꦼꦠꦸꦗꦸꦮꦤ꧀ꦢꦶꦧꦸꦠꦸꦲꦏꦺ",
"admin.dashboard.registration_mode.closed_label": "ꦠꦸꦠꦸꦥ꧀",
"admin.dashboard.registration_mode.open_hint": "ꦱꦶꦤ꧀ꦠꦼꦤ꧀ꦏꦺꦩꦮꦺꦴꦤ꧀ꦱꦒꦼꦢ꧀ꦒꦧꦸꦁ.",
"admin.dashboard.registration_mode.open_label": "ꦧꦸꦏꦏ꧀",
"admin.dashboard.registration_mode_label": "ꦥꦤ꧀ꦝꦥ꦳꧀ꦠꦫꦤ꧀",
"admin.dashboard.settings_saved": "ꦥꦿꦤꦠꦤ꧀ꦏꦱꦶꦩ꧀ꦥꦼꦤ꧀!",
"admin.dashcounters.mau_label": "ꦥꦔꦒꦼꦩ꧀ꦄꦏ꧀ꦠꦶꦥ꦳꧀ꦮꦸꦭꦤꦤ꧀",
"admin.dashcounters.status_count_label": "ꦥꦺꦴꦱ꧀ꦠꦶꦤ꧀",
"admin.dashcounters.user_count_label": "ꦠꦺꦴꦠꦭ꧀ꦥꦔꦒꦼꦩ꧀",
"admin.dashwidgets.email_list_header": "ꦝꦥ꦳꧀ꦠꦂꦆꦩꦻꦭ꧀",
"admin.dashwidgets.software_header": "ꦥꦶꦫꦤ꧀ꦠꦶꦄꦭꦸꦱ꧀",
"admin.edit_announcement.created": "ꦮꦼꦮꦫꦢꦶꦥꦸꦤ꧀ꦢꦩꦼꦭ꧀",
"admin.edit_announcement.deleted": "ꦮꦫꦮꦫꦢꦶꦧꦸꦱꦼꦏ꧀",
"admin.edit_announcement.fields.all_day_label": "ꦄꦕꦫꦱꦧꦼꦤ꧀ꦲꦫꦶ",
"admin.edit_announcement.fields.content_label": "ꦏꦺꦴꦤ꧀ꦠꦺꦤ꧀",
"admin.edit_announcement.fields.content_placeholder": "ꦏꦺꦴꦤ꧀ꦠꦺꦤ꧀ꦮꦫꦮꦫ",
"admin.edit_announcement.fields.end_time_label": "ꦠꦁꦒꦭ꧀ꦥꦸꦁꦏꦱꦤ꧀",
"admin.edit_announcement.fields.end_time_placeholder": "ꦮꦫꦮꦫꦫꦩ꧀ꦥꦸꦁꦲꦶꦁ:",
"admin.edit_announcement.fields.start_time_label": "ꦠꦁꦒꦭ꧀ꦮꦶꦮꦶꦠꦤ꧀",
"admin.edit_announcement.fields.start_time_placeholder": "ꦮꦫꦮꦫꦢꦶꦮꦶꦮꦶꦠꦶꦲꦶꦁ:",
"admin.edit_announcement.save": "ꦱꦶꦩ꧀ꦥꦼꦤ꧀",
"admin.edit_announcement.updated": "ꦮꦫꦮꦫꦏꦈꦮꦲꦶ",
"admin.latest_accounts_panel.more": "ꦏ꧀ꦭꦶꦏ꧀ꦏꦁꦒꦺꦴꦩꦶꦂꦱꦤꦶ {count, plural, one {# account} other {# accounts}}",
"admin.latest_accounts_panel.title": "ꦄꦏꦸꦤ꧀ꦥꦭꦶꦁꦄꦚꦂ",
"admin.reports.actions.close": "ꦠꦸꦠꦸꦥ꧀",
"admin.reports.actions.view_status": "ꦥꦶꦂꦱꦤꦶꦥꦺꦴꦱ꧀ꦠꦶꦔꦤ꧀",
"admin.reports.report_closed_message": "ꦭꦥꦺꦴꦫꦤ꧀ꦧꦧꦒꦤ꧀ @{name} ꦢꦶꦠꦸꦠꦸꦥ꧀",
"admin.reports.report_title": "ꦭꦥꦺꦴꦫꦤꦲꦶꦁ {acct}",
"admin.statuses.actions.delete_status": "ꦧꦸꦱꦼꦏ꧀ꦥꦺꦴꦱ꧀ꦠꦶꦔꦤ꧀",
"admin.statuses.actions.mark_status_not_sensitive": "ꦠꦼꦔꦼꦫꦶꦥꦺꦴꦱ꧀ꦠꦶꦔꦤ꧀ꦧꦺꦴꦠꦼꦤ꧀ꦒꦮꦠ꧀",
"admin.statuses.actions.mark_status_sensitive": "ꦠꦼꦔꦼꦫꦶꦥꦺꦴꦱ꧀ꦠꦶꦔꦤ꧀ꦒꦮꦠ꧀",
"admin.statuses.status_deleted_message": "ꦏꦶꦫꦶꦩꦤ꧀ꦢꦤꦶꦁ @{acct} ꦱꦩ꧀ꦥꦸꦤ꧀ꦢꦶꦧꦸꦱꦼꦏ꧀",
"admin.statuses.status_marked_message_not_sensitive": "ꦏꦶꦫꦶꦩꦤ꧀ꦢꦺꦤꦶꦁ @{acct} ꦢꦶꦥꦸꦤ꧀ꦠꦺꦼꦔꦼꦫꦶꦧꦺꦴꦠꦼꦤ꧀ꦒꦮꦠ꧀"
}

View File

@ -84,7 +84,7 @@
"account_note.placeholder": "Nie wprowadzono opisu",
"account_search.placeholder": "Szukaj konta",
"actualStatus.edited": "Edytowano {date}",
"actualStatuses.quote_tombstone": "Wpis jest niedostępny",
"actualStatuses.quote_tombstone": "Wpis jest niedostępny.",
"admin.announcements.action": "Utwórz ogłoszenie",
"admin.announcements.all_day": "Cały dzień",
"admin.announcements.delete": "Usuń",
@ -164,6 +164,8 @@
"alert.unexpected.links.support": "Wsparcie techniczne",
"alert.unexpected.message": "Wystąpił nieoczekiwany błąd.",
"alert.unexpected.return_home": "Wróć na stronę główną",
"alert.unexpected.submit_feedback": "Prześlij opinię",
"alert.unexpected.thanks": "Dziękujemy za twoją opinię!",
"aliases.account.add": "Utwórz alias",
"aliases.account_label": "Stare konto:",
"aliases.aliases_list_delete": "Odłącz alias",
@ -196,6 +198,9 @@
"birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.",
"boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
"boost_modal.title": "Repost?",
"bundle_column_error.body": "Coś poszło nie tak podczas ładowania tej strony.",
"bundle_column_error.retry": "Spróbuj ponownie",
"bundle_column_error.title": "Błąd sieci",
"card.back.label": "Wstecz",
"chat.actions.send": "Wyślij",
"chat.failed_to_send": "Nie udało się wysłać wiadomości.",
@ -230,6 +235,8 @@
"chat_message_list_intro.leave_chat.heading": "Opuść czat",
"chat_message_list_intro.leave_chat.message": "Czy na pewno chcesz opuścić ten czat? Wiadomości zostaną dla Ciebie usunięte, a czat zniknie z Twojej skrzynki.",
"chat_pane.blankslate.action": "Napisz do kogoś",
"chat_pane.blankslate.body": "Poszukaj kogoś do rozpoczęcia rozmowy.",
"chat_pane.blankslate.title": "Brak wiadomości",
"chat_search.blankslate.body": "Szukaj kogoś do rozpoczęcia rozmowy.",
"chat_search.blankslate.title": "Rozpocznij rozmowę",
"chat_search.empty_results_blankslate.body": "Spróbuj znaleźć inną nazwę.",
@ -306,6 +313,7 @@
"column.developers.service_worker": "Service Worker",
"column.direct": "Wiadomości bezpośrednie",
"column.directory": "Przeglądaj profile",
"column.dislikes": "Nie lubi",
"column.domain_blocks": "Ukryte domeny",
"column.edit_profile": "Edytuj profil",
"column.event_map": "Lokalizacja wydarzenia",
@ -360,7 +368,7 @@
"column.notifications": "Powiadomienia",
"column.pins": "Przypięte wpisy",
"column.preferences": "Preferencje",
"column.public": "Globalna oś czasu",
"column.public": "Sfederowana oś czasu",
"column.quotes": "Cytatu wpisu",
"column.reactions": "Reakcje",
"column.reblogs": "Podbicia",
@ -1364,6 +1372,8 @@
"soapbox_config.redirect_root_no_login_label": "Przekieruj stronę główną",
"soapbox_config.save": "Zapisz",
"soapbox_config.saved": "Zapisano konfigurację Soapbox!",
"soapbox_config.sentry_dsn_hint": "Adres URL DSN do zgłaszania błędów. Działa z Sentry i GlitchTip.",
"soapbox_config.sentry_dsn_label": "DSN Sentry",
"soapbox_config.tile_server_attribution_label": "Atrybucja kafelków map",
"soapbox_config.tile_server_label": "Serwer kafelków map",
"soapbox_config.verified_can_edit_name_label": "Pozwól zweryfikowanym użytkownikom na zmianę swojej nazwy wyświetlanej.",
@ -1390,7 +1400,7 @@
"status.group": "Napisano w {group}",
"status.group_mod_delete": "Usuń wpis z grupy",
"status.interactions.favourites": "{count, plural, one {Polubienie} few {Polubienia} other {Polubień}}",
"status.interactions.quotes": "{count, plural, one {cytat} few {cytaty} many {cytatów}}",
"status.interactions.quotes": "{count, plural, one {Cytat} few {Cytaty} other {Cytatów}}",
"status.interactions.reblogs": "{count, plural, one {Podanie dalej} few {Podania dalej} other {Podań dalej}}",
"status.load_more": "Załaduj więcej",
"status.mention": "Wspomnij o @{name}",

View File

@ -164,6 +164,8 @@
"alert.unexpected.links.support": "支持",
"alert.unexpected.message": "发生了意外错误。",
"alert.unexpected.return_home": "回到主页",
"alert.unexpected.submit_feedback": "提交反馈",
"alert.unexpected.thanks": "感谢您的反馈!",
"aliases.account.add": "创建别名",
"aliases.account_label": "旧帐号:",
"aliases.aliases_list_delete": "删除别名",
@ -196,6 +198,9 @@
"birthdays_modal.empty": "今天您的朋友中无人过生日。",
"boost_modal.combo": "下次按住 {combo} 即可跳过此提示",
"boost_modal.title": "转发?",
"bundle_column_error.body": "载入页面时发生错误。",
"bundle_column_error.retry": "重试",
"bundle_column_error.title": "网络错误",
"card.back.label": "返回",
"chat.actions.send": "发送",
"chat.failed_to_send": "消息发送失败。",

File diff suppressed because it is too large Load Diff

View File

@ -20,16 +20,18 @@ import 'react-datepicker/dist/react-datepicker.css';
import './iframe';
import './styles/i18n/arabic.css';
import './styles/i18n/javanese.css';
import './styles/application.scss';
import './styles/tailwind.css';
import './precheck';
import ready from './ready';
import { registerSW } from './utils/sw';
import { registerSW, lockSW } from './utils/sw';
if (BuildConfig.NODE_ENV === 'production') {
printConsoleWarning();
registerSW('/sw.js');
lockSW();
}
ready(() => {

View File

@ -1,7 +1,6 @@
import toast from 'soapbox/toast';
import type { AnyAction } from 'redux';
import type { ThunkMiddleware } from 'redux-thunk';
import type { AnyAction, Middleware } from 'redux';
/** Whether the action is considered a failure. */
const isFailType = (type: string): boolean => type.endsWith('_FAIL');
@ -21,8 +20,9 @@ const shouldShowError = ({ type, skipAlert, error }: AnyAction): boolean => {
};
/** Middleware to display Redux errors to the user. */
const errorsMiddleware = (): ThunkMiddleware =>
() => next => action => {
const errorsMiddleware = (): Middleware =>
() => next => anyAction => {
const action = anyAction as AnyAction;
if (shouldShowError(action)) {
toast.showAlertForError(action.error);
}

View File

@ -1,8 +1,7 @@
import { AnyAction } from 'redux';
import { play, soundCache } from 'soapbox/utils/sounds';
import type { ThunkMiddleware } from 'redux-thunk';
import type { AnyAction, Middleware } from 'redux';
import type { Sounds } from 'soapbox/utils/sounds';
interface Action extends AnyAction {
@ -12,8 +11,9 @@ interface Action extends AnyAction {
}
/** Middleware to play sounds in response to certain Redux actions. */
export default function soundsMiddleware(): ThunkMiddleware {
return () => next => (action: Action) => {
export default function soundsMiddleware(): Middleware {
return () => next => anyAction => {
const action = anyAction as Action;
if (action.meta?.sound && soundCache[action.meta.sound]) {
play(soundCache[action.meta.sound]);
}

View File

@ -13,10 +13,11 @@ import {
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizeMention } from 'soapbox/normalizers/mention';
import { accountSchema, cardSchema, groupSchema, pollSchema, tombstoneSchema } from 'soapbox/schemas';
import { accountSchema, cardSchema, emojiReactionSchema, groupSchema, pollSchema, tombstoneSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { maybeFromJS } from 'soapbox/utils/normalizers';
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity, EmojiReaction } from 'soapbox/types/entities';
export type StatusApprovalStatus = 'pending' | 'approval' | 'rejected';
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self' | 'group';
@ -69,6 +70,7 @@ export const StatusRecord = ImmutableRecord({
poll: null as EmbeddedEntity<Poll>,
quote: null as EmbeddedEntity<any>,
quotes_count: 0,
reactions: null as ImmutableList<EmojiReaction> | null,
reblog: null as EmbeddedEntity<any>,
reblogged: false,
reblogs_count: 0,
@ -104,8 +106,8 @@ const normalizeMentions = (status: ImmutableMap<string, any>) => {
});
};
// Normalize emojis
const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
// Normalize emoji reactions
const normalizeReactions = (entity: ImmutableMap<string, any>) => {
return entity.update('emojis', ImmutableList(), emojis => {
return emojis.map(normalizeEmoji);
});
@ -218,6 +220,16 @@ const normalizeEvent = (status: ImmutableMap<string, any>) => {
}
};
/** Normalize emojis. */
const normalizeEmojis = (status: ImmutableMap<string, any>) => {
const data = ImmutableList<ImmutableMap<string, any>>(status.getIn(['pleroma', 'emoji_reactions']) || status.get('reactions'));
const reactions = filteredArray(emojiReactionSchema).parse(data.toJS());
if (reactions) {
status.set('reactions', ImmutableList(reactions));
}
};
/** Rewrite `<p></p>` to empty string. */
const fixContent = (status: ImmutableMap<string, any>) => {
if (status.get('content') === '<p></p>') {
@ -275,6 +287,7 @@ export const normalizeStatus = (status: Record<string, any>) => {
fixQuote(status);
fixSensitivity(status);
normalizeEvent(status);
normalizeReactions(status);
fixContent(status);
normalizeFilterResults(status);
normalizeDislikes(status);

View File

@ -50,7 +50,7 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
return (
<>
<Layout.Main className='space-y-3 pt-3 dark:divide-gray-800 sm:pt-0'>
<Layout.Main className='space-y-3 pt-3 sm:pt-0 dark:divide-gray-800'>
{me && (
<Card
className={clsx('relative z-[1] transition', {

View File

@ -20,7 +20,7 @@ const LandingPage: React.FC<ILandingPage> = ({ children }) => {
return (
<>
<Layout.Main className='space-y-3 pt-3 dark:divide-gray-800 sm:pt-0'>
<Layout.Main className='space-y-3 pt-3 sm:pt-0 dark:divide-gray-800'>
{children}
{!me && (

View File

@ -274,13 +274,13 @@ export default function statuses(state = initialState, action: AnyAction): State
case EMOJI_REACT_REQUEST:
return state
.updateIn(
[action.status.id, 'pleroma', 'emoji_reactions'],
[action.status.id, 'reactions'],
emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji, action.custom),
);
case UNEMOJI_REACT_REQUEST:
return state
.updateIn(
[action.status.id, 'pleroma', 'emoji_reactions'],
[action.status.id, 'reactions'],
emojiReacts => simulateUnEmojiReact(emojiReacts as any, action.emoji),
);
case FAVOURITE_FAIL:

View File

@ -32,6 +32,7 @@ import {
} from '../actions/timelines';
import type { AnyAction } from 'redux';
import type { ImportPosition } from 'soapbox/entity-store/types';
import type { APIEntity, Status } from 'soapbox/types/entities';
const TRUNCATE_LIMIT = 40;
@ -93,6 +94,7 @@ const expandNormalizedTimeline = (
prev: string | undefined,
isPartial: boolean,
isLoadingRecent: boolean,
pos: ImportPosition = 'end',
) => {
const newIds = getStatusIds(statuses);
@ -113,10 +115,10 @@ const expandNormalizedTimeline = (
if (!newIds.isEmpty()) {
timeline.update('items', oldIds => {
if (newIds.first() > oldIds.first()!) {
return mergeStatusIds(oldIds, newIds);
} else {
if (pos === 'end') {
return mergeStatusIds(newIds, oldIds);
} else {
return mergeStatusIds(oldIds, newIds);
}
});
}

View File

@ -2,15 +2,22 @@ import { z } from 'zod';
import { emojiSchema } from './utils';
/** Pleroma emoji reaction. */
const emojiReactionSchema = z.object({
name: emojiSchema,
const baseEmojiReactionSchema = z.object({
count: z.number().nullable().catch(null),
me: z.boolean().catch(false),
/** Akkoma custom emoji reaction. */
url: z.string().url().optional().catch(undefined),
name: emojiSchema,
url: z.literal(undefined).catch(undefined),
});
const customEmojiReactionSchema = baseEmojiReactionSchema.extend({
name: z.string(),
/** Akkoma custom emoji reaction. */
url: z.string().url(),
});
/** Pleroma emoji reaction. */
const emojiReactionSchema = baseEmojiReactionSchema.or(customEmojiReactionSchema);
type EmojiReaction = z.infer<typeof emojiReactionSchema>;
export { emojiReactionSchema, type EmojiReaction };

View File

@ -53,6 +53,9 @@ const configurationSchema = coerceObject({
max_options: z.number().optional().catch(undefined),
min_expiration: z.number().optional().catch(undefined),
}),
reactions: coerceObject({
max_reactions: z.number().catch(0),
}),
statuses: coerceObject({
characters_reserved_per_url: z.number().optional().catch(undefined),
max_characters: z.number().optional().catch(undefined),
@ -180,7 +183,7 @@ const instanceV1Schema = coerceObject({
version: z.string().catch('0.0.0'),
});
const instanceSchema = z.preprocess((data: unknown) => {
const instanceSchema = z.preprocess((data: any) => {
if (data.domain) return data;
const {

View File

@ -24,11 +24,16 @@ const eventSchema = eventTemplateSchema.extend({
/** Nostr event schema that also verifies the event's signature. */
const signedEventSchema = eventSchema.refine(verifySignature);
/** NIP-46 signer options. */
const signEventOptsSchema = z.object({
pow: z.number().int().nonnegative(),
}).partial();
/** NIP-46 signer request. */
const connectRequestSchema = z.object({
id: z.string(),
method: z.literal('sign_event'),
params: z.tuple([eventTemplateSchema]),
params: z.tuple([eventTemplateSchema]).or(z.tuple([eventTemplateSchema, signEventOptsSchema])),
});
export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema };

View File

@ -19,7 +19,6 @@ import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './
import type { Resolve } from 'soapbox/utils/types';
const statusPleromaSchema = z.object({
emoji_reactions: filteredArray(emojiReactionSchema),
event: eventSchema.nullish().catch(undefined),
quote: z.literal(null).catch(null),
quote_visible: z.boolean().catch(true),
@ -51,6 +50,7 @@ const baseStatusSchema = z.object({
muted: z.coerce.boolean(),
pinned: z.coerce.boolean(),
pleroma: statusPleromaSchema.optional().catch(undefined),
reactions: filteredArray(emojiReactionSchema),
poll: pollSchema.nullable().catch(null),
quote: z.literal(null).catch(null),
quotes_count: z.number().catch(0),
@ -131,16 +131,18 @@ const statusSchema = baseStatusSchema.extend({
reblog: embeddedStatusSchema,
pleroma: statusPleromaSchema.extend({
quote: embeddedStatusSchema,
emoji_reactions: filteredArray(emojiReactionSchema),
}).optional().catch(undefined),
}).transform(({ pleroma, ...status }) => {
return {
...status,
event: pleroma?.event,
quote: pleroma?.quote || status.quote || null,
reactions: pleroma?.emoji_reactions || status.reactions || null,
// There's apparently no better way to do this...
// Just trying to remove the `event` and `quote` keys from the object.
pleroma: pleroma ? (() => {
const { event, quote, ...rest } = pleroma;
const { event, quote, emoji_reactions, ...rest } = pleroma;
return rest;
})() : undefined,
};

View File

@ -39,7 +39,7 @@ async function startSentry(dsn: string): Promise<void> {
/^moz-extension:\/\//i,
],
tracesSampleRate: 1.0,
tracesSampleRate: .1,
});
Sentry.setContext('soapbox', sourceCode);

View File

@ -1,5 +1,5 @@
import { configureStore } from '@reduxjs/toolkit';
import thunk, { ThunkDispatch } from 'redux-thunk';
import { configureStore, Tuple } from '@reduxjs/toolkit';
import { thunk, type ThunkDispatch } from 'redux-thunk';
import errorsMiddleware from './middleware/errors';
import soundsMiddleware from './middleware/sounds';
@ -9,11 +9,11 @@ import type { AnyAction } from 'redux';
export const store = configureStore({
reducer: appReducer,
middleware: [
middleware: () => new Tuple(
thunk,
errorsMiddleware(),
soundsMiddleware(),
],
),
devTools: true,
});

View File

@ -0,0 +1,39 @@
/* noto-sans-javanese-javanese-400-normal */
@font-face {
font-family: 'Soapbox i18n';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/noto-sans-javanese/files/noto-sans-javanese-javanese-400-normal.woff2) format('woff2'), url(@fontsource/noto-sans-javanese/files/noto-sans-javanese-javanese-400-normal.woff) format('woff');
unicode-range: U+A980-A9DF;
}
/* noto-sans-javanese-javanese-500-normal */
@font-face {
font-family: 'Soapbox i18n';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(@fontsource/noto-sans-javanese/files/noto-sans-javanese-javanese-500-normal.woff2) format('woff2'), url(@fontsource/noto-sans-javanese/files/noto-sans-javanese-javanese-500-normal.woff) format('woff');
unicode-range: U+A980-A9DF;
}
/* noto-sans-javanese-javanese-600-normal */
@font-face {
font-family: 'Soapbox i18n';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(@fontsource/noto-sans-javanese/files/noto-sans-javanese-javanese-600-normal.woff2) format('woff2'), url(@fontsource/noto-sans-javanese/files/noto-sans-javanese-javanese-600-normal.woff) format('woff');
unicode-range: U+A980-A9DF;
}
/* noto-sans-javanese-javanese-700-normal */
@font-face {
font-family: 'Soapbox i18n';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(@fontsource/noto-sans-javanese/files/noto-sans-javanese-javanese-700-normal.woff2) format('woff2'), url(@fontsource/noto-sans-javanese/files/noto-sans-javanese-javanese-700-normal.woff) format('woff');
unicode-range: U+A980-A9DF;
}

View File

@ -9,7 +9,7 @@ import trimStart from 'lodash/trimStart';
import { type MRFSimple, mrfSimpleSchema } from 'soapbox/schemas/pleroma';
export type Config = ImmutableMap<string, any>;
export type Policy = ImmutableMap<string, any>;
export type Policy = Record<string, any>;
const find = (
configs: ImmutableList<Config>,
@ -40,15 +40,15 @@ const toSimplePolicy = (configs: ImmutableList<Config>): MRFSimple => {
};
const fromSimplePolicy = (simplePolicy: Policy): ImmutableList<Config> => {
const mapper = (hosts: ImmutableList<string>, key: string) => fromJS({ tuple: [`:${key}`, hosts.toJS()] });
const mapper = ([key, hosts]: [key: string, hosts: ImmutableList<string>]) => fromJS({ tuple: [`:${key}`, hosts] });
const value = simplePolicy.map(mapper).toList();
const value = Object.entries(simplePolicy).map(mapper);
return ImmutableList([
ImmutableMap({
group: ':pleroma',
key: ':mrf_simple',
value,
value: ImmutableList(value),
}),
]);
};

View File

@ -1,11 +1,11 @@
import { List as ImmutableList, Map as ImmutableMap, fromJS } from 'immutable';
import { List as ImmutableList, fromJS } from 'immutable';
import { normalizeStatus } from 'soapbox/normalizers';
import { emojiReactionSchema } from 'soapbox/schemas';
import {
sortEmoji,
mergeEmojiFavourites,
oneEmojiPerAccount,
reduceEmoji,
getReactForStatus,
simulateEmojiReact,
@ -23,7 +23,7 @@ const ALLOWED_EMOJI = ImmutableList([
describe('sortEmoji', () => {
describe('with an unsorted list of emoji', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 7, 'me': true, 'name': '😃' },
{ 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 3, 'me': true, 'name': '😢' },
@ -31,7 +31,7 @@ describe('sortEmoji', () => {
{ 'count': 20, 'me': true, 'name': '👍' },
{ 'count': 7, 'me': true, 'name': '😂' },
{ 'count': 15, 'me': true, 'name': '❤' },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
it('sorts the emoji by count', () => {
expect(sortEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 20, 'me': true, 'name': '👍' },
@ -51,11 +51,11 @@ describe('mergeEmojiFavourites', () => {
const favourited = true;
describe('with existing 👍 reacts', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 20, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 15, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 7, 'me': false, 'name': '😯', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
it('combines 👍 reacts with favourites', () => {
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
{ 'count': 32, 'me': true, 'name': '👍', 'url': undefined },
@ -66,10 +66,10 @@ describe('mergeEmojiFavourites', () => {
});
describe('without existing 👍 reacts', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 15, 'me': false, 'name': '❤' },
{ 'count': 7, 'me': false, 'name': '😯' },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
it('adds 👍 reacts to the map equaling favourite count', () => {
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
{ 'count': 15, 'me': false, 'name': '❤' },
@ -88,7 +88,7 @@ describe('mergeEmojiFavourites', () => {
describe('reduceEmoji', () => {
describe('with a clusterfuck of emoji', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 1, 'me': false, 'name': '😡' },
{ 'count': 1, 'me': true, 'name': '🔪' },
{ 'count': 7, 'me': true, 'name': '😯' },
@ -99,7 +99,7 @@ describe('reduceEmoji', () => {
{ 'count': 15, 'me': true, 'name': '❤' },
{ 'count': 1, 'me': false, 'name': '👀' },
{ 'count': 1, 'me': false, 'name': '🍩' },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
it('sorts, filters, and combines emoji and favourites', () => {
expect(reduceEmoji(emojiReacts, 7, true, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 27, 'me': true, 'name': '👍' },
@ -117,22 +117,6 @@ describe('reduceEmoji', () => {
});
});
describe('oneEmojiPerAccount', () => {
it('reduces to one react per account', () => {
const emojiReacts = fromJS([
// Sorted
{ 'count': 2, 'me': true, 'name': '👍', accounts: [{ id: '1' }, { id: '2' }] },
{ 'count': 2, 'me': true, 'name': '❤', accounts: [{ id: '1' }, { id: '2' }] },
{ 'count': 1, 'me': true, 'name': '😯', accounts: [{ id: '1' }] },
{ 'count': 1, 'me': false, 'name': '😂', accounts: [{ id: '3' }] },
]) as ImmutableList<ImmutableMap<string, any>>;
expect(oneEmojiPerAccount(emojiReacts, '1')).toEqual(fromJS([
{ 'count': 2, 'me': true, 'name': '👍', accounts: [{ id: '1' }, { id: '2' }] },
{ 'count': 1, 'me': false, 'name': '😂', accounts: [{ id: '3' }] },
]));
});
});
describe('getReactForStatus', () => {
it('returns a single owned react (including favourite) for the status', () => {
const status = normalizeStatus(fromJS({
@ -146,12 +130,12 @@ describe('getReactForStatus', () => {
],
},
}));
expect(getReactForStatus(status, ALLOWED_EMOJI)?.get('name')).toEqual('❤');
expect(getReactForStatus(status, ALLOWED_EMOJI)?.name).toEqual('❤');
});
it('returns a thumbs-up for a favourite', () => {
const status = normalizeStatus(fromJS({ favourites_count: 1, favourited: true }));
expect(getReactForStatus(status)?.get('name')).toEqual('👍');
expect(getReactForStatus(status)?.name).toEqual('👍');
});
it('returns undefined when a status has no reacts (or favourites)', () => {
@ -172,10 +156,10 @@ describe('getReactForStatus', () => {
describe('simulateEmojiReact', () => {
it('adds the emoji to the list', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 3, 'me': true, 'name': '❤', 'url': undefined },
@ -183,10 +167,10 @@ describe('simulateEmojiReact', () => {
});
it('creates the emoji if it didn\'t already exist', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
@ -195,10 +179,10 @@ describe('simulateEmojiReact', () => {
});
it('adds a custom emoji to the list', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
expect(simulateEmojiReact(emojiReacts, 'soapbox', 'https://gleasonator.com/emoji/Gleasonator/soapbox.png')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
@ -209,10 +193,10 @@ describe('simulateEmojiReact', () => {
describe('simulateUnEmojiReact', () => {
it('removes the emoji from the list', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 3, 'me': true, 'name': '❤' },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
expect(simulateUnEmojiReact(emojiReacts, '❤')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
@ -220,11 +204,11 @@ describe('simulateUnEmojiReact', () => {
});
it('removes the emoji if it\'s the last one in the list', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
{ 'count': 1, 'me': true, 'name': '😯' },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
expect(simulateUnEmojiReact(emojiReacts, '😯')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
@ -232,11 +216,11 @@ describe('simulateUnEmojiReact', () => {
});
it ('removes custom emoji from the list', () => {
const emojiReacts = fromJS([
const emojiReacts = ImmutableList([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
{ 'count': 1, 'me': true, 'name': 'soapbox', 'url': 'https://gleasonator.com/emoji/Gleasonator/soapbox.png' },
]) as ImmutableList<ImmutableMap<string, any>>;
].map((react) => emojiReactionSchema.parse(react)));
expect(simulateUnEmojiReact(emojiReacts, 'soapbox')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },

View File

@ -1,9 +1,6 @@
import {
Map as ImmutableMap,
List as ImmutableList,
} from 'immutable';
import { List as ImmutableList } from 'immutable';
import type { Me } from 'soapbox/types/soapbox';
import { EmojiReaction, emojiReactionSchema } from 'soapbox/schemas';
// https://emojipedia.org/facebook
// I've customized them.
@ -16,18 +13,16 @@ export const ALLOWED_EMOJI = ImmutableList([
'😩',
]);
type Account = ImmutableMap<string, any>;
type EmojiReact = ImmutableMap<string, any>;
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji: ImmutableList<string>): ImmutableList<EmojiReact> => (
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReaction>, allowedEmoji: ImmutableList<string>): ImmutableList<EmojiReaction> => (
emojiReacts
.sortBy(emojiReact =>
-(emojiReact.get('count') + Number(allowedEmoji.includes(emojiReact.get('name')))))
-((emojiReact.count || 0) + Number(allowedEmoji.includes(emojiReact.name))))
);
export const mergeEmojiFavourites = (emojiReacts = ImmutableList<EmojiReact>(), favouritesCount: number, favourited: boolean) => {
export const mergeEmojiFavourites = (emojiReacts: ImmutableList<EmojiReaction> | null, favouritesCount: number, favourited: boolean) => {
if (!emojiReacts) return ImmutableList([emojiReactionSchema.parse({ count: favouritesCount, me: favourited, name: '👍' })]);
if (!favouritesCount) return emojiReacts;
const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.get('name') === '👍');
const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.name === '👍');
if (likeIndex > -1) {
const likeCount = Number(emojiReacts.getIn([likeIndex, 'count']));
favourited = favourited || Boolean(emojiReacts.getIn([likeIndex, 'me'], false));
@ -35,69 +30,43 @@ export const mergeEmojiFavourites = (emojiReacts = ImmutableList<EmojiReact>(),
.setIn([likeIndex, 'count'], likeCount + favouritesCount)
.setIn([likeIndex, 'me'], favourited);
} else {
return emojiReacts.push(ImmutableMap({ count: favouritesCount, me: favourited, name: '👍' }));
return emojiReacts.push(emojiReactionSchema.parse({ count: favouritesCount, me: favourited, name: '👍' }));
}
};
const hasMultiReactions = (emojiReacts: ImmutableList<EmojiReact>, account: Account): boolean => (
emojiReacts.filter(
e => e.get('accounts').filter(
(a: Account) => a.get('id') === account.get('id'),
).count() > 0,
).count() > 1
);
const inAccounts = (accounts: ImmutableList<Account>, id: string): boolean => (
accounts.filter(a => a.get('id') === id).count() > 0
);
export const oneEmojiPerAccount = (emojiReacts: ImmutableList<EmojiReact>, me: Me) => {
emojiReacts = emojiReacts.reverse();
return emojiReacts.reduce((acc, cur, idx) => {
const accounts = cur.get('accounts', ImmutableList())
.filter((a: Account) => !hasMultiReactions(acc, a));
return acc.set(idx, cur.merge({
accounts: accounts,
count: accounts.count(),
me: me ? inAccounts(accounts, me) : false,
}));
}, emojiReacts)
.filter(e => e.get('count') > 0)
.reverse();
};
export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReact>, favouritesCount: number, favourited: boolean, allowedEmoji = ALLOWED_EMOJI): ImmutableList<EmojiReact> => (
export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReaction> | null, favouritesCount: number, favourited: boolean, allowedEmoji = ALLOWED_EMOJI): ImmutableList<EmojiReaction> => (
sortEmoji(
mergeEmojiFavourites(emojiReacts, favouritesCount, favourited),
allowedEmoji,
));
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): EmojiReact | undefined => {
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): EmojiReaction | undefined => {
if (!status.reactions) return;
const result = reduceEmoji(
status.pleroma.get('emoji_reactions', ImmutableList()),
status.reactions,
status.favourites_count || 0,
status.favourited,
allowedEmoji,
).filter(e => e.get('me') === true)
).filter(e => e.me === true)
.get(0);
return typeof result?.get('name') === 'string' ? result : undefined;
return typeof result?.name === 'string' ? result : undefined;
};
export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji: string, url?: string) => {
const idx = emojiReacts.findIndex(e => e.get('name') === emoji);
export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReaction>, emoji: string, url?: string) => {
const idx = emojiReacts.findIndex(e => e.name === emoji);
const emojiReact = emojiReacts.get(idx);
if (idx > -1 && emojiReact) {
return emojiReacts.set(idx, emojiReact.merge({
count: emojiReact.get('count') + 1,
return emojiReacts.set(idx, emojiReactionSchema.parse({
...emojiReact,
count: (emojiReact.count || 0) + 1,
me: true,
url,
}));
} else {
return emojiReacts.push(ImmutableMap({
return emojiReacts.push(emojiReactionSchema.parse({
count: 1,
me: true,
name: emoji,
@ -106,17 +75,18 @@ export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji
}
};
export const simulateUnEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji: string) => {
export const simulateUnEmojiReact = (emojiReacts: ImmutableList<EmojiReaction>, emoji: string) => {
const idx = emojiReacts.findIndex(e =>
e.get('name') === emoji && e.get('me') === true);
e.name === emoji && e.me === true);
const emojiReact = emojiReacts.get(idx);
if (emojiReact) {
const newCount = emojiReact.get('count') - 1;
const newCount = (emojiReact.count || 1) - 1;
if (newCount < 1) return emojiReacts.delete(idx);
return emojiReacts.set(idx, emojiReact.merge({
count: emojiReact.get('count') - 1,
return emojiReacts.set(idx, emojiReactionSchema.parse({
...emojiReact,
count: (emojiReact.count || 1) - 1,
me: false,
}));
} else {

View File

@ -200,4 +200,11 @@ const httpErrorMessages: { code: number; name: string; description: string }[] =
},
];
export { buildErrorMessage, httpErrorMessages };
/** Whether the error is caused by a JS chunk failing to load. */
function isNetworkError(error: unknown): boolean {
return error instanceof Error
&& error.name === 'TypeError'
&& error.message.startsWith('Failed to fetch dynamically imported module: ');
}
export { buildErrorMessage, httpErrorMessages, isNetworkError };

View File

@ -14,24 +14,30 @@ const overrides = custom('features');
/** Truthy array convenience function */
const any = (arr: Array<any>): boolean => arr.some(Boolean);
/**
* Firefish, a fork of Misskey. Formerly known as Calckey.
* @see {@link https://joinfirefish.org/}
*/
export const FIREFISH = 'Firefish';
/**
* Ditto, a Nostr server with Mastodon API.
* @see {@link https://gitlab.com/soapbox-pub/ditto}
*/
export const DITTO = 'Ditto';
/**
* Firefish, a fork of Misskey. Formerly known as Calckey.
* @see {@link https://joinfirefish.org/}
*/
export const FIREFISH = 'Firefish';
/**
* Friendica, decentralized social platform implementing multiple federation protocols.
* @see {@link https://friendi.ca/}
*/
export const FRIENDICA = 'Friendica';
/**
* Iceshrimp, yet another Misskey fork.
* @see {@link https://iceshrimp.dev/}
*/
export const ICESHRIMP = 'Iceshrimp';
/**
* Mastodon, the software upon which this is all based.
* @see {@link https://joinmastodon.org/}
@ -143,6 +149,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/
accountLookup: any([
v.software === FIREFISH,
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
v.software === PLEROMA && gte(v.version, '2.4.50'),
v.software === TAKAHE && gte(v.version, '0.6.1'),
@ -192,6 +199,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see {@link https://docs.joinmastodon.org/methods/announcements/}
*/
announcements: any([
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
v.software === PLEROMA && gte(v.version, '2.2.49'),
v.software === TAKAHE && gte(v.version, '0.7.0'),
@ -230,11 +238,13 @@ const getInstanceFeatures = (instance: Instance) => {
*/
bookmarks: any([
v.software === FIREFISH,
v.software === ICESHRIMP,
v.software === FRIENDICA,
v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
v.software === PLEROMA && gte(v.version, '0.9.9'),
v.software === PIXELFED,
v.software === TAKAHE && gte(v.version, '0.9.0'),
v.software === DITTO,
]),
/**
@ -319,6 +329,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/
conversations: any([
v.software === FIREFISH,
v.software === ICESHRIMP,
v.software === FRIENDICA,
v.software === MASTODON && gte(v.compatVersion, '2.6.0'),
v.software === PLEROMA && gte(v.version, '0.9.9'),
@ -359,6 +370,7 @@ const getInstanceFeatures = (instance: Instance) => {
editProfile: any([
v.software === FIREFISH,
v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON,
v.software === MITRA,
v.software === PIXELFED,
@ -374,6 +386,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/
editStatuses: any([
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.version, '3.5.0'),
v.software === TAKAHE && gte(v.version, '0.8.0'),
features.includes('editing'),
@ -406,6 +419,13 @@ const getInstanceFeatures = (instance: Instance) => {
*/
emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'),
/**
* Ability to add emoji reactions to a status available in Mastodon forks.
* @see POST /v1/statuses/:id/react/:emoji
* @see POST /v1/statuses/:id/unreact/:emoji
*/
emojiReactsMastodon: instance.configuration.reactions.max_reactions > 0,
/**
* The backend allows only non-RGI ("Recommended for General Interchange") emoji reactions.
* @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji
@ -444,6 +464,7 @@ const getInstanceFeatures = (instance: Instance) => {
exposableReactions: any([
v.software === FIREFISH,
v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON,
v.software === TAKAHE && gte(v.version, '0.6.1'),
v.software === TRUTHSOCIAL,
@ -638,6 +659,7 @@ const getInstanceFeatures = (instance: Instance) => {
lists: any([
v.software === FIREFISH,
v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '2.1.0'),
v.software === PLEROMA && gte(v.version, '0.9.9'),
]),
@ -693,6 +715,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see PUT /api/v1/accounts/:id/mute
*/
mutesDuration: any([
v.software === ICESHRIMP,
v.software === PLEROMA && gte(v.version, '2.3.0'),
v.software === MASTODON && gte(v.compatVersion, '3.3.0'),
v.software === TAKAHE,
@ -725,6 +748,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see GET /api/v1/notifications
*/
notificationsIncludeTypes: any([
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
v.software === PLEROMA && gte(v.version, '2.4.50'),
v.software === TAKAHE && gte(v.version, '0.6.2'),
@ -749,6 +773,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/
polls: any([
v.software === FIREFISH,
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.version, '2.8.0'),
v.software === PLEROMA,
v.software === TAKAHE && gte(v.version, '0.8.0'),
@ -789,6 +814,7 @@ const getInstanceFeatures = (instance: Instance) => {
publicTimeline: any([
v.software === FIREFISH,
v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON,
v.software === PLEROMA,
v.software === TAKAHE,
@ -874,6 +900,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see POST /api/v2/search
*/
searchFromAccount: any([
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.version, '2.8.0'),
v.software === PLEROMA && gte(v.version, '1.0.0'),
]),
@ -927,6 +954,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/
suggestionsV2: any([
v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
v.software === TRUTHSOCIAL,
features.includes('v2_suggestions'),
@ -943,6 +971,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see GET /api/v1/trends/statuses
*/
trendingStatuses: any([
v.software === ICESHRIMP,
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
]),
@ -953,6 +982,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/
trends: any([
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.0.0'),
v.software === TRUTHSOCIAL,
v.software === DITTO,

View File

@ -1,9 +1,16 @@
/** Register the ServiceWorker. */
function registerSW(path: string) {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(path, { scope: '/' });
});
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.');
};
}
}
@ -22,4 +29,5 @@ const unregisterSW = async(): Promise<void> => {
export {
registerSW,
unregisterSW,
lockSW,
};

9
src/workers.ts Normal file
View File

@ -0,0 +1,9 @@
import * as Comlink from 'comlink';
import type { PowWorker } from './workers/pow.worker';
const powWorker = Comlink.wrap<typeof PowWorker>(
new Worker(new URL('./workers/pow.worker.ts', import.meta.url), { type: 'module' }),
);
export { powWorker };

10
src/workers/pow.worker.ts Normal file
View File

@ -0,0 +1,10 @@
import * as Comlink from 'comlink';
import { nip13, type UnsignedEvent } from 'nostr-tools';
export const PowWorker = {
mine<K extends number>(event: UnsignedEvent<K>, difficulty: number) {
return nip13.minePow(event, difficulty);
},
};
Comlink.expose(PowWorker);

View File

@ -1,7 +1,11 @@
const { parseColorMatrix } = require('./tailwind/colors.cjs');
import aspectRatioPlugin from '@tailwindcss/aspect-ratio';
import formsPlugin from '@tailwindcss/forms';
import typographyPlugin from '@tailwindcss/typography';
import { type Config } from 'tailwindcss';
/** @type {import('tailwindcss').Config} */
module.exports = {
import { parseColorMatrix } from './tailwind/colors';
const config: Config = {
content: ['./src/**/*.{html,js,ts,tsx}', './custom/instance/**/*.html', './index.html'],
darkMode: 'class',
theme: {
@ -98,8 +102,10 @@ module.exports = {
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
require('@tailwindcss/aspect-ratio'),
aspectRatioPlugin,
formsPlugin,
typographyPlugin,
],
};
export default config;

View File

@ -1,46 +0,0 @@
// https://tailwindcss.com/docs/customizing-colors#using-css-variables
function withOpacityValue(variable) {
return ({ opacityValue }) => {
if (opacityValue === undefined) {
return `rgb(var(${variable}))`;
}
return `rgb(var(${variable}) / ${opacityValue})`;
};
}
// Parse a single color as a CSS variable
const toColorVariable = (colorName, tint = null) => {
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, tints) => {
return tints.reduce((colorObj, tint) => {
colorObj[tint] = toColorVariable(colorName, tint);
return colorObj;
}, {});
};
// Parse color matrix into Tailwind color palette
const parseColorMatrix = colorMatrix => {
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;
}, {});
};
module.exports = {
withOpacityValue,
parseColorMatrix,
};

View File

@ -1,7 +1,7 @@
import {
withOpacityValue,
parseColorMatrix,
} from './colors.cjs';
} from './colors';
describe('withOpacityValue()', () => {
it('returns a Tailwind color function with alpha support', () => {
@ -11,8 +11,7 @@ describe('withOpacityValue()', () => {
expect(typeof result).toBe('function');
// Test calling the function
expect(result({})).toBe('rgb(var(--color-primary-500))');
expect(result({ opacityValue: .5 })).toBe('rgb(var(--color-primary-500) / 0.5)');
expect(result).toBe('rgb(var(--color-primary-500) / <alpha-value>)');
});
});
@ -29,8 +28,8 @@ describe('parseColorMatrix()', () => {
const result = parseColorMatrix(colorMatrix);
// Colors are mapped to functions which return CSS values
expect(result.primary[500]({})).toEqual('rgb(var(--color-primary-500))');
expect(result.accent[300]({ opacityValue: .3 })).toEqual('rgb(var(--color-accent-300) / 0.3)');
// @ts-ignore
expect(result.accent['300']).toEqual('rgb(var(--color-accent-300) / <alpha-value>)');
});
it('parses single-tint values', () => {
@ -46,6 +45,6 @@ describe('parseColorMatrix()', () => {
const result = parseColorMatrix(colorMatrix);
expect(result['gradient-start']({ opacityValue: .7 })).toEqual('rgb(var(--color-gradient-start) / 0.7)');
expect(result['gradient-start']).toEqual('rgb(var(--color-gradient-start) / <alpha-value>)');
});
});

47
tailwind/colors.ts Normal file
View File

@ -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}) / <alpha-value>)`;
}
/** 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<Record<string, string>>((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<RecursiveKeyValuePair>((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,
};

View File

@ -12,7 +12,7 @@ import { VitePWA } from 'vite-plugin-pwa';
import vitePluginRequire from 'vite-plugin-require';
import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig({
export default defineConfig(({ command }) => ({
build: {
assetsDir: 'packs',
assetsInlineLimit: 0,
@ -29,6 +29,9 @@ export default defineConfig({
server: {
port: 3036,
},
optimizeDeps: {
exclude: command === 'serve' ? ['@soapbox.pub/wasmboy'] : [],
},
plugins: [
checker({ typescript: true }),
// @ts-ignore
@ -100,7 +103,7 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: 'src/jest/test-setup.ts',
},
});
}));
/** Return file as string, or return empty string if the file isn't found. */
function readFileContents(path: string) {

1791
yarn.lock

File diff suppressed because it is too large Load Diff