Compare commits

..

No commits in common. "shigusegubu-themes3" and "shigusegubu" have entirely different histories.

704 changed files with 32414 additions and 81210 deletions

View file

@ -1,12 +0,0 @@
node_modules/
dist/
logs/
.DS_Store
.git/
config/local.json
pleroma-backend/
test/e2e/reports/
test/e2e-playwright/test-results/
test/e2e-playwright/playwright-report/
__screenshots__/

View file

@ -1,87 +0,0 @@
name: 'Bug report'
about: 'Bug report for Pleroma FE'
labels:
- Bug
body:
- type: input
id: env-browser
attributes:
label: Browser and OS
description: What browser are you using, including version, and what OS are you running?
placeholder: Firefox 140, Arch Linux
validations:
required: true
- type: input
id: env-instance
attributes:
label: Instance URL
validations:
required: false
- type: input
id: env-backend
attributes:
label: Backend version information
description: Backend version being used. (See Settings->Show advanced->Developer)
placeholder: Pleroma BE 2.10
validations:
required: true
- type: input
id: env-frontend
attributes:
label: Frontend version information
description: Frontend version being used. (See Settings->Show advanced->Developer)
placeholder: Pleroma FE 2.10
validations:
required: true
- type: input
id: env-extensions
attributes:
label: Browser extensions
description: List of browser extensions you are using, like uBlock, rikaichamp etc. If none leave empty.
validations:
required: false
- type: input
id: env-modifications
attributes:
label: Known instance/user customizations
description: Whether you are using a Pleroma FE fork, any mods mods or instance level styles among others.
validations:
required: false
- type: textarea
id: bug-text
attributes:
label: Bug description
description: A short description of the bug. Images can be helpful.
validations:
required: true
- type: textarea
id: bug-reproducer
attributes:
label: Reproduction steps
description: Ordered list of reproduction steps needed to make the bug happen. If you don't have reproduction steps, leave this empty.
placeholder: |
1. Log in with a fresh browser session
2. Open timeline X
3. Click on button Y
4. Z broke
validations:
required: false
- type: textarea
id: bug-seriousness
attributes:
label: Bug seriousness
value: |
* How annoying it is:
* How often does it happen:
* How many people does it affect:
* Is there a workaround for it:
- type: checkboxes
id: duplicate-issues
attributes:
label: Duplicate issues
hide_label: true
description: Before submitting this issue, search for same or similar issues on the [Pleroma FE bug tracker](https://git.pleroma.social/pleroma/pleroma-fe/issues).
options:
- label: I've searched for same or similar issues before submitting this issue.
required: true
visible: [form]

View file

@ -1,22 +0,0 @@
name: 'Feature request / Suggestion / Improvement'
about: 'Feature requests, suggestions and improvements for Pleroma FE'
labels:
- Feature Request / Enhancement
body:
- type: textarea
id: issue-text
attributes:
label: Proposal
placeholder: Make groups happen!
validations:
required: true
- type: checkboxes
id: duplicate-issues
attributes:
label: Duplicate issues
hide_label: true
description: Before submitting this issue, search for same or similar requests on the [Pleroma FE bug tracker](https://git.pleroma.social/pleroma/pleroma-fe/issues).
options:
- label: I've searched for same or similar requests before submitting this issue.
required: true
visible: [form]

View file

@ -1,12 +0,0 @@
### Checklist
- [ ] Adding a changelog: In the `changelog.d` directory, create a file named `<code>.<type>`.
<!--
`<code>` can be anything, but we recommend using a more or less unique identifier to avoid collisions, such as the branch name.
`<type>` can be `add`, `change`, `remove`, `fix`, `security` or `skip`. `skip` is only used if there is no user-visible change in the MR (for example, only editing comments in the code). Otherwise, choose a type that corresponds to your change.
In the file, write the changelog entry. For example, if an MR adds group functionality, we can create a file named `group.add` and write `Add group functionality` in it.
If one changelog entry is not enough, you may add more. But that might mean you can split it into two MRs. Only use more than one changelog entry if you really need to (for example, when one change in the code fix two different bugs, or when refactoring).
-->

3
.gitignore vendored
View file

@ -4,11 +4,8 @@ dist/
npm-debug.log
test/unit/coverage
test/e2e/reports
test/e2e-playwright/test-results
test/e2e-playwright/playwright-report
selenium-debug.log
.idea/
.gitlab-ci-local/
config/local.json
src/assets/emoji.json
logs/

View file

@ -1,7 +1,7 @@
# This file is a template, and might need editing before it works on your project.
# Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/
image: node:20
image: node:18
stages:
- check-changelog
@ -34,23 +34,12 @@ check-changelog:
- apk add git
- sh ./tools/check-changelog
lint-eslint:
lint:
stage: lint
script:
- yarn
- yarn ci-eslint
lint-biome:
stage: lint
script:
- yarn
- yarn ci-biome
lint-stylelint:
stage: lint
script:
- yarn
- yarn ci-stylelint
- yarn lint
- yarn stylelint
test:
stage: test
@ -71,135 +60,6 @@ test:
- test/**/__screenshots__
when: on_failure
e2e-pleroma:
stage: test
image: mcr.microsoft.com/playwright:v1.61.0-jammy
services:
- name: postgres:15-alpine
alias: db
- name: $PLEROMA_IMAGE
alias: pleroma
entrypoint: ["/bin/ash", "-c"]
command:
- |
set -eu
SEED_SENTINEL_PATH=/var/lib/pleroma/.e2e_seeded
CONFIG_OVERRIDE_PATH=/var/lib/pleroma/config.exs
echo '-- Waiting for database...'
while ! pg_isready -U ${DB_USER:-pleroma} -d postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma} -t 1; do
sleep 1s
done
echo '-- Writing E2E config overrides...'
cat > $CONFIG_OVERRIDE_PATH <<EOF
import Config
config :pleroma, Pleroma.Captcha,
enabled: false
config :pleroma, :instance,
registrations_open: true,
account_activation_required: false,
approval_required: false
EOF
echo '-- Running migrations...'
/opt/pleroma/bin/pleroma_ctl migrate
echo '-- Starting!'
/opt/pleroma/bin/pleroma start &
PLEROMA_PID=$!
cleanup() {
if kill -0 $PLEROMA_PID 2>/dev/null; then
kill -TERM $PLEROMA_PID
wait $PLEROMA_PID || true
fi
}
trap cleanup INT TERM
echo '-- Waiting for API...'
api_ok=false
for _i in $(seq 1 120); do
if wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null 2>&1; then
api_ok=true
break
fi
sleep 1s
done
if [ $api_ok != true ]; then
echo 'Timed out waiting for Pleroma API to become available'
exit 1
fi
if [ ! -f $SEED_SENTINEL_PATH ]; then
if [ -n ${E2E_ADMIN_USERNAME:-} ] && [ -n ${E2E_ADMIN_PASSWORD:-} ] && [ -n ${E2E_ADMIN_EMAIL:-} ]; then
echo '-- Seeding admin user' $E2E_ADMIN_USERNAME '...'
if ! /opt/pleroma/bin/pleroma_ctl user new $E2E_ADMIN_USERNAME $E2E_ADMIN_EMAIL --admin --password $E2E_ADMIN_PASSWORD -y; then
echo '-- User already exists or creation failed, ensuring admin + confirmed...'
/opt/pleroma/bin/pleroma_ctl user set $E2E_ADMIN_USERNAME --admin --confirmed
fi
else
echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)'
fi
touch $SEED_SENTINEL_PATH
fi
wait $PLEROMA_PID
tags:
- amd64
- himem
variables:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
FF_NETWORK_PER_BUILD: "true"
PLEROMA_IMAGE: git.pleroma.social/pleroma/pleroma:stable
POSTGRES_USER: pleroma
POSTGRES_PASSWORD: pleroma
POSTGRES_DB: pleroma
DB_USER: pleroma
DB_PASS: pleroma
DB_NAME: pleroma
DB_HOST: db
DB_PORT: 5432
DOMAIN: localhost
INSTANCE_NAME: Pleroma E2E
E2E_ADMIN_USERNAME: admin
E2E_ADMIN_PASSWORD: adminadmin
E2E_ADMIN_EMAIL: admin@example.com
ADMIN_EMAIL: $E2E_ADMIN_EMAIL
NOTIFY_EMAIL: $E2E_ADMIN_EMAIL
VITE_PROXY_TARGET: http://pleroma:4000
VITE_PROXY_ORIGIN: http://localhost:4000
E2E_BASE_URL: http://localhost:8099
script:
- npm install -g yarn@1.22.22
- yarn --frozen-lockfile
- |
echo "-- Waiting for Pleroma API..."
api_ok="false"
for _i in $(seq 1 120); do
if wget -qO- http://pleroma:4000/api/v1/instance >/dev/null 2>&1; then
api_ok="true"
break
fi
sleep 1s
done
if [ "$api_ok" != "true" ]; then
echo "Timed out waiting for Pleroma API to become available"
exit 1
fi
- yarn e2e:pw
artifacts:
when: on_failure
paths:
- test/e2e-playwright/test-results
- test/e2e-playwright/playwright-report
build:
stage: build
tags:

View file

@ -1,8 +0,0 @@
### Release checklist
* [ ] Bump version in `package.json`
* [ ] Compile a changelog with the `tools/collect-changelog` script
* [ ] Create an MR with an announcement to pleroma.social
#### post-merge
* [ ] Tag the release on the merge commit
* [ ] Make the tag into a Gitlab Release™
* [ ] Merge `master` into `develop` (in case the fixes are already in develop, use `git merge -s ours --no-commit` and manually merge the changelogs)

View file

@ -1 +1 @@
20.19.0
18.20.8

View file

