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/** /tmp/**
/coverage/** /coverage/**
/custom/** /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: variables:
NODE_ENV: test NODE_ENV: test
@ -45,7 +45,7 @@ lint:
- "**/*.scss" - "**/*.scss"
- "**/*.css" - "**/*.css"
- ".eslintignore" - ".eslintignore"
- ".eslintrc.cjs" - ".eslintrc.json"
- ".stylelintrc.json" - ".stylelintrc.json"
build: build:
@ -111,9 +111,9 @@ pages:
docker: docker:
stage: deploy stage: deploy
image: docker:24.0.6 image: docker:24.0.7
services: services:
- docker:24.0.6-dind - docker:24.0.7-dind
tags: tags:
- dind - 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 # 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, "color-function-notation": null,
"custom-property-pattern": null, "custom-property-pattern": null,
"declaration-block-no-redundant-longhand-properties": null, "declaration-block-no-redundant-longhand-properties": null,
"declaration-colon-newline-after": null,
"declaration-empty-line-before": "never", "declaration-empty-line-before": "never",
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }], "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }],
"max-line-length": null,
"no-descending-specificity": null, "no-descending-specificity": null,
"no-duplicate-selectors": null, "no-duplicate-selectors": null,
"no-invalid-position-at-import-rule": 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 WORKDIR /app
COPY package.json . COPY package.json .
COPY yarn.lock . COPY yarn.lock .

View File

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

View File

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

View File

@ -1,7 +1,10 @@
module.exports = ({ env }) => ({ /** @type {import('postcss-load-config').ConfigFn} */
const config = ({ env }) => ({
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
cssnano: env === 'production' ? {} : false, 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'; import { isLoggedIn } from 'soapbox/utils/auth';
@ -8,7 +8,7 @@ import { importFetchedAccounts, importFetchedStatus } from './importer';
import { favourite, unfavourite } from './interactions'; import { favourite, unfavourite } from './interactions';
import type { AppDispatch, RootState } from 'soapbox/store'; 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_REQUEST = 'EMOJI_REACT_REQUEST';
const EMOJI_REACT_SUCCESS = 'EMOJI_REACT_SUCCESS'; 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) => const simpleEmojiReact = (status: Status, emoji: string, custom?: string) =>
(dispatch: AppDispatch) => { (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)); 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)); if (undo) return dispatch(unEmojiReact(status, emoji));
return Promise.all([ return Promise.all([
...emojiReacts ...emojiReacts
.filter((emojiReact) => emojiReact.get('me') === true) .filter((emojiReact) => emojiReact.me === true)
.map(emojiReact => dispatch(unEmojiReact(status, emojiReact.get('name')))).toArray(), .map(emojiReact => dispatch(unEmojiReact(status, emojiReact.name))).toArray(),
status.favourited && dispatch(unfavourite(status)), status.favourited && dispatch(unfavourite(status)),
]).then(() => { ]).then(() => {
if (emoji === '👍') { 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'; 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 { MRFSimple } from 'soapbox/schemas/pleroma';
import type { AppDispatch, RootState } from 'soapbox/store'; 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 entries = Object.entries(simplePolicy).map(([key, hosts]) => {
const isRestricted = restrictions.get(key); const isRestricted = restrictions[key];
if (isRestricted) { if (isRestricted) {
return [key, ImmutableSet(hosts).add(host).toJS()]; return [key, ImmutableSet(hosts).add(host).toJS()];
@ -21,7 +21,7 @@ const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions:
return Object.fromEntries(entries); 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: AppDispatch, getState: () => RootState) =>
dispatch(fetchConfig()) dispatch(fetchConfig())
.then(() => { .then(() => {

View File

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

View File

@ -36,7 +36,7 @@ function useSignerStream() {
const respMsg = { const respMsg = {
id: reqMsg.data.id, 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({ const respEvent = await signEvent({

View File

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

View File

@ -12,7 +12,7 @@ const BigCard: React.FC<IBigCard> = ({ title, subtitle, children }) => {
return ( return (
<Card variant='rounded' size='xl'> <Card variant='rounded' size='xl'>
<CardBody> <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}> <Stack space={2}>
<Text size='2xl' align='center' weight='bold'>{title}</Text> <Text size='2xl' align='center' weight='bold'>{title}</Text>
{subtitle && <Text theme='muted' align='center'>{subtitle}</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 { FormattedMessage } from 'react-intl';
import { Banner, Button, HStack, Stack, Text } from 'soapbox/components/ui'; 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'); const acceptedGdpr = !!localStorage.getItem('soapbox:gdpr');
@ -14,8 +14,7 @@ const GdprBanner: React.FC = () => {
const [slideout, setSlideout] = useState(false); const [slideout, setSlideout] = useState(false);
const instance = useInstance(); const instance = useInstance();
const soapbox = useSoapboxConfig(); const { gdprUrl } = useSoapboxConfig();
const isLoggedIn = useAppSelector(state => !!state.me);
const handleAccept = () => { const handleAccept = () => {
localStorage.setItem('soapbox:gdpr', 'true'); localStorage.setItem('soapbox:gdpr', 'true');
@ -23,15 +22,13 @@ const GdprBanner: React.FC = () => {
setTimeout(() => setShown(true), 200); setTimeout(() => setShown(true), 200);
}; };
const showBanner = soapbox.gdpr && !isLoggedIn && !shown; if (shown) {
if (!showBanner) {
return null; return null;
} }
return ( return (
<Banner theme='opaque' className={clsx('transition-transform', { 'translate-y-full': slideout })}> <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}> <Stack space={2}>
<Text size='xl' weight='bold'> <Text size='xl' weight='bold'>
<FormattedMessage id='gdpr.title' defaultMessage='{siteTitle} uses cookies' values={{ siteTitle: instance.title }} /> <FormattedMessage id='gdpr.title' defaultMessage='{siteTitle} uses cookies' values={{ siteTitle: instance.title }} />
@ -47,8 +44,8 @@ const GdprBanner: React.FC = () => {
</Stack> </Stack>
<HStack space={2} alignItems='center' className='flex-none'> <HStack space={2} alignItems='center' className='flex-none'>
{soapbox.gdprUrl && ( {gdprUrl && (
<a href={soapbox.gdprUrl} tabIndex={-1} className='inline-flex'> <a href={gdprUrl} tabIndex={-1} className='inline-flex'>
<Button theme='secondary'> <Button theme='secondary'>
<FormattedMessage id='gdpr.learn_more' defaultMessage='Learn more' /> <FormattedMessage id='gdpr.learn_more' defaultMessage='Learn more' />
</Button> </Button>

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'; 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 Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
@ -15,6 +15,8 @@ import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maxi
import type { Property } from 'csstype'; import type { Property } from 'csstype';
import type { List as ImmutableList } from 'immutable'; import type { List as ImmutableList } from 'immutable';
const Gameboy = React.lazy(() => import('./gameboy'));
const ATTACHMENT_LIMIT = 4; const ATTACHMENT_LIMIT = 4;
const MAX_FILENAME_LENGTH = 45; const MAX_FILENAME_LENGTH = 45;
@ -141,8 +143,24 @@ const Item: React.FC<IItem> = ({
} }
let thumbnail: React.ReactNode = ''; 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 filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH);
const attachmentIcon = ( const attachmentIcon = (
<Icon <Icon
@ -215,7 +233,6 @@ const Item: React.FC<IItem> = ({
</div> </div>
); );
} else if (attachment.type === 'audio') { } else if (attachment.type === 'audio') {
const ext = attachment.url.split('.').pop()?.toUpperCase();
thumbnail = ( thumbnail = (
<a <a
className={clsx('media-gallery__item-thumbnail')} className={clsx('media-gallery__item-thumbnail')}
@ -225,11 +242,10 @@ const Item: React.FC<IItem> = ({
title={attachment.description} title={attachment.description}
> >
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span> <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> </a>
); );
} else if (attachment.type === 'video') { } else if (attachment.type === 'video') {
const ext = attachment.url.split('.').pop()?.toUpperCase();
thumbnail = ( thumbnail = (
<a <a
className={clsx('media-gallery__item-thumbnail')} className={clsx('media-gallery__item-thumbnail')}
@ -246,7 +262,7 @@ const Item: React.FC<IItem> = ({
> >
<source src={attachment.url} /> <source src={attachment.url} />
</video> </video>
<span className='media-gallery__file-extension__label'>{ext}</span> <span className='media-gallery__file-extension__label uppercase'>{ext}</span>
</a> </a>
); );
} }

View File

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

View File

@ -84,7 +84,7 @@ const SiteErrorBoundary: React.FC<ISiteErrorBoundary> = ({ children }) => {
<div className='py-8'> <div className='py-8'>
<div className='mx-auto max-w-xl space-y-2 text-center'> <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.' /> <FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
</h1> </h1>
<p className='text-lg text-gray-700 dark:text-gray-600'> <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 React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory, useRouteMatch } from 'react-router-dom'; import { useHistory, useRouteMatch } from 'react-router-dom';
@ -626,15 +625,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const reblogCount = status.reblogs_count; const reblogCount = status.reblogs_count;
const favouriteCount = status.favourites_count; const favouriteCount = status.favourites_count;
const emojiReactCount = reduceEmoji( const emojiReactCount = status.reactions ? reduceEmoji(
(status.pleroma.get('emoji_reactions') || ImmutableList()) as ImmutableList<any>, status.reactions,
favouriteCount, favouriteCount,
status.favourited, status.favourited,
allowedEmoji, allowedEmoji,
).reduce((acc, cur) => acc + cur.get('count'), 0); ).reduce((acc, cur) => acc + (cur.count || 0), 0) : undefined;
const meEmojiReact = getReactForStatus(status, allowedEmoji); 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 = { const reactMessages = {
'👍': messages.reactionLike, '👍': messages.reactionLike,

View File

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

View File

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

View File

@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
}; };
const handleClick: React.EventHandler<React.MouseEvent> = e => { 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 (isUserTouching()) {
if (ownAccount) { 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-900 dark:text-gray-100': !props.disabled,
'text-gray-600': 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-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, 'pr-10 rtl:pl-10 rtl:pr-3': isPassword || append,
'pl-8': typeof icon !== 'undefined', 'pl-8': typeof icon !== 'undefined',
'pl-16': typeof prepend !== 'undefined', 'pl-16': typeof prepend !== 'undefined',

View File

@ -13,7 +13,7 @@ const Select = React.forwardRef<HTMLSelectElement, ISelect>((props, ref) => {
<select <select
ref={ref} ref={ref}
className={clsx( 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, className,
)} )}
{...filteredProps} {...filteredProps}

View File

@ -45,7 +45,7 @@ const TagInput: React.FC<ITagInput> = ({ tags, onChange, placeholder }) => {
return ( return (
<div className='relative mt-1 grow shadow-sm'> <div className='relative mt-1 grow shadow-sm'>
<HStack <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} space={2}
wrap wrap
> >

View File

@ -91,7 +91,7 @@ const Textarea = React.forwardRef(({
ref={ref} ref={ref}
rows={rows} rows={rows}
onChange={handleChange} 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': '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', theme === 'default',
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent', '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 ( return (
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'> <div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'>
<div> <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>
<div className='px-4 sm:px-6'> <div className='px-4 sm:px-6'>
@ -620,7 +620,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
)} )}
<div> <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()} {renderHeader()}
<div className='absolute left-2 top-2'> <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> <span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input <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' type='text'
value={value} value={value}
onChange={handleChange} onChange={handleChange}

View File

@ -16,7 +16,7 @@ const ConsumersList: React.FC<IConsumersList> = () => {
if (providers.length > 0) { if (providers.length > 0) {
return ( 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'> <Text size='xs' theme='muted'>
<FormattedMessage id='oauth_consumers.title' defaultMessage='Other ways to sign in' /> <FormattedMessage id='oauth_consumers.title' defaultMessage='Other ways to sign in' />
</Text> </Text>

View File

@ -60,7 +60,7 @@ const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
<div <div
ref={containerRef} ref={containerRef}
style={{ height }} 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 ? ( {isOnboarded ? (
<div <div
@ -68,7 +68,7 @@ const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
data-testid='chat-page' data-testid='chat-page'
> >
<Stack <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, 'hidden sm:block': isSidebarHidden,
})} })}
> >

View File

@ -121,7 +121,7 @@ const ChatPageMain = () => {
<HStack alignItems='center'> <HStack alignItems='center'>
<IconButton <IconButton
src={require('@tabler/icons/arrow-left.svg')} 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')} onClick={() => history.push('/chats')}
/> />

View File

@ -24,7 +24,7 @@ const ChatPageNew: React.FC<IChatPageNew> = () => {
<HStack alignItems='center'> <HStack alignItems='center'>
<IconButton <IconButton
src={require('@tabler/icons/arrow-left.svg')} 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')} onClick={() => history.push('/chats')}
/> />

View File

@ -51,7 +51,7 @@ const ChatPageSettings = () => {
<HStack alignItems='center'> <HStack alignItems='center'>
<IconButton <IconButton
src={require('@tabler/icons/arrow-left.svg')} 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')} onClick={() => history.push('/chats')}
/> />

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@ const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onC
<label <label
ref={picker} ref={picker}
className={clsx( 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, 'border-2 border-primary-600 border-dashed !z-[99]': isDragging,
'ring-2 ring-offset-2 ring-primary-600': isDraggedOver, 'ring-2 ring-offset-2 ring-primary-600': isDraggedOver,

View File

@ -80,7 +80,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
return ( return (
<> <>
<div className='-mx-4 -mt-4'> <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> </div>
<PlaceholderEventHeader /> <PlaceholderEventHeader />
@ -364,7 +364,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
return ( return (
<> <>
<div className='-mx-4 -mt-4'> <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 && ( {banner && (
<a href={banner.url} onClick={handleHeaderClick} target='_blank'> <a href={banner.url} onClick={handleHeaderClick} target='_blank'>
<StillImage <StillImage

View File

@ -24,7 +24,7 @@ const SuggestionItem: React.FC<ISuggestionItem> = ({ accountId }) => {
if (!account) return null; if (!account) return null;
return ( 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 <Link
to={`/@${account.acct}`} to={`/@${account.acct}`}
title={account.acct} title={account.acct}

View File

@ -36,7 +36,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
return ( return (
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6' data-testid='group-header-missing'> <div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6' data-testid='group-header-missing'>
<div> <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>
<div className='px-4 sm:px-6'> <div className='px-4 sm:px-6'>
@ -92,7 +92,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
<StillImage <StillImage
src={group.header} src={group.header}
alt={intl.formatMessage(messages.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)} onError={() => setIsHeaderMissing(true)}
/> />
); );
@ -109,7 +109,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
return ( return (
<div <div
data-testid='group-header-image' 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 ? ( {isHeaderMissing ? (
<Icon src={require('@tabler/icons/photo-off.svg')} className='h-6 w-6 text-gray-500 dark:text-gray-700' /> <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 List, { ListItem } from 'soapbox/components/list';
import { Column, Toggle } from 'soapbox/components/ui'; import { Column, Toggle } from 'soapbox/components/ui';
import Timeline from 'soapbox/features/ui/components/timeline'; 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 { interface IHashtagTimeline {
params?: { params?: {
@ -22,7 +22,7 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const tag = useAppSelector((state) => state.tags.get(id)); const tag = useAppSelector((state) => state.tags.get(id));
const next = useAppSelector(state => state.timelines.get(`hashtag:${id}`)?.next); const next = useAppSelector(state => state.timelines.get(`hashtag:${id}`)?.next);
const { isLoggedIn } = useLoggedIn();
const handleLoadMore = (maxId: string) => { const handleLoadMore = (maxId: string) => {
dispatch(expandHashtagTimeline(id, { url: next, maxId })); dispatch(expandHashtagTimeline(id, { url: next, maxId }));
@ -50,7 +50,7 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
return ( return (
<Column label={`#${id}`} transparent> <Column label={`#${id}`} transparent>
{features.followHashtags && ( {features.followHashtags && isLoggedIn && (
<List> <List>
<ListItem <ListItem
label={<FormattedMessage id='hashtag.follow' defaultMessage='Follow hashtag' />} label={<FormattedMessage id='hashtag.follow' defaultMessage='Follow hashtag' />}

View File

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

View File

@ -26,7 +26,7 @@ const FediverseStep = ({ onNext }: { onNext: () => void }) => {
</Text> </Text>
<Stack space={4}> <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}> <Stack space={4}>
<Text theme='muted'> <Text theme='muted'>
<FormattedMessage <FormattedMessage

View File

@ -8,7 +8,7 @@ import PlaceholderStatusContent from './placeholder-status-content';
/** Fake notification to display while data is loading. */ /** Fake notification to display while data is loading. */
const PlaceholderNotification = () => ( 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='w-full animate-pulse'>
<div className='mb-2'> <div className='mb-2'>
<PlaceholderStatusContent minLength={20} maxLength={20} /> <PlaceholderStatusContent minLength={20} maxLength={20} />

View File

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

View File

@ -1,6 +1,4 @@
import clsx from 'clsx'; import clsx from 'clsx';import React from 'react';
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -59,7 +57,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
const getNormalizedReacts = () => { const getNormalizedReacts = () => {
return reduceEmoji( return reduceEmoji(
ImmutableList(status.pleroma.get('emoji_reactions') as any), status.reactions,
status.favourites_count, status.favourites_count,
status.favourited, status.favourited,
allowedEmoji, allowedEmoji,
@ -164,20 +162,22 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
const getEmojiReacts = () => { const getEmojiReacts = () => {
const emojiReacts = getNormalizedReacts(); const emojiReacts = getNormalizedReacts();
const count = emojiReacts.reduce((acc, cur) => ( const count = emojiReacts.reduce((acc, cur) => (
acc + cur.get('count') acc + (cur.count || 0)
), 0); ), 0);
const handleClick = features.emojiReacts ? handleOpenReactionsModal : handleOpenFavouritesModal;
if (count) { if (count) {
return ( return (
<InteractionCounter count={count} onClick={features.exposableReactions ? handleOpenReactionsModal : undefined}> <InteractionCounter count={count} onClick={features.exposableReactions ? handleClick : undefined}>
<HStack space={0.5} alignItems='center'> <HStack space={0.5} alignItems='center'>
{emojiReacts.take(3).map((e, i) => { {emojiReacts.take(3).map((e, i) => {
return ( return (
<Emoji <Emoji
key={i} key={i}
className='h-4.5 w-4.5 flex-none' className='h-4.5 w-4.5 flex-none'
emoji={e.get('name')} emoji={e.name}
src={e.get('url')} src={e.url}
/> />
); );
})} })}
@ -193,7 +193,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
<HStack space={3}> <HStack space={3}>
{getReposts()} {getReposts()}
{getQuotes()} {getQuotes()}
{features.emojiReacts ? getEmojiReacts() : getFavourites()} {(features.emojiReacts || features.emojiReactsMastodon) ? getEmojiReacts() : getFavourites()}
{getDislikes()} {getDislikes()}
</HStack> </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 <FormGroup
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />} 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 ? ( {banner ? (
<> <>
<img className='h-full w-full object-cover' src={banner.url} alt='' /> <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' />} labelText={<FormattedMessage id='compose_event.fields.description_label' defaultMessage='Event description' />}
> >
<ComposeEditor <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' placeholderClassName='pt-2'
composeId='compose-event-modal' composeId='compose-event-modal'
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)} placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)}

View File

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

View File

@ -42,7 +42,7 @@ const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
<Stack space={3}> <Stack space={3}>
<Stack> <Stack>
<label <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='' />} {group.header && <img className='h-full w-full object-cover' src={group.header} alt='' />}
</label> </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='mx-auto max-w-7xl px-2 sm:px-6 lg:px-8'>
<div className='relative flex h-12 justify-between lg:h-16'> <div className='relative flex h-12 justify-between lg:h-16'>
{account && ( {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}> <button onClick={onOpenSidebar}>
<Avatar src={account.avatar} size={34} /> <Avatar src={account.avatar} size={34} />
</button> </button>
@ -125,7 +125,7 @@ const Navbar = () => {
</Button> </Button>
</div> </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 <Input
required required
value={username} 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/timeline' developerOnly page={DefaultPage} component={TestTimeline} content={children} />
<WrappedRoute path='/developers/sw' developerOnly page={DefaultPage} component={ServiceWorkerInfo} content={children} /> <WrappedRoute path='/developers/sw' developerOnly page={DefaultPage} component={ServiceWorkerInfo} content={children} />
<WrappedRoute path='/developers' page={DefaultPage} component={Developers} 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} /> <WrappedRoute path='/error' developerOnly page={EmptyPage} component={IntentionalError} content={children} />
{hasCrypto && <WrappedRoute path='/donate/crypto' publicRoute page={DefaultPage} component={CryptoDonate} 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> </Layout>
{(me && !shouldHideFAB()) && ( {(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 /> <FloatingActionButton />
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

@ -198,6 +198,9 @@
"birthdays_modal.empty": "None of your friends have birthday today.", "birthdays_modal.empty": "None of your friends have birthday today.",
"boost_modal.combo": "You can press {combo} to skip this next time", "boost_modal.combo": "You can press {combo} to skip this next time",
"boost_modal.title": "Repost?", "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", "card.back.label": "Back",
"chat.actions.send": "Send", "chat.actions.send": "Send",
"chat.failed_to_send": "Message failed to 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.block_domain": "{domain}全体を非表示",
"account.blocked": "ブロック済み", "account.blocked": "ブロック済み",
"account.chat": "@{name}さんとチャット", "account.chat": "@{name}さんとチャット",
"account.copy": "プロフィールへのリンクをコピー",
"account.deactivated": "非アクティブ化", "account.deactivated": "非アクティブ化",
"account.direct": "@{name}さんにダイレクトメッセージ", "account.direct": "@{name}さんにダイレクトメッセージ",
"account.domain_blocked": "ドメイン非表示", "account.domain_blocked": "ドメイン非表示",
@ -82,6 +83,11 @@
"account_search.placeholder": "アカウントを検索", "account_search.placeholder": "アカウントを検索",
"actualStatus.edited": "{date}に編集済", "actualStatus.edited": "{date}に編集済",
"actualStatuses.quote_tombstone": "Post is unavailable.", "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.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_hint": "Users can sign up, but their account only gets activated when an admin approves it.",
"admin.dashboard.registration_mode.approval_label": "Approval Required", "admin.dashboard.registration_mode.approval_label": "Approval Required",
@ -98,6 +104,12 @@
"admin.dashcounters.user_count_label": "total users", "admin.dashcounters.user_count_label": "total users",
"admin.dashwidgets.email_list_header": "Email list", "admin.dashwidgets.email_list_header": "Email list",
"admin.dashwidgets.software_header": "Software", "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.more": "クリックして {count} 人のおすすめユーザーを表示",
"admin.latest_accounts_panel.title": "Latest Accounts", "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.", "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_note.placeholder": "Nie wprowadzono opisu",
"account_search.placeholder": "Szukaj konta", "account_search.placeholder": "Szukaj konta",
"actualStatus.edited": "Edytowano {date}", "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.action": "Utwórz ogłoszenie",
"admin.announcements.all_day": "Cały dzień", "admin.announcements.all_day": "Cały dzień",
"admin.announcements.delete": "Usuń", "admin.announcements.delete": "Usuń",
@ -164,6 +164,8 @@
"alert.unexpected.links.support": "Wsparcie techniczne", "alert.unexpected.links.support": "Wsparcie techniczne",
"alert.unexpected.message": "Wystąpił nieoczekiwany błąd.", "alert.unexpected.message": "Wystąpił nieoczekiwany błąd.",
"alert.unexpected.return_home": "Wróć na stronę główną", "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.add": "Utwórz alias",
"aliases.account_label": "Stare konto:", "aliases.account_label": "Stare konto:",
"aliases.aliases_list_delete": "Odłącz alias", "aliases.aliases_list_delete": "Odłącz alias",
@ -196,6 +198,9 @@
"birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.", "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.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
"boost_modal.title": "Repost?", "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", "card.back.label": "Wstecz",
"chat.actions.send": "Wyślij", "chat.actions.send": "Wyślij",
"chat.failed_to_send": "Nie udało się wysłać wiadomości.", "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.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_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.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.body": "Szukaj kogoś do rozpoczęcia rozmowy.",
"chat_search.blankslate.title": "Rozpocznij rozmowę", "chat_search.blankslate.title": "Rozpocznij rozmowę",
"chat_search.empty_results_blankslate.body": "Spróbuj znaleźć inną nazwę.", "chat_search.empty_results_blankslate.body": "Spróbuj znaleźć inną nazwę.",
@ -306,6 +313,7 @@
"column.developers.service_worker": "Service Worker", "column.developers.service_worker": "Service Worker",
"column.direct": "Wiadomości bezpośrednie", "column.direct": "Wiadomości bezpośrednie",
"column.directory": "Przeglądaj profile", "column.directory": "Przeglądaj profile",
"column.dislikes": "Nie lubi",
"column.domain_blocks": "Ukryte domeny", "column.domain_blocks": "Ukryte domeny",
"column.edit_profile": "Edytuj profil", "column.edit_profile": "Edytuj profil",
"column.event_map": "Lokalizacja wydarzenia", "column.event_map": "Lokalizacja wydarzenia",
@ -360,7 +368,7 @@
"column.notifications": "Powiadomienia", "column.notifications": "Powiadomienia",
"column.pins": "Przypięte wpisy", "column.pins": "Przypięte wpisy",
"column.preferences": "Preferencje", "column.preferences": "Preferencje",
"column.public": "Globalna oś czasu", "column.public": "Sfederowana oś czasu",
"column.quotes": "Cytatu wpisu", "column.quotes": "Cytatu wpisu",
"column.reactions": "Reakcje", "column.reactions": "Reakcje",
"column.reblogs": "Podbicia", "column.reblogs": "Podbicia",
@ -1364,6 +1372,8 @@
"soapbox_config.redirect_root_no_login_label": "Przekieruj stronę główną", "soapbox_config.redirect_root_no_login_label": "Przekieruj stronę główną",
"soapbox_config.save": "Zapisz", "soapbox_config.save": "Zapisz",
"soapbox_config.saved": "Zapisano konfigurację Soapbox!", "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_attribution_label": "Atrybucja kafelków map",
"soapbox_config.tile_server_label": "Serwer 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.", "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": "Napisano w {group}",
"status.group_mod_delete": "Usuń wpis z grupy", "status.group_mod_delete": "Usuń wpis z grupy",
"status.interactions.favourites": "{count, plural, one {Polubienie} few {Polubienia} other {Polubień}}", "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.interactions.reblogs": "{count, plural, one {Podanie dalej} few {Podania dalej} other {Podań dalej}}",
"status.load_more": "Załaduj więcej", "status.load_more": "Załaduj więcej",
"status.mention": "Wspomnij o @{name}", "status.mention": "Wspomnij o @{name}",

View File

@ -164,6 +164,8 @@
"alert.unexpected.links.support": "支持", "alert.unexpected.links.support": "支持",
"alert.unexpected.message": "发生了意外错误。", "alert.unexpected.message": "发生了意外错误。",
"alert.unexpected.return_home": "回到主页", "alert.unexpected.return_home": "回到主页",
"alert.unexpected.submit_feedback": "提交反馈",
"alert.unexpected.thanks": "感谢您的反馈!",
"aliases.account.add": "创建别名", "aliases.account.add": "创建别名",
"aliases.account_label": "旧帐号:", "aliases.account_label": "旧帐号:",
"aliases.aliases_list_delete": "删除别名", "aliases.aliases_list_delete": "删除别名",
@ -196,6 +198,9 @@
"birthdays_modal.empty": "今天您的朋友中无人过生日。", "birthdays_modal.empty": "今天您的朋友中无人过生日。",
"boost_modal.combo": "下次按住 {combo} 即可跳过此提示", "boost_modal.combo": "下次按住 {combo} 即可跳过此提示",
"boost_modal.title": "转发?", "boost_modal.title": "转发?",
"bundle_column_error.body": "载入页面时发生错误。",
"bundle_column_error.retry": "重试",
"bundle_column_error.title": "网络错误",
"card.back.label": "返回", "card.back.label": "返回",
"chat.actions.send": "发送", "chat.actions.send": "发送",
"chat.failed_to_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 './iframe';
import './styles/i18n/arabic.css'; import './styles/i18n/arabic.css';
import './styles/i18n/javanese.css';
import './styles/application.scss'; import './styles/application.scss';
import './styles/tailwind.css'; import './styles/tailwind.css';
import './precheck'; import './precheck';
import ready from './ready'; import ready from './ready';
import { registerSW } from './utils/sw'; import { registerSW, lockSW } from './utils/sw';
if (BuildConfig.NODE_ENV === 'production') { if (BuildConfig.NODE_ENV === 'production') {
printConsoleWarning(); printConsoleWarning();
registerSW('/sw.js'); registerSW('/sw.js');
lockSW();
} }
ready(() => { ready(() => {

View File

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

View File

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

View File

@ -13,10 +13,11 @@ import {
import { normalizeAttachment } from 'soapbox/normalizers/attachment'; import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmoji } from 'soapbox/normalizers/emoji'; import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizeMention } from 'soapbox/normalizers/mention'; 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 { 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 StatusApprovalStatus = 'pending' | 'approval' | 'rejected';
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self' | 'group'; export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self' | 'group';
@ -69,6 +70,7 @@ export const StatusRecord = ImmutableRecord({
poll: null as EmbeddedEntity<Poll>, poll: null as EmbeddedEntity<Poll>,
quote: null as EmbeddedEntity<any>, quote: null as EmbeddedEntity<any>,
quotes_count: 0, quotes_count: 0,
reactions: null as ImmutableList<EmojiReaction> | null,
reblog: null as EmbeddedEntity<any>, reblog: null as EmbeddedEntity<any>,
reblogged: false, reblogged: false,
reblogs_count: 0, reblogs_count: 0,
@ -104,8 +106,8 @@ const normalizeMentions = (status: ImmutableMap<string, any>) => {
}); });
}; };
// Normalize emojis // Normalize emoji reactions
const normalizeEmojis = (entity: ImmutableMap<string, any>) => { const normalizeReactions = (entity: ImmutableMap<string, any>) => {
return entity.update('emojis', ImmutableList(), emojis => { return entity.update('emojis', ImmutableList(), emojis => {
return emojis.map(normalizeEmoji); 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. */ /** Rewrite `<p></p>` to empty string. */
const fixContent = (status: ImmutableMap<string, any>) => { const fixContent = (status: ImmutableMap<string, any>) => {
if (status.get('content') === '<p></p>') { if (status.get('content') === '<p></p>') {
@ -275,6 +287,7 @@ export const normalizeStatus = (status: Record<string, any>) => {
fixQuote(status); fixQuote(status);
fixSensitivity(status); fixSensitivity(status);
normalizeEvent(status); normalizeEvent(status);
normalizeReactions(status);
fixContent(status); fixContent(status);
normalizeFilterResults(status); normalizeFilterResults(status);
normalizeDislikes(status); normalizeDislikes(status);

View File

@ -50,7 +50,7 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
return ( 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 && ( {me && (
<Card <Card
className={clsx('relative z-[1] transition', { className={clsx('relative z-[1] transition', {

View File

@ -20,7 +20,7 @@ const LandingPage: React.FC<ILandingPage> = ({ children }) => {
return ( 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} {children}
{!me && ( {!me && (

View File

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

View File

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

View File

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

View File

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

View File

@ -24,11 +24,16 @@ const eventSchema = eventTemplateSchema.extend({
/** Nostr event schema that also verifies the event's signature. */ /** Nostr event schema that also verifies the event's signature. */
const signedEventSchema = eventSchema.refine(verifySignature); const signedEventSchema = eventSchema.refine(verifySignature);
/** NIP-46 signer options. */
const signEventOptsSchema = z.object({
pow: z.number().int().nonnegative(),
}).partial();
/** NIP-46 signer request. */ /** NIP-46 signer request. */
const connectRequestSchema = z.object({ const connectRequestSchema = z.object({
id: z.string(), id: z.string(),
method: z.literal('sign_event'), method: z.literal('sign_event'),
params: z.tuple([eventTemplateSchema]), params: z.tuple([eventTemplateSchema]).or(z.tuple([eventTemplateSchema, signEventOptsSchema])),
}); });
export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema }; 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'; import type { Resolve } from 'soapbox/utils/types';
const statusPleromaSchema = z.object({ const statusPleromaSchema = z.object({
emoji_reactions: filteredArray(emojiReactionSchema),
event: eventSchema.nullish().catch(undefined), event: eventSchema.nullish().catch(undefined),
quote: z.literal(null).catch(null), quote: z.literal(null).catch(null),
quote_visible: z.boolean().catch(true), quote_visible: z.boolean().catch(true),
@ -51,6 +50,7 @@ const baseStatusSchema = z.object({
muted: z.coerce.boolean(), muted: z.coerce.boolean(),
pinned: z.coerce.boolean(), pinned: z.coerce.boolean(),
pleroma: statusPleromaSchema.optional().catch(undefined), pleroma: statusPleromaSchema.optional().catch(undefined),
reactions: filteredArray(emojiReactionSchema),
poll: pollSchema.nullable().catch(null), poll: pollSchema.nullable().catch(null),
quote: z.literal(null).catch(null), quote: z.literal(null).catch(null),
quotes_count: z.number().catch(0), quotes_count: z.number().catch(0),
@ -131,16 +131,18 @@ const statusSchema = baseStatusSchema.extend({
reblog: embeddedStatusSchema, reblog: embeddedStatusSchema,
pleroma: statusPleromaSchema.extend({ pleroma: statusPleromaSchema.extend({
quote: embeddedStatusSchema, quote: embeddedStatusSchema,
emoji_reactions: filteredArray(emojiReactionSchema),
}).optional().catch(undefined), }).optional().catch(undefined),
}).transform(({ pleroma, ...status }) => { }).transform(({ pleroma, ...status }) => {
return { return {
...status, ...status,
event: pleroma?.event, event: pleroma?.event,
quote: pleroma?.quote || status.quote || null, quote: pleroma?.quote || status.quote || null,
reactions: pleroma?.emoji_reactions || status.reactions || null,
// There's apparently no better way to do this... // There's apparently no better way to do this...
// Just trying to remove the `event` and `quote` keys from the object. // Just trying to remove the `event` and `quote` keys from the object.
pleroma: pleroma ? (() => { pleroma: pleroma ? (() => {
const { event, quote, ...rest } = pleroma; const { event, quote, emoji_reactions, ...rest } = pleroma;
return rest; return rest;
})() : undefined, })() : undefined,
}; };

View File

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

View File

@ -1,5 +1,5 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore, Tuple } from '@reduxjs/toolkit';
import thunk, { ThunkDispatch } from 'redux-thunk'; import { thunk, type ThunkDispatch } from 'redux-thunk';
import errorsMiddleware from './middleware/errors'; import errorsMiddleware from './middleware/errors';
import soundsMiddleware from './middleware/sounds'; import soundsMiddleware from './middleware/sounds';
@ -9,11 +9,11 @@ import type { AnyAction } from 'redux';
export const store = configureStore({ export const store = configureStore({
reducer: appReducer, reducer: appReducer,
middleware: [ middleware: () => new Tuple(
thunk, thunk,
errorsMiddleware(), errorsMiddleware(),
soundsMiddleware(), soundsMiddleware(),
], ),
devTools: true, 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'; import { type MRFSimple, mrfSimpleSchema } from 'soapbox/schemas/pleroma';
export type Config = ImmutableMap<string, any>; export type Config = ImmutableMap<string, any>;
export type Policy = ImmutableMap<string, any>; export type Policy = Record<string, any>;
const find = ( const find = (
configs: ImmutableList<Config>, configs: ImmutableList<Config>,
@ -40,15 +40,15 @@ const toSimplePolicy = (configs: ImmutableList<Config>): MRFSimple => {
}; };
const fromSimplePolicy = (simplePolicy: Policy): ImmutableList<Config> => { 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([ return ImmutableList([
ImmutableMap({ ImmutableMap({
group: ':pleroma', group: ':pleroma',
key: ':mrf_simple', 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 { normalizeStatus } from 'soapbox/normalizers';
import { emojiReactionSchema } from 'soapbox/schemas';
import { import {
sortEmoji, sortEmoji,
mergeEmojiFavourites, mergeEmojiFavourites,
oneEmojiPerAccount,
reduceEmoji, reduceEmoji,
getReactForStatus, getReactForStatus,
simulateEmojiReact, simulateEmojiReact,
@ -23,7 +23,7 @@ const ALLOWED_EMOJI = ImmutableList([
describe('sortEmoji', () => { describe('sortEmoji', () => {
describe('with an unsorted list of emoji', () => { describe('with an unsorted list of emoji', () => {
const emojiReacts = fromJS([ const emojiReacts = ImmutableList([
{ 'count': 7, 'me': true, 'name': '😃' }, { 'count': 7, 'me': true, 'name': '😃' },
{ 'count': 7, 'me': true, 'name': '😯' }, { 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 3, 'me': true, 'name': '😢' }, { 'count': 3, 'me': true, 'name': '😢' },
@ -31,7 +31,7 @@ describe('sortEmoji', () => {
{ 'count': 20, 'me': true, 'name': '👍' }, { 'count': 20, 'me': true, 'name': '👍' },
{ 'count': 7, 'me': true, 'name': '😂' }, { 'count': 7, 'me': true, 'name': '😂' },
{ 'count': 15, 'me': true, 'name': '❤' }, { 'count': 15, 'me': true, 'name': '❤' },
]) as ImmutableList<ImmutableMap<string, any>>; ].map((react) => emojiReactionSchema.parse(react)));
it('sorts the emoji by count', () => { it('sorts the emoji by count', () => {
expect(sortEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([ expect(sortEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 20, 'me': true, 'name': '👍' }, { 'count': 20, 'me': true, 'name': '👍' },
@ -51,11 +51,11 @@ describe('mergeEmojiFavourites', () => {
const favourited = true; const favourited = true;
describe('with existing 👍 reacts', () => { describe('with existing 👍 reacts', () => {
const emojiReacts = fromJS([ const emojiReacts = ImmutableList([
{ 'count': 20, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 20, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 15, 'me': false, 'name': '❤', 'url': undefined }, { 'count': 15, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 7, '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', () => { it('combines 👍 reacts with favourites', () => {
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([ expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
{ 'count': 32, 'me': true, 'name': '👍', 'url': undefined }, { 'count': 32, 'me': true, 'name': '👍', 'url': undefined },
@ -66,10 +66,10 @@ describe('mergeEmojiFavourites', () => {
}); });
describe('without existing 👍 reacts', () => { describe('without existing 👍 reacts', () => {
const emojiReacts = fromJS([ const emojiReacts = ImmutableList([
{ 'count': 15, 'me': false, 'name': '❤' }, { 'count': 15, 'me': false, 'name': '❤' },
{ 'count': 7, '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', () => { it('adds 👍 reacts to the map equaling favourite count', () => {
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([ expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
{ 'count': 15, 'me': false, 'name': '❤' }, { 'count': 15, 'me': false, 'name': '❤' },
@ -88,7 +88,7 @@ describe('mergeEmojiFavourites', () => {
describe('reduceEmoji', () => { describe('reduceEmoji', () => {
describe('with a clusterfuck of emoji', () => { describe('with a clusterfuck of emoji', () => {
const emojiReacts = fromJS([ const emojiReacts = ImmutableList([
{ 'count': 1, 'me': false, 'name': '😡' }, { 'count': 1, 'me': false, 'name': '😡' },
{ 'count': 1, 'me': true, 'name': '🔪' }, { 'count': 1, 'me': true, 'name': '🔪' },
{ 'count': 7, 'me': true, 'name': '😯' }, { 'count': 7, 'me': true, 'name': '😯' },
@ -99,7 +99,7 @@ describe('reduceEmoji', () => {
{ 'count': 15, 'me': true, 'name': '❤' }, { 'count': 15, 'me': true, 'name': '❤' },
{ 'count': 1, 'me': false, 'name': '👀' }, { 'count': 1, 'me': false, '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', () => { it('sorts, filters, and combines emoji and favourites', () => {
expect(reduceEmoji(emojiReacts, 7, true, ALLOWED_EMOJI)).toEqual(fromJS([ expect(reduceEmoji(emojiReacts, 7, true, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 27, 'me': true, 'name': '👍' }, { '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', () => { describe('getReactForStatus', () => {
it('returns a single owned react (including favourite) for the status', () => { it('returns a single owned react (including favourite) for the status', () => {
const status = normalizeStatus(fromJS({ 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', () => { it('returns a thumbs-up for a favourite', () => {
const status = normalizeStatus(fromJS({ favourites_count: 1, favourited: true })); 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)', () => { it('returns undefined when a status has no reacts (or favourites)', () => {
@ -172,10 +156,10 @@ describe('getReactForStatus', () => {
describe('simulateEmojiReact', () => { describe('simulateEmojiReact', () => {
it('adds the emoji to the list', () => { 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 },
{ '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([ expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 3, 'me': true, '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', () => { 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 },
{ '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([ expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ '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', () => { 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 },
{ '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([ 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 },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
@ -209,10 +193,10 @@ describe('simulateEmojiReact', () => {
describe('simulateUnEmojiReact', () => { describe('simulateUnEmojiReact', () => {
it('removes the emoji from the list', () => { it('removes the emoji from the list', () => {
const emojiReacts = fromJS([ const emojiReacts = ImmutableList([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 3, 'me': true, 'name': '❤' }, { 'count': 3, 'me': true, 'name': '❤' },
]) as ImmutableList<ImmutableMap<string, any>>; ].map((react) => emojiReactionSchema.parse(react)));
expect(simulateUnEmojiReact(emojiReacts, '❤')).toEqual(fromJS([ expect(simulateUnEmojiReact(emojiReacts, '❤')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍' },
{ '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', () => { 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': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤' },
{ 'count': 1, 'me': true, 'name': '😯' }, { 'count': 1, 'me': true, 'name': '😯' },
]) as ImmutableList<ImmutableMap<string, any>>; ].map((react) => emojiReactionSchema.parse(react)));
expect(simulateUnEmojiReact(emojiReacts, '😯')).toEqual(fromJS([ expect(simulateUnEmojiReact(emojiReacts, '😯')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤' },
@ -232,11 +216,11 @@ describe('simulateUnEmojiReact', () => {
}); });
it ('removes custom emoji from the list', () => { it ('removes custom emoji from the list', () => {
const emojiReacts = fromJS([ const emojiReacts = ImmutableList([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤' },
{ 'count': 1, 'me': true, 'name': 'soapbox', 'url': 'https://gleasonator.com/emoji/Gleasonator/soapbox.png' }, { '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([ expect(simulateUnEmojiReact(emojiReacts, 'soapbox')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤' },

View File

@ -1,9 +1,6 @@
import { import { List as ImmutableList } from 'immutable';
Map as ImmutableMap,
List as ImmutableList,
} from 'immutable';
import type { Me } from 'soapbox/types/soapbox'; import { EmojiReaction, emojiReactionSchema } from 'soapbox/schemas';
// https://emojipedia.org/facebook // https://emojipedia.org/facebook
// I've customized them. // I've customized them.
@ -16,18 +13,16 @@ export const ALLOWED_EMOJI = ImmutableList([
'😩', '😩',
]); ]);
type Account = ImmutableMap<string, any>; export const sortEmoji = (emojiReacts: ImmutableList<EmojiReaction>, allowedEmoji: ImmutableList<string>): ImmutableList<EmojiReaction> => (
type EmojiReact = ImmutableMap<string, any>;
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji: ImmutableList<string>): ImmutableList<EmojiReact> => (
emojiReacts emojiReacts
.sortBy(emojiReact => .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; if (!favouritesCount) return emojiReacts;
const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.get('name') === '👍'); const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.name === '👍');
if (likeIndex > -1) { if (likeIndex > -1) {
const likeCount = Number(emojiReacts.getIn([likeIndex, 'count'])); const likeCount = Number(emojiReacts.getIn([likeIndex, 'count']));
favourited = favourited || Boolean(emojiReacts.getIn([likeIndex, 'me'], false)); 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, 'count'], likeCount + favouritesCount)
.setIn([likeIndex, 'me'], favourited); .setIn([likeIndex, 'me'], favourited);
} else { } 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 => ( export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReaction> | null, favouritesCount: number, favourited: boolean, allowedEmoji = ALLOWED_EMOJI): ImmutableList<EmojiReaction> => (
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> => (
sortEmoji( sortEmoji(
mergeEmojiFavourites(emojiReacts, favouritesCount, favourited), mergeEmojiFavourites(emojiReacts, favouritesCount, favourited),
allowedEmoji, 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( const result = reduceEmoji(
status.pleroma.get('emoji_reactions', ImmutableList()), status.reactions,
status.favourites_count || 0, status.favourites_count || 0,
status.favourited, status.favourited,
allowedEmoji, allowedEmoji,
).filter(e => e.get('me') === true) ).filter(e => e.me === true)
.get(0); .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) => { export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReaction>, emoji: string, url?: string) => {
const idx = emojiReacts.findIndex(e => e.get('name') === emoji); const idx = emojiReacts.findIndex(e => e.name === emoji);
const emojiReact = emojiReacts.get(idx); const emojiReact = emojiReacts.get(idx);
if (idx > -1 && emojiReact) { if (idx > -1 && emojiReact) {
return emojiReacts.set(idx, emojiReact.merge({ return emojiReacts.set(idx, emojiReactionSchema.parse({
count: emojiReact.get('count') + 1, ...emojiReact,
count: (emojiReact.count || 0) + 1,
me: true, me: true,
url, url,
})); }));
} else { } else {
return emojiReacts.push(ImmutableMap({ return emojiReacts.push(emojiReactionSchema.parse({
count: 1, count: 1,
me: true, me: true,
name: emoji, 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 => const idx = emojiReacts.findIndex(e =>
e.get('name') === emoji && e.get('me') === true); e.name === emoji && e.me === true);
const emojiReact = emojiReacts.get(idx); const emojiReact = emojiReacts.get(idx);
if (emojiReact) { if (emojiReact) {
const newCount = emojiReact.get('count') - 1; const newCount = (emojiReact.count || 1) - 1;
if (newCount < 1) return emojiReacts.delete(idx); if (newCount < 1) return emojiReacts.delete(idx);
return emojiReacts.set(idx, emojiReact.merge({ return emojiReacts.set(idx, emojiReactionSchema.parse({
count: emojiReact.get('count') - 1, ...emojiReact,
count: (emojiReact.count || 1) - 1,
me: false, me: false,
})); }));
} else { } 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 */ /** Truthy array convenience function */
const any = (arr: Array<any>): boolean => arr.some(Boolean); 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. * Ditto, a Nostr server with Mastodon API.
* @see {@link https://gitlab.com/soapbox-pub/ditto} * @see {@link https://gitlab.com/soapbox-pub/ditto}
*/ */
export const DITTO = '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. * Friendica, decentralized social platform implementing multiple federation protocols.
* @see {@link https://friendi.ca/} * @see {@link https://friendi.ca/}
*/ */
export const FRIENDICA = 'Friendica'; 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. * Mastodon, the software upon which this is all based.
* @see {@link https://joinmastodon.org/} * @see {@link https://joinmastodon.org/}
@ -143,6 +149,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
accountLookup: any([ accountLookup: any([
v.software === FIREFISH, v.software === FIREFISH,
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.4.0'), v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
v.software === PLEROMA && gte(v.version, '2.4.50'), v.software === PLEROMA && gte(v.version, '2.4.50'),
v.software === TAKAHE && gte(v.version, '0.6.1'), 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/} * @see {@link https://docs.joinmastodon.org/methods/announcements/}
*/ */
announcements: any([ announcements: any([
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.1.0'), v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
v.software === PLEROMA && gte(v.version, '2.2.49'), v.software === PLEROMA && gte(v.version, '2.2.49'),
v.software === TAKAHE && gte(v.version, '0.7.0'), v.software === TAKAHE && gte(v.version, '0.7.0'),
@ -230,11 +238,13 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
bookmarks: any([ bookmarks: any([
v.software === FIREFISH, v.software === FIREFISH,
v.software === ICESHRIMP,
v.software === FRIENDICA, v.software === FRIENDICA,
v.software === MASTODON && gte(v.compatVersion, '3.1.0'), v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
v.software === PLEROMA && gte(v.version, '0.9.9'), v.software === PLEROMA && gte(v.version, '0.9.9'),
v.software === PIXELFED, v.software === PIXELFED,
v.software === TAKAHE && gte(v.version, '0.9.0'), v.software === TAKAHE && gte(v.version, '0.9.0'),
v.software === DITTO,
]), ]),
/** /**
@ -319,6 +329,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
conversations: any([ conversations: any([
v.software === FIREFISH, v.software === FIREFISH,
v.software === ICESHRIMP,
v.software === FRIENDICA, v.software === FRIENDICA,
v.software === MASTODON && gte(v.compatVersion, '2.6.0'), v.software === MASTODON && gte(v.compatVersion, '2.6.0'),
v.software === PLEROMA && gte(v.version, '0.9.9'), v.software === PLEROMA && gte(v.version, '0.9.9'),
@ -359,6 +370,7 @@ const getInstanceFeatures = (instance: Instance) => {
editProfile: any([ editProfile: any([
v.software === FIREFISH, v.software === FIREFISH,
v.software === FRIENDICA, v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON, v.software === MASTODON,
v.software === MITRA, v.software === MITRA,
v.software === PIXELFED, v.software === PIXELFED,
@ -374,6 +386,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
editStatuses: any([ editStatuses: any([
v.software === FRIENDICA && gte(v.version, '2022.12.0'), v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.version, '3.5.0'), v.software === MASTODON && gte(v.version, '3.5.0'),
v.software === TAKAHE && gte(v.version, '0.8.0'), v.software === TAKAHE && gte(v.version, '0.8.0'),
features.includes('editing'), features.includes('editing'),
@ -406,6 +419,13 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'), 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. * The backend allows only non-RGI ("Recommended for General Interchange") emoji reactions.
* @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji * @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji
@ -444,6 +464,7 @@ const getInstanceFeatures = (instance: Instance) => {
exposableReactions: any([ exposableReactions: any([
v.software === FIREFISH, v.software === FIREFISH,
v.software === FRIENDICA, v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON, v.software === MASTODON,
v.software === TAKAHE && gte(v.version, '0.6.1'), v.software === TAKAHE && gte(v.version, '0.6.1'),
v.software === TRUTHSOCIAL, v.software === TRUTHSOCIAL,
@ -638,6 +659,7 @@ const getInstanceFeatures = (instance: Instance) => {
lists: any([ lists: any([
v.software === FIREFISH, v.software === FIREFISH,
v.software === FRIENDICA, v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '2.1.0'), v.software === MASTODON && gte(v.compatVersion, '2.1.0'),
v.software === PLEROMA && gte(v.version, '0.9.9'), v.software === PLEROMA && gte(v.version, '0.9.9'),
]), ]),
@ -693,6 +715,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see PUT /api/v1/accounts/:id/mute * @see PUT /api/v1/accounts/:id/mute
*/ */
mutesDuration: any([ mutesDuration: any([
v.software === ICESHRIMP,
v.software === PLEROMA && gte(v.version, '2.3.0'), v.software === PLEROMA && gte(v.version, '2.3.0'),
v.software === MASTODON && gte(v.compatVersion, '3.3.0'), v.software === MASTODON && gte(v.compatVersion, '3.3.0'),
v.software === TAKAHE, v.software === TAKAHE,
@ -725,6 +748,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see GET /api/v1/notifications * @see GET /api/v1/notifications
*/ */
notificationsIncludeTypes: any([ notificationsIncludeTypes: any([
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.5.0'), v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
v.software === PLEROMA && gte(v.version, '2.4.50'), v.software === PLEROMA && gte(v.version, '2.4.50'),
v.software === TAKAHE && gte(v.version, '0.6.2'), v.software === TAKAHE && gte(v.version, '0.6.2'),
@ -749,6 +773,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
polls: any([ polls: any([
v.software === FIREFISH, v.software === FIREFISH,
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.version, '2.8.0'), v.software === MASTODON && gte(v.version, '2.8.0'),
v.software === PLEROMA, v.software === PLEROMA,
v.software === TAKAHE && gte(v.version, '0.8.0'), v.software === TAKAHE && gte(v.version, '0.8.0'),
@ -789,6 +814,7 @@ const getInstanceFeatures = (instance: Instance) => {
publicTimeline: any([ publicTimeline: any([
v.software === FIREFISH, v.software === FIREFISH,
v.software === FRIENDICA, v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON, v.software === MASTODON,
v.software === PLEROMA, v.software === PLEROMA,
v.software === TAKAHE, v.software === TAKAHE,
@ -874,6 +900,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see POST /api/v2/search * @see POST /api/v2/search
*/ */
searchFromAccount: any([ searchFromAccount: any([
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.version, '2.8.0'), v.software === MASTODON && gte(v.version, '2.8.0'),
v.software === PLEROMA && gte(v.version, '1.0.0'), v.software === PLEROMA && gte(v.version, '1.0.0'),
]), ]),
@ -927,6 +954,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
suggestionsV2: any([ suggestionsV2: any([
v.software === FRIENDICA, v.software === FRIENDICA,
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.4.0'), v.software === MASTODON && gte(v.compatVersion, '3.4.0'),
v.software === TRUTHSOCIAL, v.software === TRUTHSOCIAL,
features.includes('v2_suggestions'), features.includes('v2_suggestions'),
@ -943,6 +971,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see GET /api/v1/trends/statuses * @see GET /api/v1/trends/statuses
*/ */
trendingStatuses: any([ trendingStatuses: any([
v.software === ICESHRIMP,
v.software === FRIENDICA && gte(v.version, '2022.12.0'), v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === MASTODON && gte(v.compatVersion, '3.5.0'), v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
]), ]),
@ -953,6 +982,7 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
trends: any([ trends: any([
v.software === FRIENDICA && gte(v.version, '2022.12.0'), v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === ICESHRIMP,
v.software === MASTODON && gte(v.compatVersion, '3.0.0'), v.software === MASTODON && gte(v.compatVersion, '3.0.0'),
v.software === TRUTHSOCIAL, v.software === TRUTHSOCIAL,
v.software === DITTO, v.software === DITTO,

View File

@ -1,9 +1,16 @@
/** Register the ServiceWorker. */ /** Register the ServiceWorker. */
function registerSW(path: string) { function registerSW(path: string) {
if ('serviceWorker' in navigator) { 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 { export {
registerSW, registerSW,
unregisterSW, 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} */ import { parseColorMatrix } from './tailwind/colors';
module.exports = {
const config: Config = {
content: ['./src/**/*.{html,js,ts,tsx}', './custom/instance/**/*.html', './index.html'], content: ['./src/**/*.{html,js,ts,tsx}', './custom/instance/**/*.html', './index.html'],
darkMode: 'class', darkMode: 'class',
theme: { theme: {
@ -98,8 +102,10 @@ module.exports = {
}, },
}, },
plugins: [ plugins: [
require('@tailwindcss/forms'), aspectRatioPlugin,
require('@tailwindcss/typography'), formsPlugin,
require('@tailwindcss/aspect-ratio'), 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 { import {
withOpacityValue, withOpacityValue,
parseColorMatrix, parseColorMatrix,
} from './colors.cjs'; } from './colors';
describe('withOpacityValue()', () => { describe('withOpacityValue()', () => {
it('returns a Tailwind color function with alpha support', () => { it('returns a Tailwind color function with alpha support', () => {
@ -11,8 +11,7 @@ describe('withOpacityValue()', () => {
expect(typeof result).toBe('function'); expect(typeof result).toBe('function');
// Test calling the function // Test calling the function
expect(result({})).toBe('rgb(var(--color-primary-500))'); expect(result).toBe('rgb(var(--color-primary-500) / <alpha-value>)');
expect(result({ opacityValue: .5 })).toBe('rgb(var(--color-primary-500) / 0.5)');
}); });
}); });
@ -29,8 +28,8 @@ describe('parseColorMatrix()', () => {
const result = parseColorMatrix(colorMatrix); const result = parseColorMatrix(colorMatrix);
// Colors are mapped to functions which return CSS values // Colors are mapped to functions which return CSS values
expect(result.primary[500]({})).toEqual('rgb(var(--color-primary-500))'); // @ts-ignore
expect(result.accent[300]({ opacityValue: .3 })).toEqual('rgb(var(--color-accent-300) / 0.3)'); expect(result.accent['300']).toEqual('rgb(var(--color-accent-300) / <alpha-value>)');
}); });
it('parses single-tint values', () => { it('parses single-tint values', () => {
@ -46,6 +45,6 @@ describe('parseColorMatrix()', () => {
const result = parseColorMatrix(colorMatrix); 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 vitePluginRequire from 'vite-plugin-require';
import { viteStaticCopy } from 'vite-plugin-static-copy'; import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig({ export default defineConfig(({ command }) => ({
build: { build: {
assetsDir: 'packs', assetsDir: 'packs',
assetsInlineLimit: 0, assetsInlineLimit: 0,
@ -29,6 +29,9 @@ export default defineConfig({
server: { server: {
port: 3036, port: 3036,
}, },
optimizeDeps: {
exclude: command === 'serve' ? ['@soapbox.pub/wasmboy'] : [],
},
plugins: [ plugins: [
checker({ typescript: true }), checker({ typescript: true }),
// @ts-ignore // @ts-ignore
@ -100,7 +103,7 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: 'src/jest/test-setup.ts', setupFiles: 'src/jest/test-setup.ts',
}, },
}); }));
/** Return file as string, or return empty string if the file isn't found. */ /** Return file as string, or return empty string if the file isn't found. */
function readFileContents(path: string) { function readFileContents(path: string) {

1791
yarn.lock

File diff suppressed because it is too large Load Diff