Compare commits

..

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

805 changed files with 43852 additions and 94629 deletions

5
.babelrc Normal file
View file

@ -0,0 +1,5 @@
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-jsx"],
"comments": true
}

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).
-->

6
.gitignore vendored
View file

@ -4,15 +4,9 @@ dist/
npm-debug.log npm-debug.log
test/unit/coverage test/unit/coverage
test/e2e/reports test/e2e/reports
test/e2e-playwright/test-results
test/e2e-playwright/playwright-report
selenium-debug.log selenium-debug.log
.idea/ .idea/
.gitlab-ci-local/
config/local.json config/local.json
src/assets/emoji.json src/assets/emoji.json
logs/ logs/
__screenshots__/ __screenshots__/
.vitest-attachments/
html/
coverage/

83
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,83 @@
# 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:18
stages:
- check-changelog
- lint
- build
- test
- deploy
# https://git.pleroma.social/help/ci/yaml/workflow.md#switch-between-branch-pipelines-and-merge-request-pipelines
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
check-changelog:
stage: check-changelog
image: alpine
rules:
- if: $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == 'pleroma/pleroma-fe' && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^renovate/
when: never
- if: $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == 'pleroma/pleroma-fe' && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME == 'weblate'
when: never
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"
before_script: ''
after_script: ''
cache: {}
script:
- apk add git
- sh ./tools/check-changelog
lint:
stage: lint
script:
- yarn
- yarn lint
- yarn stylelint
test:
stage: test
tags:
- amd64
- himem
variables:
APT_CACHE_DIR: apt-cache
script:
- mkdir -pv $APT_CACHE_DIR && apt-get -qq update
- yarn
- yarn playwright install firefox
- yarn playwright install-deps
- yarn unit-ci
artifacts:
# When the test fails, upload screenshots for better context on why it fails
paths:
- test/**/__screenshots__
when: on_failure
build:
stage: build
tags:
- amd64
- himem
script:
- yarn
- yarn build
artifacts:
paths:
- dist/
docs-deploy:
stage: deploy
image: alpine:latest
only:
- develop@pleroma/pleroma-fe
before_script:
- apk add curl
script:
- curl -X POST -F"token=$DOCS_PIPELINE_TRIGGER" -F'ref=master' https://git.pleroma.social/api/v4/projects/673/trigger/pipeline

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, "custom-property-pattern": null,
"keyframes-name-pattern": null, "keyframes-name-pattern": null,
"scss/operator-no-newline-after": 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": [ "declaration-block-no-redundant-longhand-properties": [
true, true,
{ {

View file

@ -1,36 +0,0 @@
when:
- 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:24-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
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,17 +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
steps:
build:
image: docker.io/node:24-alpine
commands:
- yarn --frozen-lockfile
- yarn build

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,45 +0,0 @@
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
labels:
platform: linux/amd64
memory: 'high'
depends_on:
- build
- test
- test-e2e
clone:
- name: git
image: woodpeckerci/plugin-git
settings:
depth: 0
partial: false
variables:
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:
generate-coverage:
image: mcr.microsoft.com/playwright:v1.61.0-jammy
environment:
FF_NETWORK_PER_BUILD: "true"
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
entrypoint: *script_file_entrypoint
commands:
- yarn --frozen-lockfile
- yarn unit-ci-coverage
code-analysis:
image: sonarsource/sonar-scanner-cli:11
environment:
SONAR_TOKEN:
from_secret: sonarqube-token
entrypoint: sonar-scanner

View file

@ -1,26 +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:24-alpine
commands:
- yarn --frozen-lockfile
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,105 +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'
depends_on:
- build
- test
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,65 +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'
depends_on:
- build
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:
- npm install --global yarn
- |
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,124 +2,6 @@
All notable changes to this project will be documented in this file. 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## 2.11.1
### Fixed
- User note not working
## 2.11.0
### Added
- Initial MFM rendering support
- 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)
- Displaying other user's backgrounds (if supported by BE)
- Add quoting by URL and in replies
- Settings synchronization
- User highlight synchronization
- User administration + post scope/sensitivity admin change support
### Changed
- Migrated to Vite 8 and optimized our imports, more stuff is loaded on-demand, reducing the initial load time and transfer size
- Overall improved spacing 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
### Fixed
- Fix HTML attribute parsing for escaped quotes
- Fix emojis breaking user bio/description editing
- 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
- Fixed status action mute hiding itself on click
- Fix reply form crash when quote-reply settings are unavailable
## 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 ## 2.8.0
### Changed ### 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 - 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
@ -152,8 +34,8 @@ This does not guarantee that browsers will or will not work.
- Support displaying time in absolute format - Support displaying time in absolute format
- Add draft management system - Add draft management system
- Compress most kinds of images on upload. - Compress most kinds of images on upload.
- Added option to always convert images to JPEG format instead of using WebP when compressing images. - 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 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 - Inform users that Smithereen public polls are public
- Splash screen + loading indicator to make process of identifying initialization issues and load performance - 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 - UI for making v3 themes and palettes, support for bundling v3 themes

View file

@ -1,14 +1,12 @@
# Pleroma-FE # Pleroma-FE
> Highly-customizable frontend designed for Pleroma. > Highly-customizable frontend designed for Pleroma.
![screenshot](./image-1.png) ![screenshot](./image-1.png)
[![Pipeline Status](https://ci.pleroma.com/api/badges/2/status.svg)](https://ci.pleroma.com/repos/2) [![Coverage](https://sonarqube.pleroma.dev/api/project_badges/measure?project=Pleroma-FE&metric=coverage&token=sqb_f8a5ec836caede0e2c6c62a9bfe66e62a89e2b01)](https://sonarqube.pleroma.dev/dashboard?id=Pleroma-FE) [![Maintainability Rating](https://sonarqube.pleroma.dev/api/project_badges/measure?project=Pleroma-FE&metric=software_quality_maintainability_rating&token=sqb_f8a5ec836caede0e2c6c62a9bfe66e62a89e2b01)](https://sonarqube.pleroma.dev/dashboard?id=Pleroma-FE) [![Reliability Rating](https://sonarqube.pleroma.dev/api/project_badges/measure?project=Pleroma-FE&metric=software_quality_reliability_rating&token=sqb_f8a5ec836caede0e2c6c62a9bfe66e62a89e2b01)](https://sonarqube.pleroma.dev/dashboard?id=Pleroma-FE) [![Security Rating](https://sonarqube.pleroma.dev/api/project_badges/measure?project=Pleroma-FE&metric=software_quality_security_rating&token=sqb_f8a5ec836caede0e2c6c62a9bfe66e62a89e2b01)](https://sonarqube.pleroma.dev/dashboard?id=Pleroma-FE) <a href="https://translate.pleroma.social/engage/pleroma/"><img src="https://translate.pleroma.social/widget/pleroma/pleroma-fe/svg-badge.svg" alt="Translation status"></a>
# For Translators # 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. Pleroma-FE will set your language by your browser locale, but you can change language in settings.
@ -34,15 +32,10 @@ yarn unit
# For Contributors: # For Contributors:
You can make local build proxy requests to specific instance with an environment variable `VITE_PROXY_TARGET`, generally it is a good idea to also set port for that specific proxy, if you intend on testing multiple instances, i.e.: 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:
```bash * `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.
yarn && PORT=8080 VITE_PROXY_TARGET=https://coolinstance.tld yarn dev * `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode.
```
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:
* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/pleroma/frontend_configurations`. 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. 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,161 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.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": {
"preset": "none",
"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"
},
"style": {
"useVueMultiWordComponentNames": "off"
}
}
},
"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/composables/**"],
":BLANK_LINE:",
[
":PATH:",
"src/**",
"src/stores/**",
"src/components/**",
"src/composables/**"
],
":BLANK_LINE:",
"@fortawesome/fontawesome-svg-core",
"@fortawesome/*"
]
}
}
}
}
}
}