@ -12,8 +12,6 @@
"custom-property-pattern": null,
"keyframes-name-pattern": null,
"scss/operator-no-newline-after": null,
"declaration-property-value-no-unknown": true,
"scss/declaration-property-value-no-unknown": true,
"declaration-block-no-redundant-longhand-properties": [
true,
{

View file

@ -1,43 +0,0 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
depends_on:
- test
- test-e2e
labels:
platform: linux/amd64
memory: 'high'
steps:
build:
image: docker.io/node:20-alpine
commands:
- apk add --no-cache zip git
- yarn --frozen-lockfile
- yarn build
- if [ "${CI_PIPELINE_EVENT}" = "push" ] || [ "${CI_PIPELINE_EVENT}" = "manual" ]; then zip -9qr ${CI_REPO_DEFAULT_BRANCH}.zip dist/; fi
upload-artifacts:
image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
settings:
user:
from_secret: pleroma-ci-user
password:
from_secret: pleroma-ci-password
update: true
owner: 'pleroma'
package_name: 'pleroma-fe-builds'
package_version: ${CI_REPO_DEFAULT_BRANCH}
file_source: ./${CI_REPO_DEFAULT_BRANCH}.zip
file_name: latest.zip

View file

@ -1,10 +0,0 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
steps:
check-changelog:
image: docker.io/alpine:3.23
commands:
- apk add --no-cache git
- sh ./tools/check-changelog

View file

@ -1,32 +0,0 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
steps:
install-depends:
image: &node-image
docker.io/node:20-alpine
commands:
- yarn --frozen-lockfile
eslint:
image: *node-image
depends_on: install-depends
commands:
- yarn ci-eslint
biome:
image: *node-image
depends_on: install-depends
commands:
- yarn ci-biome
stylelint:
image: *node-image
depends_on: install-depends
commands:
- yarn ci-stylelint

View file

@ -1,101 +0,0 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
labels:
platform: linux/amd64
memory: 'high'
variables:
artifacts_uploader_settings: &artifacts_uploader_settings
user:
from_secret: pleroma-ci-user
password:
from_secret: pleroma-ci-password
owner: 'pleroma'
package_name: 'pleroma-fe-test-artifacts'
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
test:
image: mcr.microsoft.com/playwright:v1.61.0-jammy
entrypoint: *script_file_entrypoint
environment:
APT_CACHE_DIR: apt-cache
DEBIAN_FRONTEND: noninteractive
E2E_BASE_URL: http://localhost:8099
FF_NETWORK_PER_BUILD: "true"
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
VITE_PROXY_ORIGIN: "http://pleroma:4000"
VITE_PROXY_TARGET: "http://pleroma:4000"
commands:
- |
if [ "${CI_PIPELINE_EVENT}" != "pull_request" ]; then
mkdir -pv $APT_CACHE_DIR && apt-get -qq update
apt-get install -y zip
fi
- npm install -g yarn@1.22.22
- yarn --frozen-lockfile
- |
echo "-- Waiting for Pleroma API..."
api_ok="false"
for _i in $(seq 1 120); do
if wget -qO- http://pleroma:4000/api/v1/instance >/dev/null 2>&1; then
api_ok="true"
break
fi
sleep 1s
done
if [ "$api_ok" != "true" ]; then
echo "Timed out waiting for Pleroma API to become available"
exit 1
fi
- |
if ! yarn e2e:pw; then
[ "${CI_PIPELINE_EVENT}" = "pull_request" ] || zip -9qr ${CI_COMMIT_SHA:0:8}-e2e.zip ./test/e2e-playwright/test-results ./test/e2e-playwright/playwright-report
exit 1
fi
upload-artifacts:
image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
status: [failure]
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
status: [failure]
settings:
<<: *artifacts_uploader_settings
package_version: ${CI_REPO_DEFAULT_BRANCH}-${CI_COMMIT_SHA:0:8}
file_source: ./${CI_COMMIT_SHA:0:8}-e2e.zip
file_name: ${CI_COMMIT_SHA:0:8}-e2e.zip
services:
postgres:
image: docker.io/postgres:13-alpine
environment:
POSTGRES_DB: pleroma_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
pleroma:
image: git.pleroma.social/pleroma/pleroma:stable-e2e
environment:
ADMIN_EMAIL: "admin@example.com"
NOTIFY_EMAIL: "admin@example.com"
DB_USER: postgres
DB_PASS: postgres
DB_NAME: pleroma_test
DB_HOST: postgres
INSTANCE_NAME: Pleroma E2E
E2E_ADMIN_USERNAME: admin
E2E_ADMIN_PASSWORD: adminadmin
E2E_ADMIN_EMAIL: "admin@example.com"

View file

@ -1,61 +0,0 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
labels:
platform: linux/amd64
memory: 'high'
variables:
artifacts_uploader_settings: &artifacts_uploader_settings
user:
from_secret: pleroma-ci-user
password:
from_secret: pleroma-ci-password
owner: 'pleroma'
package_name: 'pleroma-fe-test-artifacts'
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
test:
image: mcr.microsoft.com/playwright:v1.61.0-jammy
environment:
APT_CACHE_DIR: apt-cache
DEBIAN_FRONTEND: noninteractive
FF_NETWORK_PER_BUILD: "true"
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
entrypoint: *script_file_entrypoint
commands:
- |
if [ "${CI_PIPELINE_EVENT}" != "pull_request" ]; then
mkdir -pv $APT_CACHE_DIR && apt-get -qq update
apt-get -y install zip
fi
- yarn --frozen-lockfile
- |
if ! yarn unit-ci; then
[ "${CI_PIPELINE_EVENT}" = "pull_request" ] || zip -9qr ${CI_COMMIT_SHA:0:8}-screenshots.zip $(find . -type d -name __screenshots__)
exit 1
fi
upload-artifacts:
image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
status: [failure]
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
status: [failure]
settings:
<<: *artifacts_uploader_settings
package_version: ${CI_REPO_DEFAULT_BRANCH}-${CI_COMMIT_SHA:0:8}
file_source: ./${CI_COMMIT_SHA:0:8}-screenshots.zip
file_name: ${CI_COMMIT_SHA:0:8}-screenshots.zip

View file

@ -2,76 +2,6 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## 2.10.1
### Fixed
- fixed being unable to set actor type from profile page
- fixed error when clicking mute menu itself (instead of submenu items)
- fixed mute -> domain status submenu not working
### Internal
- Add playwright E2E-tests with an optional docker-based backend
## 2.10.0
### Changed
- Temporary changes modal now shows actual countdown instead of fixed timeout
- Disabled elements are more disabled now
- Rearranged and split settings to make more sense and be less of a wall of text
- On mobile settings now take up full width and presented in navigation style
improved styles for settings
### Added
- Most of the remaining AdminFE tabs were added into Admin Dashboard
- It's now possible to customize PWA Manfiest from PleromaFE
- Make every configuration option default-overridable by instance admins
### Fixed
- Fixed settings not appearing if user never touched "show advanced" toggle
- Fix display of the broken/deleted/banned users
- Fixed incorrect emoji display in post interaction lists
- Fixed list title not being saved when editing
- Fixed poll notifications not being expandable
## 2.9.3
### Fixed
- Being unable to update profile
## 2.9.2
### Changed
- BREAKING: due to some internal technical changes logging into AdminFE through PleromaFE is no longer possible
- User card/profile got an overhaul
- Profile editing overhaul
- Visually combined subject and content fields in post form
- Moved post form's emoji button into input field
- Minor visual changes and fixes
- Clicking on fav/rt/emoji notifications' contents expands/collapses it
- Reduced time taken processing theme by half
- Splash screen only appears if loading takes more than 2 seconds
### Added
- Mutes received an update, adding support for regex, muting based on username and expiration time.
- Mutes are now synchronized across sessions
- Support for expiring mutes and blocks (if available)
- Clicking on emoji shows bigger version of it alongside with its shortcode
- Admins also are able to copy it into a local pack
- Added support for Akkoma and IceShrimp.NET backends
- Compatibility with stricter CSP (Akkoma backend)
- Added a way to upload new packs from a URL or ZIP file via the Admin Dashboard
- Unify show/hide content buttons
- Add support for detachable scrollTop button
- Option to left-align user bio
- Cache assets and emojis with service worker
- Indicate currently active V3 theme as a body element class
- Add arithmetic blend ISS function
### Fixed
- Display counter for status action buttons when they are in the menu
- Fix bookmark button alignment in the extra actions menu
- Instance favicons are no longer stretched
- A lot more scalable UI fixes
- Emoji picker now should work fine when emoji size is increased
## 2.8.0
### Changed
- BREAKING: static/img/nsfw.2958239.png is now static/img/nsfw.DepQPhG0.png, which may affect people who specify exactly this path as the cover image
@ -104,8 +34,8 @@ This does not guarantee that browsers will or will not work.
- Support displaying time in absolute format
- Add draft management system
- Compress most kinds of images on upload.
- Added option to always convert images to JPEG format instead of using WebP when compressing images.
- Added configurable image compression option in general settings, allowing users to control whether images are compressed before upload.
- Added option to always convert images to JPEG format instead of using WebP when compressing images.
- Added configurable image compression option in general settings, allowing users to control whether images are compressed before upload.
- Inform users that Smithereen public polls are public
- Splash screen + loading indicator to make process of identifying initialization issues and load performance
- UI for making v3 themes and palettes, support for bundling v3 themes

View file

@ -6,7 +6,7 @@
# For Translators
To translate Pleroma-FE, use our weblate server: https://translate.pleroma.social/. If you need to add your language it should be added as a json file in [src/i18n/](https://git.pleroma.social/pleroma/pleroma-fe/src/src/i18n/) folder and added in a list within [src/i18n/languages.js](https://git.pleroma.social/pleroma/pleroma-fe/src/src/i18n/languages.js).
To translate Pleroma-FE, use our weblate server: https://translate.pleroma.social/. If you need to add your language it should be added as a json file in [src/i18n/](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/) folder and added in a list within [src/i18n/languages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/languages.js).
Pleroma-FE will set your language by your browser locale, but you can change language in settings.
@ -32,10 +32,10 @@ yarn unit
# For Contributors:
You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/src/config/local.example.json)) to enable some convenience dev options:
You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/config/local.example.json)) to enable some convenience dev options:
* `target`: makes local dev server redirect to some existing instance's BE instead of local BE, useful for testing things in near-production environment and searching for real-life use-cases.
* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/pleroma/frontend_configurations`. Only works in dev mode.
* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode.
FE Build process also leaves current commit hash in global variable `___pleromafe_commit_hash` so that you can easily see which pleroma-fe commit instance is running, also helps pinpointing which commit was used when FE was bundled into BE.

View file

@ -1,150 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist", "!!tools/emojis.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"domains": {
"vue": "recommended"
},
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useIsNan": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
},
"globals": []
},
"overrides": [
{
"includes": ["**/*.spec.js", "test/fixtures/*.js"],
"javascript": {
"globals": [
"vi",
"describe",
"it",
"test",
"expect",
"before",
"beforeEach",
"after",
"afterEach"
]
}
},
{
"includes": ["**/*.vue"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off"
}
}
}
}
],
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
[":NODE:", ":PACKAGE:", "!src/**", "!@fortawesome/**"],
":BLANK_LINE:",
[":PATH:", "src/components/**"],
":BLANK_LINE:",
[":PATH:", "src/stores/**"],
":BLANK_LINE:",
[":PATH:", "src/**", "src/stores/**", "src/components/**"],
":BLANK_LINE:",
"@fortawesome/fontawesome-svg-core",
"@fortawesome/*"
]
}
}
}
}
}
}

View file

@ -1,5 +1,5 @@
import chalk from 'chalk'
import semver from 'semver'
import chalk from 'chalk'
import packageConfig from '../package.json' with { type: 'json' }
@ -7,8 +7,8 @@ var versionRequirements = [
{
name: 'node',
currentVersion: semver.clean(process.version),
versionRequirement: packageConfig.engines.node,
},
versionRequirement: packageConfig.engines.node
}
]
export default function () {
@ -16,26 +16,20 @@ export default function () {
for (let i = 0; i < versionRequirements.length; i++) {
const mod = versionRequirements[i]
if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
warnings.push(
mod.name +
': ' +
chalk.red(mod.currentVersion) +
' should be ' +
chalk.green(mod.versionRequirement),
warnings.push(mod.name + ': ' +
chalk.red(mod.currentVersion) + ' should be ' +
chalk.green(mod.versionRequirement)
)
}
}
if (warnings.length) {
console.warn(
chalk.yellow(
'\nTo use this template, you must update following to modules:\n',
),
)
console.warn(chalk.yellow('\nTo use this template, you must update following to modules:\n'))
for (let i = 0; i < warnings.length; i++) {
const warning = warnings[i]
console.warn(' ' + warning)
}
console.warn()
process.exit(1)
}
}

View file

@ -1,8 +1,8 @@
import childProcess from 'child_process'
export const getCommitHash = () => {
const subst = '$Format:%h$'
if (!subst.match(/Format:/)) {
export const getCommitHash = (() => {
const subst = "$Format:%h$"
if(!subst.match(/Format:/)) {
return subst
} else {
try {
@ -15,4 +15,4 @@ export const getCommitHash = () => {
return 'UNKNOWN'
}
}
}
})

View file

@ -1,8 +1,8 @@
import { cp } from 'node:fs/promises'
import { resolve } from 'node:path'
import serveStatic from 'serve-static'
import { resolve } from 'node:path'
import { cp } from 'node:fs/promises'
const getPrefix = (s) => {
const getPrefix = s => {
const padEnd = s.endsWith('/') ? s : s + '/'
return padEnd.startsWith('/') ? padEnd : '/' + padEnd
}
@ -13,31 +13,28 @@ const copyPlugin = ({ inUrl, inFs }) => {
let copyTarget
const handler = serveStatic(inFs)
return [
{
name: 'copy-plugin-serve',
apply: 'serve',
configureServer(server) {
server.middlewares.use(prefix, handler)
},
return [{
name: 'copy-plugin-serve',
apply: 'serve',
configureServer (server) {
server.middlewares.use(prefix, handler)
}
}, {
name: 'copy-plugin-build',
apply: 'build',
configResolved (config) {
copyTarget = resolve(config.root, config.build.outDir, subdir)
},
{
name: 'copy-plugin-build',
apply: 'build',
configResolved(config) {
copyTarget = resolve(config.root, config.build.outDir, subdir)
},
closeBundle: {
order: 'post',
sequential: true,
async handler() {
console.info(`Copying '${inFs}' to ${copyTarget}...`)
await cp(inFs, copyTarget, { recursive: true })
console.info('Done.')
},
},
},
]
closeBundle: {
order: 'post',
sequential: true,
async handler () {
console.log(`Copying '${inFs}' to ${copyTarget}...`)
await cp(inFs, copyTarget, { recursive: true })
console.log('Done.')
}
}
}]
}
export default copyPlugin

View file

@ -1,23 +1,21 @@
import { access } from 'node:fs/promises'
import { resolve } from 'node:path'
import { languages } from '../src/i18n/languages.js'
import { access } from 'node:fs/promises'
import { languages, langCodeToCldrName } from '../src/i18n/languages.js'
const annotationsImportPrefix = '@kazvmoe-infra/unicode-emoji-json/annotations/'
const specialAnnotationsLocale = {
ja_easy: 'ja',
ja_easy: 'ja'
}
const internalToAnnotationsLocale = (internal) =>
specialAnnotationsLocale[internal] || internal
const internalToAnnotationsLocale = (internal) => specialAnnotationsLocale[internal] || internal
// This gets all the annotations that are accessible (whose language
// can be chosen in the settings). Data for other languages are
// discarded because there is no way for it to be fetched.
const getAllAccessibleAnnotations = async (projectRoot) => {
const imports = (
await Promise.all(
languages.map(async (lang) => {
const imports = (await Promise.all(
languages
.map(async lang => {
const destLang = internalToAnnotationsLocale(lang)
const importModule = `${annotationsImportPrefix}${destLang}.json`
const importFile = resolve(projectRoot, 'node_modules', importModule)
@ -25,18 +23,11 @@ const getAllAccessibleAnnotations = async (projectRoot) => {
await access(importFile)
return `'${lang}': () => import('${importModule}')`
} catch (e) {
if (e.message.match(/ENOENT/)) {
console.warn(`Missing emoji annotations locale: ${destLang}`)
} else {
console.error('test', e.message)
}
return
}
}),
)
)
.filter((k) => k)
.join(',\n')
})))
.filter(k => k)
.join(',\n')
return `
export const annotationsLoader = {
@ -52,21 +43,21 @@ const emojisPlugin = () => {
let projectRoot
return {
name: 'emojis-plugin',
configResolved(conf) {
configResolved (conf) {
projectRoot = conf.root
},
resolveId(id) {
resolveId (id) {
if (id === emojiAnnotationsId) {
return emojiAnnotationsIdResolved
}
return null
},
async load(id) {
async load (id) {
if (id === emojiAnnotationsIdResolved) {
return await getAllAccessibleAnnotations(projectRoot)
}
return null
},
}
}
}

View file

@ -1,5 +1,5 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
const target = 'node_modules/msw/lib/mockServiceWorker.js'
@ -8,10 +8,10 @@ const mswPlugin = () => {
return {
name: 'msw-plugin',
apply: 'serve',
configResolved(conf) {
configResolved (conf) {
projectRoot = conf.root
},
configureServer(server) {
configureServer (server) {
server.middlewares.use(async (req, res, next) => {
if (req.path === '/mockServiceWorker.js') {
const file = await readFile(resolve(projectRoot, target))
@ -21,7 +21,7 @@ const mswPlugin = () => {
next()
}
})
},
}
}
}

View file

@ -1,12 +1,11 @@
import { languages, langCodeToJsonName } from '../src/i18n/languages.js'
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { langCodeToJsonName, languages } from '../src/i18n/languages.js'
const i18nDir = resolve(
dirname(dirname(fileURLToPath(import.meta.url))),
'src/i18n',
'src/i18n'
)
export const i18nFiles = languages.reduce((acc, lang) => {
@ -17,15 +16,13 @@ export const i18nFiles = languages.reduce((acc, lang) => {
}, {})
export const generateServiceWorkerMessages = async () => {
const msgArray = await Promise.all(
Object.entries(i18nFiles).map(async ([lang, file]) => {
const fileContent = await readFile(file, 'utf-8')
const msg = {
notifications: JSON.parse(fileContent).notifications || {},
}
return [lang, msg]
}),
)
const msgArray = await Promise.all(Object.entries(i18nFiles).map(async ([lang, file]) => {
const fileContent = await readFile(file, 'utf-8')
const msg = {
notifications: JSON.parse(fileContent).notifications || {}
}
return [lang, msg]
}))
return msgArray.reduce((acc, [lang, msg]) => {
acc[lang] = msg
return acc

View file

@ -1,12 +1,9 @@
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { exactRegex } from '@rolldown/pluginutils'
import { dirname, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
import { build } from 'vite'
import {
generateServiceWorkerMessages,
i18nFiles,
} from './service_worker_messages.js'
import * as esbuild from 'esbuild'
import { generateServiceWorkerMessages, i18nFiles } from './service_worker_messages.js'
const getSWMessagesAsText = async () => {
const messages = await generateServiceWorkerMessages()
@ -14,24 +11,123 @@ const getSWMessagesAsText = async () => {
}
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const swEnvName = 'virtual:pleroma-fe/service_worker_env'
const swEnvNameResolved = '\0' + swEnvName
const getDevSwEnv = () => `self.serviceWorkerOption = { assets: [] };`
const getProdSwEnv = ({ assets }) => `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
export const devSwPlugin = ({
swSrc,
swDest,
transformSW,
alias
}) => {
const swFullSrc = resolve(projectRoot, swSrc)
const esbuildAlias = {}
Object.entries(alias).forEach(([source, dest]) => {
esbuildAlias[source] = dest.startsWith('/') ? projectRoot + dest : dest
})
return {
name: 'dev-sw-plugin',
apply: 'serve',
configResolved (conf) {
},
resolveId (id) {
const name = id.startsWith('/') ? id.slice(1) : id
if (name === swDest) {
return swFullSrc
} else if (name === swEnvName) {
return swEnvNameResolved
}
return null
},
async load (id) {
if (id === swFullSrc) {
return readFile(swFullSrc, 'utf-8')
} else if (id === swEnvNameResolved) {
return getDevSwEnv()
}
return null
},
/**
* vite does not bundle the service worker
* during dev, and firefox does not support ESM as service worker
* https://bugzilla.mozilla.org/show_bug.cgi?id=1360870
*/
async transform (code, id) {
if (id === swFullSrc && transformSW) {
const res = await esbuild.build({
entryPoints: [swSrc],
bundle: true,
write: false,
outfile: 'sw-pleroma.js',
alias: esbuildAlias,
plugins: [{
name: 'vite-like-root-resolve',
setup (b) {
b.onResolve(
{ filter: new RegExp(/^\//) },
args => ({
path: resolve(projectRoot, args.path.slice(1))
})
)
}
}, {
name: 'sw-messages',
setup (b) {
b.onResolve(
{ filter: new RegExp('^' + swMessagesName + '$') },
args => ({
path: args.path,
namespace: 'sw-messages'
}))
b.onLoad(
{ filter: /.*/, namespace: 'sw-messages' },
async () => ({
contents: await getSWMessagesAsText()
}))
}
}, {
name: 'sw-env',
setup (b) {
b.onResolve(
{ filter: new RegExp('^' + swEnvName + '$') },
args => ({
path: args.path,
namespace: 'sw-env'
}))
b.onLoad(
{ filter: /.*/, namespace: 'sw-env' },
() => ({
contents: getDevSwEnv()
}))
}
}]
})
const text = res.outputFiles[0].text
return text
}
}
}
}
// Idea taken from
// https://github.com/vite-pwa/vite-plugin-pwa/blob/main/src/plugins/build.ts
// rollup does not support compiling to iife if we want to code-split;
// however, we must compile the service worker to iife because of browser support.
// Run another vite build just for the service worker targeting iife at
// the end of the build.
export const buildSwPlugin = ({ swSrc, swDest }) => {
const swFullSrc = resolve(projectRoot, swSrc)
const swEnvName = 'virtual:pleroma-fe/service_worker_env'
const swEnvNameResolved = '\0' + swEnvName
export const buildSwPlugin = ({
swSrc,
swDest,
}) => {
let config
return {
name: 'build-sw-plugin',
enforce: 'post',
configResolved(resolvedConfig) {
resolvedConfig
apply: 'build',
configResolved (resolvedConfig) {
config = {
define: resolvedConfig.define,
resolve: resolvedConfig.resolve,
@ -39,89 +135,53 @@ export const buildSwPlugin = ({ swSrc, swDest }) => {
publicDir: false,
build: {
...resolvedConfig.build,
emptyOutDir: false,
rolldownOptions: {
input: {
main: swSrc,
},
context: 'self',
output: {
entryFileNames: swDest,
codeSplitting: false,
format: 'iife',
},
lib: {
entry: swSrc,
formats: ['iife'],
name: 'sw_pleroma'
},
emptyOutDir: false,
rollupOptions: {
output: {
entryFileNames: swDest
}
}
},
configFile: false,
configFile: false
}
},
generateBundle: {
order: 'post',
sequential: true,
async handler(_, bundle) {
async handler (_, bundle) {
const assets = Object.keys(bundle)
.filter((name) => !/\.map$/.test(name))
.map((name) => '/' + name)
.filter(name => !/\.map$/.test(name))
.map(name => '/' + name)
config.plugins.push({
name: 'build-sw-env-plugin',
mode: 'production',
resolveId: {
filter: { id: exactRegex(swEnvName) },
handler: () => swEnvNameResolved,
},
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler() {
return `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
},
resolveId (id) {
if (id === swEnvName) {
return swEnvNameResolved
}
return null
},
load (id) {
if (id === swEnvNameResolved) {
return getProdSwEnv({ assets })
}
return null
}
})
},
},
resolveId: {
filter: { id: new RegExp(swDest) },
handler() {
return swFullSrc
},
},
load: {
filter: { id: new RegExp(swFullSrc) },
async handler() {
config.plugins.push({
name: 'dummy-sw-env',
mode: 'development',
resolveId: {
filter: { id: exactRegex(swEnvName) },
handler: () => swEnvNameResolved,
},
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler: () => 'self.serviceWorkerOption = { assets: [] }',
},
})
try {
const swBundle = await build(config)
return swBundle.output[0]
} catch (e) {
console.error('Error building ServiceWorker:', e)
}
},
}
},
closeBundle: {
order: 'post',
sequential: true,
async handler() {
if (process.env.VITEST) return
console.info('Building service worker for production')
try {
await build(config)
} catch (e) {
console.error('Error building ServiceWorker:', e)
}
},
},
async handler () {
console.log('Building service worker for production')
await build(config)
}
}
}
}
@ -131,9 +191,9 @@ const swMessagesNameResolved = '\0' + swMessagesName
export const swMessagesPlugin = () => {
return {
name: 'sw-messages-plugin',
resolveId(id) {
resolveId (id) {
if (id === swMessagesName) {
Object.values(i18nFiles).forEach((f) => {
Object.values(i18nFiles).forEach(f => {
this.addWatchFile(f)
})
return swMessagesNameResolved
@ -141,11 +201,11 @@ export const swMessagesPlugin = () => {
return null
}
},
async load(id) {
async load (id) {
if (id === swMessagesNameResolved) {
return await getSWMessagesAsText()
}
return null
},
}
}
}

View file

@ -1,21 +1,22 @@
import emojis from '@kazvmoe-infra/unicode-emoji-json/data-by-group.json' with {
type: 'json',
}
import emojis from '@kazvmoe-infra/unicode-emoji-json/data-by-group.json' with { type: 'json' }
import fs from 'fs'
Object.keys(emojis).map((k) => {
emojis[k].map((e) => {
delete e.unicode_version
delete e.emoji_version
delete e.skin_tone_support_unicode_version
Object.keys(emojis)
.map(k => {
emojis[k].map(e => {
delete e.unicode_version
delete e.emoji_version
delete e.skin_tone_support_unicode_version
})
})
})
const res = {}
Object.keys(emojis).map((k) => {
const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase()
res[groupId] = emojis[k]
})
Object.keys(emojis)
.map(k => {
const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase()
res[groupId] = emojis[k]
})
console.info('Updating emojis...')
fs.writeFileSync('src/assets/emoji.json', JSON.stringify(res))

View file

@ -0,0 +1 @@
Display counter for status action buttons when they are on the menu

View file

@ -0,0 +1 @@
Added support for Akkoma and IceShrimp.NET backend

View file

@ -0,0 +1,2 @@
Add arithmetic blend ISS function

View file

@ -1 +0,0 @@
Fix HTML attribute parsing for escaped quotes

View file

@ -0,0 +1 @@
Add support for detachable scrollTop button

View file

@ -0,0 +1 @@
Fix bookmark button alignment in the extra actions menu

1
changelog.d/csp.add Normal file
View file

@ -0,0 +1 @@
Compatibility with stricter CSP (Akkoma backend)

View file

@ -1 +0,0 @@
Migrated to Vite 8 and optimized our imports, more stuff is loaded on-demand, reducing the initial load time and transfer size

View file

@ -1 +0,0 @@
Fix emojis breaking user bio/description editing

View file

@ -1,6 +0,0 @@
button to remove all drafts
option to remove forced aspect ratio for user profiles (requested)
showing user tags (mrf policies for user + custom if present)
version information now is also in about page
mention autosuggest now sorts by recent activity
non-square emoji support (toggleable by user)

View file

@ -1,7 +0,0 @@
overall improved spacings in status action buttons and post form
logout confirm button is now dangerous
reply/quote now is a radio group and wraps, fixes overflow on languages where labels are too wide
personal note input is now bigger
moved "edit pinned" to the bottom for status action buttons.
dots status action button drops down instead of up to avoid hiding the action buttons
improved attachment description (alt text) input and display

View file

@ -1,12 +0,0 @@
navbar wide logo cropping search input
danger buttons being too bright
user background upload failure no longer breaks new uploads + displays an error
importing theme from old theme editor
removed duplicate federationpolicy entry in admin tab
repeater name overflowing content
reply popover is now shown if replied-to status is muted
second language input not having header
post form's bottom left buttons not showing their toggled state
some font overrides not working
popovers opening outside of window's boundaries
occasional blank page when showing new posts

View file

@ -1 +0,0 @@
Fixed status action mute hiding itself on click

View file

@ -0,0 +1 @@
Synchronized mutes, advanced mute control (regexp, expiry, naming)

View file

@ -0,0 +1 @@
Fix error styling for user profiles

View file

@ -1 +0,0 @@
displaying other user's backgrounds (if supported by BE)

View file

@ -1 +0,0 @@
Add quoting by URL and in replies

View file

@ -1 +0,0 @@
Fix reply form crash when quote-reply settings are unavailable

View file

@ -0,0 +1 @@
Cache assets and emojis with service worker

View file

@ -1,2 +0,0 @@
settings synchronization
user highlight synchronization

View file

@ -0,0 +1 @@
Indicate currently active V3 theme as a body element class

View file

@ -0,0 +1 @@
Unify show/hide content buttons

View file

@ -1 +0,0 @@
User administration + post scope/sensitivity admin change support

View file

@ -1,59 +0,0 @@
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: pleroma
POSTGRES_PASSWORD: pleroma
POSTGRES_DB: pleroma
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pleroma -d pleroma"]
interval: 2s
timeout: 2s
retries: 30
pleroma:
image: ${PLEROMA_IMAGE:-git.pleroma.social/pleroma/pleroma:stable}
environment:
DB_USER: pleroma
DB_PASS: pleroma
DB_NAME: pleroma
DB_HOST: db
DB_PORT: 5432
DOMAIN: localhost
INSTANCE_NAME: Pleroma E2E
ADMIN_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com}
NOTIFY_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com}
E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-admin}
E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-adminadmin}
E2E_ADMIN_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com}
depends_on:
db:
condition: service_healthy
volumes:
- ./docker/pleroma/entrypoint.e2e.sh:/opt/pleroma/entrypoint.e2e.sh:ro
entrypoint: ["/bin/ash", "/opt/pleroma/entrypoint.e2e.sh"]
healthcheck:
# NOTE: "localhost" may resolve to ::1 in some images (IPv6) while Pleroma only
# listens on IPv4 in this container. Use 127.0.0.1 to avoid false negatives.
test: ["CMD-SHELL", "test -f /var/lib/pleroma/.e2e_seeded && wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null || exit 1"]
interval: 5s
timeout: 3s
retries: 60
ports:
- 4000:4000
e2e:
build:
context: .
dockerfile: docker/e2e/Dockerfile.e2e
depends_on:
pleroma:
condition: service_healthy
environment:
CI: "1"
VITE_PROXY_TARGET: http://pleroma:4000
VITE_PROXY_ORIGIN: http://localhost:4000
E2E_BASE_URL: http://localhost:8099
E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-admin}
E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-adminadmin}
command: ["yarn", "e2e:pw"]

View file

@ -1,16 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.61.0-jammy
WORKDIR /app
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
RUN npm install -g yarn@1.22.22
COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
COPY . .
ENV CI=1
CMD ["yarn", "e2e:pw"]

View file

@ -1,71 +0,0 @@
#!/bin/ash
set -eu
SEED_SENTINEL_PATH="/var/lib/pleroma/.e2e_seeded"
CONFIG_OVERRIDE_PATH="/var/lib/pleroma/config.exs"
echo "-- Waiting for database..."
while ! pg_isready -U "${DB_USER:-pleroma}" -d "postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma}" -t 1; do
sleep 1s
done
echo "-- Writing E2E config overrides..."
cat > "$CONFIG_OVERRIDE_PATH" <<'EOF'
import Config
config :pleroma, Pleroma.Captcha,
enabled: false
config :pleroma, :instance,
registrations_open: true,
account_activation_required: false,
approval_required: false
EOF
echo "-- Running migrations..."
/opt/pleroma/bin/pleroma_ctl migrate
echo "-- Starting!"
/opt/pleroma/bin/pleroma start &
PLEROMA_PID="$!"
cleanup() {
if [ -n "${PLEROMA_PID:-}" ] && kill -0 "$PLEROMA_PID" 2>/dev/null; then
kill -TERM "$PLEROMA_PID"
wait "$PLEROMA_PID" || true
fi
}
trap cleanup INT TERM
echo "-- Waiting for API..."
api_ok="false"
for _i in $(seq 1 120); do
if wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null 2>&1; then
api_ok="true"
break
fi
sleep 1s
done
if [ "$api_ok" != "true" ]; then
echo "Timed out waiting for Pleroma API to become available"
exit 1
fi
if [ ! -f "$SEED_SENTINEL_PATH" ]; then
if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then
echo "-- Seeding admin user (${E2E_ADMIN_USERNAME})..."
if ! /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then
echo "-- User already exists (or creation failed), ensuring admin + confirmed..."
/opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed
fi
else
echo "-- Skipping admin seeding (missing E2E_ADMIN_* env)"
fi
touch "$SEED_SENTINEL_PATH"
fi
wait "$PLEROMA_PID"

View file

@ -7,9 +7,9 @@
PleromaFE gets its configuration from several sources, in order of preference (the one above overrides ones below it)
1. `/api/pleroma/frontend_configurations` - this is generated by backend and includes FE/Client-specific data. PleromaFE uses the `pleroma_fe` field of it. For more info on changing config on BE, look [here](../backend/configuration/cheatsheet.md#frontend_configurations)
2. `/static/config.json` - this is a static FE-provided file, containing only FE specific configuration. This file is completely optional and could be removed but is useful as a fallback if some configuration JSON property isn't present in BE-provided config. It's also a reference point to check what default configuration are and what JSON properties even exist. In local dev mode it could be used to override BE configuration, more about that in HACKING.md. File is located [here](https://git.pleroma.social/pleroma/pleroma-fe/src/public/static/config.json).
3. Built-in defaults. Those are hard-coded defaults that are used when `/static/config.json` is not available and BE-provided configuration JSON is missing some JSON properties. ( [Code](https://git.pleroma.social/pleroma/pleroma-fe/src/src/stores/instance.js) )
1. `/api/statusnet/config.json` - this is generated on Backend and contains multiple things including instance name, char limit etc. It also contains FE/Client-specific data, PleromaFE uses `pleromafe` field of it. For more info on changing config on BE, look [here](../backend/configuration/cheatsheet.md#frontend_configurations)
2. `/static/config.json` - this is a static FE-provided file, containing only FE specific configuration. This file is completely optional and could be removed but is useful as a fallback if some configuration JSON property isn't present in BE-provided config. It's also a reference point to check what default configuration are and what JSON properties even exist. In local dev mode it could be used to override BE configuration, more about that in HACKING.md. File is located [here](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/static/config.json).
3. Built-in defaults. Those are hard-coded defaults that are used when `/static/config.json` is not available and BE-provided configuration JSON is missing some JSON properties. ( [Code](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/modules/instance.js) )
## Instance-defaults

View file

@ -79,7 +79,7 @@ server {
In 99% cases PleromaFE uses [MastoAPI](https://docs.joinmastodon.org/api/) with [Pleroma Extensions](../backend/API/differences_in_mastoapi_responses.md) to fetch the data. The rest is either QvitterAPI leftovers or pleroma-exclusive APIs. QvitterAPI doesn't exactly have documentation and uses different JSON structure and sometimes different parameters and workflows, [this](https://twitter-api.readthedocs.io/en/latest/index.html) could be a good reference though. Some pleroma-exclusive API may still be using QvitterAPI JSON structure.
PleromaFE supports both formats by transforming them into internal format which is basically QvitterAPI one with some additions and renaming. All data is passed trough [Entity Normalizer](https://git.pleroma.social/pleroma/pleroma-fe/src/src/services/entity_normalizer/entity_normalizer.service.js) which can serve as a reference of API and what's actually used, it's also a host for all the hacks and data transformation.
PleromaFE supports both formats by transforming them into internal format which is basically QvitterAPI one with some additions and renaming. All data is passed trough [Entity Normalizer](https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/src/services/entity_normalizer/entity_normalizer.service.js) which can serve as a reference of API and what's actually used, it's also a host for all the hacks and data transformation.
For most part, PleromaFE tries to store all the info it can get in global vuex store - every user and post are passed trough updating mechanism where data is either added or merged with existing data, reactively updating the information throughout UI, so if in newest request user's post counter increased, it will be instantly updated in open user profile cards. This is also used to find users, posts and sometimes to build timelines and/or request parameters.

View file

@ -1,34 +1,37 @@
import js from '@eslint/js'
import { defineConfig, globalIgnores } from 'eslint/config'
import vue from 'eslint-plugin-vue'
import globals from 'globals'
import vue from "eslint-plugin-vue";
import js from "@eslint/js";
import globals from "globals";
export default defineConfig([
export default [
...vue.configs['flat/recommended'],
globalIgnores(['**/*.js', 'build/', 'dist/', 'config/']),
js.configs.recommended,
{
files: ['src/**/*.vue'],
plugins: { js },
extends: ['js/recommended'],
files: ["**/*.js", "**/*.mjs", "**/*.vue"],
ignores: ["build/*.js", "config/*.js"],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
sourceType: "module",
parserOptions: {
parser: '@babel/eslint-parser',
parser: "@babel/eslint-parser",
},
globals: {
...globals.browser,
...globals.vitest,
...globals.chai,
...globals.commonjs,
...globals.serviceworker,
},
...globals.serviceworker
}
},
rules: {
'arrow-parens': 0,
'generator-star-spacing': 0,
'no-debugger': 0,
'vue/require-prop-types': 0,
'vue/multi-word-component-names': 0,
},
},
])
}
}
]

View file

@ -11,12 +11,14 @@
<link rel="preload" href="/static/pleromatan_apology_fox_small.webp" as="image" />
<!-- putting styles here to avoid having to wait for styles to load up -->
<link rel="stylesheet" id="splashscreen" href="/static/splash.css" />
<link rel="stylesheet" id="custom-styles-holder" type="text/css" href="/static/empty.css" />
<link rel="stylesheet" id="pleroma-eager-styles" type="text/css" href="/static/empty.css" />
<link rel="stylesheet" id="pleroma-lazy-styles" type="text/css" href="/static/empty.css" />
<link rel="stylesheet" id="theme-holder" type="text/css" href="/static/empty.css" />
<!--server-generated-meta-->
</head>
<body>
<noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="splash" class="initial-hidden">
<div id="splash">
<!-- we are hiding entire graphic so no point showing credit -->
<div aria-hidden="true" id="splash-credit">
Art by pipivovott

View file

@ -1,8 +1,8 @@
{
"name": "pleroma_fe",
"version": "2.10.1",
"version": "2.7.1",
"description": "Pleroma frontend, the default frontend of Pleroma social network server",
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/src/CONTRIBUTORS.md>",
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/CONTRIBUTORS.md>",
"private": false,
"scripts": {
"dev": "node build/update-emoji.js && vite dev",
@ -10,117 +10,109 @@
"unit": "node build/update-emoji.js && vitest --run",
"unit-ci": "node build/update-emoji.js && vitest --run --browser.headless",
"unit:watch": "node build/update-emoji.js && vitest",
"e2e:pw": "playwright test --config test/e2e-playwright/playwright.config.mjs",
"e2e": "sh ./tools/e2e/run.sh",
"e2e": "node test/e2e/runner.js",
"test": "yarn run unit && yarn run e2e",
"ci-biome": "yarn exec biome check",
"ci-eslint": "yarn exec eslint",
"ci-stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'",
"lint": "yarn ci-biome; yarn ci-eslint; yarn ci-stylelint",
"lint-fix": "yarn exec eslint -- --fix; yarn exec stylelint '**/*.scss' '**/*.vue' --fix; biome check --write"
"stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'",
"lint": "eslint src test/unit/specs test/e2e/specs",
"lint-fix": "eslint --fix src test/unit/specs test/e2e/specs"
},
"dependencies": {
"@babel/runtime": "7.28.4",
"@babel/runtime": "7.27.1",
"@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "7.1.0",
"@fortawesome/vue-fontawesome": "3.1.2",
"@kazvmoe-infra/pinch-zoom-element": "1.3.0",
"@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/vue-fontawesome": "3.0.8",
"@floatingghost/pinch-zoom-element": "1.3.1",
"@kazvmoe-infra/unicode-emoji-json": "0.4.0",
"@ruffle-rs/ruffle": "0.1.0-nightly.2025.6.22",
"@ruffle-rs/ruffle": "0.1.0-nightly.2025.1.13",
"@vuelidate/core": "2.0.3",
"@vuelidate/validators": "2.0.4",
"@web3-storage/parse-link-header": "^3.1.0",
"body-scroll-lock": "3.1.5",
"chromatism": "3.0.0",
"click-outside-vue3": "4.0.1",
"cropperjs": "2.0.1",
"cropperjs": "2.0.0",
"escape-html": "1.0.3",
"globals": "^16.0.0",
"hash-sum": "^2.0.0",
"js-cookie": "3.0.5",
"localforage": "1.10.0",
"parse-link-header": "2.0.0",
"phoenix": "1.8.1",
"pinia": "^3.0.4",
"phoenix": "1.7.21",
"pinia": "^3.0.0",
"punycode.js": "2.3.1",
"qrcode": "1.5.4",
"querystring-es3": "0.2.1",
"url": "0.11.4",
"utf8": "3.0.0",
"uuid": "11.1.0",
"vue": "3.5.22",
"vue": "3.5.17",
"vue-i18n": "11",
"vue-router": "4.6.4",
"vue-router": "4.5.1",
"vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0"
},
"devDependencies": {
"@babel/core": "7.28.5",
"@babel/eslint-parser": "7.28.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@babel/register": "7.28.3",
"@biomejs/biome": "2.3.11",
"@pinia/testing": "1.0.3",
"@babel/core": "7.27.1",
"@babel/eslint-parser": "7.27.1",
"@babel/plugin-transform-runtime": "7.27.1",
"@babel/preset-env": "7.27.2",
"@babel/register": "7.27.1",
"@ungap/event-target": "0.2.4",
"@vitejs/devtools": "^0.3.1",
"@vitejs/plugin-vue": "^6.0.7",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vitest/browser-playwright": "^4.1.7",
"@vitest/browser": "^4.1.7",
"@vitest/ui": "^4.1.7",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/browser": "^3.0.7",
"@vitest/ui": "^3.0.7",
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.5.0",
"@vue/compiler-sfc": "3.5.22",
"@vue/babel-plugin-jsx": "1.4.0",
"@vue/compiler-sfc": "3.5.17",
"@vue/test-utils": "2.4.6",
"autoprefixer": "10.4.21",
"babel-plugin-lodash": "3.3.4",
"chai": "5.3.3",
"chalk": "5.6.2",
"chai": "5.2.0",
"chalk": "5.4.1",
"chromedriver": "135.0.4",
"connect-history-api-fallback": "2.0.0",
"cross-spawn": "7.0.6",
"custom-event-polyfill": "1.0.7",
"eslint": "9.39.2",
"eslint": "9.26.0",
"vue-eslint-parser": "10.1.3",
"eslint-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.23.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-n": "17.18.0",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-vue": "10.6.2",
"eslint-plugin-vue": "10.1.0",
"eventsource-polyfill": "0.9.6",
"express": "5.1.0",
"function-bind": "1.1.2",
"http-proxy-middleware": "3.0.5",
"iso-639-1": "3.1.5",
"lodash": "4.17.21",
"msw": "2.14.6",
"nightwatch": "3.12.2",
"oxc": "^1.0.1",
"playwright": "1.61.0",
"postcss": "8.5.6",
"msw": "2.10.2",
"nightwatch": "3.12.1",
"playwright": "1.52.0",
"postcss": "8.5.3",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"sass-embedded": "^1.100.0",
"sass": "1.89.2",
"selenium-server": "3.141.59",
"semver": "7.7.3",
"semver": "7.7.2",
"serve-static": "2.2.0",
"shelljs": "0.10.0",
"sinon": "20.0.0",
"sinon-chai": "4.0.1",
"stylelint": "16.25.0",
"sinon-chai": "4.0.0",
"stylelint": "16.19.1",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recommended": "^16.0.0",
"stylelint-config-recommended-scss": "^14.0.0",
"stylelint-config-recommended-vue": "^1.6.0",
"stylelint-config-standard": "38.0.0",
"vite": "^8.0.0",
"vite-plugin-eslint2": "^5.1.0",
"vite-plugin-stylelint": "^6.1.0",
"vitest": "^4.1.7",
"vue-eslint-parser": "10.2.0"
"vite": "^6.1.0",
"vite-plugin-eslint2": "^5.0.3",
"vite-plugin-stylelint": "^6.0.0",
"vitest": "^3.0.7"
},
"type": "module",
"engines": {

View file

@ -1,5 +1,7 @@
import autoprefixer from 'autoprefixer'
export default {
plugins: [autoprefixer],
plugins: [
autoprefixer
]
}

1
public/static/empty.css Normal file
View file

@ -0,0 +1 @@
// nothing here //

View file

@ -1,26 +1,6 @@
{
"pleroma-dark": [
"Pleroma Dark",
"#121a24",
"#182230",
"#b9b9ba",
"#d8a070",
"#d31014",
"#0fa00f",
"#0095ff",
"#ffa500"
],
"pleroma-light": [
"Pleroma Light",
"#f2f4f6",
"#dbe0e8",
"#304055",
"#f86f0f",
"#d31014",
"#0fa00f",
"#0095ff",
"#ffa500"
],
"pleroma-dark": [ "Pleroma Dark", "#121a24", "#182230", "#b9b9ba", "#d8a070", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ],
"pleroma-light": [ "Pleroma Light", "#f2f4f6", "#dbe0e8", "#304055", "#f86f0f", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ],
"classic-dark": {
"name": "Classic Dark",
"bg": "#161c20",
@ -32,28 +12,8 @@
"cBlue": "#0095ff",
"cOrange": "#ffa500"
},
"bird": [
"Bird",
"#f8fafd",
"#e6ecf0",
"#14171a",
"#0084b8",
"#e0245e",
"#17bf63",
"#1b95e0",
"#fab81e"
],
"pleroma-amoled": [
"Pleroma Dark AMOLED",
"#000000",
"#111111",
"#b0b0b1",
"#d8a070",
"#aa0000",
"#0fa00f",
"#0095ff",
"#d59500"
],
"bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"],
"pleroma-amoled": [ "Pleroma Dark AMOLED", "#000000", "#111111", "#b0b0b1", "#d8a070", "#aa0000", "#0fa00f", "#0095ff", "#d59500"],
"tomorrow-night": {
"name": "Tomorrow Night",
"bg": "#1d1f21",
@ -76,28 +36,8 @@
"cGreen": "#50FA7B",
"cOrange": "#FFB86C"
},
"ir-black": [
"Ir Black",
"#000000",
"#242422",
"#b5b3aa",
"#ff6c60",
"#FF6C60",
"#A8FF60",
"#96CBFE",
"#FFFFB6"
],
"monokai": [
"Monokai",
"#272822",
"#383830",
"#f8f8f2",
"#f92672",
"#F92672",
"#a6e22e",
"#66d9ef",
"#f4bf75"
],
"ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ],
"monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ],
"purple-stream": {
"name": "Purple stream",
"bg": "#17171A",

View file

@ -5,14 +5,15 @@ body {
#splash {
--scale: 1;
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: auto;
grid-template-columns: auto;
align-content: center;
place-items: center;
align-items: center;
justify-content: center;
justify-items: center;
flex-direction: column;
background: #0f161e;
font-family: sans-serif;
@ -20,20 +21,13 @@ body {
position: absolute;
z-index: 9999;
font-size: calc(1vw + 1vh + 1vmin);
opacity: 1;
transition: opacity 500ms ease-out 2s;
}
#splash.hidden,
#splash.initial-hidden {
opacity: 0;
}
#splash-credit {
position: absolute;
font-size: 1em;
bottom: 1em;
right: 1em;
font-size: 14px;
bottom: 16px;
right: 16px;
}
#splash-container {
@ -65,17 +59,16 @@ body {
z-index: 2;
grid-template-rows: repeat(8, 1fr);
grid-template-columns: repeat(5, 1fr);
grid-template-areas:
"P P . L L"
"P P . L L"
"P P . L L"
"P P . L L"
"P P . . ."
"P P . . ."
"P P . E E"
"P P . E E";
grid-template-areas: "P P . L L"
"P P . L L"
"P P . L L"
"P P . L L"
"P P . . ."
"P P . . ."
"P P . E E"
"P P . E E";
--logoChunkSize: calc(2em * 0.5 * var(--scale));
--logoChunkSize: calc(2em * 0.5 * var(--scale))
}
.chunk {
@ -85,7 +78,7 @@ body {
#chunk-P {
grid-area: P;
border-top-left-radius: calc(var(--logoChunkSize) / 2);
border-top-left-radius: calc(var(--logoChunkSize) / 2);
}
#chunk-L {

View file

@ -1,4 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"]
"extends": [
"config:base"
]
}

View file

@ -1,267 +1,187 @@
import { throttle } from 'lodash'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import DesktopNav from 'src/components/desktop_nav/desktop_nav.vue'
import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import GlobalError from 'src/components/global_error/global_error.vue'
import GlobalNoticeList from 'src/components/global_notice_list/global_notice_list.vue'
import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MobileNav from 'src/components/mobile_nav/mobile_nav.vue'
import MobilePostStatusButton from 'src/components/mobile_post_status_button/mobile_post_status_button.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import UserPanel from 'src/components/user_panel/user_panel.vue'
import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowHeight, windowWidth } from './services/window_utils/window_utils'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex'
import { defineAsyncComponent } from 'vue'
import { useShoutStore } from './stores/shout'
import { useInterfaceStore } from './stores/interface'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useShoutStore } from 'src/stores/shout.js'
// Helper to unwrap reactive proxies
window.toValue = (x) => JSON.parse(JSON.stringify(x))
import { throttle } from 'lodash'
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
Notifications: defineAsyncComponent(() => import('./components/notifications/notifications.vue')),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel: defineAsyncComponent(
() =>
import('src/components/who_to_follow_panel/who_to_follow_panel.vue'),
),
ShoutPanel: defineAsyncComponent(
() => import('src/components/shout_panel/shout_panel.vue'),
),
MediaModal: defineAsyncComponent(
() => import('src/components/media_modal/media_modal.vue'),
),
WhoToFollowPanel,
ShoutPanel,
MediaModal,
SideDrawer,
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal: defineAsyncComponent(
() => import('src/components/settings_modal/settings_modal.vue'),
),
UpdateNotification: defineAsyncComponent(
() =>
import('src/components/update_notification/update_notification.vue'),
),
PostStatusModal: defineAsyncComponent(
() => import('src/components/post_status_modal/post_status_modal.vue'),
),
UserReportingModal: defineAsyncComponent(
() =>
import('src/components/user_reporting_modal/user_reporting_modal.vue'),
),
EditStatusModal: defineAsyncComponent(
() => import('src/components/edit_status_modal/edit_status_modal.vue'),
),
StatusHistoryModal: defineAsyncComponent(
() =>
import('src/components/status_history_modal/status_history_modal.vue'),
),
GlobalError,
GlobalNoticeList,
SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')),
UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')),
UserReportingModal,
PostStatusModal,
EditStatusModal,
StatusHistoryModal,
GlobalNoticeList
},
data: () => ({
mobileActivePanel: 'timeline',
mobileActivePanel: 'timeline'
}),
provide() {
return {
allowNonSquareEmoji: useMergedConfigStore().mergedConfig.nonSquareEmoji,
}
},
watch: {
themeApplied() {
themeApplied () {
this.removeSplash()
},
currentTheme() {
currentTheme () {
this.setThemeBodyClass()
},
layoutType() {
layoutType () {
document.getElementById('modal').classList = ['-' + this.layoutType]
},
}
},
created() {
created () {
// Load the locale from the storage
const value = useMergedConfigStore().mergedConfig.interfaceLanguage
useI18nStore().setLanguage(value)
useEmojiStore().loadUnicodeEmojiData(value)
const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
document.getElementById('modal').classList = ['-' + this.layoutType]
// Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200)
},
mounted() {
mounted () {
window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (this.themeApplied) {
if (useInterfaceStore().themeApplied) {
this.setThemeBodyClass()
this.removeSplash()
}
getOrCreateServiceWorker()
},
unmounted() {
unmounted () {
window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
},
computed: {
currentTheme() {
if (this.styleDataUsed) {
const styleMeta = this.styleDataUsed.find(
(x) => x.component === '@meta',
)
themeApplied () {
return useInterfaceStore().themeApplied
},
currentTheme () {
if (useInterfaceStore().styleDataUsed) {
const styleMeta = useInterfaceStore().styleDataUsed.find(x => x.component === '@meta')
if (styleMeta !== undefined) {
return styleMeta.directives.name.replaceAll(' ', '-').toLowerCase()
return styleMeta.directives.name.replaceAll(" ", "-").toLowerCase()
}
}
return 'stock'
},
layoutModalClass() {
layoutModalClass () {
return '-' + this.layoutType
},
classes() {
classes () {
return [
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown,
'-has-new-post-button': this.newPostButtonShown
},
'-' + this.layoutType,
'-' + this.layoutType
]
},
navClasses() {
const { navbarColumnStretch } = useMergedConfigStore().mergedConfig
navClasses () {
const { navbarColumnStretch } = this.$store.getters.mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : []),
...(navbarColumnStretch ? ['-column-stretch'] : [])
]
},
currentUser() {
return this.$store.state.users.currentUser
},
userBackground() {
return this.currentUser.background_image
},
foreignProfileBackground() {
return (
useMergedConfigStore().mergedConfig.allowForeignUserBackground &&
useInterfaceStore().foreignProfileBackground
)
},
instanceBackground() {
return useMergedConfigStore().mergedConfig.hideInstanceWallpaper
currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image },
instanceBackground () {
return this.mergedConfig.hideInstanceWallpaper
? null
: this.instanceBackgroundUrl
: this.$store.state.instance.background
},
background() {
return (
this.foreignProfileBackground ||
this.userBackground ||
this.instanceBackground
)
},
bgStyle() {
background () { return this.userBackground || this.instanceBackground },
bgStyle () {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`,
'--body-background-image': `url(${this.background})`
}
}
},
shoutJoined() {
return useShoutStore().joined
shout () { return useShoutStore().joined },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
},
isChats() {
isChats () {
return this.$route.name === 'chat' || this.$route.name === 'chats'
},
isListEdit() {
isListEdit () {
return this.$route.name === 'lists-edit'
},
newPostButtonShown() {
newPostButtonShown () {
if (this.isChats) return false
if (this.isListEdit) return false
return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton ||
this.layoutType === 'mobile'
)
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
shoutboxPosition() {
return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton || false
)
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
editingAvailable () { return this.$store.state.instance.editingAvailable },
shoutboxPosition () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
},
hideShoutbox() {
return useMergedConfigStore().mergedConfig.hideShoutbox
hideShoutbox () {
return this.$store.getters.mergedConfig.hideShoutbox
},
reverseLayout() {
const { thirdColumnMode, sidebarRight: reverseSetting } =
useMergedConfigStore().mergedConfig
layoutType () { return useInterfaceStore().layoutType },
privateMode () { return this.$store.state.instance.private },
reverseLayout () {
const { thirdColumnMode, sidebarRight: reverseSetting } = this.$store.getters.mergedConfig
if (this.layoutType !== 'wide') {
return reverseSetting
} else {
return thirdColumnMode === 'notifications'
? reverseSetting
: !reverseSetting
return thirdColumnMode === 'notifications' ? reverseSetting : !reverseSetting
}
},
noSticky() {
return useMergedConfigStore().mergedConfig.disableStickyHeaders
},
showScrollbars() {
return useMergedConfigStore().mergedConfig.showScrollbars
},
scrollParent() {
return window /* this.$refs.appContentRef */
},
showInstanceSpecificPanel() {
return (
this.instanceSpecificPanelPresent &&
!useMergedConfigStore().mergedConfig.hideISP
)
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, [
'themeApplied',
'styleDataUsed',
'layoutType',
]),
...mapState(useInstanceStore, ['styleDataUsed']),
...mapState(useInstanceCapabilitiesStore, [
'suggestionsEnabled',
'editingAvailable',
]),
...mapState(useInstanceStore, {
instanceBackgroundUrl: (store) => store.instanceIdentity.background,
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars },
scrollParent () { return window; /* this.$refs.appContentRef */ },
...mapGetters(['mergedConfig'])
},
methods: {
resizeHandler() {
resizeHandler () {
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
},
scrollHandler() {
const scrollPosition =
this.scrollParent === window
? window.scrollY
: this.scrollParent.scrollTop
scrollHandler () {
const scrollPosition = this.scrollParent === window ? window.scrollY : this.scrollParent.scrollTop
if (scrollPosition != 0) {
this.$refs.appContentRef.classList.add(['-scrolled'])
@ -269,10 +189,10 @@ export default {
this.$refs.appContentRef.classList.remove(['-scrolled'])
}
},
setThemeBodyClass() {
setThemeBodyClass () {
const themeName = this.currentTheme
const classList = Array.from(document.body.classList)
const oldTheme = classList.filter((c) => c.startsWith('theme-'))
const oldTheme = classList.filter(c => c.startsWith('theme-'))
if (themeName !== null && themeName !== '') {
const newTheme = `theme-${themeName.toLowerCase()}`
@ -288,19 +208,14 @@ export default {
document.body.classList.remove(...oldTheme)
}
},
removeSplash() {
document.querySelector('#status').textContent = this.$t(
'splash.fun_' + Math.ceil(Math.random() * 4),
)
removeSplash () {
document.querySelector('#status').textContent = this.$t('splash.fun_' + Math.ceil(Math.random() * 4))
const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove()
})
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden')
},
},
}
}
}

View file

@ -3,7 +3,7 @@
@use "panel";
@import '@fortawesome/fontawesome-svg-core/styles.css';
@import '@kazvmoe-infra/pinch-zoom-element/dist/pinch-zoom.css';
@import '@floatingghost/pinch-zoom-element/dist/pinch-zoom.css';
:root {
--status-margin: 0.75em;
@ -21,7 +21,7 @@
}
html {
font-size: var(--textSize, 1rem);
font-size: var(--textSize, 14px);
--navbar-height: var(--navbarSize, 3.5rem);
--emoji-size: var(--emojiSize, 32px);
@ -50,7 +50,7 @@ body {
// have a cursor/pointer to operate them
@media (any-pointer: fine) {
* {
scrollbar-color: var(--icon) transparent;
scrollbar-color: var(--fg) transparent;
&::-webkit-scrollbar {
background: transparent;
@ -130,7 +130,7 @@ body {
}
// Body should have background to scrollbar otherwise it will use white (body color?)
html {
scrollbar-color: var(--icon) var(--wallpaper);
scrollbar-color: var(--fg) var(--wallpaper);
background: var(--wallpaper);
}
}
@ -144,25 +144,6 @@ h4 {
margin: 0;
}
code {
background: var(--bg);
border: 1px solid var(--fg);
border-radius: var(--roundness);
padding: 0 0.2em;
& pre,
pre & {
display: block;
overflow-x: auto;
padding: 0.2em;
}
&.pre {
white-space: pre;
display: block;
}
}
.iconLetter {
display: inline-block;
text-align: center;
@ -219,7 +200,6 @@ nav {
background-color: var(--wallpaper);
background-image: var(--body-background-image);
background-position: 50%;
transition: background-image 1s;
}
.underlay {
@ -402,10 +382,6 @@ nav {
font-family: sans-serif;
font-family: var(--font);
&.-transparent {
backdrop-filter: blur(0.125em) contrast(60%);
}
&::-moz-focus-inner {
border: none;
}
@ -430,14 +406,6 @@ nav {
button:not(.button-default) {
color: var(--text);
font-size: 100%;
text-align: initial;
padding: 0;
background: none;
border: none;
outline: none;
display: inline;
font-family: inherit;
line-height: unset;
}
&.disabled {
@ -445,6 +413,45 @@ nav {
}
}
.list-item {
border-color: var(--border);
border-style: solid;
border-width: 0;
border-top-width: 1px;
&.-active,
&:hover {
border-top-width: 1px;
border-bottom-width: 1px;
}
&.-active + &,
&:hover + & {
border-top-width: 0;
}
&:hover + .menu-item-collapsible:not(.-expanded) + &,
&.-active + .menu-item-collapsible:not(.-expanded) + & {
border-top-width: 0;
}
&[aria-expanded="true"] {
border-bottom-width: 1px;
}
&:first-child {
border-top-right-radius: var(--roundness);
border-top-left-radius: var(--roundness);
border-top-width: 0;
}
&:last-child {
border-bottom-right-radius: var(--roundness);
border-bottom-left-radius: var(--roundness);
border-bottom-width: 0;
}
}
.menu-item,
.list-item {
display: block;
@ -463,6 +470,22 @@ nav {
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
&.-non-interactive {
cursor: auto;
}
a,
button:not(.button-default) {
text-align: initial;
padding: 0;
background: none;
border: none;
outline: none;
display: inline;
font-family: inherit;
line-height: unset;
}
}
.button-unstyled {
@ -486,12 +509,6 @@ nav {
}
}
label {
&.-disabled {
color: var(--textFaint);
}
}
input,
textarea {
border: none;
@ -508,10 +525,6 @@ textarea {
height: unset;
}
&::placeholder {
color: var(--textFaint)
}
--_padding: 0.5em;
border: none;
@ -532,10 +545,6 @@ textarea {
&[disabled="disabled"],
&.disabled {
cursor: not-allowed;
color: var(--textFaint);
/* stylelint-disable-next-line declaration-no-important */
background-color: transparent !important;
}
&[type="range"] {
@ -561,8 +570,6 @@ textarea {
& + label::before {
opacity: 0.5;
}
background-color: var(--background);
}
+ label::before {
@ -662,8 +669,7 @@ option {
list-style: none;
display: grid;
grid-auto-flow: row dense;
grid-template-columns: repeat(auto-fit, minmax(20em, 1fr));
grid-gap: 0.5em;
grid-template-columns: 1fr 1fr;
li {
border: 1px solid var(--border);
@ -673,6 +679,11 @@ option {
}
}
.btn-block {
display: block;
width: 100%;
}
.btn-group {
position: relative;
display: inline-flex;
@ -684,6 +695,7 @@ option {
--_roundness-right: 0;
position: relative;
flex: 1 1 auto;
}
> *:first-child,
@ -730,15 +742,17 @@ option {
}
&.-dot {
min-height: 0.6em;
max-height: 0.6em;
min-width: 0.6em;
max-width: 0.6em;
left: calc(50% + 0.5em);
top: calc(50% - 1em);
line-height: 0;
min-height: 8px;
max-height: 8px;
min-width: 8px;
max-width: 8px;
padding: 0;
margin: 0;
line-height: 0;
font-size: 0;
left: calc(50% - 4px);
top: calc(50% - 4px);
margin-left: 6px;
margin-top: -6px;
}
&.-counter {
@ -760,19 +774,6 @@ option {
padding: 0 0.25em;
border-radius: var(--roundness);
border: 1px solid var(--border);
&.-dismissible {
display: flex;
padding-left: 0.5em;
margin: 0;
align-items: baseline;
line-height: 2;
span {
display: block;
flex: 1 0 auto;
}
}
}
.faint {
@ -782,20 +783,21 @@ option {
color: var(--text);
}
.notice-dismissible {
display: flex;
padding: 0.75em 1em;
align-items: baseline;
line-height: 1.5;
.visibility-notice {
padding: 0.5em;
border: 1px solid var(--textFaint);
border-radius: var(--roundness);
}
p,
span {
display: block;
flex: 1 1 auto;
margin: 0;
}
.notice-dismissible {
padding-right: 4rem;
position: relative;
.dismiss {
position: absolute;
top: 0;
right: 0;
padding: 0.5em;
color: inherit;
}
}
@ -934,7 +936,12 @@ option {
#splash {
pointer-events: none;
// transition: opacity 0.5s;
transition: opacity 0.5s;
opacity: 1;
&.hidden {
opacity: 0;
}
#status {
&.css-ok {
@ -1073,7 +1080,7 @@ option {
scale: 1.0063 0.9938;
translate: 0 -10%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in-out;
animation-timing-function: ease-in-ou;
}
90% {

View file

@ -28,10 +28,10 @@
>
<user-panel />
<template v-if="layoutType !== 'mobile'">
<NavPanel />
<InstanceSpecificPanel v-if="showInstanceSpecificPanel" />
<FeaturesPanel v-if="!currentUser && showFeaturesPanel" />
<WhoToFollowPanel v-if="currentUser && suggestionsEnabled" />
<nav-panel />
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<features-panel v-if="!currentUser && showFeaturesPanel" />
<who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
<div id="notifs-sidebar" />
</template>
</div>
@ -60,8 +60,8 @@
/>
</div>
<MediaModal />
<ShoutPanel
v-if="currentUser && !hideShoutbox && shoutJoined"
<shout-panel
v-if="currentUser && shout && !hideShoutbox"
:floating="true"
class="floating-shout mobile-hidden"
:class="{ '-left': shoutboxPosition }"
@ -73,7 +73,6 @@
<StatusHistoryModal v-if="editingAvailable" />
<SettingsModal :class="layoutModalClass" />
<UpdateNotification />
<GlobalError />
<GlobalNoticeList />
</div>
</template>

View file

@ -1,491 +0,0 @@
import { promisedRequest } from './helpers.js'
const REPORTS = '/api/v1/pleroma/admin/reports'
const CONFIG_URL = '/api/v1/pleroma/admin/config'
const DESCRIPTIONS_URL = '/api/v1/pleroma/admin/config/descriptions'
const ANNOUNCEMENTS_URL = (id = '') =>
`/api/v1/pleroma/admin/announcements/${id}`
const FRONTENDS_URL = '/api/v1/pleroma/admin/frontends'
const FRONTENDS_INSTALL_URL = '/api/v1/pleroma/admin/frontends/install'
const USERS_URL = (nickname = '') => `/api/v1/pleroma/admin/users/${nickname}`
const USERS_URL_LIST = ({
page,
pageSize,
filters = {},
query = '',
name = '',
email = '',
}) => {
const {
local = false,
external = false,
active = false,
needApproval = false,
unconfirmed = false,
deactivated = false,
isAdmin = true,
isModerator = true,
} = filters
const filters_str = [
local && 'local',
external && 'external',
active && 'active',
needApproval && 'need_approval',
unconfirmed && 'unconfirmed',
deactivated && 'deactivated',
isAdmin && 'is_admin',
isModerator && 'is_moderator',
]
.filter((x) => x)
.join(',')
return `/api/v1/pleroma/admin/users?page=${page}&page_size=${pageSize}&filters=${filters_str}&query=${query}&name=${name}&email=${email}`
}
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (right) =>
`/api/pleroma/admin/users/permission_group/${right}`
const ACTIVATE_USERS_URL = '/api/pleroma/admin/users/activate'
const DEACTIVATE_USERS_URL = '/api/pleroma/admin/users/deactivate'
const SUGGEST_USERS_URL = '/api/pleroma/admin/users/suggest'
const UNSUGGEST_USERS_URL = '/api/pleroma/admin/users/unsuggest'
const APPROVE_USERS_URL = '/api/v1/pleroma/admin/users/approve'
const CONFIRM_USERS_URL = '/api/v1/pleroma/admin/users/confirm_email'
const RESEND_CONFIRMATION_EMAIL_URL =
'/api/v1/pleroma/admin/users/resend_confirmation_email'
const LIST_STATUSES_URL = ({ id, page, pageSize, godmode, withReblogs }) =>
`/api/v1/pleroma/admin/users/${id}/statuses?page_size=${pageSize}&page=${page}&godmode=${godmode}&with_reblogs=${withReblogs}`
const CHANGE_STATUS_SCOPE_URL = (id) => `/api/v1/pleroma/admin/statuses/${id}`
const REQUIRE_PASSWORD_CHANGE_URL =
'/api/v1/pleroma/admin/users/force_password_reset'
const DISABLE_MFA_URL = '/api/v1/pleroma/admin/users/disable_mfa'
const EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
const EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
const EMOJI_PACK_URL = (name) => `/api/v1/pleroma/emoji/pack?name=${name}`
const EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download'
const EMOJI_PACKS_DL_REMOTE_ZIP_URL = '/api/v1/pleroma/emoji/packs/download_zip'
const EMOJI_PACKS_LS_REMOTE_URL = (url, page, pageSize) =>
`/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}`
const EMOJI_UPDATE_FILE_URL = (name) =>
`/api/v1/pleroma/emoji/packs/files?name=${name}`
//
export const setUsersTags = ({
tags,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: TAG_USER_URL,
method: value ? 'PUT' : 'DELETE',
credentials,
payload: {
nicknames,
tags,
},
})
export const setUsersRight = ({
right,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: PERMISSION_GROUP_URL(right),
method: value ? 'POST' : 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const setUsersActivationStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? ACTIVATE_USERS_URL : DEACTIVATE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersApprovalStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: APPROVE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersConfirmationStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: CONFIRM_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersSuggestionStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? SUGGEST_USERS_URL : UNSUGGEST_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const getUserData = ({ credentials, screen_name: nickname }) =>
promisedRequest({
url: USERS_URL(nickname),
method: 'GET',
credentials,
})
export const deleteAccounts = ({ credentials, screen_names: nicknames }) =>
promisedRequest({
url: USERS_URL(),
method: 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const getAnnouncements = ({ id, credentials }) =>
promisedRequest({ url: ANNOUNCEMENTS_URL(id), credentials })
// the reported list is hardly useful because standards are for dating i guess,
// so make sure to fetchIfMissing right afterward using this call
export const listUsers = ({ opts, credentials }) =>
promisedRequest({
url: USERS_URL_LIST(opts),
credentials,
method: 'GET',
})
export const resendConfirmationEmail = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: RESEND_CONFIRMATION_EMAIL_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const requirePasswordChange = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: REQUIRE_PASSWORD_CHANGE_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const disableMFA = ({ screen_name: nickname, credentials }) =>
promisedRequest({
url: DISABLE_MFA_URL,
credentials,
method: 'PUT',
payload: {
nickname,
},
})
export const listStatuses = ({ opts, credentials }) =>
promisedRequest({
url: LIST_STATUSES_URL(opts),
credentials,
method: 'GET',
})
export const changeStatusScope = ({
opts: { id, sensitive, visibility },
credentials,
}) => {
var payload = {}
if (typeof sensitive !== 'undefined') {
payload['sensitive'] = sensitive
}
if (typeof visibility !== 'undefined') {
payload['visibility'] = visibility
}
return promisedRequest({
url: CHANGE_STATUS_SCOPE_URL(id),
credentials,
method: 'PUT',
payload,
})
}
export const announcementToPayload = ({
content,
startsAt,
endsAt,
allDay,
}) => {
const payload = { content }
if (typeof startsAt !== 'undefined') {
payload.starts_at = startsAt ? new Date(startsAt).toISOString() : null
}
if (typeof endsAt !== 'undefined') {
payload.ends_at = endsAt ? new Date(endsAt).toISOString() : null
}
if (typeof allDay !== 'undefined') {
payload.all_day = allDay
}
return payload
}
export const postAnnouncement = ({
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(),
credentials,
method: 'POST',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const editAnnouncement = ({
id,
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'PATCH',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const deleteAnnouncement = ({ id, credentials }) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'DELETE',
})
export const setReportState = ({ id, state, credentials }) => {
return promisedRequest({
url: REPORTS,
credentials,
method: 'PATCH',
payload: {
reports: [
{
id,
state,
},
],
},
})
}
export const getInstanceDBConfig = ({ credentials }) =>
promisedRequest({
url: CONFIG_URL,
credentials,
})
export const getInstanceConfigDescriptions = ({ credentials }) =>
promisedRequest({
url: DESCRIPTIONS_URL,
credentials,
})
export const getAvailableFrontends = ({ credentials }) =>
promisedRequest({
url: FRONTENDS_URL,
credentials,
})
export const pushInstanceDBConfig = ({ credentials, payload }) =>
promisedRequest({
url: CONFIG_URL,
method: 'POST',
credentials,
payload,
})
export const installFrontend = ({ credentials, payload }) =>
promisedRequest({
url: FRONTENDS_INSTALL_URL,
credentials,
method: 'POST',
payload,
})
// Emoji packs
export const deleteEmojiPack = ({ name }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'DELETE',
})
export const reloadEmoji = ({ credentials }) =>
promisedRequest({
url: EMOJI_RELOAD_URL,
method: 'POST',
credentials,
})
export const importEmojiFromFS = ({ credentials }) =>
promisedRequest({
url: EMOJI_IMPORT_FS_URL,
credentials,
})
export const createEmojiPack = ({ name, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'POST',
credentials,
})
export const listRemoteEmojiPacks = ({
instance,
page,
pageSize,
credentials,
}) => {
if (!instance.startsWith('http')) {
instance = 'https://' + instance
}
return promisedRequest({
url: EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize),
credentials,
})
}
export const downloadRemoteEmojiPack = ({
instance,
packName,
as,
credentials,
}) =>
promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_URL,
credentials,
method: 'POST',
payload: {
url: instance,
name: packName,
as,
},
})
export const downloadRemoteEmojiPackZIP = ({
url,
packName,
file,
credentials,
}) => {
const data = new FormData()
if (file) data.set('file', file)
if (url) data.set('url', url)
data.set('name', packName)
return promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_ZIP_URL,
method: 'POST',
formData: data,
})
}
export const saveEmojiPackMetadata = ({ name, newData, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
credentials,
method: 'PATCH',
payload: { metadata: newData },
})
export const addNewEmojiFile = ({ packName, file, shortcode, filename }) => {
const data = new FormData()
if (filename.trim() !== '') {
data.set('filename', filename)
}
if (shortcode.trim() !== '') {
data.set('shortcode', shortcode)
}
data.set('file', file)
return promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
method: 'POST',
formData: data,
})
}
export const updateEmojiFile = ({
packName,
shortcode,
newShortcode,
newFilename,
credentials,
force,
}) =>
promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
credentials,
method: 'PATCH',
payload: {
shortcode,
new_shortcode: newShortcode,
new_filename: newFilename,
force,
},
})
export const deleteEmojiFile = ({ packName, shortcode }) =>
promisedRequest({
url: `${EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`,
method: 'DELETE',
})

View file

@ -1,87 +0,0 @@
import { paramsString, promisedRequest } from './helpers.js'
import { parseChat } from 'src/services/entity_normalizer/entity_normalizer.service.js'
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = (id, { maxId, sinceId, limit }) =>
`/api/v1/pleroma/chats/${id}/messages${paramsString({ maxId, sinceId, limit })}`
const PLEROMA_CHAT_READ_URL = (id) => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) =>
`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
export const chats = ({ credentials }) =>
promisedRequest({
url: PLEROMA_CHATS_URL,
credentials,
}).then(({ data }) => ({
chatList: data.map(parseChat).filter((c) => c),
}))
export const getOrCreateChat = ({ accountId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
})
export const chatMessages = ({
id,
credentials,
maxId,
sinceId,
limit = 20,
}) => {
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET',
credentials,
})
}
export const sendChatMessage = ({
id,
content,
mediaId = null,
idempotencyKey,
credentials,
}) => {
const payload = {
content,
}
if (mediaId) {
payload.media_id = mediaId
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
payload,
credentials,
headers,
})
}
export const readChat = ({ id, lastReadId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_READ_URL(id),
method: 'POST',
payload: {
last_read_id: lastReadId,
},
credentials,
})
export const deleteChatMessage = ({ chatId, messageId, credentials }) =>
promisedRequest({
url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
method: 'DELETE',
credentials,
})

View file

@ -1,140 +0,0 @@
import { snakeCase } from 'lodash'
import { StatusCodeError } from 'src/services/errors/errors'
export const paramsString = (params = {}) => {
if (params == null || params === undefined) return ''
if (typeof params !== 'object' || Array.isArray(params)) {
throw new Error('Params are not an object!')
}
const entries = (() => {
if (params instanceof Map) {
return params.entries()
} else {
return Object.entries(params)
}
})()
if (entries.length === 0) return ''
const arrays = []
const nonArrays = []
entries.forEach(([k, v]) => {
if (v == null) return // Drop nulls
if (
(typeof v === 'object' && !Array.isArray(v)) ||
typeof v === 'function'
) {
throw new Error('Param cannot be non-primitive!')
}
if (Array.isArray(v)) {
arrays.push([k, v])
} else {
nonArrays.push([k, v])
}
})
arrays.forEach(([k, array]) => {
array.forEach((v) => {
if (
typeof v === 'object' ||
typeof v === 'function' ||
typeof v === 'undefined'
)
throw new Error('Array param cannot contain non-primitives!')
})
})
return (
'?' +
[
...nonArrays.map(([k, v]) => [snakeCase(k), v]),
// turning [a,[1,2,3]] into [[a[],1],[a[],2],[a[],3]]
...arrays.reduce(
(acc, [k, arrayValue]) => [
...acc,
...arrayValue.map((v) => [snakeCase(k) + '[]', v]),
],
[],
),
]
.map(([k, v]) => `${k}=${window.encodeURIComponent(v)}`)
.join('&')
)
}
export const promisedRequest = async ({
method,
url,
payload,
formData,
cache,
credentials,
headers = {},
}) => {
const options = {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...headers,
},
}
if (!formData) {
options.headers['Content-Type'] = 'application/json'
}
if (cache) {
options.cache = cache
}
if (formData || payload) {
options.body = formData || JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials),
}
}
const response = await fetch(url, options)
const data = await (async () => {
const [contentType] = response.headers
.get('content-type')
.split(';')
.map((x) => x.toLowerCase().trim())
const contentLength = parseInt(response.headers.get('content-length'))
if (contentLength === 0) return null
switch (contentType) {
case 'text/plain':
return await response.text()
case 'application/json':
return await response.json()
default:
return await response.bytes()
}
})()
const { ok, status } = response
if (ok) {
return { response, status, data }
} else {
throw new StatusCodeError(response.status, data, { url, options }, response)
}
}
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return {}
}
}

View file

@ -1,45 +0,0 @@
import { promisedRequest } from './helpers.js'
export const verifyOTPCode = ({
clientId,
clientSecret,
instance,
mfaToken,
code,
}) => {
const formData = new window.FormData()
formData.append('client_id', clientId)
formData.append('client_secret', clientSecret)
formData.append('mfa_token', mfaToken)
formData.append('code', code)
formData.append('challenge_type', 'totp')
return promisedRequest({
url: '/oauth/mfa/challenge',
method: 'POST',
formData,
})
}
export const verifyRecoveryCode = ({
clientId,
clientSecret,
instance,
mfaToken,
code,
}) => {
const formData = new window.FormData()
formData.append('client_id', clientId)
formData.append('client_secret', clientSecret)
formData.append('mfa_token', mfaToken)
formData.append('code', code)
formData.append('challenge_type', 'recovery')
return promisedRequest({
url: `${instance}/oauth/mfa/challenge`,
method: 'POST',
formData,
})
}

View file

@ -1,146 +0,0 @@
import { paramsString, promisedRequest } from './helpers.js'
const REDIRECT_URI = `${window.location.origin}/oauth-callback`
export const MASTODON_APP_VERIFY_URL = '/api/v1/apps/verify_credentials'
export const MASTODON_APP_URL = '/api/v1/apps'
export const OAUTH_TOKEN_URL = '/oauth/token'
export const OAUTH_MFA_CHALLENGE_URL = '/oauth/mfa/challenge'
export const OAUTH_REVOKE_URL = '/oauth/revoke'
export const createApp = () => {
const formData = new window.FormData()
formData.append('client_name', 'PleromaFE')
formData.append('website', 'https://pleroma.social')
formData.append('redirect_uris', REDIRECT_URI)
formData.append('scopes', 'read write follow push admin')
return promisedRequest({
method: 'POST',
url: MASTODON_APP_URL,
formData,
}).then(({ data, ...rest }) => ({
...rest,
data: {
...data,
clientId: data.client_id,
clientSecret: data.client_secret,
},
}))
}
export const verifyAppToken = ({ credentials }) =>
promisedRequest({
url: MASTODON_APP_VERIFY_URL,
credentials,
})
export const getLoginUrl = ({ instance, clientId }) => {
const data = {
responseType: 'code',
clientId,
redirectUri: REDIRECT_URI,
scope: 'read write follow push admin',
}
return `${instance}/oauth/authorize${paramsString(data)}`
}
export const getTokenWithCredentials = ({
clientId,
clientSecret,
username,
password,
}) => {
const formData = new window.FormData()
formData.append('client_id', clientId)
formData.append('client_secret', clientSecret)
formData.append('grant_type', 'password')
formData.append('username', username)
formData.append('password', password)
return promisedRequest({
url: OAUTH_TOKEN_URL,
method: 'POST',
formData,
})
}
export const getToken = ({ clientId, clientSecret, code }) => {
const formData = new window.FormData()
formData.append('client_id', clientId)
formData.append('client_secret', clientSecret)
formData.append('grant_type', 'authorization_code')
formData.append('code', code)
formData.append('redirect_uri', `${window.location.origin}/oauth-callback`)
return promisedRequest({
url: OAUTH_TOKEN_URL,
method: 'POST',
formData,
})
}
export const getClientToken = ({ clientId, clientSecret }) => {
const formData = new window.FormData()
formData.append('client_id', clientId)
formData.append('client_secret', clientSecret)
formData.append('grant_type', 'client_credentials')
formData.append('redirect_uri', `${window.location.origin}/oauth-callback`)
return promisedRequest({
url: OAUTH_TOKEN_URL,
method: 'POST',
formData,
})
}
export const verifyOTPCode = ({ app, mfaToken, code }) => {
const formData = new window.FormData()
formData.append('client_id', app.client_id)
formData.append('client_secret', app.client_secret)
formData.append('mfa_token', mfaToken)
formData.append('code', code)
formData.append('challenge_type', 'totp')
return promisedRequest({
url: OAUTH_MFA_CHALLENGE_URL,
method: 'POST',
formData,
})
}
export const verifyRecoveryCode = ({ app, mfaToken, code }) => {
const formData = new window.FormData()
formData.append('client_id', app.client_id)
formData.append('client_secret', app.client_secret)
formData.append('mfa_token', mfaToken)
formData.append('code', code)
formData.append('challenge_type', 'recovery')
return promisedRequest({
url: OAUTH_MFA_CHALLENGE_URL,
method: 'POST',
formData,
})
}
export const revokeToken = ({ app, token }) => {
const formData = new window.FormData()
formData.append('client_id', app.clientId)
formData.append('client_secret', app.clientSecret)
formData.append('token', token)
return promisedRequest({
url: OAUTH_REVOKE_URL,
method: 'POST',
formData,
})
}

View file

@ -1,279 +0,0 @@
import { paramsString, promisedRequest } from './helpers.js'
import { MASTODON_USER_TIMELINE_URL } from './timelines.js'
import {
parseSource,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_SUGGESTIONS_URL = '/api/v1/suggestions'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const MASTODON_PASSWORD_RESET_URL = ({ email }) =>
`/auth/password${paramsString({ email })}`
const MASTODON_FOLLOWING_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/following${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
const MASTODON_FOLLOWERS_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
export const MASTODON_STATUS_URL = (id) => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = (id) => `/api/v1/statuses/${id}/context`
const MASTODON_STATUS_SOURCE_URL = (id) => `/api/v1/statuses/${id}/source`
const MASTODON_STATUS_HISTORY_URL = (id) => `/api/v1/statuses/${id}/history`
const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_LOOKUP_URL = ({ acct }) =>
`/api/v1/accounts/lookup${paramsString({ acct })}`
const MASTODON_POLL_URL = (id = '') => `/api/v1/polls/${id}`
const MASTODON_STATUS_FAVORITEDBY_URL = (id) =>
`/api/v1/statuses/${id}/favourited_by`
const MASTODON_STATUS_REBLOGGEDBY_URL = (id) =>
`/api/v1/statuses/${id}/reblogged_by`
const MASTODON_SEARCH_2 = ({
q,
resolve,
limit,
offset,
following,
type,
withRelationships,
accountId,
excludeUnreviewed,
}) =>
`/api/v2/search${paramsString({ q, resolve, limit, offset, following, type, withRelationships, accountId, excludeUnreviewed })}`
const MASTODON_USER_SEARCH_URL = ({ q, resolve }) =>
`/api/v1/accounts/search${paramsString({ q, resolve })}`
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = (id) =>
`/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_SCROBBLES_URL = (id, { maxId, sinceId, minId, limit, offset }) =>
`/api/v1/pleroma/accounts/${id}/scrobbles${paramsString({ maxId, sinceId, minId, limit, offset })}`
const EMOJI_PACKS_URL = (page, pageSize) =>
`/api/v1/pleroma/emoji/packs${paramsString({ page, pageSize })}`
// Params needed:
// nickname
// email
// fullname
// password
// password_confirm
//
// Optional
// bio
// homepage
// location
// token
// language
export const register = ({ params, credentials }) => {
const { nickname, ...rest } = params
return promisedRequest({
url: MASTODON_REGISTRATION_URL,
method: 'POST',
credentials,
payload: {
nickname,
locale: 'en_US',
agreement: true,
...rest,
},
})
}
export const getCaptcha = () =>
promisedRequest({
url: '/api/pleroma/captcha',
})
export const fetchUser = ({ id, credentials }) =>
promisedRequest({
url: `${MASTODON_USER_URL}/${id}`,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const fetchUserByName = ({ name, credentials }) =>
promisedRequest({
url: MASTODON_USER_LOOKUP_URL({ acct: name }),
credentials,
})
.then(({ data }) => data.id)
.catch((error) => {
if (error && error.statusCode === 404) {
// Either the backend does not support lookup endpoint,
// or there is no user with such name. Fallback and treat name as id.
return name
} else {
throw error
}
})
.then((id) => fetchUser({ id, credentials }))
export const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) =>
promisedRequest({
url: MASTODON_FOLLOWING_URL(id, { maxId, sinceId, limit }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchFollowers = ({
id,
maxId,
sinceId,
limit = 20,
credentials,
}) =>
promisedRequest({
url: MASTODON_FOLLOWERS_URL(id, {
maxId,
sinceId,
limit,
withRelationships: true,
}),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchConversation = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_CONTEXT_URL(id),
credentials,
}).then((result) => ({
...result,
data: {
...result.data,
ancestors: result.data.ancestors.map(parseStatus),
descendants: result.data.descendants.map(parseStatus),
},
}))
export const fetchStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_URL(id),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const fetchStatusSource = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_SOURCE_URL(id),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseSource(data) }))
export const fetchStatusHistory = ({ status, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_HISTORY_URL(status.id),
credentials,
}).then(({ data, ...rest }) => {
return [...data].reverse().map((item) => {
item.originalStatus = status
return { ...rest, data: parseStatus(item) }
})
})
export const listEmojiPacks = ({ page, pageSize, credentials }) =>
promisedRequest({
url: EMOJI_PACKS_URL(page, pageSize),
})
export const fetchPinnedStatuses = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_USER_TIMELINE_URL(id, { pinned: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseStatus) }))
export const verifyCredentials = ({ credentials }) =>
promisedRequest({
url: MASTODON_LOGIN_URL,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const resetPassword = ({ email }) => {
return promisedRequest({
url: MASTODON_PASSWORD_RESET_URL({ email }),
method: 'POST',
})
}
export const suggestions = ({ credentials }) =>
promisedRequest({
url: MASTODON_SUGGESTIONS_URL,
credentials,
})
export const fetchPoll = ({ pollId, credentials }) =>
promisedRequest({
url: MASTODON_POLL_URL(encodeURIComponent(pollId)),
method: 'GET',
credentials,
})
export const fetchFavoritedByUsers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_FAVORITEDBY_URL(id),
method: 'GET',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchRebloggedByUsers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_REBLOGGEDBY_URL(id),
method: 'GET',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchEmojiReactions = ({ id, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_REACTIONS_URL(id),
credentials,
}).then(({ data, ...rest }) => ({
...rest,
data: data.map((r) => {
r.accounts = r.accounts.map(parseUser)
return r
}),
}))
export const searchUsers = ({ credentials, query }) =>
promisedRequest({
url: MASTODON_USER_SEARCH_URL({ q: query, resolve: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const search2 = ({
credentials,
q,
resolve,
limit,
offset,
following,
type,
}) => {
return promisedRequest({
url: MASTODON_SEARCH_2({
q,
resolve,
limit,
offset,
following,
type,
withRelationships: true,
}),
credentials,
}).then(({ data, ...rest }) => {
data.accounts = data.accounts.slice(0, limit).map((u) => parseUser(u))
data.statuses = data.statuses.slice(0, limit).map((s) => parseStatus(s))
return { ...rest, data }
})
}
export const fetchKnownDomains = ({ credentials }) =>
promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials })
export const fetchScrobbles = ({ accountId, limit = 1 }) =>
promisedRequest({
url: PLEROMA_SCROBBLES_URL(accountId, { limit }),
})

View file

@ -1,198 +0,0 @@
import { paramsString, promisedRequest } from './helpers.js'
import {
parseLinkHeaderPagination,
parseNotification,
parseStatus,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_USER_HOME_TIMELINE_URL = ({
minId,
sinceId,
maxId,
limit,
replyVisibility,
}) =>
`/api/v1/timelines/home${paramsString({ minId, sinceId, maxId, limit, replyVisibility })}`
const MASTODON_LIST_TIMELINE_URL = (
id,
{ minId, sinceId, maxId, limit, replyVisibility },
) =>
`/api/v1/timelines/list/${id}${paramsString({ minId, sinceId, maxId, limit, replyVisibility })}`
const MASTODON_DIRECT_MESSAGES_TIMELINE_URL = ({
minId,
sinceId,
maxId,
limit,
replyVisibility,
}) =>
`/api/v1/timelines/direct${paramsString({ minId, sinceId, maxId, limit, replyVisibility })}`
const MASTODON_PUBLIC_TIMELINE = ({
minId,
sinceId,
maxId,
limit,
replyVisibility,
local,
remote,
onlyMedia,
}) =>
`/api/v1/timelines/public${paramsString({ minId, sinceId, maxId, limit, replyVisibility, local, remote, onlyMedia })}`
const MASTODON_TAG_TIMELINE_URL = (
tag,
{ minId, sinceId, maxId, limit, replyVisibility },
) =>
`/api/v1/timelines/tag/${tag}${paramsString({ minId, sinceId, maxId, limit, replyVisibility })}`
export const MASTODON_USER_TIMELINE_URL = (
id,
{ minId, sinceId, maxId, limit, replyVisibility, pinned, onlyMedia },
) =>
`/api/v1/accounts/${id}/statuses${paramsString({ minId, sinceId, maxId, limit, replyVisibility, pinned, onlyMedia })}`
const MASTODON_USER_FAVORITES_TIMELINE_URL = ({
minId,
sinceId,
maxId,
limit,
replyVisibility,
}) =>
`/api/v1/favourites${paramsString({ minId, sinceId, maxId, limit, replyVisibility })}`
const MASTODON_BOOKMARK_TIMELINE_URL = ({
minId,
sinceId,
maxId,
limit,
replyVisibility,
folderId,
}) =>
`/api/v1/bookmarks${paramsString({ minId, sinceId, maxId, limit, replyVisibility, folderId })}`
const PLEROMA_STATUS_QUOTES_URL = (
id,
{ minId, sinceId, maxId, limit, replyVisibility },
) =>
`/api/v1/pleroma/statuses/${id}/quotes${paramsString({ minId, sinceId, maxId, limit, replyVisibility })}`
const PLEROMA_USER_FAVORITES_TIMELINE_URL = (
id,
{ minId, sinceId, maxId, limit, replyVisibility },
) =>
`/api/v1/pleroma/accounts/${id}/favourites${paramsString({ minId, sinceId, maxId, limit, replyVisibility })}`
const AKKOMA_BUBBLE_TIMELINE_URL = ({
minId,
sinceId,
maxId,
limit,
replyVisibility,
}) =>
`/api/v1/timelines/bubble${paramsString({ minId, sinceId, maxId, limit, replyVisibility })}`
const MASTODON_USER_NOTIFICATIONS_URL = ({
minId,
sinceId,
maxId,
limit,
includeTypes,
replyVisibility,
}) =>
`/api/v1/notifications${paramsString({ minId, sinceId, maxId, limit, includeTypes, replyVisibility })}`
export const fetchTimeline = ({
timeline,
credentials,
sinceId,
minId,
maxId,
userId,
listId,
statusId,
tag,
withMuted,
replyVisibility = 'all',
includeTypes = [],
bookmarkFolderId,
}) => {
const timelineUrls = {
friends: MASTODON_USER_HOME_TIMELINE_URL,
public: MASTODON_PUBLIC_TIMELINE,
publicAndExternal: MASTODON_PUBLIC_TIMELINE,
dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL,
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
list: MASTODON_LIST_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
publicFavorites: PLEROMA_USER_FAVORITES_TIMELINE_URL,
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL,
bubble: AKKOMA_BUBBLE_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL,
quotes: PLEROMA_STATUS_QUOTES_URL,
notifications: MASTODON_USER_NOTIFICATIONS_URL,
}
const urlFunc = timelineUrls[timeline]
const twoArgs = new Set([
'user',
'media',
'list',
'publicFavorites',
'tag',
'quotes',
])
const params = {
minId,
sinceId,
maxId,
limit: 20,
}
const id = (() => {
switch (timeline) {
case 'user':
case 'media':
return userId
case 'list':
return listId
case 'quotes':
return statusId
case 'tag':
return tag
}
})()
const isNotifications = timeline === 'notifications'
if (timeline === 'media') {
params.onlyMedia = true
}
if (timeline === 'public') {
params.local = true
}
if (timeline !== 'favorites' && timeline !== 'bookmarks') {
params.withMuted = withMuted
}
if (replyVisibility !== 'all') {
params.replyVisibility = replyVisibility
}
if (timeline === 'bookmarks' && bookmarkFolderId) {
params.folderId = bookmarkFolderId
}
if (isNotifications && includeTypes.length > 0) {
params.includeTypes = includeTypes
}
const url = twoArgs.has(timeline) ? urlFunc(id, params) : urlFunc(params)
return promisedRequest({ url, credentials }).then((result) => {
const pagination = parseLinkHeaderPagination(
result.response.headers.get('Link'),
{
flakeId: timeline !== 'bookmarks' && timeline !== 'notifications',
},
)
return {
...result,
data: result.data.map(isNotifications ? parseNotification : parseStatus),
pagination,
}
})
}

View file

@ -1,921 +0,0 @@
import { concat, last } from 'lodash'
import { paramsString, promisedRequest } from './helpers.js'
import { fetchFriends, MASTODON_STATUS_URL } from './public.js'
import {
parseAttachment,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MUTES_IMPORT_URL = '/api/pleroma/mutes_import'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
const ALIASES_URL = '/api/pleroma/aliases'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'
const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes'
const MFA_SETUP_OTP_URL = '/api/pleroma/accounts/mfa/setup/totp'
const MFA_CONFIRM_OTP_URL = '/api/pleroma/accounts/mfa/confirm/totp'
const MFA_DISABLE_OTP_URL = '/api/pleroma/accounts/mfa/totp'
const MASTODON_DISMISS_NOTIFICATION_URL = (id) =>
`/api/v1/notifications/${id}/dismiss`
const MASTODON_FAVORITE_URL = (id) => `/api/v1/statuses/${id}/favourite`
const MASTODON_UNFAVORITE_URL = (id) => `/api/v1/statuses/${id}/unfavourite`
const MASTODON_RETWEET_URL = (id) => `/api/v1/statuses/${id}/reblog`
const MASTODON_UNRETWEET_URL = (id) => `/api/v1/statuses/${id}/unreblog`
const MASTODON_DELETE_URL = (id) => `/api/v1/statuses/${id}`
const MASTODON_FOLLOW_URL = (id) => `/api/v1/accounts/${id}/follow`
const MASTODON_UNFOLLOW_URL = (id) => `/api/v1/accounts/${id}/unfollow`
const MASTODON_FOLLOW_REQUESTS_URL = '/api/v1/follow_requests'
const MASTODON_APPROVE_USER_URL = (id) =>
`/api/v1/follow_requests/${id}/authorize`
const MASTODON_DENY_USER_URL = (id) => `/api/v1/follow_requests/${id}/reject`
const MASTODON_USER_RELATIONSHIPS_URL = ({ id, withSuspended }) =>
`/api/v1/accounts/relationships/${paramsString({ id, withSuspended })}`
const MASTODON_USER_IN_LISTS = (id) => `/api/v1/accounts/${id}/lists`
export const MASTODON_LIST_URL = (id = '') => `/api/v1/lists/${id}`
export const MASTODON_LIST_ACCOUNTS_URL = (id) => `/api/v1/lists/${id}/accounts`
const MASTODON_USER_BLOCKS_URL = ({
maxId,
sinceId,
limit,
withRelationships,
}) =>
`/api/v1/blocks/${paramsString({ maxId, sinceId, limit, withRelationships })}`
const MASTODON_USER_MUTES_URL = ({
maxId,
sinceId,
limit,
withRelationships,
}) =>
`/api/v1/mutes/${paramsString({ maxId, sinceId, limit, withRelationships })}`
const MASTODON_BLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/block`
const MASTODON_UNBLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/unblock`
const MASTODON_MUTE_USER_URL = (id) => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = (id) => `/api/v1/accounts/${id}/unmute`
const MASTODON_REMOVE_USER_FROM_FOLLOWERS = (id) =>
`/api/v1/accounts/${id}/remove_from_followers`
const MASTODON_USER_NOTE_URL = (id) => `/api/v1/accounts/${id}/note`
const MASTODON_BOOKMARK_STATUS_URL = (id) => `/api/v1/statuses/${id}/bookmark`
const MASTODON_UNBOOKMARK_STATUS_URL = (id) =>
`/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = (id) => `/api/v1/polls/${id}/votes`
const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials'
const MASTODON_REPORT_USER_URL = '/api/v1/reports'
const MASTODON_PIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/pin`
const MASTODON_UNPIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/unpin`
const MASTODON_MUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/mute`
const MASTODON_UNMUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/unmute`
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_ANNOUNCEMENTS_URL = '/api/v1/announcements'
const MASTODON_ANNOUNCEMENTS_DISMISS_URL = (id) =>
`/api/v1/announcements/${id}/dismiss`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const PLEROMA_BOOKMARK_FOLDERS_URL = '/api/v1/pleroma/bookmark_folders'
const PLEROMA_BOOKMARK_FOLDER_URL = (id) =>
`/api/v1/pleroma/bookmark_folders/${id}`
// #Posts
export const favorite = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_FAVORITE_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unfavorite = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNFAVORITE_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const retweet = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_RETWEET_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unretweet = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNRETWEET_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const reactWithEmoji = ({ id, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_REACT_URL(id, emoji),
method: 'PUT',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unreactWithEmoji = ({ id, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_UNREACT_URL(id, emoji),
method: 'DELETE',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const bookmarkStatus = ({ id, credentials, ...options }) =>
promisedRequest({
url: MASTODON_BOOKMARK_STATUS_URL(id),
credentials,
method: 'POST',
payload: {
folder_id: options.folder_id,
},
})
export const unbookmarkStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNBOOKMARK_STATUS_URL(id),
credentials,
method: 'POST',
})
export const pinOwnStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_PIN_OWN_STATUS(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unpinOwnStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNPIN_OWN_STATUS(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const muteConversation = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_MUTE_CONVERSATION(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unmuteConversation = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNMUTE_CONVERSATION(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const vote = ({ pollId, choices, credentials }) => {
return promisedRequest({
url: MASTODON_VOTE_URL(encodeURIComponent(pollId)),
method: 'POST',
credentials,
payload: {
choices,
},
})
}
// #Posting
export const postStatus = ({
credentials,
status,
spoilerText,
visibility,
sensitive,
poll,
mediaIds = [],
inReplyToStatusId,
quoteId,
contentType,
preview,
idempotencyKey,
}) => {
const form = new FormData()
const pollOptions = poll.options || []
form.append('status', status)
form.append('source', 'Pleroma FE')
if (spoilerText) form.append('spoiler_text', spoilerText)
if (visibility) form.append('visibility', visibility)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach((val) => {
form.append('media_ids[]', val)
})
if (pollOptions.some((option) => option !== '')) {
const normalizedPoll = {
expires_in: parseInt(poll.expiresIn, 10),
multiple: poll.multiple,
}
Object.keys(normalizedPoll).forEach((key) => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach((option) => {
form.append('poll[options][]', option)
})
}
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
if (quoteId) {
form.append('quote_id', quoteId)
}
if (preview) {
form.append('preview', 'true')
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: MASTODON_POST_STATUS_URL,
formData: form,
method: 'POST',
credentials,
headers,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
}
export const editStatus = ({
id,
credentials,
status,
spoilerText,
sensitive,
poll,
mediaIds = [],
contentType,
}) => {
const form = new FormData()
const pollOptions = poll.options || []
form.append('status', status)
if (spoilerText) form.append('spoiler_text', spoilerText)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach((val) => {
form.append('media_ids[]', val)
})
if (pollOptions.some((option) => option !== '')) {
const normalizedPoll = {
expires_in: parseInt(poll.expiresIn, 10),
multiple: poll.multiple,
}
Object.keys(normalizedPoll).forEach((key) => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach((option) => {
form.append('poll[options][]', option)
})
}
return promisedRequest({
url: MASTODON_STATUS_URL(id),
formData: form,
method: 'PUT',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
}
export const deleteStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_DELETE_URL(id),
credentials,
method: 'DELETE',
})
export const uploadMedia = ({ formData, credentials }) =>
promisedRequest({
url: MASTODON_MEDIA_UPLOAD_URL,
formData,
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseAttachment(data) }))
export const setMediaDescription = ({ id, description, credentials }) =>
promisedRequest({
url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
method: 'PUT',
credentials,
payload: {
description,
},
}).then(({ data, ...rest }) => ({ ...rest, data: parseAttachment(data) }))
// #Notifications
export const dismissNotification = ({ credentials, id }) =>
promisedRequest({
url: MASTODON_DISMISS_NOTIFICATION_URL(id),
method: 'POST',
payload: { id },
credentials,
})
export const markNotificationsAsSeen = ({
id,
credentials,
single = false,
}) => {
const formData = new FormData()
if (single) {
formData.append('id', id)
} else {
formData.append('max_id', id)
}
return promisedRequest({
url: NOTIFICATION_READ_URL,
formData,
credentials,
method: 'POST',
})
}
// #Announcements
export const getAnnouncements = ({ credentials }) =>
promisedRequest({ url: MASTODON_ANNOUNCEMENTS_URL, credentials })
export const dismissAnnouncement = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_ANNOUNCEMENTS_DISMISS_URL(id),
credentials,
method: 'POST',
})
// #Imports
export const importMutes = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: MUTES_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: BLOCKS_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const importFollows = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: FOLLOW_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const exportFriends = ({ id, credentials }) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO refactor this
return new Promise(async (resolve, reject) => {
try {
let friends = []
let more = true
while (more) {
const maxId = friends.length > 0 ? last(friends).id : undefined
const users = await fetchFriends({
id,
maxId,
credentials,
withRelationships: true,
})
friends = concat(friends, users)
if (users.length === 0) {
more = false
}
}
resolve(friends)
} catch (err) {
reject(err)
}
})
}
// #Profile settings
export const updateNotificationSettings = ({ credentials, settings }) => {
return promisedRequest({
url: NOTIFICATION_SETTINGS_URL,
credentials,
method: 'PUT',
payload: settings,
})
}
export const updateProfileImages = ({
credentials,
avatar = null,
avatarName = null,
banner = null,
background = null,
}) => {
const form = new FormData()
if (avatar !== null) {
if (avatarName !== null) {
form.append('avatar', avatar, avatarName)
} else {
form.append('avatar', avatar)
}
}
if (banner !== null) form.append('header', banner)
if (background !== null) form.append('pleroma_background_image', background)
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
method: 'PATCH',
formData: form,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
}
export const updateProfile = ({ credentials, params }) => {
const formData = new FormData()
for (const name in params) {
if (name === 'fields_attributes') {
params[name].forEach((param, i) => {
formData.append(name + `[${i}][name]`, param.name)
formData.append(name + `[${i}][value]`, param.value)
})
} else {
if (typeof params[name] === 'object') {
console.warn(
'Object detected in updateProfile API call. This will not work, use updateProfileJSON instead.',
)
console.warn('Object:\n' + JSON.stringify(params[name], null, 2))
}
formData.append(name, params[name])
}
}
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
method: 'PATCH',
formData,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
}
export const updateProfileJSON = ({ credentials, params }) =>
promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
payload: params,
method: 'PATCH',
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const changeEmail = ({ credentials, email, password }) => {
const form = new FormData()
form.append('email', email)
form.append('password', password)
return promisedRequest({
url: CHANGE_EMAIL_URL,
formData: form,
method: 'POST',
credentials,
})
}
export const moveAccount = ({ credentials, password, targetAccount }) => {
const form = new FormData()
form.append('password', password)
form.append('target_account', targetAccount)
return promisedRequest({
url: MOVE_ACCOUNT_URL,
formData: form,
method: 'POST',
credentials,
})
}
export const changePassword = ({
credentials,
password,
newPassword,
newPasswordConfirmation,
}) => {
const form = new FormData()
form.append('password', password)
form.append('new_password', newPassword)
form.append('new_password_confirmation', newPasswordConfirmation)
return promisedRequest({
url: CHANGE_PASSWORD_URL,
formData: form,
method: 'POST',
credentials,
})
}
// #MFA
export const settingsMFA = ({ credentials }) =>
promisedRequest({
url: MFA_SETTINGS_URL,
credentials,
method: 'GET',
})
export const mfaDisableOTP = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return promisedRequest({
url: MFA_DISABLE_OTP_URL,
formData: form,
method: 'DELETE',
credentials,
})
}
export const mfaConfirmOTP = ({ credentials, password, token }) => {
const form = new FormData()
form.append('password', password)
form.append('code', token)
return promisedRequest({
url: MFA_CONFIRM_OTP_URL,
formData: form,
credentials,
method: 'POST',
})
}
export const mfaSetupOTP = ({ credentials }) =>
promisedRequest({
url: MFA_SETUP_OTP_URL,
credentials,
method: 'GET',
})
export const generateMfaBackupCodes = ({ credentials }) =>
promisedRequest({
url: MFA_BACKUP_CODES_URL,
credentials,
method: 'GET',
})
// #Aliases
export const addAlias = ({ credentials, alias }) =>
promisedRequest({
url: ALIASES_URL,
method: 'PUT',
credentials,
payload: { alias },
})
export const deleteAlias = ({ credentials, alias }) =>
promisedRequest({
url: ALIASES_URL,
method: 'DELETE',
credentials,
payload: { alias },
})
export const listAliases = ({ credentials }) =>
promisedRequest({
url: ALIASES_URL,
method: 'GET',
credentials,
params: {
_cacheBooster: new Date().getTime(),
},
})
// User manipulation
export const fetchUserRelationship = ({ id, withSuspended, credentials }) =>
promisedRequest({
url: MASTODON_USER_RELATIONSHIPS_URL({ id, withSuspended }),
credentials,
})
export const followUser = ({ id, credentials, ...options }) => {
const payload = {}
if (options.reblogs !== undefined) {
payload.reblogs = options.reblogs
}
if (options.notify !== undefined) {
payload.notify = options.notify
}
return promisedRequest({
url: MASTODON_FOLLOW_URL(id),
payload,
credentials,
method: 'POST',
})
}
export const unfollowUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNFOLLOW_URL(id),
credentials,
method: 'POST',
})
export const fetchUserInLists = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_USER_IN_LISTS(id),
credentials,
})
export const removeUserFromFollowers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_REMOVE_USER_FROM_FOLLOWERS(id),
credentials,
method: 'POST',
})
export const fetchFollowRequests = ({ credentials }) =>
promisedRequest({
url: MASTODON_FOLLOW_REQUESTS_URL,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const approveUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_APPROVE_USER_URL(id),
credentials,
method: 'POST',
})
export const denyUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_DENY_USER_URL(id),
credentials,
method: 'POST',
})
export const editUserNote = ({ id, credentials, comment }) =>
promisedRequest({
url: MASTODON_USER_NOTE_URL(id),
credentials,
payload: {
comment,
},
method: 'POST',
})
export const fetchMutes = ({ maxId, credentials }) =>
promisedRequest({
url: MASTODON_USER_MUTES_URL({ maxId, withRelationships: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const muteUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.expires_in = expiresIn
}
return promisedRequest({
url: MASTODON_MUTE_USER_URL(id),
credentials,
method: 'POST',
payload,
})
}
export const unmuteUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNMUTE_USER_URL(id),
credentials,
method: 'POST',
})
export const fetchBlocks = ({ maxId, credentials }) =>
promisedRequest({
url: MASTODON_USER_BLOCKS_URL({ maxId, withRelationships: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const blockUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.duration = expiresIn
}
return promisedRequest({
url: MASTODON_BLOCK_USER_URL(id),
credentials,
method: 'POST',
payload,
})
}
export const unblockUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNBLOCK_USER_URL(id),
credentials,
method: 'POST',
})
export const reportUser = ({
credentials,
userId,
statusIds,
comment,
forward,
}) =>
promisedRequest({
url: MASTODON_REPORT_USER_URL,
method: 'POST',
payload: {
account_id: userId,
status_ids: statusIds,
comment,
forward,
},
credentials,
})
// #Domain mutes
export const fetchDomainMutes = ({ credentials }) =>
promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials })
export const muteDomain = ({ domain, credentials }) =>
promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'POST',
payload: { domain },
credentials,
})
export const unmuteDomain = ({ domain, credentials }) =>
promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'DELETE',
payload: { domain },
credentials,
})
// #Backups
export const addBackup = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'POST',
credentials,
})
export const listBackups = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'GET',
credentials,
params: {
_cacheBooster: new Date().getTime(),
},
})
// #OAuth
export const fetchOAuthTokens = ({ credentials }) =>
promisedRequest({
url: '/api/oauth_tokens.json',
credentials,
})
export const revokeOAuthToken = ({ id, credentials }) =>
promisedRequest({
url: `/api/oauth_tokens/${id}`,
credentials,
method: 'DELETE',
})
// #Lists
export const fetchLists = ({ credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(),
credentials,
})
export const createList = ({ title, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(),
credentials,
method: 'POST',
payload: { title },
})
export const getList = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
credentials,
})
export const updateList = ({ listId, title, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
credentials,
method: 'PUT',
payload: { title },
})
export const getListAccounts = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
}).then((data) => data.map(({ id }) => id))
export const addAccountsToList = ({ listId, accountIds, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
method: 'POST',
payload: { account_ids: accountIds },
})
export const removeAccountsFromList = ({ listId, accountIds, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
method: 'DELETE',
payload: { account_ids: accountIds },
})
export const deleteList = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
method: 'DELETE',
credentials,
})
// #Bookmarks
export const fetchBookmarkFolders = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDERS_URL,
credentials,
})
export const createBookmarkFolder = ({ name, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDERS_URL,
credentials,
method: 'POST',
payload: { name, emoji },
})
export const updateBookmarkFolder = ({ folderId, name, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDER_URL(folderId),
credentials,
method: 'PATCH',
payload: { name, emoji },
})
export const deleteBookmarkFolder = ({ folderId, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDER_URL(folderId),
method: 'DELETE',
credentials,
})
// #So long and thanks for all the fish
export const deleteAccount = ({ credentials, password }) => {
const formData = new FormData()
formData.append('password', password)
return promisedRequest({
url: DELETE_ACCOUNT_URL,
formData,
method: 'POST',
credentials,
})
}

View file

@ -1,176 +0,0 @@
import { paramsString } from './helpers.js'
import {
parseChat,
parseNotification,
parseStatus,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_STREAMING = ({ accessToken, stream }) =>
`/api/v1/streaming${paramsString({ accessToken, stream })}`
export const getMastodonSocketURI = ({ credentials, stream }) => {
return MASTODON_STREAMING({ accessToken: credentials, stream })
}
const MASTODON_STREAMING_EVENTS = new Set([
'update',
'notification',
'delete',
'filters_changed',
'status.update',
])
const PLEROMA_STREAMING_EVENTS = new Set([
'pleroma:chat_update',
'pleroma:respond',
])
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({
url,
preprocessor = handleMastoWS,
id = 'Unknown',
credentials,
}) => {
const eventTarget = new EventTarget()
const socket = new WebSocket(url)
if (!socket) throw new Error(`Failed to create socket ${id}`)
const proxy = (original, eventName, processor = (a) => a) => {
original.addEventListener(eventName, (eventData) => {
eventTarget.dispatchEvent(
new CustomEvent(eventName, { detail: processor(eventData) }),
)
})
}
socket.addEventListener('open', (wsEvent) => {
console.debug(`[WS][${id}] Socket connected`, wsEvent)
if (credentials) {
socket.send(
JSON.stringify({
type: 'pleroma:authenticate',
token: credentials,
}),
)
}
})
socket.addEventListener('error', (wsEvent) => {
console.debug(`[WS][${id}] Socket errored`, wsEvent)
})
socket.addEventListener('close', (wsEvent) => {
console.debug(
`[WS][${id}] Socket disconnected with code ${wsEvent.code}`,
wsEvent,
)
})
// Commented code reason: very spammy, uncomment to enable message debug logging
/*
socket.addEventListener('message', (wsEvent) => {
console.debug(
`[WS][${id}] Message received`,
wsEvent
)
})
/**/
const onAuthenticated = () => {
eventTarget.dispatchEvent(new CustomEvent('pleroma:authenticated'))
}
proxy(socket, 'open')
proxy(socket, 'close')
proxy(socket, 'message', (event) => preprocessor(event, { onAuthenticated }))
proxy(socket, 'error')
// 1000 = Normal Closure
eventTarget.close = () => {
socket.close(1000, 'Shutting down socket')
}
eventTarget.getState = () => socket.readyState
eventTarget.subscribe = (stream, args = {}) => {
console.debug(`[WS][${id}] Subscribing to stream ${stream} with args`, args)
socket.send(
JSON.stringify({
type: 'subscribe',
stream,
...args,
}),
)
}
eventTarget.unsubscribe = (stream, args = {}) => {
console.debug(
`[WS][${id}] Unsubscribing from stream ${stream} with args`,
args,
)
socket.send(
JSON.stringify({
type: 'unsubscribe',
stream,
...args,
}),
)
}
return eventTarget
}
export const handleMastoWS = (
wsEvent,
{
onAuthenticated = () => {
/* no-op */
},
} = {},
) => {
const { data } = wsEvent
if (!data) return
const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent
if (
MASTODON_STREAMING_EVENTS.has(event) ||
PLEROMA_STREAMING_EVENTS.has(event)
) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') {
return { event, id: payload }
}
const data = payload ? JSON.parse(payload) : null
if (event === 'pleroma:respond') {
if (data.type === 'pleroma:authenticate') {
if (data.result === 'success') {
console.debug('[WS] Successfully authenticated')
onAuthenticated()
} else {
if (data.error === 'already_authenticated') {
onAuthenticated()
} else {
console.error('[WS] Unable to authenticate:', data.error)
wsEvent.target.close()
}
}
}
return null
} else if (event === 'update') {
return { event, status: parseStatus(data) }
} else if (event === 'status.update') {
return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
return { event, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
return null
}
}
export const WSConnectionStatus = Object.freeze({
JOINED: 1,
CLOSED: 2,
ERROR: 3,
DISABLED: 4,
STARTING: 5,
STARTING_INITIAL: 6,
})

View file

@ -1,51 +1,29 @@
/* global process */
import vClickOutside from 'click-outside-vue3'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { config } from '@fortawesome/fontawesome-svg-core'
import {
FontAwesomeIcon,
FontAwesomeLayers,
} from '@fortawesome/vue-fontawesome'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import { config } from '@fortawesome/fontawesome-svg-core';
config.autoAddCss = false
import App from '../App.vue'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { applyStyleConfig } from '../services/style_setter/style_setter.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import routes from './routes'
import { useAuthFlowStore } from 'src/stores/auth_flow'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import {
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
INSTANCE_IDENTIY_EXTERNAL,
} from 'src/modules/default_config_state.js'
import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import { useOAuthStore } from 'src/stores/oauth'
import { useI18nStore } from 'src/stores/i18n'
import { useInterfaceStore } from 'src/stores/interface'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useAuthFlowStore } from 'src/stores/auth_flow'
let staticInitialResults = null
@ -54,9 +32,7 @@ const parsedInitialResults = () => {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(
document.getElementById('initial-results').textContent,
)
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent)
}
return staticInitialResults
}
@ -78,7 +54,7 @@ const preloadFetch = async (request) => {
return {
ok: true,
json: () => requestData,
text: () => requestData,
text: () => requestData
}
}
@ -87,38 +63,20 @@ const getInstanceConfig = async ({ store }) => {
const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
const textLimit = data.max_toot_chars
const textlimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
useInstanceCapabilitiesStore().set(
'pleromaExtensionsAvailable',
data.pleroma,
)
useInstanceStore().set({
path: 'limits.textLimit',
value: textLimit,
})
useInstanceStore().set({
path: 'accountApprovalRequired',
value: data.approval_required,
})
useInstanceStore().set({
path: 'birthdayRequired',
value: !!data.pleroma?.metadata.birthday_required,
})
useInstanceStore().set({
path: 'birthdayMinAge',
value: data.pleroma?.metadata.birthday_min_age || 0,
})
store.dispatch('setInstanceOption', { name: 'pleromaExtensionsAvailable', value: data.pleroma })
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
store.dispatch('setInstanceOption', { name: 'birthdayRequired', value: !!data.pleroma?.metadata.birthday_required })
store.dispatch('setInstanceOption', { name: 'birthdayMinAge', value: data.pleroma?.metadata.birthday_min_age || 0 })
if (vapidPublicKey) {
useInstanceStore().set({
path: 'vapidPublicKey',
value: vapidPublicKey,
})
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
} else {
throw res
throw (res)
}
} catch (error) {
console.error('Could not load instance config, potentially fatal')
@ -135,12 +93,10 @@ const getBackendProvidedConfig = async () => {
const data = await res.json()
return data.pleroma_fe
} else {
throw res
throw (res)
}
} catch (error) {
console.error(
'Could not load backend-provided frontend config, potentially fatal',
)
console.error('Could not load backend-provided frontend config, potentially fatal')
console.error(error)
}
}
@ -151,13 +107,11 @@ const getStaticConfig = async () => {
if (res.ok) {
return res.json()
} else {
throw res
throw (res)
}
} catch (error) {
console.warn(
'Failed to load static/config.json, continuing without it.',
error,
)
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(error)
return {}
}
}
@ -175,25 +129,51 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
config = Object.assign({}, staticConfig, apiConfig)
}
Object.keys(INSTANCE_IDENTITY_DEFAULT_DEFINITIONS).forEach((source) => {
if (source === 'name') return
if (INSTANCE_IDENTIY_EXTERNAL.has(source)) return
useInstanceStore().set({
value:
config[source] ?? INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[source].default,
path: `instanceIdentity.${source}`,
})
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', { name, value: config[name] })
}
copyInstanceOption('theme')
copyInstanceOption('style')
copyInstanceOption('palette')
copyInstanceOption('embeddedToS')
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideBotIndication')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
Object.keys(INSTANCE_DEFAULT_CONFIG_DEFINITIONS).forEach((source) =>
useInstanceStore().set({
value:
config[source] ?? INSTANCE_DEFAULT_CONFIG_DEFINITIONS[source].default,
path: `prefsStorage.${source}`,
}),
)
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
copyInstanceOption('logoLeft')
useAuthFlowStore().setInitialStrategy(config.loginMethod)
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('showFeaturesPanel')
copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
}
const getTOS = async ({ store }) => {
@ -201,9 +181,9 @@ const getTOS = async ({ store }) => {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
useInstanceStore().set({ path: 'instanceIdentity.tos', value: html })
store.dispatch('setInstanceOption', { name: 'tos', value: html })
} else {
throw res
throw (res)
}
} catch (e) {
console.warn("Can't load TOS\n", e)
@ -215,12 +195,9 @@ const getInstancePanel = async ({ store }) => {
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
useInstanceStore().set({
path: 'instanceIdentity.instanceSpecificPanelContent',
value: html,
})
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
} else {
throw res
throw (res)
}
} catch (e) {
console.warn("Can't load instance panel\n", e)
@ -232,39 +209,41 @@ const getStickers = async ({ store }) => {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (
await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta,
}
}),
)
).sort((a, b) => {
const stickers = (await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta
}
})
)).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
useEmojiStore().setStickers(stickers)
store.dispatch('setInstanceOption', { name: 'stickers', value: stickers })
} else {
throw res
throw (res)
}
} catch (e) {
console.warn("Can't load stickers\n", e)
}
}
const getAppSecret = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.userToken) {
store.commit('setBackendInteractor', backendInteractorService(oauth.getToken))
}
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map((uri) => uri.split('/').pop())
useInstanceStore().set({
path: 'staffAccounts',
value: nicknames,
})
const nicknames = accounts.map(uri => uri.split('/').pop())
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
}
const getNodeInfo = async ({ store }) => {
@ -275,187 +254,97 @@ const getNodeInfo = async ({ store }) => {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
useInstanceStore().set({
path: 'instanceIdentity.name',
value: metadata.nodeName,
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', {
name: 'pleromaCustomEmojiReactionsAvailable',
value:
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions')
})
useInstanceStore().set({
path: 'registrationOpen',
value: data.openRegistrations,
})
useInstanceCapabilitiesStore().set(
'mediaProxyAvailable',
features.includes('media_proxy'),
)
useInstanceCapabilitiesStore().set(
'safeDM',
features.includes('safe_dm_mentions'),
)
useInstanceCapabilitiesStore().set(
'shoutAvailable',
features.includes('chat'),
)
useInstanceCapabilitiesStore().set(
'pleromaChatMessagesAvailable',
features.includes('pleroma_chat_messages'),
)
useInstanceCapabilitiesStore().set(
'pleromaCustomEmojiReactionsAvailable',
store.dispatch('setInstanceOption', { name: 'pleromaBookmarkFoldersAvailable', value: features.includes('pleroma:bookmark_folders') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
store.dispatch('setInstanceOption', { name: 'quotingAvailable', value: features.includes('quote_posting') })
store.dispatch('setInstanceOption', { name: 'groupActorAvailable', value: features.includes('pleroma:group_actors') })
store.dispatch('setInstanceOption', { name: 'localBubbleInstances', value: metadata.localBubbleInstances ?? [] })
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions'),
)
useInstanceCapabilitiesStore().set(
'pleromaBookmarkFoldersAvailable',
features.includes('pleroma:bookmark_folders'),
)
useInstanceCapabilitiesStore().set(
'gopherAvailable',
features.includes('gopher'),
)
useInstanceCapabilitiesStore().set(
'pollsAvailable',
features.includes('polls'),
)
useInstanceCapabilitiesStore().set(
'editingAvailable',
features.includes('editing'),
)
useInstanceCapabilitiesStore().set(
'mailerEnabled',
metadata.mailerEnabled,
)
useInstanceCapabilitiesStore().set(
'quotingAvailable',
features.includes('quote_posting'),
)
useInstanceCapabilitiesStore().set(
'groupActorAvailable',
features.includes('pleroma:group_actors'),
)
useInstanceCapabilitiesStore().set(
'blockExpiration',
features.includes('pleroma:block_expiration'),
)
useInstanceStore().set({
path: 'localBubbleInstances',
value: metadata.localBubbleInstances ?? [],
})
useInstanceCapabilitiesStore().set(
'localBubble',
(metadata.localBubbleInstances ?? []).length > 0,
)
useInstanceStore().set({
path: 'limits.pollLimits',
value: metadata.pollLimits,
})
const uploadLimits = metadata.uploadLimits
useInstanceStore().set({
path: 'limits.uploadlimit',
value: parseInt(uploadLimits.general),
})
useInstanceStore().set({
path: 'limits.avatarlimit',
value: parseInt(uploadLimits.avatar),
})
useInstanceStore().set({
path: 'limits.backgroundlimit',
value: parseInt(uploadLimits.background),
})
useInstanceStore().set({
path: 'limits.bannerlimit',
value: parseInt(uploadLimits.banner),
})
useInstanceStore().set({
path: 'limits.fieldsLimits',
value: metadata.fieldsLimits,
})
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
useInstanceStore().set({
path: 'restrictedNicknames',
value: metadata.restrictedNicknames,
})
useInstanceCapabilitiesStore().set('postFormats', metadata.postFormats)
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
const suggestions = metadata.suggestions
useInstanceCapabilitiesStore().set(
'suggestionsEnabled',
suggestions.enabled,
)
// this is unused, why?
useInstanceCapabilitiesStore().set('suggestionsWeb', suggestions.web)
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
const software = data.software
useInstanceStore().set({
path: 'backendVersion',
value: software.version,
})
useInstanceStore().set({
path: 'backendRepository',
value: software.repository,
})
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
store.dispatch('setInstanceOption', { name: 'backendRepository', value: software.repository })
const priv = metadata.private
useInstanceStore().set({ path: 'privateMode', value: priv })
store.dispatch('setInstanceOption', { name: 'private', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
useInstanceStore().set({
path: 'frontendVersion',
value: frontendVersion,
})
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
const federation = metadata.federation
useInstanceCapabilitiesStore().set(
'tagPolicyAvailable',
typeof federation.mrf_policies === 'undefined'
store.dispatch('setInstanceOption', {
name: 'tagPolicyAvailable',
value: typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy'),
)
useInstanceStore().set({
path: 'federationPolicy',
value: federation,
: metadata.federation.mrf_policies.includes('TagPolicy')
})
useInstanceStore().set({
path: 'federating',
value:
typeof federation.enabled === 'undefined' ? true : federation.enabled,
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
store.dispatch('setInstanceOption', {
name: 'federating',
value: typeof federation.enabled === 'undefined'
? true
: federation.enabled
})
const accountActivationRequired = metadata.accountActivationRequired
useInstanceStore().set({
path: 'accountActivationRequired',
value: accountActivationRequired,
})
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired })
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw res
throw (res)
}
} catch (e) {
console.warn('Could not load nodeinfo', e)
console.warn('Could not load nodeinfo')
console.warn(e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([
getBackendProvidedConfig({ store }),
getStaticConfig(),
])
const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
getAppSecret({ store })
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.userToken) {
return store.dispatch('loginUser', oauth.userToken)
if (oauth.getUserToken) {
return store.dispatch('loginUser', oauth.getUserToken)
}
return Promise.resolve()
}
@ -467,16 +356,6 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
// "Plugins are only applied to stores created after the plugins themselves, and after pinia is passed to the app, otherwise they won't be applied."
app.use(pinia)
app.config.errorHandler = (error, instance, info) => {
console.error(
'Global Vue Error Handler caught an error:',
error,
instance,
info,
)
useInterfaceStore().setGlobalError({ error, instance, info })
}
const waitForAllStoresToLoad = async () => {
// the stores that do not persist technically do not need to be awaited here,
// but that involves either hard-coding the stores in some place (prone to errors)
@ -485,37 +364,29 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
if (process.env.NODE_ENV === 'development') {
// do some checks to avoid common errors
if (!Object.keys(allStores).length) {
throw new Error(
'No stores are available. Check the code in src/boot/after_store.js',
)
throw new Error('No stores are available. Check the code in src/boot/after_store.js')
}
}
await Promise.all(
Object.entries(allStores).map(async ([name, mod]) => {
const isStoreName = (name) => name.startsWith('use')
if (process.env.NODE_ENV === 'development') {
if (Object.keys(mod).filter(isStoreName).length !== 1) {
throw new Error(
'Each store file must export exactly one store as a named export. Check your code in src/stores/',
)
Object.entries(allStores)
.map(async ([name, mod]) => {
const isStoreName = name => name.startsWith('use')
if (process.env.NODE_ENV === 'development') {
if (Object.keys(mod).filter(isStoreName).length !== 1) {
throw new Error('Each store file must export exactly one store as a named export. Check your code in src/stores/')
}
}
}
const storeFuncName = Object.keys(mod).find(isStoreName)
if (storeFuncName && typeof mod[storeFuncName] === 'function') {
const p = mod[storeFuncName]().$persistLoaded
if (!(p instanceof Promise)) {
throw new Error(
`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`,
)
const storeFuncName = Object.keys(mod).find(isStoreName)
if (storeFuncName && typeof mod[storeFuncName] === 'function') {
const p = mod[storeFuncName]().$persistLoaded
if (!(p instanceof Promise)) {
throw new Error(`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`)
}
await p
} else {
throw new Error(`Store module ${name} does not export a 'use...' function`)
}
await p
} else {
throw new Error(
`Store module ${name} does not export a 'use...' function`,
)
}
}),
)
}))
}
try {
@ -526,45 +397,30 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
}
if (storageError) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'errors.storage_unavailable',
level: 'error',
})
useInterfaceStore().pushGlobalNotice({ messageKey: 'errors.storage_unavailable', level: 'error' })
}
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
window.syncConfig = useSyncConfigStore()
window.mergedConfig = useMergedConfigStore()
window.localConfig = useLocalConfigStore()
window.highlightConfig = useUserHighlightStore()
FaviconService.initFaviconService()
initServiceWorker(store)
window.addEventListener('focus', () => updateFocus())
const overrides = window.___pleromafe_dev_overrides || {}
const server =
typeof overrides.target !== 'undefined'
? overrides.target
: window.location.origin
useInstanceStore().set({ path: 'server', value: server })
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
store.dispatch('setInstanceOption', { name: 'server', value: server })
await setConfig({ store })
try {
await useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
await useInterfaceStore().applyTheme().catch((e) => { console.error('Error setting theme', e) })
} catch (e) {
window.splashError(e)
return Promise.reject(e)
}
applyStyleConfig(useMergedConfigStore().mergedConfig, i18n.global)
applyConfig(store.state.config, i18n.global)
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
@ -572,9 +428,13 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
checkOAuthToken({ store }),
getInstancePanel({ store }),
getNodeInfo({ store }),
getInstanceConfig({ store }),
]).catch((e) => Promise.reject(e))
getInstanceConfig({ store })
]).catch(e => Promise.reject(e))
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
store.dispatch('loadDrafts')
useAnnouncementsStore().startFetchingAnnouncements()
getTOS({ store })
getStickers({ store })
@ -582,11 +442,11 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
history: createWebHistory(),
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some((m) => m.meta.dontScroll)) {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { left: 0, top: 0 }
},
}
})
useI18nStore().setI18n(i18n)
@ -608,9 +468,6 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers)
app.component('Status', Status)
app.component('RichContent', RichContent)
app.component('StillImage', StillImage)
// remove after vue 3.3
app.config.unwrapInjectedRef = true