35
build/check-versions.mjs Normal file
View file

@ -0,0 +1,35 @@
import semver from 'semver'
import chalk from 'chalk'
import packageConfig from '../package.json' with { type: 'json' }
var versionRequirements = [
{
name: 'node',
currentVersion: semver.clean(process.version),
versionRequirement: packageConfig.engines.node
}
]
export default function () {
const warnings = []
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)
)
}
}
if (warnings.length) {
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,15 +1,13 @@
import childProcess from 'node:child_process' import childProcess from 'child_process'
export const getCommitHash = () => { export const getCommitHash = (() => {
const subst = '$Format:%h$' const subst = "$Format:%h$"
if (!/Format:/.exec(subst)) { if(!subst.match(/Format:/)) {
return subst return subst
} else { } else {
try { try {
return childProcess return childProcess
.execSync( .execSync('git rev-parse --short HEAD')
'PATH=/usr/bin:/bin:/usr/local/bin:/sbin:/usr/sbin git rev-parse --short HEAD',
)
.toString() .toString()
.trim() .trim()
} catch (e) { } catch (e) {
@ -17,4 +15,4 @@ export const getCommitHash = () => {
return 'UNKNOWN' 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 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 + '/' const padEnd = s.endsWith('/') ? s : s + '/'
return padEnd.startsWith('/') ? padEnd : '/' + padEnd return padEnd.startsWith('/') ? padEnd : '/' + padEnd
} }
@ -13,31 +13,28 @@ const copyPlugin = ({ inUrl, inFs }) => {
let copyTarget let copyTarget
const handler = serveStatic(inFs) const handler = serveStatic(inFs)
return [ return [{
{ name: 'copy-plugin-serve',
name: 'copy-plugin-serve', apply: 'serve',
apply: 'serve', configureServer (server) {
configureServer(server) { server.middlewares.use(prefix, handler)
server.middlewares.use(prefix, handler) }
}, }, {
name: 'copy-plugin-build',
apply: 'build',
configResolved (config) {
copyTarget = resolve(config.root, config.build.outDir, subdir)
}, },
{ closeBundle: {
name: 'copy-plugin-build', order: 'post',
apply: 'build', sequential: true,
configResolved(config) { async handler () {
copyTarget = resolve(config.root, config.build.outDir, subdir) console.log(`Copying '${inFs}' to ${copyTarget}...`)
}, await cp(inFs, copyTarget, { recursive: true })
closeBundle: { console.log('Done.')
order: 'post', }
sequential: true, }
async handler() { }]
console.info(`Copying '${inFs}' to ${copyTarget}...`)
await cp(inFs, copyTarget, { recursive: true })
console.info('Done.')
},
},
},
]
} }
export default copyPlugin export default copyPlugin

View file

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

28
build/msw_plugin.js Normal file
View file

@ -0,0 +1,28 @@
import { resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
const target = 'node_modules/msw/lib/mockServiceWorker.js'
const mswPlugin = () => {
let projectRoot
return {
name: 'msw-plugin',
apply: 'serve',
configResolved (conf) {
projectRoot = conf.root
},
configureServer (server) {
server.middlewares.use(async (req, res, next) => {
if (req.path === '/mockServiceWorker.js') {
const file = await readFile(resolve(projectRoot, target))
res.set('Content-Type', 'text/javascript')
res.send(file)
} else {
next()
}
})
}
}
}
export default mswPlugin

View file

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

View file

@ -1,12 +1,9 @@
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' 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 { build } from 'vite'
import * as esbuild from 'esbuild'
import { import { generateServiceWorkerMessages, i18nFiles } from './service_worker_messages.js'
generateServiceWorkerMessages,
i18nFiles,
} from './service_worker_messages.js'
const getSWMessagesAsText = async () => { const getSWMessagesAsText = async () => {
const messages = await generateServiceWorkerMessages() const messages = await generateServiceWorkerMessages()
@ -14,23 +11,123 @@ const getSWMessagesAsText = async () => {
} }
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))) 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 // Idea taken from
// https://github.com/vite-pwa/vite-plugin-pwa/blob/main/src/plugins/build.ts // 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; // 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. // 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 // Run another vite build just for the service worker targeting iife at
// the end of the build. // the end of the build.
export const buildSwPlugin = ({ swSrc, swDest }) => { export const buildSwPlugin = ({
const swFullSrc = resolve(projectRoot, swSrc) swSrc,
const swEnvName = 'virtual:pleroma-fe/service_worker_env' swDest,
const swEnvNameResolved = '\0' + swEnvName }) => {
let config let config
return { return {
name: 'build-sw-plugin', name: 'build-sw-plugin',
enforce: 'post', enforce: 'post',
configResolved(resolvedConfig) { apply: 'build',
configResolved (resolvedConfig) {
config = { config = {
define: resolvedConfig.define, define: resolvedConfig.define,
resolve: resolvedConfig.resolve, resolve: resolvedConfig.resolve,
@ -38,89 +135,53 @@ export const buildSwPlugin = ({ swSrc, swDest }) => {
publicDir: false, publicDir: false,
build: { build: {
...resolvedConfig.build, ...resolvedConfig.build,
emptyOutDir: false, lib: {
rolldownOptions: { entry: swSrc,
input: { formats: ['iife'],
main: swSrc, name: 'sw_pleroma'
},
context: 'self',
output: {
entryFileNames: swDest,
codeSplitting: false,
format: 'iife',
},
}, },
emptyOutDir: false,
rollupOptions: {
output: {
entryFileNames: swDest
}
}
}, },
configFile: false, configFile: false
} }
}, },
generateBundle: { generateBundle: {
order: 'post', order: 'post',
sequential: true, sequential: true,
async handler(_, bundle) { async handler (_, bundle) {
const assets = Object.keys(bundle) const assets = Object.keys(bundle)
.filter((name) => !name.endsWith('.map')) .filter(name => !/\.map$/.test(name))
.map((name) => '/' + name) .map(name => '/' + name)
config.plugins.push({ config.plugins.push({
name: 'build-sw-env-plugin', name: 'build-sw-env-plugin',
mode: 'production', resolveId (id) {
resolveId: { if (id === swEnvName) {
filter: { id: exactRegex(swEnvName) }, return swEnvNameResolved
handler: () => swEnvNameResolved, }
}, return null
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler() {
return `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
},
}, },
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: { closeBundle: {
order: 'post', order: 'post',
sequential: true, sequential: true,
async handler() { async handler () {
if (process.env.VITEST) return console.log('Building service worker for production')
console.info('Building service worker for production') await build(config)
try { }
await build(config) }
} catch (e) {
console.error('Error building ServiceWorker:', e)
}
},
},
} }
} }
@ -130,9 +191,9 @@ const swMessagesNameResolved = '\0' + swMessagesName
export const swMessagesPlugin = () => { export const swMessagesPlugin = () => {
return { return {
name: 'sw-messages-plugin', name: 'sw-messages-plugin',
resolveId(id) { resolveId (id) {
if (id === swMessagesName) { if (id === swMessagesName) {
Object.values(i18nFiles).forEach((f) => { Object.values(i18nFiles).forEach(f => {
this.addWatchFile(f) this.addWatchFile(f)
}) })
return swMessagesNameResolved return swMessagesNameResolved
@ -140,11 +201,11 @@ export const swMessagesPlugin = () => {
return null return null
} }
}, },
async load(id) { async load (id) {
if (id === swMessagesNameResolved) { if (id === swMessagesNameResolved) {
return await getSWMessagesAsText() return await getSWMessagesAsText()
} }
return null return null
}, }
} }
} }

View file

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

View file

@ -1,4 +0,0 @@
Fixed crashing on Firefox 153
Fixed serif-fonts being used by default in some cases
Fixed font select component showing and using objects instead of strings
Fixed generic ('sans-serif', 'monospace' etc) fonts not working

View file

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

View file

@ -1 +0,0 @@
fixed tapping "Mute..." and "Change visiblity" (admin action) in extra status actions closing the dropdown

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 image cropper error preventing avatar upload

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

View file

@ -1 +0,0 @@
Chat view for threads

View file

@ -1 +0,0 @@
fix pinned indicator looking wrong on CJK locales

View file

@ -1 +0,0 @@
Fix legacy settings migration being discarded after it ran.

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

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

View file

@ -1 +0,0 @@
Fixed drafts creating duplicates on edit instead of updating

View file

@ -1 +0,0 @@
Fix style of extra notifications

View file

@ -1 +0,0 @@
Fix error dialog when posting in chat

View file

@ -1 +0,0 @@
domain mute fixedw

View file

View file

@ -1 +0,0 @@
Fix follower remove API call

View file

@ -1 +0,0 @@
Fix followers list showing followed users as non-followed

View file

@ -1 +0,0 @@
Prevent service worker from caching media-proxy responses

View file

@ -1 +0,0 @@
Fix MFA login and recovery code authentication.

View file

View file

@ -1 +0,0 @@
fix moderation tools button possibly not appearing for admins

View file

@ -1 +0,0 @@
Fixed moderation actions requiring confirmation closing user popover

View file

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

View file

@ -1 +0,0 @@
Do not crash even on css rule insertion failure

View file

@ -1 +0,0 @@
Building PleromaFE now requires NodeJS version 24 or newer

View file

@ -1 +0,0 @@
Fix posting not working when using preview

View file

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

View file

@ -1 +0,0 @@
Fixed server-side domain mutes rendering/api calls

View file

View file

@ -1 +0,0 @@
More timelines now can utilize WebSocket streaming

View file

@ -1 +0,0 @@
switches between streaming and polling should be more reliable now

View file

@ -1,3 +0,0 @@
Added an indicator next to instance's name showing WebSocket connection status (if enabled).
Timeline no longer show "loading" indicator at the bottom when fetching newer posts.
Added small indicator on top of timeline when new posts are being fetched

View file

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

View file

@ -1 +0,0 @@
Fix theme lists failing to load when custom resource indexes are unavailable

View file

@ -1 +0,0 @@
Fix Theme 2 fonts falling back to serif after upgrade

View file

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

View file

@ -1 +0,0 @@
Fix Themes 2.0 applying incorrect fonts

View file

@ -1 +0,0 @@
Fixed themes 3 not loading in appearance tab

View file

@ -1 +0,0 @@
Fixed layout selector (third column/reverse) not working immideately upon change

View file

@ -1 +0,0 @@
Fixed status index approxmiation in timeline rendering for dynamically changing viewport geometries

View file

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

View file

@ -1 +0,0 @@
Implemented new virtual scrolling. Opening huge threads should be faster now

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,20 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.61.0-jammy
RUN npm install -g yarn@1.22.22
RUN adduser --system playwright --group --shell=/bin/bash
USER playwright
WORKDIR /app
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
COPY --chown=playwright ./build ./src ./test ./public ./static ./yarn.lock ./package.json .
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) 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) 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/src/public/static/config.json). 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/src/src/stores/instance.js) ) 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 ## Instance-defaults

View file

@ -79,9 +79,9 @@ 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. 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 pinia 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. 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.
PleromaFE also tries to persist this store, however only stable data is stored, such as user authentication and preferences, user highlights. Persistence is performed by saving and loading chunk of vuex store in browser's LocalStorage/IndexedDB. PleromaFE also tries to persist this store, however only stable data is stored, such as user authentication and preferences, user highlights. Persistence is performed by saving and loading chunk of vuex store in browser's LocalStorage/IndexedDB.

37
eslint.config.mjs Normal file
View file

@ -0,0 +1,37 @@
import vue from "eslint-plugin-vue";
import js from "@eslint/js";
import globals from "globals";
export default [
...vue.configs['flat/recommended'],
js.configs.recommended,
{
files: ["**/*.js", "**/*.mjs", "**/*.vue"],
ignores: ["build/*.js", "config/*.js"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module",
parserOptions: {
parser: "@babel/eslint-parser",
},
globals: {
...globals.browser,
...globals.vitest,
...globals.chai,
...globals.commonjs,
...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" /> <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 --> <!-- 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="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--> <!--server-generated-meta-->
</head> </head>
<body> <body>
<noscript>To use Pleroma, please enable JavaScript.</noscript> <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 --> <!-- we are hiding entire graphic so no point showing credit -->
<div aria-hidden="true" id="splash-credit"> <div aria-hidden="true" id="splash-credit">
Art by pipivovott Art by pipivovott
@ -31,7 +33,7 @@
<div class="chunk" id="chunk-E"> <div class="chunk" id="chunk-E">
</div> </div>
</div> </div>
<img id="mascot" alt="A cute mascot" src="/static/pleromatan_apology_small.webp"> <img id="mascot" src="/static/pleromatan_apology_small.webp">
</div> </div>
<div id="status" class="css-ok"> <div id="status" class="css-ok">
<!-- (。>﹏<) --> <!-- (。>﹏<) -->

View file

@ -1,94 +1,122 @@
{ {
"name": "pleroma_fe", "name": "pleroma_fe",
"version": "2.11.1", "version": "2.7.1",
"description": "Pleroma frontend, the default frontend of Pleroma social network server", "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, "private": false,
"scripts": { "scripts": {
"dev": "node build/update-emoji.js && vite dev", "dev": "node build/update-emoji.js && vite dev",
"build": "node build/update-emoji.js && vite build", "build": "node build/update-emoji.js && vite build",
"unit": "node build/update-emoji.js && vitest --run --coverage", "unit": "node build/update-emoji.js && vitest --run",
"unit-ci": "node build/update-emoji.js && vitest --run --browser.headless", "unit-ci": "node build/update-emoji.js && vitest --run --browser.headless",
"unit-ci-coverage": "node build/update-emoji.js && vitest --run --coverage --browser.headless", "unit:watch": "node build/update-emoji.js && vitest",
"unit:watch": "node build/update-emoji.js && vitest --coverage", "e2e": "node test/e2e/runner.js",
"e2e:pw": "playwright test --config test/e2e-playwright/playwright.config.mjs",
"e2e": "sh ./tools/e2e/run.sh",
"test": "yarn run unit && yarn run e2e", "test": "yarn run unit && yarn run e2e",
"ci-biome": "yarn exec biome check", "stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'",
"ci-stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'", "lint": "eslint src test/unit/specs test/e2e/specs",
"lint": "yarn ci-biome; yarn ci-stylelint", "lint-fix": "eslint --fix src test/unit/specs test/e2e/specs"
"lint-fix": "yarn exec stylelint '**/*.scss' '**/*.vue' --fix; biome check --write"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.27.1",
"@chenfengyuan/vue-qrcode": "2.0.0", "@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "7.3.1", "@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-regular-svg-icons": "7.3.1", "@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "7.3.1", "@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/vue-fontawesome": "3.3.3", "@fortawesome/vue-fontawesome": "3.0.8",
"@kazvmoe-infra/pinch-zoom-element": "1.3.0", "@floatingghost/pinch-zoom-element": "1.3.1",
"@kazvmoe-infra/unicode-emoji-json": "0.4.0", "@kazvmoe-infra/unicode-emoji-json": "0.4.0",
"@ruffle-rs/ruffle": "0.6.0-nightly.2026.9.4", "@ruffle-rs/ruffle": "0.1.0-nightly.2025.1.13",
"@vuelidate/core": "2.0.3", "@vuelidate/core": "2.0.3",
"@vuelidate/validators": "2.0.4", "@vuelidate/validators": "2.0.4",
"@web3-storage/parse-link-header": "3.1.0", "@web3-storage/parse-link-header": "^3.1.0",
"body-scroll-lock": "3.1.5", "body-scroll-lock": "3.1.5",
"chromatism": "3.0.0", "chromatism": "3.0.0",
"click-outside-vue3": "4.0.1", "click-outside-vue3": "4.0.1",
"cropperjs": "2.2.0", "cropperjs": "2.0.0",
"escape-html": "1.0.3", "escape-html": "1.0.3",
"hash-sum": "2.0.0", "globals": "^16.0.0",
"js-cookie": "3.0.8", "hash-sum": "^2.0.0",
"js-cookie": "3.0.5",
"localforage": "1.10.0", "localforage": "1.10.0",
"lodash-es": "4.17.21", "parse-link-header": "2.0.0",
"phoenix": "1.8.1", "phoenix": "1.7.21",
"pinia": "4.0.3", "pinia": "^3.0.0",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"querystring-es3": "0.2.1",
"url": "0.11.4",
"utf8": "3.0.0", "utf8": "3.0.0",
"uuid": "14.0.2", "uuid": "11.1.0",
"vue": "3.5.42", "vue": "3.5.17",
"vue-i18n": "11.4.10", "vue-i18n": "11",
"vue-router": "5.3.1", "vue-router": "4.5.1",
"vue-virtual-scroller": "3.0.5" "vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.11", "@babel/core": "7.27.1",
"@pinia/testing": "2.0.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", "@ungap/event-target": "0.2.4",
"@vitejs/devtools": "0.7.2", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue": "6.0.8", "@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitejs/plugin-vue-jsx": "5.1.6", "@vitest/browser": "^3.0.7",
"@vitest/browser": "5.0.0", "@vitest/ui": "^3.0.7",
"@vitest/browser-playwright": "5.0.0", "@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vitest/coverage-v8": "5.0.0", "@vue/babel-plugin-jsx": "1.4.0",
"@vitest/ui": "5.0.0", "@vue/compiler-sfc": "3.5.17",
"@vue/compiler-sfc": "3.5.42", "@vue/test-utils": "2.4.6",
"@vue/compiler-dom": "3.5.42", "autoprefixer": "10.4.21",
"@vue/devtools-api": "8.2.0", "babel-plugin-lodash": "3.3.4",
"@vue/test-utils": "2.5.0", "chai": "5.2.0",
"autoprefixer": "10.5.4", "chalk": "5.4.1",
"iso-639-1": "3.1.6", "chromedriver": "135.0.4",
"playwright": "1.61.0", "connect-history-api-fallback": "2.0.0",
"postcss": "8.5.28", "cross-spawn": "7.0.6",
"postcss-html": "2.0.0", "custom-event-polyfill": "1.0.7",
"postcss-scss": "4.0.9", "eslint": "9.26.0",
"sass-embedded": "1.104.0", "vue-eslint-parser": "10.1.3",
"eslint-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-n": "17.18.0",
"eslint-plugin-promise": "7.2.1",
"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.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": "1.89.2",
"selenium-server": "3.141.59",
"semver": "7.7.2",
"serve-static": "2.2.0", "serve-static": "2.2.0",
"stylelint": "17.14.0", "shelljs": "0.10.0",
"stylelint-config-html": "2.0.0", "sinon": "20.0.0",
"stylelint-config-recommended": "18.0.0", "sinon-chai": "4.0.0",
"stylelint-config-recommended-scss": "17.0.1", "stylelint": "16.19.1",
"stylelint-config-recommended-vue": "2.0.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-standard": "40.0.0", "stylelint-config-recommended": "^16.0.0",
"vite": "8.2.2", "stylelint-config-recommended-scss": "^14.0.0",
"vite-plugin-biome": "^1.2.0", "stylelint-config-recommended-vue": "^1.6.0",
"vite-plugin-stylelint": "6.3.0", "stylelint-config-standard": "38.0.0",
"vite-plugin-vue-devtools": "8.2.1", "vite": "^6.1.0",
"vitest": "5.0.0" "vite-plugin-eslint2": "^5.0.3",
"vite-plugin-stylelint": "^6.0.0",
"vitest": "^3.0.7"
}, },
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">= 24.0.0" "node": ">= 16.0.0"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View file

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

View file

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

View file

@ -6,4 +6,4 @@
Pleroma install containing the real ToS for your instance.</p> Pleroma install containing the real ToS for your instance.</p>
<p>See the <a href='https://docs.pleroma.social/backend/configuration/static_dir/'>Pleroma documentation</a> for more information.</p> <p>See the <a href='https://docs.pleroma.social/backend/configuration/static_dir/'>Pleroma documentation</a> for more information.</p>
<br> <br>
<img src="/static/logo.svg" alt="Pleroma Logo" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" /> <img src="/static/logo.svg" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" />

View file

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

View file

@ -1,5 +0,0 @@
sonar.projectKey=Pleroma-FE
sonar.qualitygatesonar.host.url.wait=true
sonar.host.url=https://sonarqube.pleroma.dev
sonar.sources=src,build,tools
sonar.tests=test

View file

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

View file

@ -1,10 +1,9 @@
// stylelint-disable rscss/class-format
/* stylelint-disable no-descending-specificity */
@use "panel"; @use "panel";
@import '@fortawesome/fontawesome-svg-core/styles.css'; @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';
// stylelint-disable rscss/class-format
/* stylelint-disable no-descending-specificity */
:root { :root {
--status-margin: 0.75em; --status-margin: 0.75em;
@ -22,7 +21,7 @@
} }
html { html {
font-size: var(--textSize, 1rem); font-size: var(--textSize, 14px);
--navbar-height: var(--navbarSize, 3.5rem); --navbar-height: var(--navbarSize, 3.5rem);
--emoji-size: var(--emojiSize, 32px); --emoji-size: var(--emojiSize, 32px);
@ -51,7 +50,7 @@ body {
// have a cursor/pointer to operate them // have a cursor/pointer to operate them
@media (any-pointer: fine) { @media (any-pointer: fine) {
* { * {
scrollbar-color: var(--icon) transparent; scrollbar-color: var(--fg) transparent;
&::-webkit-scrollbar { &::-webkit-scrollbar {
background: transparent; background: transparent;
@ -131,7 +130,7 @@ body {
} }
// Body should have background to scrollbar otherwise it will use white (body color?) // Body should have background to scrollbar otherwise it will use white (body color?)
html { html {
scrollbar-color: var(--icon) var(--wallpaper); scrollbar-color: var(--fg) var(--wallpaper);
background: var(--wallpaper); background: var(--wallpaper);
} }
} }
@ -145,25 +144,6 @@ h4 {
margin: 0; margin: 0;
} }
code {
background: var(--background);
border: 1px solid var(--border);
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 { .iconLetter {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
@ -220,7 +200,6 @@ nav {
background-color: var(--wallpaper); background-color: var(--wallpaper);
background-image: var(--body-background-image); background-image: var(--body-background-image);
background-position: 50%; background-position: 50%;
transition: background-image 1s;
} }
.underlay { .underlay {
@ -403,10 +382,6 @@ nav {
font-family: sans-serif; font-family: sans-serif;
font-family: var(--font); font-family: var(--font);
&.-transparent {
backdrop-filter: blur(0.125em) contrast(60%);
}
&::-moz-focus-inner { &::-moz-focus-inner {
border: none; border: none;
} }
@ -431,14 +406,6 @@ nav {
button:not(.button-default) { button:not(.button-default) {
color: var(--text); color: var(--text);
font-size: 100%; font-size: 100%;
text-align: initial;
padding: 0;
background: none;
border: none;
outline: none;
display: inline;
font-family: inherit;
line-height: unset;
} }
&.disabled { &.disabled {
@ -446,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, .menu-item,
.list-item { .list-item {
display: block; display: block;
@ -464,6 +470,22 @@ nav {
--__line-height: 1.5em; --__line-height: 1.5em;
--__horizontal-gap: 0.75em; --__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em; --__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 { .button-unstyled {
@ -487,12 +509,6 @@ nav {
} }
} }
label {
&.-disabled {
color: var(--textFaint);
}
}
input, input,
textarea { textarea {
border: none; border: none;
@ -509,10 +525,6 @@ textarea {
height: unset; height: unset;
} }
&::placeholder {
color: var(--textFaint)
}
--_padding: 0.5em; --_padding: 0.5em;
border: none; border: none;
@ -533,10 +545,6 @@ textarea {
&[disabled="disabled"], &[disabled="disabled"],
&.disabled { &.disabled {
cursor: not-allowed; cursor: not-allowed;
color: var(--textFaint);
/* stylelint-disable-next-line declaration-no-important */
background-color: transparent !important;
} }
&[type="range"] { &[type="range"] {
@ -562,8 +570,6 @@ textarea {
& + label::before { & + label::before {
opacity: 0.5; opacity: 0.5;
} }
background-color: var(--background);
} }
+ label::before { + label::before {
@ -663,8 +669,7 @@ option {
list-style: none; list-style: none;
display: grid; display: grid;
grid-auto-flow: row dense; grid-auto-flow: row dense;
grid-template-columns: repeat(auto-fit, minmax(20em, 1fr)); grid-template-columns: 1fr 1fr;
gap: 0.5em;
li { li {
border: 1px solid var(--border); border: 1px solid var(--border);
@ -674,6 +679,11 @@ option {
} }
} }
.btn-block {
display: block;
width: 100%;
}
.btn-group { .btn-group {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@ -685,6 +695,7 @@ option {
--_roundness-right: 0; --_roundness-right: 0;
position: relative; position: relative;
flex: 1 1 auto;
} }
> *:first-child, > *:first-child,
@ -731,15 +742,17 @@ option {
} }
&.-dot { &.-dot {
min-height: 0.6em; min-height: 8px;
max-height: 0.6em; max-height: 8px;
min-width: 0.6em; min-width: 8px;
max-width: 0.6em; max-width: 8px;
left: calc(50% + 0.5em);
top: calc(50% - 1em);
line-height: 0;
padding: 0; 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 { &.-counter {
@ -761,19 +774,6 @@ option {
padding: 0 0.25em; padding: 0 0.25em;
border-radius: var(--roundness); border-radius: var(--roundness);
border: 1px solid var(--border); 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 { .faint {
@ -783,20 +783,21 @@ option {
color: var(--text); color: var(--text);
} }
.notice-dismissible { .visibility-notice {
display: flex; padding: 0.5em;
padding: 0.75em 1em; border: 1px solid var(--textFaint);
align-items: baseline; border-radius: var(--roundness);
line-height: 1.5; }
p, .notice-dismissible {
span { padding-right: 4rem;
display: block; position: relative;
flex: 1 1 auto;
margin: 0;
}
.dismiss { .dismiss {
position: absolute;
top: 0;
right: 0;
padding: 0.5em;
color: inherit; color: inherit;
} }
} }
@ -923,9 +924,7 @@ option {
margin: -1px; margin: -1px;
overflow: hidden; overflow: hidden;
visibility: visible; visibility: visible;
/* stylelint-disable property-no-deprecated */
clip: rect(0 0 0 0); clip: rect(0 0 0 0);
/* stylelint-enable property-no-deprecated */
padding: 0; padding: 0;
position: absolute; position: absolute;
} }
@ -937,6 +936,12 @@ option {
#splash { #splash {
pointer-events: none; pointer-events: none;
transition: opacity 0.5s;
opacity: 1;
&.hidden {
opacity: 0;
}
#status { #status {
&.css-ok { &.css-ok {
@ -1075,7 +1080,7 @@ option {
scale: 1.0063 0.9938; scale: 1.0063 0.9938;
translate: 0 -10%; translate: 0 -10%;
transform: rotateZ(var(--defaultZ)); transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in-out; animation-timing-function: ease-in-ou;
} }
90% { 90% {

View file

@ -26,12 +26,12 @@
class="column -scrollable" class="column -scrollable"
:class="{ '-show-scrollbar': showScrollbars }" :class="{ '-show-scrollbar': showScrollbars }"
> >
<UserPanel /> <user-panel />
<template v-if="layoutType !== 'mobile'"> <template v-if="layoutType !== 'mobile'">
<NavPanel /> <nav-panel />
<InstanceSpecificPanel v-if="showInstanceSpecificPanel" /> <instance-specific-panel v-if="showInstanceSpecificPanel" />
<FeaturesPanel v-if="!currentUser && showFeaturesPanel" /> <features-panel v-if="!currentUser && showFeaturesPanel" />
<WhoToFollowPanel v-if="currentUser && suggestionsEnabled" /> <who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
<div id="notifs-sidebar" /> <div id="notifs-sidebar" />
</template> </template>
</div> </div>
@ -60,8 +60,8 @@
/> />
</div> </div>
<MediaModal /> <MediaModal />
<ShoutPanel <shout-panel
v-if="currentUser && !hideShoutbox && shoutJoined" v-if="currentUser && shout && !hideShoutbox"
:floating="true" :floating="true"
class="floating-shout mobile-hidden" class="floating-shout mobile-hidden"
:class="{ '-left': shoutboxPosition }" :class="{ '-left': shoutboxPosition }"
@ -73,9 +73,7 @@
<StatusHistoryModal v-if="editingAvailable" /> <StatusHistoryModal v-if="editingAvailable" />
<SettingsModal :class="layoutModalClass" /> <SettingsModal :class="layoutModalClass" />
<UpdateNotification /> <UpdateNotification />
<GlobalError />
<GlobalNoticeList /> <GlobalNoticeList />
<FollowRequestConfirm v-if="currentUser" />
</div> </div>
</template> </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(Boolean)
.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,
}) => {
const payload = {}
if (sensitive !== undefined) {
payload['sensitive'] = sensitive
}
if (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 (startsAt !== undefined) {
payload.starts_at = startsAt ? new Date(startsAt).toISOString() : null
}
if (endsAt !== undefined) {
payload.ends_at = endsAt ? new Date(endsAt).toISOString() : null
}
if (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,94 +0,0 @@
import { paramsString, promisedRequest } from './helpers.js'
import {
parseChat,
parseChatMessage,
} 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 }) => ({
data: data.map(parseChat).filter(Boolean),
}))
export const getOrCreateChat = ({ accountId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
}).then(({ data }) => ({ data: parseChat(data) }))
export const chatMessages = ({
id,
credentials,
maxId,
sinceId,
limit = 20,
}) => {
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET',
credentials,
}).then(({ data }) => ({
data: data.map(parseChatMessage).filter(Boolean),
}))
}
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,
}).then(({ data }) => ({
data: parseChatMessage(data),
}))
}
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,139 +0,0 @@
import { snakeCase } from 'lodash-es'
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 TypeError('Params are not an object!')
}
const entries = (() => {
if (params instanceof Map) {
return [...params.entries()]
} else {
return Object.entries(params)
}
})()
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 TypeError('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' || v === undefined)
throw new TypeError('Array param cannot contain non-primitives!')
})
})
if (nonArrays.length + arrays.length === 0) return ''
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 = 'GET',
url,
payload,
formData,
forceContentType,
cache,
credentials,
headers = {},
}) => {
const options = {
method,
credentials: 'same-origin',
headers: {
Accept: forceContentType ?? '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 = Number.parseInt(
response.headers.get('content-length'),
)
if (contentLength === 0) return null
switch (forceContentType ?? 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, timestamp: Date.now() }
} 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,299 +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 })}`
export const MASTODON_FOLLOWING_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/following${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
export 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}`
export const MASTODON_STATUS_CONTEXT_URL = (id) =>
`/api/v1/statuses/${id}/context`
export const MASTODON_STATUS_SOURCE_URL = (id) =>
`/api/v1/statuses/${id}/source`
export 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}`
export const MASTODON_STATUS_FAVORITEDBY_URL = (id) =>
`/api/v1/statuses/${id}/favourited_by`
export 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'
export 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?.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,
withRelationships: true,
}),
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 = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_HISTORY_URL(id),
credentials,
}).then(({ data, ...rest }) => {
return {
...rest,
data: [...data].reverse().map((item) => {
// History data is missing a lot of stuff present in original
// but we're really only missing the id for the timeago, the
// rest seem to render just fine.
item.id = id
return 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,204 +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,
userPinned: 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',
'userPinned',
'media',
'list',
'publicFavorites',
'tag',
'quotes',
])
const params = {
minId,
sinceId,
maxId,
limit: 20,
}
const id = (() => {
switch (timeline) {
case 'user':
case 'userPinned':
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 === 'userPinned') {
params.pinned = 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,
}
})
}

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