View file

@ -1,28 +1,42 @@
import { defineAsyncComponent } from 'vue'
import BookmarkTimeline from 'src/components/bookmark_timeline/bookmark_timeline.vue'
import BubbleTimeline from 'src/components/bubble_timeline/bubble_timeline.vue'
import ConversationPage from 'src/components/conversation-page/conversation-page.vue'
import DMs from 'src/components/dm_timeline/dm_timeline.vue'
import FriendsTimeline from 'src/components/friends_timeline/friends_timeline.vue'
import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import ChatList from 'components/chat_list/chat_list.vue'
import Chat from 'components/chat/chat.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue'
import PasswordReset from 'components/password_reset/password_reset.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js'
import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
import Lists from 'components/lists/lists.vue'
import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
import ListsEdit from 'components/lists_edit/lists_edit.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import PublicAndExternalTimeline from 'src/components/public_and_external_timeline/public_and_external_timeline.vue'
import PublicTimeline from 'src/components/public_timeline/public_timeline.vue'
import QuotesTimeline from 'src/components/quotes_timeline/quotes_timeline.vue'
import RemoteUserResolver from 'src/components/remote_user_resolver/remote_user_resolver.vue'
import TagTimeline from 'src/components/tag_timeline/tag_timeline.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue'
import Drafts from 'components/drafts/drafts.vue'
import BookmarkFolders from '../components/bookmark_folders/bookmark_folders.vue'
import BookmarkFolderEdit from '../components/bookmark_folder_edit/bookmark_folder_edit.vue'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
if (store.state.users.currentUser) {
next()
} else {
next(
useInstanceStore().instanceIdentity.redirectRootNoLogin || '/main/all',
)
next(store.state.instance.redirectRootNoLogin || '/main/all')
}
}
@ -31,283 +45,64 @@ export default (store) => {
name: 'root',
path: '/',
redirect: () => {
return (
(store.state.users.currentUser
? useInstanceStore().instanceIdentity.redirectRootLogin
: useInstanceStore().instanceIdentity.redirectRootNoLogin) ||
'/main/all'
)
},
},
{
name: 'public-external-timeline',
path: '/main/all',
component: PublicAndExternalTimeline,
},
{
name: 'public-timeline',
path: '/main/public',
component: PublicTimeline,
},
{
name: 'friends',
path: '/main/friends',
component: FriendsTimeline,
beforeEnter: validateAuthenticatedRoute,
return (store.state.users.currentUser
? store.state.instance.redirectRootLogin
: store.state.instance.redirectRootNoLogin) || '/main/all'
}
},
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'bubble', path: '/bubble', component: BubbleTimeline },
{
name: 'conversation',
path: '/notice/:id',
component: ConversationPage,
meta: { dontScroll: true },
},
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
{
name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
beforeEnter: validateAuthenticatedRoute
},
{
name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'external-user-profile',
path: '/users/$:id',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'user-profile-admin-view',
path: '/users/$:id/admin_view',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile_admin_view.vue'),
),
},
{
name: 'interactions',
path: '/users/:username/interactions',
component: defineAsyncComponent(
() => import('src/components/interactions/interactions.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'dms',
path: '/users/:username/dms',
component: DMs,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'registration',
path: '/registration',
component: defineAsyncComponent(
() => import('src/components/registration/registration.vue'),
),
},
{
name: 'password-reset',
path: '/password-reset',
component: defineAsyncComponent(
() => import('src/components/password_reset/password_reset.vue'),
),
props: true,
},
{
name: 'registration-token',
path: '/registration/:token',
component: defineAsyncComponent(
() => import('src/components/registration/registration.vue'),
),
},
{
name: 'friend-requests',
path: '/friend-requests',
component: defineAsyncComponent(
() => import('src/components/follow_requests/follow_requests.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'notifications',
path: '/:username/notifications',
component: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
props: () => ({ disableTeleport: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'login',
path: '/login',
component: defineAsyncComponent(
() => import('src/components/auth_form/auth_form.js'),
),
},
{
name: 'shout-panel',
path: '/shout-panel',
component: defineAsyncComponent(
() => import('src/components/shout_panel/shout_panel.vue'),
),
props: () => ({ floating: false }),
},
{
name: 'oauth-callback',
path: '/oauth-callback',
component: defineAsyncComponent(
() => import('src/components/oauth_callback/oauth_callback.vue'),
),
props: (route) => ({ code: route.query.code }),
},
{
name: 'search',
path: '/search',
component: defineAsyncComponent(
() => import('src/components/search/search.vue'),
),
props: (route) => ({ query: route.query.query }),
},
{
name: 'who-to-follow',
path: '/who-to-follow',
component: defineAsyncComponent(
() => import('src/components/who_to_follow/who_to_follow.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'about',
path: '/about',
component: defineAsyncComponent(
() => import('src/components/about/about.vue'),
),
},
{
name: 'announcements',
path: '/announcements',
component: defineAsyncComponent(
() =>
import('src/components/announcements_page/announcements_page.vue'),
),
},
{
name: 'drafts',
path: '/drafts',
component: defineAsyncComponent(
() => import('src/components/drafts/drafts.vue'),
),
},
{
name: 'user-profile',
path: '/users/:name',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'legacy-user-profile',
path: '/:name',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'lists',
path: '/lists',
component: defineAsyncComponent(
() => import('src/components/lists/lists.vue'),
),
},
{
name: 'lists-timeline',
path: '/lists/:id',
component: defineAsyncComponent(
() => import('src/components/lists_timeline/lists_timeline.vue'),
),
},
{
name: 'lists-edit',
path: '/lists/:id/edit',
component: defineAsyncComponent(
() => import('src/components/lists_edit/lists_edit.vue'),
),
},
{
name: 'lists-new',
path: '/lists/new',
component: defineAsyncComponent(
() => import('src/components/lists_edit/lists_edit.vue'),
),
},
{
name: 'edit-navigation',
path: '/nav-edit',
component: NavPanel,
props: () => ({ forceExpand: true, forceEditMode: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'bookmark-folders',
path: '/bookmark_folders',
component: defineAsyncComponent(
() => import('src/components/bookmark_folders/bookmark_folders.vue'),
),
},
{
name: 'bookmark-folder-new',
path: '/bookmarks/new-folder',
component: defineAsyncComponent(
() =>
import(
'src/components/bookmark_folder_edit/bookmark_folder_edit.vue'
),
),
},
{
name: 'bookmark-folder',
path: '/bookmarks/:id',
component: BookmarkTimeline,
},
{
name: 'bookmark-folder-edit',
path: '/bookmarks/:id/edit',
component: defineAsyncComponent(
() =>
import(
'src/components/bookmark_folder_edit/bookmark_folder_edit.vue'
),
),
beforeEnter: validateAuthenticatedRoute
},
{ name: 'external-user-profile', path: '/users/$:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, props: () => ({ disableTeleport: true }), beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'shout-panel', path: '/shout-panel', component: ShoutPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
{ name: 'announcements', path: '/announcements', component: AnnouncementsPage },
{ name: 'drafts', path: '/drafts', component: Drafts },
{ name: 'user-profile', path: '/users/:name', component: UserProfile },
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists },
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
{ name: 'lists-new', path: '/lists/new', component: ListsEdit },
{ name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute },
{ name: 'bookmark-folders', path: '/bookmark_folders', component: BookmarkFolders },
{ name: 'bookmark-folder-new', path: '/bookmarks/new-folder', component: BookmarkFolderEdit },
{ name: 'bookmark-folder', path: '/bookmarks/:id', component: BookmarkTimeline },
{ name: 'bookmark-folder-edit', path: '/bookmarks/:id/edit', component: BookmarkFolderEdit }
]
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
if (store.state.instance.pleromaChatMessagesAvailable) {
routes = routes.concat([
{
name: 'chat',
path: '/users/:username/chats/:recipient_id',
component: defineAsyncComponent(
() => import('src/components/chat/chat.vue'),
),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'chats',
path: '/users/:username/chats',
component: defineAsyncComponent(
() => import('src/components/chat_list/chat_list.vue'),
),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
])
}

View file

@ -1,16 +1,8 @@
import { mapState } from 'pinia'
import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MRFTransparencyPanel from 'src/components/mrf_transparency_panel/mrf_transparency_panel.vue'
import StaffPanel from 'src/components/staff_panel/staff_panel.vue'
import TermsOfServicePanel from 'src/components/terms_of_service_panel/terms_of_service_panel.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const pleromaFeCommitUrl =
'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from '../features_panel/features_panel.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
import StaffPanel from '../staff_panel/staff_panel.vue'
import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue'
const About = {
components: {
@ -18,28 +10,16 @@ const About = {
FeaturesPanel,
TermsOfServicePanel,
StaffPanel,
MRFTransparencyPanel,
MRFTransparencyPanel
},
computed: {
showFeaturesPanel() {
return useInstanceStore().instanceIdentity.showFeaturesPanel
},
frontendVersionLink() {
return pleromaFeCommitUrl + this.frontendVersion
},
...mapState(useInstanceStore, [
'backendVersion',
'backendRepository',
'frontendVersion',
]),
showInstanceSpecificPanel() {
return (
useInstanceStore().instanceIdentity.showInstanceSpecificPanel &&
!useMergedConfigStore().mergedConfig.hideISP &&
useInstanceStore().instanceIdentity.instanceSpecificPanelContent
)
},
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
}
}
}
export default About

View file

@ -1,47 +1,11 @@
<template>
<div class="About column-inner">
<div class="column-inner">
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<staff-panel />
<terms-of-service-panel />
<MRFTransparencyPanel />
<features-panel v-if="showFeaturesPanel" />
<div class="panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('settings.version.title') }}
</div>
</div>
<div class="panel-body">
<dl>
<dt>{{ $t('settings.version.backend_version') }}</dt>
<dd>
<a
:href="backendRepository"
target="_blank"
>
{{ backendVersion }}
</a>
</dd>
<dt>{{ $t('settings.version.frontend_version') }}</dt>
<dd>
<a
:href="frontendVersionLink"
target="_blank"
>
{{ frontendVersion }}
</a>
</dd>
</dl>
</div>
</div>
</div>
</template>
<script src="./about.js"></script>
<style>
.About {
dl {
padding-left: 1em;
}
}
</style>

View file

@ -1,111 +1,99 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisV
} from '@fortawesome/free-solid-svg-icons'
import { useReportsStore } from 'src/stores/reports'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
library.add(faEllipsisV)
library.add(
faEllipsisV
)
const AccountActions = {
props: ['user', 'relationship'],
data() {
props: [
'user', 'relationship'
],
data () {
return {
showingConfirmBlock: false,
showingConfirmRemoveFollower: false,
showingConfirmRemoveFollower: false
}
},
components: {
ProgressButton,
Popover,
UserListMenu,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
UserTimedFilterModal: defineAsyncComponent(
() =>
import(
'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
),
),
ConfirmModal
},
methods: {
showConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = true
showConfirmBlock () {
this.showingConfirmBlock = true
},
hideConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = false
},
hideConfirmBlock() {
hideConfirmBlock () {
this.showingConfirmBlock = false
},
showRepeats() {
showConfirmRemoveUserFromFollowers () {
this.showingConfirmRemoveFollower = true
},
hideConfirmRemoveUserFromFollowers () {
this.showingConfirmRemoveFollower = false
},
showRepeats () {
this.$store.dispatch('showReblogs', this.user.id)
},
hideRepeats() {
hideRepeats () {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser() {
if (this.$refs.timedBlockDialog) {
this.$refs.timedBlockDialog.optionallyPrompt()
blockUser () {
if (!this.shouldConfirmBlock) {
this.doBlockUser()
} else {
if (!this.shouldConfirmBlock) {
this.doBlockUser()
} else {
this.showingConfirmBlock = true
}
this.showConfirmBlock()
}
},
doBlockUser() {
this.$store.dispatch('blockUser', { id: this.user.id })
doBlockUser () {
this.$store.dispatch('blockUser', this.user.id)
this.hideConfirmBlock()
},
unblockUser() {
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
removeUserFromFollowers() {
removeUserFromFollowers () {
if (!this.shouldConfirmRemoveUserFromFollowers) {
this.doRemoveUserFromFollowers()
} else {
this.showConfirmRemoveUserFromFollowers()
}
},
doRemoveUserFromFollowers() {
doRemoveUserFromFollowers () {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
this.hideConfirmRemoveUserFromFollowers()
},
reportUser() {
reportUser () {
useReportsStore().openUserReportingModal({ userId: this.user.id })
},
openChat() {
openChat () {
this.$router.push({
name: 'chat',
params: {
username: this.$store.state.users.currentUser.screen_name,
recipient_id: this.user.id,
},
params: { username: this.$store.state.users.currentUser.screen_name, recipient_id: this.user.id }
})
},
}
},
computed: {
shouldConfirmBlock() {
return useMergedConfigStore().mergedConfig.modalOnBlock
shouldConfirmBlock () {
return this.$store.getters.mergedConfig.modalOnBlock
},
shouldConfirmRemoveUserFromFollowers() {
return useMergedConfigStore().mergedConfig.modalOnRemoveUserFromFollowers
shouldConfirmRemoveUserFromFollowers () {
return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
},
...mapState(useInstanceCapabilitiesStore, [
'blockExpiration',
'pleromaChatMessagesAvailable',
]),
},
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
}
}
export default AccountActions

View file

@ -3,6 +3,7 @@
<Popover
trigger="click"
placement="bottom"
:bound-to="{ x: 'container' }"
remove-padding
>
<template #content>
@ -94,9 +95,8 @@
</template>
</Popover>
<teleport to="#modal">
<ConfirmModal
v-if="showingConfirmBlock && !blockExpiration"
ref="blockDialog"
<confirm-modal
v-if="showingConfirmBlock"
:title="$t('user_card.block_confirm_title')"
:confirm-text="$t('user_card.block_confirm_accept_button')"
:cancel-text="$t('user_card.block_confirm_cancel_button')"
@ -114,10 +114,10 @@
/>
</template>
</i18n-t>
</ConfirmModal>
</confirm-modal>
</teleport>
<teleport to="#modal">
<ConfirmModal
<confirm-modal
v-if="showingConfirmRemoveFollower"
:title="$t('user_card.remove_follower_confirm_title')"
:confirm-text="$t('user_card.remove_follower_confirm_accept_button')"
@ -136,13 +136,7 @@
/>
</template>
</i18n-t>
</ConfirmModal>
<UserTimedFilterModal
v-if="blockExpiration"
ref="timedBlockDialog"
:is-mute="false"
:user="user"
/>
</confirm-modal>
</teleport>
</div>
</template>

View file

@ -1,58 +1,57 @@
export default {
name: 'Alert',
selector: '.alert',
validInnerComponents: ['Text', 'Icon', 'Link', 'Border', 'ButtonUnstyled'],
validInnerComponents: [
'Text',
'Icon',
'Link',
'Border',
'ButtonUnstyled'
],
variants: {
normal: '.neutral',
info: '.info',
error: '.error',
warning: '.warning',
success: '.success',
success: '.success'
},
editor: {
border: 1,
aspect: '3 / 1',
aspect: '3 / 1'
},
defaultRules: [
{
directives: {
background: '--text',
opacity: 0.5,
blur: '9px',
},
blur: '9px'
}
},
{
parent: {
component: 'Alert',
component: 'Alert'
},
component: 'Border',
directives: {
textColor: '--parent',
},
textColor: '--parent'
}
},
{
variant: 'error',
directives: {
background: '--cRed',
},
background: '--cRed'
}
},
{
variant: 'warning',
directives: {
background: '--cOrange',
},
background: '--cOrange'
}
},
{
variant: 'success',
directives: {
background: '--cGreen',
},
},
{
variant: 'info',
directives: {
background: '--cBlue',
},
},
],
background: '--cGreen'
}
}
]
}

View file

@ -1,126 +1,109 @@
import { mapState } from 'vuex'
import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
import RichContent from '../rich_content/rich_content.jsx'
import localeService from '../../services/locale/locale.service.js'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useAnnouncementsStore } from 'src/stores/announcements'
const Announcement = {
components: {
AnnouncementEditor,
RichContent
},
data() {
data () {
return {
editing: false,
editedAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: undefined,
allDay: undefined
},
editError: '',
editError: ''
}
},
props: {
announcement: Object,
announcement: Object
},
computed: {
...mapState({
currentUser: (state) => state.users.currentUser,
currentUser: state => state.users.currentUser
}),
canEditAnnouncement() {
return (
this.currentUser &&
this.currentUser.privileges.has('announcements_manage_announcements')
)
canEditAnnouncement () {
return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements')
},
content() {
content () {
return this.announcement.content
},
isRead() {
isRead () {
return this.announcement.read
},
publishedAt() {
publishedAt () {
const time = this.announcement.published_at
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
startsAt() {
startsAt () {
const time = this.announcement.starts_at
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
endsAt() {
endsAt () {
const time = this.announcement.ends_at
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
inactive() {
inactive () {
return this.announcement.inactive
},
}
},
methods: {
markAsRead() {
markAsRead () {
if (!this.isRead) {
return useAnnouncementsStore().markAnnouncementAsRead(
this.announcement.id,
)
return useAnnouncementsStore().markAnnouncementAsRead(this.announcement.id)
}
},
deleteAnnouncement() {
deleteAnnouncement () {
return useAnnouncementsStore().deleteAnnouncement(this.announcement.id)
},
formatTimeOrDate(time, locale) {
formatTimeOrDate (time, locale) {
const d = new Date(time)
return this.announcement.all_day
? d.toLocaleDateString(locale)
: d.toLocaleString(locale)
return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
},
enterEditMode() {
enterEditMode () {
this.editedAnnouncement.content = this.announcement.pleroma.raw_content
this.editedAnnouncement.startsAt = this.announcement.starts_at
this.editedAnnouncement.endsAt = this.announcement.ends_at
this.editedAnnouncement.allDay = this.announcement.all_day
this.editing = true
},
submitEdit() {
useAnnouncementsStore()
.editAnnouncement({
id: this.announcement.id,
...this.editedAnnouncement,
})
submitEdit () {
useAnnouncementsStore().editAnnouncement({
id: this.announcement.id,
...this.editedAnnouncement
})
.then(() => {
this.editing = false
})
.catch((error) => {
.catch(error => {
this.editError = error.error
})
},
cancelEdit() {
cancelEdit () {
this.editing = false
},
clearError() {
clearError () {
this.editError = undefined
},
},
}
}
}
export default Announcement

View file

@ -1,13 +1,13 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Checkbox from '../checkbox/checkbox.vue'
const AnnouncementEditor = {
components: {
Checkbox,
Checkbox
},
props: {
announcement: Object,
disabled: Boolean,
},
disabled: Boolean
}
}
export default AnnouncementEditor

View file

@ -1,65 +1,59 @@
import { mapState } from 'vuex'
import Announcement from 'src/components/announcement/announcement.vue'
import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import Announcement from '../announcement/announcement.vue'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
const AnnouncementsPage = {
components: {
Announcement,
AnnouncementEditor,
AnnouncementEditor
},
data() {
data () {
return {
newAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: false,
allDay: false
},
posting: false,
error: undefined,
error: undefined
}
},
mounted() {
mounted () {
useAnnouncementsStore().fetchAnnouncements()
},
computed: {
...mapState({
currentUser: (state) => state.users.currentUser,
currentUser: state => state.users.currentUser
}),
announcements() {
announcements () {
return useAnnouncementsStore().announcements
},
canPostAnnouncement() {
return (
this.currentUser &&
this.currentUser.privileges.has('announcements_manage_announcements')
)
},
canPostAnnouncement () {
return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements')
}
},
methods: {
postAnnouncement() {
postAnnouncement () {
this.posting = true
useAnnouncementsStore()
.postAnnouncement(this.newAnnouncement)
useAnnouncementsStore().postAnnouncement(this.newAnnouncement)
.then(() => {
this.newAnnouncement.content = ''
this.startsAt = undefined
this.endsAt = undefined
})
.catch((error) => {
.catch(error => {
this.error = error.error
})
.finally(() => {
this.posting = false
})
},
clearError() {
clearError () {
this.error = undefined
},
},
}
}
}
export default AnnouncementsPage

View file

@ -21,10 +21,10 @@
export default {
emits: ['resetAsyncComponent'],
methods: {
retry() {
retry () {
this.$emit('resetAsyncComponent')
},
},
}
}
}
</script>

View file

@ -1,28 +1,24 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import StillImage from '../still-image/still-image.vue'
import Flash from '../flash/flash.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMediaViewerStore } from 'src/stores/media_viewer'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAlignRight,
faFile,
faImage,
faMusic,
faPencilAlt,
faPlayCircle,
faSearchPlus,
faStop,
faTimes,
faTrashAlt,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight
} from '@fortawesome/free-solid-svg-icons'
import { useMediaViewerStore } from 'src/stores/media_viewer'
library.add(
faFile,
@ -35,7 +31,7 @@ library.add(
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight,
faAlignRight
)
const Attachment = {
@ -50,75 +46,72 @@ const Attachment = {
'remove',
'shiftUp',
'shiftDn',
'edit',
'edit'
],
emits: ['play', 'pause', 'naturalSizeLoad'],
data() {
data () {
return {
localDescription: this.description || this.attachment.description,
nsfwImage:
useInstanceStore().instanceIdentity.nsfwCensorImage || nsfwImage,
hideNsfwLocal: useMergedConfigStore().mergedConfig.hideNsfw,
preloadImage: useMergedConfigStore().mergedConfig.preloadImage,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img: this.attachment.type === 'image' && document.createElement('img'),
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
showDescription: false
}
},
components: {
Flash: defineAsyncComponent(() => import('src/components/flash/flash.vue')),
VideoAttachment: defineAsyncComponent(
() => import('src/components/video_attachment/video_attachment.vue'),
),
Popover,
Flash,
StillImage,
VideoAttachment
},
computed: {
classNames() {
classNames () {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined,
'-compact': this.compact,
'-compact': this.compact
},
'-type-' + this.attachment.type,
'-type-' + this.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`
]
},
usePlaceholder() {
usePlaceholder () {
return this.size === 'hide'
},
useContainFit() {
return this.mergedConfig.useContainFit
useContainFit () {
return this.$store.getters.mergedConfig.useContainFit
},
placeholderName() {
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
return this.attachment.type.toUpperCase()
return this.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass() {
if (this.attachment.type === 'image') return 'image'
if (this.attachment.type === 'video') return 'video'
if (this.attachment.type === 'audio') return 'music'
placeholderIconClass () {
if (this.type === 'image') return 'image'
if (this.type === 'video') return 'video'
if (this.type === 'audio') return 'music'
return 'file'
},
referrerpolicy() {
return useInstanceCapabilitiesStore().mediaProxyAvailable
? ''
: 'no-referrer'
referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
},
hidden() {
type () {
return fileTypeService.fileType(this.attachment.mimetype)
},
hidden () {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty() {
return this.attachment.type === 'html' && !this.attachment.oembed
isEmpty () {
return (this.type === 'html' && !this.attachment.oembed)
},
useModal() {
useModal () {
let modalTypes = []
switch (this.size) {
case 'hide':
@ -131,63 +124,64 @@ const Attachment = {
: ['image']
break
}
return modalTypes.includes(this.attachment.type)
return modalTypes.includes(this.type)
},
videoTag() {
videoTag () {
return this.useModal ? 'button' : 'span'
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapGetters(['mergedConfig'])
},
watch: {
'attachment.description'(newVal) {
'attachment.description' (newVal) {
this.localDescription = newVal
},
localDescription(newVal) {
localDescription (newVal) {
this.onEdit(newVal)
},
}
},
methods: {
linkClicked({ target }) {
linkClicked ({ target }) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
openModal() {
openModal () {
if (this.useModal) {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
} else if (this.attachment.type === 'unknown') {
} else if (this.type === 'unknown') {
window.open(this.attachment.url)
}
},
openModalForce() {
openModalForce () {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
},
onEdit(event) {
onEdit (event) {
this.edit && this.edit(this.attachment, event)
},
onRemove() {
onRemove () {
this.remove && this.remove(this.attachment)
},
onShiftUp() {
onShiftUp () {
this.shiftUp && this.shiftUp(this.attachment)
},
onShiftDn() {
onShiftDn () {
this.shiftDn && this.shiftDn(this.attachment)
},
stopFlash() {
stopFlash () {
this.$refs.flash.closePlayer()
},
setFlashLoaded(event) {
setFlashLoaded (event) {
this.flashLoaded = event
},
toggleHidden(event) {
toggleDescription () {
this.showDescription = !this.showDescription
},
toggleHidden (event) {
if (
this.mergedConfig.useOneClickNsfw &&
!this.showHidden &&
(this.attachment.type !== 'video' ||
this.mergedConfig.playVideosInModal)
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
(this.type !== 'video' || this.mergedConfig.playVideosInModal)
) {
this.openModal(event)
return
@ -207,12 +201,12 @@ const Attachment = {
this.showHidden = !this.showHidden
}
},
onImageLoad(image) {
onImageLoad (image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
},
},
}
}
}
export default Attachment

View file

@ -107,9 +107,9 @@
.play-icon {
position: absolute;
font-size: 4.5em;
top: calc(50% - 2.25rem);
left: calc(50% - 2.25rem);
font-size: 64px;
top: calc(50% - 32px);
left: calc(50% - 32px);
color: rgb(255 255 255 / 75%);
text-shadow: 0 0 2px rgb(0 0 0 / 40%);
@ -134,7 +134,7 @@
width: 2em;
height: 2em;
margin-left: 0.5em;
font-size: 1em;
font-size: 1.25em;
}
}
@ -265,27 +265,3 @@
}
}
}
.description-popover {
padding: 1em;
width: 50ch;
max-width: 90vw;
overflow: hidden;
box-sizing: border-box;
summary {
display: inline-block;
margin-bottom: 0.5em;
font-weight: bold;
pointer-events: none;
}
span {
display: block;
overflow-y: auto;
max-height: 10.5em;
text-wrap: pretty;
line-height: 1.5;
white-space: pre-wrap;
}
}

View file

@ -0,0 +1,27 @@
export default {
name: 'Attachment',
selector: '.Attachment',
notEditable: true,
validInnerComponents: [
'Border',
'Button',
'Input'
],
defaultRules: [
{
directives: {
roundness: 3
}
},
{
component: 'Button',
parent: {
component: 'Attachment'
},
directives: {
background: '#FFFFFF',
opacity: 0.5
}
}
]
}

View file

@ -6,7 +6,7 @@
@click="openModal"
>
<a
v-if="attachment.type !== 'html'"
v-if="type !== 'html'"
class="placeholder"
target="_blank"
:href="attachment.url"
@ -23,23 +23,28 @@
>
<button
v-if="remove"
class="button-default attachment-button -transparent"
class="button-default attachment-button"
@click.prevent="onRemove"
>
<FAIcon icon="trash-alt" />
</button>
</div>
<div
v-if="size !== 'hide' && !hideDescription && edit"
v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)"
class="description-container"
:class="{ '-static': !edit }"
>
<textarea
<input
v-if="edit"
v-model="localDescription"
type="text"
class="input description-field"
:placeholder="$t('post_status.media_description')"
/>
@keydown.enter.prevent=""
>
<p v-else>
{{ localDescription }}
</p>
</div>
</button>
<div
@ -65,7 +70,7 @@
:src="nsfwImage"
>
<FAIcon
v-if="attachment.type === 'video'"
v-if="type === 'video'"
class="play-icon"
icon="play-circle"
/>
@ -75,32 +80,24 @@
class="attachment-buttons"
>
<button
v-if="attachment.type === 'flash' && flashLoaded"
class="button-default attachment-button -transparent"
v-if="type === 'flash' && flashLoaded"
class="button-default attachment-button"
:title="$t('status.attachment_stop_flash')"
@click.prevent="stopFlash"
>
<FAIcon icon="stop" />
</button>
<Popover
v-if="attachment.description && size !== 'small' && !edit && attachment.type !== 'unknown'"
trigger="click"
popover-class="popover popover-default description-popover"
:trigger-attrs="{ 'class': 'button-default attachment-button -transparent', 'title': $t('status.attachment_description') }"
>
<template #trigger>
<FAIcon icon="align-right" />
</template>
<template #content>
<details open>
<summary>{{ $t('status.attachment_description') }}</summary>
<span>{{ localDescription }}</span>
</details>
</template>
</Popover>
<button
v-if="!useModal && attachment.type !== 'unknown'"
class="button-default attachment-button -transparent"
v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
class="button-default attachment-button"
:title="$t('status.show_attachment_description')"
@click.prevent="toggleDescription"
>
<FAIcon icon="align-right" />
</button>
<button
v-if="!useModal && type !== 'unknown'"
class="button-default attachment-button"
:title="$t('status.show_attachment_in_modal')"
@click.prevent="openModalForce"
>
@ -108,7 +105,7 @@
</button>
<button
v-if="nsfw && hideNsfwLocal"
class="button-default attachment-button -transparent"
class="button-default attachment-button"
:title="$t('status.hide_attachment')"
@click.prevent="toggleHidden"
>
@ -116,7 +113,7 @@
</button>
<button
v-if="shiftUp"
class="button-default attachment-button -transparent"
class="button-default attachment-button"
:title="$t('status.move_up')"
@click.prevent="onShiftUp"
>
@ -124,7 +121,7 @@
</button>
<button
v-if="shiftDn"
class="button-default attachment-button -transparent"
class="button-default attachment-button"
:title="$t('status.move_down')"
@click.prevent="onShiftDn"
>
@ -132,7 +129,7 @@
</button>
<button
v-if="remove"
class="button-default attachment-button -transparent"
class="button-default attachment-button"
:title="$t('status.remove_attachment')"
@click.prevent="onRemove"
>
@ -141,7 +138,7 @@
</div>
<a
v-if="attachment.type === 'image' && (!hidden || preloadImage)"
v-if="type === 'image' && (!hidden || preloadImage)"
class="image-container"
:class="{'-hidden': hidden && preloadImage }"
:href="attachment.url"
@ -159,7 +156,7 @@
</a>
<a
v-if="attachment.type === 'unknown' && !hidden"
v-if="type === 'unknown' && !hidden"
class="placeholder-container"
:href="attachment.url"
target="_blank"
@ -176,7 +173,7 @@
<component
:is="videoTag"
v-if="attachment.type === 'video' && !hidden"
v-if="type === 'video' && !hidden"
class="video-container"
:href="attachment.url"
@click.stop.prevent="openModal"
@ -196,13 +193,13 @@
</component>
<span
v-if="attachment.type === 'audio' && !hidden"
v-if="type === 'audio' && !hidden"
class="audio-container"
:href="attachment.url"
@click.stop.prevent="openModal"
>
<audio
v-if="attachment.type === 'audio'"
v-if="type === 'audio'"
:src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@ -213,7 +210,7 @@
</span>
<div
v-if="attachment.type === 'html' && attachment.oembed"
v-if="type === 'html' && attachment.oembed"
class="oembed-container"
@click.prevent="linkClicked"
>
@ -232,7 +229,7 @@
</div>
<span
v-if="attachment.type === 'flash' && !hidden"
v-if="type === 'flash' && !hidden"
class="flash-container"
:href="attachment.url"
@click.stop.prevent="openModal"
@ -247,16 +244,21 @@
</span>
</div>
<div
v-if="size !== 'hide' && !hideDescription && edit"
v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))"
class="description-container"
:class="{ '-static': !edit }"
>
<textarea
<input
v-if="edit"
v-model="localDescription"
type="text"
class="input description-field"
:placeholder="$t('post_status.media_description')"
/>
@keydown.enter.prevent=""
>
<p v-else>
{{ localDescription }}
</p>
</div>
</div>
</template>

View file

@ -1,34 +1,28 @@
import { mapState } from 'pinia'
import { h, resolveComponent } from 'vue'
import LoginForm from 'src/components/login_form/login_form.vue'
import MFARecoveryForm from 'src/components/mfa_form/recovery_form.vue'
import MFATOTPForm from 'src/components/mfa_form/totp_form.vue'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
import LoginForm from '../login_form/login_form.vue'
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
import MFATOTPForm from '../mfa_form/totp_form.vue'
import { mapState } from 'pinia'
import { useAuthFlowStore } from 'src/stores/auth_flow'
const AuthForm = {
name: 'AuthForm',
render() {
render () {
return h(resolveComponent(this.authForm))
},
computed: {
authForm() {
if (this.requiredTOTP) {
return 'MFATOTPForm'
}
if (this.requiredRecovery) {
return 'MFARecoveryForm'
}
authForm () {
if (this.requiredTOTP) { return 'MFATOTPForm' }
if (this.requiredRecovery) { return 'MFARecoveryForm' }
return 'LoginForm'
},
...mapState(useAuthFlowStore, ['requiredTOTP', 'requiredRecovery']),
...mapState(useAuthFlowStore, ['requiredTOTP', 'requiredRecovery'])
},
components: {
MFARecoveryForm,
MFATOTPForm,
LoginForm,
},
LoginForm
}
}
export default AuthForm

View file

@ -2,55 +2,51 @@ const debounceMilliseconds = 500
export default {
props: {
query: {
// function to query results and return a promise
query: { // function to query results and return a promise
type: Function,
required: true,
required: true
},
filter: {
// function to filter results in real time
type: Function,
filter: { // function to filter results in real time
type: Function
},
placeholder: {
type: String,
default: 'Search...',
},
default: 'Search...'
}
},
data() {
data () {
return {
term: '',
timeout: null,
results: [],
resultsVisible: false,
resultsVisible: false
}
},
computed: {
filtered() {
filtered () {
return this.filter ? this.filter(this.results) : this.results
},
}
},
watch: {
term(val) {
term (val) {
this.fetchResults(val)
},
}
},
methods: {
fetchResults(term) {
fetchResults (term) {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.results = []
if (term) {
this.query(term).then((results) => {
this.results = results
})
this.query(term).then((results) => { this.results = results })
}
}, debounceMilliseconds)
},
onInputClick() {
onInputClick () {
this.resultsVisible = true
},
onClickOutside() {
onClickOutside () {
this.resultsVisible = false
},
},
}
}
}

View file

@ -1,28 +1,21 @@
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import UserAvatar from '../user_avatar/user_avatar.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const AvatarList = {
props: ['users'],
computed: {
slicedUsers() {
slicedUsers () {
return this.users ? this.users.slice(0, 15) : []
},
}
},
components: {
UserAvatar,
UserAvatar
},
methods: {
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}
export default AvatarList

View file

@ -1,27 +1,30 @@
export default {
name: 'Badge',
selector: '.badge',
validInnerComponents: ['Text', 'Icon'],
validInnerComponents: [
'Text',
'Icon'
],
variants: {
notification: '.-notification',
notification: '.-notification'
},
defaultRules: [
{
component: 'Root',
directives: {
'--badgeNotification': 'color | --cRed',
},
'--badgeNotification': 'color | --cRed'
}
},
{
directives: {
background: '--cGreen',
},
background: '--cGreen'
}
},
{
variant: 'notification',
directives: {
background: '--cRed',
},
},
],
background: '--cRed'
}
}
]
}

View file

@ -1,42 +1,24 @@
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import UserPopover from '../user_popover/user_popover.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
props: {
user: {
type: Object,
},
showLineLabels: {
type: Boolean,
default: false,
},
},
props: [
'user'
],
components: {
UserPopover,
UserAvatar,
UserLink,
RichContent,
UserLink
},
methods: {
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
},
computed: {
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}
export default BasicUserCard

View file

@ -23,22 +23,13 @@
:title="user.name"
class="basic-user-card-user-name"
>
<strong v-if="showLineLabels">
{{ $t('admin_dash.users.labels.name_colon') }}
{{ ' ' }}
</strong>
<RichContent
class="basic-user-card-user-name-value"
:html="user.name"
:emoji="user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
/>
</div>
<div>
<strong v-if="showLineLabels">
{{ $t('admin_dash.users.labels.handle_colon') }}
{{ ' ' }}
</strong>
<user-link
class="basic-user-card-screen-name"
:user="user"
@ -54,10 +45,8 @@
<style lang="scss">
.basic-user-card {
display: flex;
flex: 1 1 10em;
min-width: 1em;
flex: 1 0;
margin: 0;
line-height: 1.25;
--emoji-size: 1em;
@ -79,7 +68,7 @@
&-user-name-value,
&-screen-name {
display: inline;
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;

Some files were not shown because too many files have changed in this diff Show more