Merge branch 'weight-removal' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2026-09-02 21:04:46 +03:00
commit f8ba217f0c
55 changed files with 2354 additions and 4109 deletions

View file

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

@ -14,7 +14,7 @@ labels:
steps:
build:
image: docker.io/node:20-alpine
image: docker.io/node:26-alpine
commands:
- apk add --no-cache zip git
- yarn --frozen-lockfile

View file

@ -11,7 +11,7 @@ labels:
steps:
build:
image: docker.io/node:20-alpine
image: docker.io/node:26-alpine
commands:
- yarn --frozen-lockfile
- yarn build

View file

@ -9,7 +9,7 @@ when:
steps:
install-depends:
image: &node-image
docker.io/node:20-alpine
docker.io/node:26-alpine
commands:
- yarn --frozen-lockfile

View file

@ -81,7 +81,7 @@ In 99% cases PleromaFE uses [MastoAPI](https://docs.joinmastodon.org/api/) with
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.
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.
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.
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.

View file

@ -1,29 +1,15 @@
import js from '@eslint/js'
import { defineConfig, globalIgnores } from 'eslint/config'
import vue from 'eslint-plugin-vue'
import globals from 'globals'
export default defineConfig([
...vue.configs['flat/recommended'],
globalIgnores(['**/*.js', 'build/', 'dist/', 'config/']),
{
files: ['src/**/*.vue'],
plugins: { js },
extends: ['js/recommended'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
parserOptions: {
parser: '@babel/eslint-parser',
},
globals: {
...globals.browser,
...globals.vitest,
...globals.chai,
...globals.commonjs,
...globals.serviceworker,
},
},
rules: {

View file

@ -21,7 +21,7 @@
"lint-fix": "yarn exec eslint -- --fix; yarn exec stylelint '**/*.scss' '**/*.vue' --fix; biome check --write"
},
"dependencies": {
"@babel/runtime": "7.28.4",
"@babel/runtime": "^8.0.0",
"@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.1.0",
@ -38,79 +38,58 @@
"click-outside-vue3": "4.0.1",
"cropperjs": "2.0.1",
"escape-html": "1.0.3",
"globals": "^16.0.0",
"hash-sum": "^2.0.0",
"js-cookie": "3.0.5",
"localforage": "1.10.0",
"lodash-es": "4.17.21",
"parse-link-header": "2.0.0",
"phoenix": "1.8.1",
"pinia": "^3.0.4",
"pinia": "4.0.3",
"punycode.js": "2.3.1",
"qrcode": "1.5.4",
"querystring-es3": "0.2.1",
"url": "0.11.4",
"utf8": "3.0.0",
"uuid": "11.1.0",
"vue": "3.5.22",
"vue-i18n": "11",
"vue-router": "4.6.4",
"vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0"
"vue": "3.5.42",
"vue-i18n": "11.4.0",
"vue-router": "5.3.1",
"vue-virtual-scroller": "^3.0.5"
},
"devDependencies": {
"@babel/core": "7.28.5",
"@babel/eslint-parser": "7.28.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@babel/register": "7.28.3",
"@biomejs/biome": "2.3.11",
"@babel/core": "^8.0.0",
"@babel/eslint-parser": "^8.0.0",
"@babel/plugin-transform-runtime": "^8.0.0",
"@babel/preset-env": "^8.0.0",
"@babel/register": "^8.0.0",
"@biomejs/biome": "2.5.11",
"@pinia/testing": "1.0.3",
"@ungap/event-target": "0.2.4",
"@vitejs/devtools": "^0.3.1",
"@vitejs/plugin-vue": "^6.0.7",
"@vitejs/plugin-vue": "^6.0.8",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.7",
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.5.0",
"@vitest/browser": "^4.1.11",
"@vitest/browser-playwright": "^4.1.11",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"@vue/babel-plugin-jsx": "3.0.0",
"@vue/compiler-sfc": "3.5.22",
"@vue/test-utils": "2.4.6",
"@vue/devtools-api": "^8.1.5",
"@vue/test-utils": "2.5.0",
"autoprefixer": "10.4.21",
"chai": "5.3.3",
"chalk": "5.6.2",
"chromedriver": "135.0.4",
"connect-history-api-fallback": "2.0.0",
"cross-spawn": "7.0.6",
"custom-event-polyfill": "1.0.7",
"eslint": "9.39.2",
"eslint-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.23.1",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-vue": "10.6.2",
"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-es": "4.17.21",
"msw": "2.14.6",
"nightwatch": "3.12.2",
"oxc": "^1.0.1",
"playwright": "1.61.0",
"postcss": "8.5.6",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"sass-embedded": "^1.100.0",
"selenium-server": "3.141.59",
"semver": "7.7.3",
"serve-static": "2.2.0",
"shelljs": "0.10.0",
"sinon": "20.0.0",
"sinon-chai": "4.0.1",
"stylelint": "16.25.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recommended": "^16.0.0",
@ -120,12 +99,13 @@
"vite": "^8.0.0",
"vite-plugin-eslint2": "^5.1.0",
"vite-plugin-stylelint": "^6.1.0",
"vitest": "^4.1.7",
"vite-plugin-vue-devtools": "^8.2.1",
"vitest": "^4.1.11",
"vue-eslint-parser": "10.2.0"
},
"type": "module",
"engines": {
"node": ">= 16.0.0"
"node": ">= 26.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View file

@ -17,8 +17,11 @@ const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
const ALIASES_URL = '/api/pleroma/aliases'
const NOTIFICATION_SETTINGS_URL = ({ blockFromStrangers, hideNotificationContents }) =>
`/api/pleroma/notification_settings${paramsString({ blockFromStrangers, hideNotificationContents })}`
const NOTIFICATION_SETTINGS_URL = ({
blockFromStrangers,
hideNotificationContents,
}) =>
`/api/pleroma/notification_settings${paramsString({ blockFromStrangers, hideNotificationContents })}`
export const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'

View file

@ -71,7 +71,6 @@ const chatNew = {
this.loading = true
this.userIds = []
this.$store
useSearchStore()
.search({ q: query, resolve: true, type: 'accounts' })
.then((data) => {

View file

@ -1,11 +1,10 @@
import { cloneDeep } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Gallery from 'src/components/gallery/gallery.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useDraftsStore } from 'src/stores/drafts.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -33,7 +32,6 @@ const Draft = {
},
data() {
return {
referenceDraft: cloneDeep(this.draft),
editing: false,
showingConfirmDialog: false,
}
@ -50,14 +48,6 @@ const Draft = {
return {}
}
},
safeToSave() {
return (
this.draft.status ||
this.draft.files?.length ||
this.draft.hasPoll ||
this.draft.hasQuote
)
},
postStatusFormProps() {
return {
draftId: this.draft.id,
@ -69,18 +59,12 @@ const Draft = {
? useStatusesStore().allStatuses.get(this.draft.refId)
: undefined
},
localCollapseSubjectDefault() {
return useMergedConfigStore().mergedConfig.collapseMessageWithSubject
},
},
watch: {
editing(newVal) {
if (newVal) return
if (this.safeToSave) {
this.$store.dispatch('addOrSaveDraft', { draft: this.draft })
} else {
this.$store.dispatch('addOrSaveDraft', { draft: this.referenceDraft })
}
// (Post|Edit)StatusForm handles draft saving
this.$refs.form.saveDraft()
},
},
methods: {
@ -91,9 +75,11 @@ const Draft = {
this.showingConfirmDialog = true
},
doAbandon() {
this.$store.dispatch('abandonDraft', { id: this.draft.id }).then(() => {
this.hideConfirmDialog()
})
useDraftsStore()
.abandonDraft(this.draft.id)
.then(() => {
this.hideConfirmDialog()
})
},
hideConfirmDialog() {
this.showingConfirmDialog = false

View file

@ -67,11 +67,13 @@
<div v-if="editing">
<PostStatusForm
v-if="draft.type !== 'edit'"
ref="form"
:hide-draft="true"
v-bind="postStatusFormProps"
/>
<EditStatusForm
v-else
ref="form"
:hide-draft="true"
:params="postStatusFormProps"
/>

View file

@ -3,6 +3,8 @@ import { defineAsyncComponent } from 'vue'
import Draft from 'src/components/draft/draft.vue'
import List from 'src/components/list/list.vue'
import { useDraftsStore } from 'src/stores/drafts.js'
const Drafts = {
components: {
Draft,
@ -18,7 +20,7 @@ const Drafts = {
},
computed: {
drafts() {
return this.$store.getters.draftsArray
return useDraftsStore().draftsArray
},
},
methods: {
@ -26,8 +28,8 @@ const Drafts = {
this.showingConfirmDialog = true
},
doAbandonAll() {
this.$store
.dispatch('abandonAllDrafts')
useDraftsStore()
.abandonAllDrafts()
.then(() => this.hideConfirmDialog())
},
hideConfirmDialog() {

View file

@ -15,9 +15,11 @@ const EditStatusForm = {
requestClose() {
this.$refs.postStatusForm.requestClose()
},
saveDraft() {
this.$refs.postStatusForm.saveDraft()
},
doEditStatus({ status, spoilerText, sensitive, media, contentType, poll }) {
const params = {
store: this.$store,
statusId: this.params.statusId,
status,
spoilerText,

View file

@ -30,7 +30,7 @@ const ExtraNotifications = {
return (
this.mergedConfig.showExtraNotifications &&
this.mergedConfig.showAnnouncementsInExtraNotifications &&
this.unreadAnnouncementCount
this.unreadAnnouncementsCount
)
},
shouldShowFollowRequests() {
@ -56,7 +56,7 @@ const ExtraNotifications = {
return useUsersStore().currentUser
},
...mapState(useAnnouncementsStore, {
unreadAnnouncementCount: 'unreadAnnouncementCount',
unreadAnnouncementsCount: 'unreadAnnouncementsCount',
}),
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useChatsStore, ['unreadChatsCount']),

View file

@ -31,7 +31,7 @@
class="fa-scale-110 icon"
icon="bullhorn"
/>
{{ $t('notifications.unread_announcements', { num: unreadAnnouncementCount }, unreadAnnouncementCount) }}
{{ $t('notifications.unread_announcements', { num: unreadAnnouncementsCount }, unreadAnnouncementsCount) }}
</router-link>
</div>
<div

View file

@ -122,7 +122,6 @@ const mediaUpload = {
},
async uploadFile(file) {
const self = this
const store = this.$store
if (file.size > useInstanceStore().uploadlimit) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(
@ -145,7 +144,7 @@ const mediaUpload = {
self.$emit('uploading')
self.uploadCount++
statusPosterService.uploadMedia({ store, formData }).then(
statusPosterService.uploadMedia({ formData }).then(
(fileData) => {
self.$emit('uploaded', fileData)
self.decreaseUploadCount()

View file

@ -70,7 +70,7 @@ const MobileNav = {
countExtraNotifications(
useMergedConfigStore().mergedConfig,
useChatsStore().unreadChatsCount,
useAnnouncementsStore().unreadAnnouncementCount,
useAnnouncementsStore().unreadAnnouncementsCount,
useFollowRequestsStore().followRequestsCount,
)
)
@ -96,7 +96,7 @@ const MobileNav = {
closingDrawerMarksAsSeen() {
return useMergedConfigStore().mergedConfig.closingDrawerMarksAsSeen
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapState(useAnnouncementsStore, ['unreadAnnouncementsCount']),
...mapState(useMergedConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),

View file

@ -19,7 +19,7 @@
icon="bars"
/>
<div
v-if="(unreadChatsCount && !chatsPinned) || unreadAnnouncementCount"
v-if="(unreadChatsCount && !chatsPinned) || unreadAnnouncementsCount"
class="badge -dot -notification"
/>
</button>

View file

@ -112,7 +112,7 @@ const NavPanel = {
},
computed: {
...mapState(useAnnouncementsStore, {
unreadAnnouncementCount: 'unreadAnnouncementCount',
unreadAnnouncementsCount: 'unreadAnnouncementsCount',
supportsAnnouncements: (store) => store.supportsAnnouncements,
}),
...mapState(useInstanceCapabilitiesStore, [

View file

@ -76,7 +76,7 @@ export const ROOT_ITEMS = {
icon: 'comments',
label: 'nav.chats',
badgeStyle: 'notification',
badgeGetter: 'unreadChatsCount',
badgeGetter: 'unreadChats',
criteria: ['chats'],
},
friendRequests: {
@ -85,7 +85,7 @@ export const ROOT_ITEMS = {
label: 'nav.friend_requests',
badgeStyle: 'notification',
criteria: ['lockedUser'],
badgeGetter: 'followRequestsCount',
badgeGetter: 'followRequests',
},
about: {
route: 'about',
@ -99,7 +99,7 @@ export const ROOT_ITEMS = {
label: 'nav.announcements',
store: 'announcements',
badgeStyle: 'notification',
badgeGetter: 'unreadAnnouncementCount',
badgeGetter: 'unreadAnnouncements',
criteria: ['announcements'],
},
drafts: {
@ -107,7 +107,7 @@ export const ROOT_ITEMS = {
icon: 'file-pen',
label: 'nav.drafts',
badgeStyle: 'neutral',
badgeGetter: 'draftCount',
badgeGetter: 'drafts',
},
}

View file

@ -1,9 +1,12 @@
import { mapState, mapStores } from 'pinia'
import { mapState } from 'pinia'
import { routeTo } from 'src/components/navigation/navigation.js'
import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useDraftsStore } from 'src/stores/drafts.js'
import { useFollowRequestsStore } from 'src/stores/follow_requests.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
@ -40,11 +43,19 @@ const NavigationEntry = {
routeTo() {
return routeTo(this.item, this.currentUser)
},
getters() {
return this.$store.getters
badges() {
return {
drafts: this.draftsCount,
unreadAnnouncements: this.unreadAnnouncementsCount,
followRequests: this.followRequestsCount,
unreadChats: this.unreadChatsCount,
}
},
...mapStores(useAnnouncementsStore),
...mapState(useAnnouncementsStore, ['unreadAnnouncementsCount']),
...mapState(useDraftsStore, ['draftsCount']),
...mapState(useUsersStore, ['currentUser']),
...mapState(useChatsStore, ['unreadChatsCount']),
...mapState(useFollowRequestsStore, ['followRequestsCount']),
...mapState(useSyncConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems),

View file

@ -47,17 +47,11 @@
</component>
<slot />
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
v-if="item.badgeGetter && badges[item.badgeGetter]"
class="badge"
:class="[`-${item.badgeStyle}`]"
>
{{ getters[item.badgeGetter] }}
</div>
<div
v-else-if="item.badgeGetter && item.store && this[`${item.store}Store`][item.badgeGetter]"
class="badge badge-notification"
>
{{ this[`${item.store}Store`][item.badgeGetter] }}
{{ badges[item.badgeGetter] }}
</div>
<button
v-if="showPin && currentUser"

View file

@ -13,6 +13,8 @@ import {
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import { useChatsStore } from 'src/stores/chats.js'
import { useDraftsStore } from 'src/stores/drafts.js'
import { useFollowRequestsStore } from 'src/stores/follow_requests.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
@ -56,18 +58,27 @@ const NavPanel = {
},
components: {},
computed: {
getters() {
return this.$store.getters
badges() {
return {
drafts: this.draftsCount,
unreadAnnouncements: this.unreadAnnouncementsCount,
followRequests: this.followRequestsCount,
unreadChats: this.unreadChatsCount,
}
},
...mapState(useListsStore, {
lists: getListEntries,
}),
...mapState(useAnnouncementsStore, {
supportsAnnouncements: (store) => store.supportsAnnouncements,
unreadAnnouncementsCount: 'unreadAnnouncementsCount',
}),
...mapState(useDraftsStore, ['draftsCount']),
...mapState(useFollowRequestsStore, ['followRequestsCount']),
...mapState(useBookmarkFoldersStore, {
bookmarks: getBookmarkFolderEntries,
}),
...mapState(useChatsStore, ['unreadChatsCount']),
...mapState(useSyncConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems),
@ -78,7 +89,6 @@ const NavPanel = {
'localBubble',
]),
...mapState(useUsersStore, ['currentUser']),
...mapState(useFollowRequestsStore, ['followRequestsCount']),
pinnedList() {
if (!this.currentUser) {
return filterNavigation(

View file

@ -23,7 +23,7 @@
:src="item.iconEmojiUrl"
/>
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
v-if="item.badgeGetter && badges[item.badgeGetter]"
class="badge -dot"
:class="[`-${item.badgeStyle}`]"
/>

View file

@ -13,6 +13,7 @@ import {
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useFollowRequestsStore } from 'src/stores/follow_requests.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
@ -148,8 +149,7 @@ const Notification = {
id: this.user.id,
credentials: useOAuthStore().token,
})
// TODO Fix this
this.$store.dispatch('removeFollowRequest', this.user)
useFollowRequestsStore().remove(this.user.id)
useNotificationsStore().markSingleNotificationAsSeen(this.notification.id)
this.hideApproveConfirmDialog()
},
@ -166,8 +166,7 @@ const Notification = {
credentials: useOAuthStore().token,
}).then(() => {
useNotificationsStore().dismissNotificationLocal(this.notification.id)
// TODO Fix this
this.$store.dispatch('removeFollowRequest', this.user)
useFollowRequestsStore().remove(this.user.id)
})
this.hideDenyConfirmDialog()
},

View file

@ -110,7 +110,7 @@ const Notifications = {
return countExtraNotifications(
useMergedConfigStore().mergedConfig,
useChatsStore().unreadChatsCount,
useAnnouncementsStore().unreadAnnouncementCount,
useAnnouncementsStore().unreadAnnouncementsCount,
useFollowRequestsStore().followRequestsCount,
)
},
@ -118,7 +118,7 @@ const Notifications = {
return (
this.unseenNotifications.length +
this.unreadChatsCount +
this.unreadAnnouncementCount
this.unreadAnnouncementsCount
)
},
loading() {
@ -157,7 +157,7 @@ const Notifications = {
showExtraNotifications() {
return !this.noExtra
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapState(useAnnouncementsStore, ['unreadAnnouncementsCount']),
...mapState(useChatsStore, ['unreadChatsCount']),
...mapState(useInterfaceStore, ['layoutType']),
},

View file

@ -24,6 +24,7 @@ import { findOffset } from '../../services/offset_finder/offset_finder.service.j
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import { useDraftsStore } from 'src/stores/drafts.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
@ -400,8 +401,6 @@ const PostStatusForm = {
contentType: this.newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
store: this.$store,
}
},
@ -412,7 +411,6 @@ const PostStatusForm = {
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
@ -574,7 +572,7 @@ const PostStatusForm = {
...mapState(useUsersStore, ['currentUser']),
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
mobileLayout: (state) => state.mobileLayout,
}),
},
watch: {
@ -751,7 +749,6 @@ const PostStatusForm = {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({
store: this.$store,
id,
description,
})
@ -985,13 +982,13 @@ const PostStatusForm = {
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return this.$store
.dispatch('addOrSaveDraft', {
draft: {
type: this.statusType,
refId: this.refId,
...this.newStatus,
},
return useDraftsStore()
.addOrSaveDraft({
type: this.statusType,
refId: this.refId,
...this.newStatus,
// Draft ID overwrites status ID (which is undefined for fresh statuses)
id: this.draftId,
})
.then((id) => {
if (this.newStatus.id !== id) {
@ -1024,14 +1021,14 @@ const PostStatusForm = {
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.draftId })
return useDraftsStore().abandonDraft(this.draftId)
},
getDraft() {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
const maybeDraft = useDraftsStore().drafts.get(this.draftId)
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
const existingDrafts = useDraftsStore().draftsByTypeAndRefId(
this.statusType,
this.refId,
)

View file

@ -8,8 +8,8 @@ import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useProfileConfigStore } from 'src/stores/profile_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
export default {
components: {
@ -413,8 +413,8 @@ export default {
hardReset() {
switch (this.realSource) {
case 'admin':
return this.$store
.dispatch('resetAdminSetting', { path: this.path })
return useAdminSettingsStore()
.resetAdminSetting({ path: this.path })
.then(() => {
this.draft = this.state
})

View file

@ -1,5 +1,3 @@
// eslint-disable-next-line no-unused
import { mapState } from 'pinia'
import { Fragment } from 'vue'

View file

@ -16,8 +16,8 @@ import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.j
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useProfileConfigStore } from 'src/stores/profile_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { updateProfile } from 'src/api/user.js'

View file

@ -1,5 +1,4 @@
import { mapActions, mapState } from 'pinia'
import { mapGetters } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import UserCard from 'src/components/user_card/user_card.vue'
@ -8,6 +7,7 @@ import { unseenNotifications } from '../../services/notification_utils/notificat
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useChatsStore } from 'src/stores/chats.js'
import { useDraftsStore } from 'src/stores/drafts.js'
import { useFollowRequestsStore } from 'src/stores/follow_requests.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
@ -62,10 +62,6 @@ const SideDrawer = {
GestureService.DIRECTION_LEFT,
this.toggleDrawer,
)
if (this.currentUser?.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: {
UserCard,
@ -101,7 +97,7 @@ const SideDrawer = {
...mapState(useFollowRequestsStore, ['followRequestsCount']),
...mapState(useAnnouncementsStore, [
'supportsAnnouncements',
'unreadAnnouncementCount',
'unreadAnnouncementsCount',
]),
...mapState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable',
@ -114,7 +110,7 @@ const SideDrawer = {
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
...mapState(useChatsStore, ['unreadChatsCount']),
...mapGetters(['draftCount']),
...mapState(useDraftsStore, ['draftCount']),
},
methods: {
toggleDrawer() {

View file

@ -248,10 +248,10 @@
icon="bullhorn"
/> {{ $t("nav.announcements") }}
<span
v-if="unreadAnnouncementCount"
v-if="unreadAnnouncementsCount"
class="badge -notification"
>
{{ unreadAnnouncementCount }}
{{ unreadAnnouncementsCount }}
</span>
</router-link>
</li>

View file

@ -97,9 +97,6 @@ const StatusActionButtons = {
replying: this.replying,
emojiPickerShown: this.emojiPickerShown,
emit: this.$emit,
dispatch: this.$store.dispatch,
state: this.$store.state,
getters: this.$store.getters,
router: this.$router,
currentUser: this.currentUser,
loggedIn: !!this.currentUser,

View file

@ -29,14 +29,13 @@ const StickerPicker = {
}
},
pick(sticker, name) {
const store = this.$store
// TODO remove this workaround by finding a way to bypass reuploads
fetch(sticker).then((res) => {
res.blob().then((blob) => {
const file = new File([blob], name, { mimetype: 'image/png' })
const formData = new FormData()
formData.append('file', file)
statusPosterService.uploadMedia({ store, formData }).then(
statusPosterService.uploadMedia({ formData }).then(
(fileData) => {
this.$emit('uploaded', fileData)
this.clear()

View file

@ -1,5 +1,3 @@
// eslint-disable-next-line no-unused
import { Fragment } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'

View file

@ -418,7 +418,6 @@ export default {
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {

View file

@ -1,11 +1,7 @@
import { cloneDeep, each, get, merge, set } from 'lodash'
import { cloneDeep, get, set } from 'lodash'
import { storage } from './storage.js'
import { useInterfaceStore } from 'src/stores/interface'
let loaded = false
const defaultReducer = (state, paths) =>
paths.length === 0
? state
@ -14,86 +10,10 @@ const defaultReducer = (state, paths) =>
return substate
}, {})
const saveImmedeatelyActions = [
'markNotificationsAsSeen',
'setHighlight',
'setOption',
'setClientData',
'setToken',
'clearToken',
]
const defaultStorage = (() => {
return storage
})()
export default function createPersistedState({
key = 'vuex-lz',
paths = [],
getState = (key, storage) => {
const value = storage.getItem(key)
return value
},
setState = (key, state, storage) => {
if (!loaded) {
console.info('waiting for old state to be loaded...')
return Promise.resolve()
} else {
return storage.setItem(key, state)
}
},
reducer = defaultReducer,
storage = defaultStorage,
subscriber = (store) => (handler) => store.subscribe(handler),
} = {}) {
return getState(key, storage).then((savedState) => {
return (store) => {
try {
if (savedState !== null && typeof savedState === 'object') {
// build user cache
const usersState = savedState.users || {}
usersState.usersObject = {}
const users = usersState.users || []
each(users, (user) => {
usersState.usersObject[user.id] = user
})
savedState.users = usersState
store.replaceState(merge({}, store.state, savedState))
}
loaded = true
} catch (e) {
console.error("Couldn't load state")
console.error(e)
loaded = true
}
subscriber(store)((mutation, state) => {
try {
if (saveImmedeatelyActions.includes(mutation.type)) {
setState(key, reducer(cloneDeep(state), paths), storage).then(
(success) => {
if (success !== undefined) {
if (mutation.type === 'setOption') {
useInterfaceStore().settingsSaved({ success })
}
}
},
(error) => {
if (mutation.type === 'setOption') {
useInterfaceStore().settingsSaved({ error })
}
},
)
}
} catch (e) {
console.error("Couldn't persist state:")
console.error(e)
}
})
}
})
}
/**
* This persists state for pinia, which falls back to read from the vuex state
* if pinia persisted state does not exist.

View file

@ -1,9 +1,7 @@
/* global process */
import { createPinia } from 'pinia'
import { createStore } from 'vuex'
import 'custom-event-polyfill'
import './lib/event_target_polyfill.js'
// Polyfill for Array.prototype.toSorted (ES2023)
@ -17,11 +15,8 @@ import { createI18n } from 'vue-i18n'
import afterStoreSetup from './boot/after_store.js'
import messages from './i18n/messages.js'
import createPersistedState, {
piniaPersistPlugin,
} from './lib/persisted_state.js'
import { piniaPersistPlugin } from './lib/persisted_state.js'
import { piniaPushNotificationsPlugin } from './lib/push_notifications_plugin.js'
import vuexModules from './modules/index.js'
import { piniaLanguagePlugin } from 'src/lib/language.js'
import { piniaStylePlugin } from 'src/lib/style.js'
@ -37,10 +32,6 @@ const i18n = createI18n({
messages.setLanguage(i18n.global, currentLocale)
const persistedStateOptions = {
paths: ['oauth', 'config'],
}
;(async () => {
const isFox = Math.floor(Math.random() * 2) > 0 ? '_fox' : ''
@ -69,20 +60,12 @@ const persistedStateOptions = {
try {
let storageError
const plugins = []
const pinia = createPinia()
pinia.use(piniaPersistPlugin())
pinia.use(piniaLanguagePlugin)
pinia.use(piniaStylePlugin)
pinia.use(piniaPushNotificationsPlugin)
try {
const persistedState = await createPersistedState(persistedStateOptions)
plugins.push(persistedState)
} catch (e) {
console.error('Storage error', e)
storageError = e
}
document.querySelector('#splash').classList.remove('initial-hidden')
document.querySelector('#mascot').src =
`/static/pleromatan_apology${isFox}_small.webp`
@ -93,18 +76,8 @@ const persistedStateOptions = {
'update.art_by',
{ linkToArtist: 'pipivovott' },
)
const store = createStore({
modules: vuexModules,
plugins,
options: {
devtools: process.env.NODE_ENV !== 'production',
},
strict: false, // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})
window.vuex = store
// Temporarily passing pinia and vuex stores along with storageError result until migration is fully complete.
return await afterStoreSetup({ pinia, store, storageError, i18n })
// Temporarily passing pinia stores along with storageError result until migration is fully complete.
return await afterStoreSetup({ pinia, storageError, i18n })
} catch (e) {
splashError(i18n, e)
}

View file

@ -1,99 +0,0 @@
import { storage } from 'src/lib/storage.js'
export const defaultState = {
drafts: {},
}
export const mutations = {
addOrSaveDraft(state, { draft }) {
state.drafts[draft.id] = draft
},
abandonDraft(state, { id }) {
delete state.drafts[id]
},
loadDrafts(state, data) {
state.drafts = data
},
}
const storageKey = 'pleroma-fe-drafts'
/*
* Note: we do not use the persist state plugin because
* it is not impossible for a user to have two windows at
* the same time. The persist state plugin is just overriding
* everything with the current state. This isn't good because
* if a draft is created in one window and another draft is
* created in another, the draft in the first window will just
* be overriden.
* Here, we can't guarantee 100% atomicity unless one uses
* different keys, which will just pollute the whole storage.
* It is indeed best to have backend support for this.
*/
const getStorageData = async () =>
(await storage.getItem(storageKey)) ||
{
/* no-op */
}
const saveDraftToStorage = async (draft) => {
const currentData = await getStorageData()
currentData[draft.id] = JSON.parse(JSON.stringify(draft))
await storage.setItem(storageKey, currentData)
}
const deleteDraftFromStorage = async (ids) => {
const currentData = await getStorageData()
ids.forEach((id) => {
delete currentData[id]
})
await storage.setItem(storageKey, currentData)
}
export const actions = {
async addOrSaveDraft(store, { draft }) {
const id = draft.id || new Date().getTime().toString()
const draftWithId = { ...draft, id }
store.commit('addOrSaveDraft', { draft: draftWithId })
await saveDraftToStorage(draftWithId)
return id
},
async abandonDraft(store, { id }) {
store.commit('abandonDraft', { id })
await deleteDraftFromStorage([id])
},
async abandonAllDrafts(store) {
const ids = Object.keys(store.state.drafts)
ids.forEach((id) => store.commit('abandonDraft', { id }))
await deleteDraftFromStorage(ids)
},
async loadDrafts(store) {
const currentData = await getStorageData()
store.commit('loadDrafts', currentData)
},
}
export const getters = {
draftsByTypeAndRefId(state) {
return (type, refId) => {
return Object.values(state.drafts).filter(
(draft) => draft.type === type && draft.refId === refId,
)
}
},
draftsArray(state) {
return Object.values(state.drafts)
},
draftCount(state) {
return Object.values(state.drafts).length
},
}
const drafts = {
state: defaultState,
mutations,
getters,
actions,
}
export default drafts

View file

@ -1,5 +1 @@
import drafts from './drafts.js'
export default {
drafts,
}
export default {}

View file

@ -40,7 +40,6 @@ export class RegistrationError extends Error {
// the error is probably a JSON object with a single key, "errors", whose value is another JSON object containing the real errors
if (typeof error === 'string') {
error = JSON.parse(error)
// eslint-disable-next-line
if (Object.hasOwn(error, 'error')) {
error = JSON.parse(error.error)
}

View file

@ -16,7 +16,7 @@ export const useAnnouncementsStore = defineStore('announcements', {
userActions: {},
}),
getters: {
unreadAnnouncementCount() {
unreadAnnouncementsCount() {
if (!useUsersStore().currentUser) {
return 0
}

76
src/stores/drafts.js Normal file
View file

@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { storage } from 'src/lib/storage.js'
const storageKey = 'pleroma-fe-drafts'
/*
* Note: we do not use the persist state plugin because
* it is not impossible for a user to have two windows at
* the same time. The persist state plugin is just overriding
* everything with the current state. This isn't good because
* if a draft is created in one window and another draft is
* created in another, the draft in the first window will just
* be overriden.
* Here, we can't guarantee 100% atomicity unless one uses
* different keys, which will just pollute the whole storage.
* It is indeed best to have backend support for this.
*/
const getStorageData = async () => await storage.getItem(storageKey)
const saveDraftToStorage = async (draft) => {
const currentData = (await getStorageData()) ?? {}
currentData[draft.id] = JSON.parse(JSON.stringify(draft))
await storage.setItem(storageKey, currentData)
}
const deleteDraftFromStorage = async (ids) => {
const currentData = (await getStorageData()) ?? {}
ids.forEach((id) => {
delete currentData[id]
})
await storage.setItem(storageKey, currentData)
}
export const useDraftsStore = defineStore('drafts', {
state: () => ({
drafts: new Map(),
}),
getters: {
draftsByTypeAndRefId(state) {
return (type, refId) => {
return [...state.drafts.values()].filter(
(draft) => draft.type === type && draft.refId === refId,
)
}
},
draftsArray(state) {
return [...state.drafts.values()]
},
draftsCount(state) {
return state.drafts.size
},
},
actions: {
async abandonDraft(id) {
this.drafts.delete(id)
await deleteDraftFromStorage([id])
},
async loadDrafts() {
const currentData = await getStorageData()
this.drafts = new Map(Object.entries(currentData))
},
async addOrSaveDraft(draft) {
const id = draft.id ?? new Date().getTime().toString()
const draftWithId = { ...draft, id }
this.drafts.set(id, draftWithId)
await saveDraftToStorage(draftWithId)
return id
},
async abandonAllDrafts(store) {
const ids = [...this.drafts.keys()]
ids.forEach((id) => this.abandonDraft(id))
await deleteDraftFromStorage(ids)
},
},
})

View file

@ -38,10 +38,6 @@ export const useInstanceCapabilitiesStore = defineStore(
}
this[capability] = value
if (capability === 'shoutAvailable') {
window.vuex.dispatch('initializeSocket')
}
},
},
},

View file

@ -97,7 +97,9 @@ export const settingsMap = {
}
export const defaultState = () => ({
config: Object.fromEntries(Object.keys(settingsMap).map((key) => [key, null]))
config: Object.fromEntries(
Object.keys(settingsMap).map((key) => [key, null]),
),
})
export const useProfileConfigStore = defineStore('profileConfig', {
@ -123,7 +125,7 @@ export const useProfileConfigStore = defineStore('profileConfig', {
return
}
useUsersStore().addNewUsers(result)
const [user] = useUsersStore().addNewUsers(result)
this.update(user)
} catch (e) {
console.warn('Error setting server-side option:', e)

View file

@ -5,6 +5,7 @@ import { defineStore } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useDraftsStore } from 'src/stores/drafts.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useFollowRequestsStore } from 'src/stores/follow_requests.js'
import { useInstanceStore } from 'src/stores/instance.js'
@ -14,11 +15,11 @@ import { useListsStore } from 'src/stores/lists.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useProfileConfigStore } from 'src/stores/profile_config.js'
import { useShoutStore } from 'src/stores/shout.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useProfileConfigStore } from 'src/stores/profile_config.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
@ -611,13 +612,6 @@ export const useUsersStore = defineStore('users', {
// Login/Logout
async loginUser(accessToken) {
const store = window.vuex
const dispatch =
store?.dispatch ??
(() => {
/* no-op */
}) // for tests
this.loggingIn = true
try {
@ -670,7 +664,7 @@ export const useUsersStore = defineStore('users', {
useSyncConfigStore().setFlag({ flag: 'configMigration', value: 0 })
/**/
if (user.token) {
if (user.token && useInstanceCapabilitiesStore().shoutAvailable) {
// Shoutbox
useShoutStore().initializeSocket()
useShoutStore().initializeShout()
@ -689,7 +683,6 @@ export const useUsersStore = defineStore('users', {
useBookmarkFoldersStore().startFetching()
if (user.locked) {
dispatch('startFetchingFollowRequests')
useFollowRequestsStore().startFetching()
}
@ -701,7 +694,7 @@ export const useUsersStore = defineStore('users', {
useAnnouncementsStore().startFetching()
this.fetchMutes()
dispatch('loadDrafts')
useDraftsStore().loadDrafts()
} catch (error) {
console.error(error)
@ -723,7 +716,6 @@ export const useUsersStore = defineStore('users', {
}
},
logout() {
const store = window.vuex
const oauth = useOAuthStore()
// Pause fetching
@ -739,8 +731,6 @@ export const useUsersStore = defineStore('users', {
useFollowRequestsStore().stopFetching()
}
store?.dispatch('stopFetchingFollowRequests')
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return oauth
@ -798,7 +788,7 @@ export const useUsersStore = defineStore('users', {
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
useChatsStore().startFetching()
store?.dispatch('startFetchingFollowRequests')
useFollowRequestsStore().startFetching()
})
.finally(() => {
useNotificationsStore().resume()

View file

@ -1,22 +0,0 @@
import { cloneDeep } from 'lodash'
import { createStore } from 'vuex'
import vuexModules from 'src/modules/index.js'
const tweakModules = (modules) => {
const res = {}
Object.entries(modules).forEach(([name, module]) => {
const m = { ...module }
m.state = cloneDeep(module.state)
res[name] = m
})
return res
}
const makeMockStore = () => {
return createStore({
modules: tweakModules(vuexModules),
})
}
export default makeMockStore

View file

@ -5,26 +5,15 @@ import VueVirtualScroller from 'vue-virtual-scroller'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import makeMockStore from './mock_store'
import routes from 'src/boot/routes'
export const $t = (msg) => msg
const $i18n = { t: (msg) => msg }
const applyAfterStore = (store, afterStore) => {
afterStore(store)
return store
}
const getDefaultOpts = ({
afterStore = () => {
/* no-op */
},
} = {}) => ({
const getDefaultOpts = () => ({
global: {
plugins: [
applyAfterStore(makeMockStore(), afterStore),
VueVirtualScroller,
createRouter({
history: createMemoryHistory(),
@ -87,9 +76,8 @@ const customBehaviors = () => {
config.plugins.VueWrapper.install(customBehaviors)
export const mountOpts = (allOpts = {}) => {
const { afterStore, ...opts } = allOpts
const defaultOpts = getDefaultOpts({ afterStore })
export const mountOpts = (opts = {}) => {
const defaultOpts = getDefaultOpts()
const mergedOpts = {
...opts,
global: {

View file

@ -29,11 +29,6 @@ const message3 = {
const global = {
mocks: {
$store: {
state: {
api: {},
},
},
$route: {
params: {
recipient_id: 2,

View file

@ -1,193 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { $t, mountOpts, waitForEvent } from '../../../fixtures/setup_test'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUsersStore } from 'src/stores/users.js'
const autoSaveOrNot = (caseFn, caseTitle, runFn) => {
caseFn(`${caseTitle} with auto-save`, function () {
return runFn.bind(this)(true)
})
caseFn(`${caseTitle} with no auto-save`, function () {
return runFn.bind(this)(false)
})
}
const saveManually = async (wrapper) => {
const morePostActions = wrapper.findByText(
'button',
$t('post_status.more_post_actions'),
)
await morePostActions.trigger('click')
const btn = wrapper.findByText(
'button',
$t('post_status.save_to_drafts_button'),
)
await btn.trigger('click')
}
const waitSaveTime = 4000
const currentUser = {
id: 'current-user',
default_scope: 'public',
locked: false,
}
describe('Draft saving', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
useUsersStore().currentUser = currentUser
})
afterEach(() => {
vi.useRealTimers()
})
autoSaveOrNot(
it,
'should save when the button is clicked',
async (autoSave) => {
const wrapper = mount(PostStatusForm, mountOpts())
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: autoSave,
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
await saveManually(wrapper)
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
expect(wrapper.vm.$store.getters.draftsArray[0].status).to.equal(
'mew mew',
)
},
)
it('should auto-save if it is enabled', async function () {
vi.useFakeTimers()
const wrapper = mount(PostStatusForm, mountOpts())
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: true,
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
await vi.advanceTimersByTimeAsync(waitSaveTime)
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
expect(wrapper.vm.$store.getters.draftsArray[0].status).to.equal('mew mew')
})
it('should auto-save when close if auto-save is on', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: true,
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await waitForEvent(wrapper, 'close-accepted')
})
it('should save when close if auto-save is off, and unsavedPostAction is save', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'save',
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await waitForEvent(wrapper, 'close-accepted')
})
it('should discard when close if auto-save is off, and unsavedPostAction is discard', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'discard',
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
await waitForEvent(wrapper, 'close-accepted')
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
})
it('should confirm when close if auto-save is off, and unsavedPostAction is confirm', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
const store = useMergedConfigStore(createTestingPinia())
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'confirm',
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
await nextTick()
await flushPromises()
const saveButton = await vi.waitFor(() => {
const button = wrapper.findByText(
'button',
$t('post_status.close_confirm_save_button'),
)
if (!button) throw new Error('Save button not present')
return button
})
expect(saveButton).to.be.ok
await saveButton.trigger('click')
console.info('clicked')
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await flushPromises()
await waitForEvent(wrapper, 'close-accepted')
})
})

View file

@ -1,16 +1,44 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { $t, mountOpts, waitForEvent } from 'test/fixtures/setup_test.js'
import { vi } from 'vitest'
import { nextTick } from 'vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { mountOpts } from '../../../fixtures/setup_test'
import { useDraftsStore } from 'src/stores/drafts.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUsersStore } from 'src/stores/users.js'
const autoSaveOrNot = (caseFn, caseTitle, runFn) => {
caseFn(`${caseTitle} with auto-save`, function () {
return runFn.bind(this)(true)
})
caseFn(`${caseTitle} with no auto-save`, function () {
return runFn.bind(this)(false)
})
}
const saveManually = async (wrapper) => {
const morePostActions = wrapper.findByText(
'button',
$t('post_status.more_post_actions'),
)
await morePostActions.trigger('click')
const btn = wrapper.findByText(
'button',
$t('post_status.save_to_drafts_button'),
)
await btn.trigger('click')
}
const waitSaveTime = 4000
const currentUser = {
id: 'current-user',
default_scope: 'public',
@ -36,303 +64,476 @@ const repliedStatus2 = {
}
describe('PostStatusForm', () => {
beforeEach(() => {
vi.useFakeTimers()
setActivePinia(createTestingPinia())
useUsersStore().currentUser = currentUser
useStatusesStore().allStatuses = new Map([
[repliedStatus.id, repliedStatus],
])
})
describe('Basic functionality', () => {
beforeEach(() => {
vi.useFakeTimers()
setActivePinia(createTestingPinia())
useUsersStore().currentUser = currentUser
useStatusesStore().allStatuses = new Map([
[repliedStatus.id, repliedStatus],
])
})
it('Clean empty initial state', () => {
const wrapper = mount(PostStatusForm, mountOpts())
afterEach(() => {
vi.useRealTimers()
})
expect(wrapper.vm.statusType).to.equal('new')
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
expect(wrapper.vm.newStatus.mentions).to.eql('')
expect(wrapper.vm.newStatus.status).to.eql('')
})
it('Clean empty initial state', () => {
const wrapper = mount(PostStatusForm, mountOpts())
it('Reset cleans form to pristine state equal to state form was when created', () => {
const wrapper = mount(PostStatusForm, mountOpts())
expect(wrapper.vm.statusType).to.equal('new')
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
expect(wrapper.vm.newStatus.mentions).to.eql('')
expect(wrapper.vm.newStatus.status).to.eql('')
})
const initial = { ...wrapper.vm.newStatus }
wrapper.vm.clearStatus()
it('Reset cleans form to pristine state equal to state form was when created', () => {
const wrapper = mount(PostStatusForm, mountOpts())
expect(wrapper.vm.newStatus).to.eql(initial)
})
const initial = { ...wrapper.vm.newStatus }
wrapper.vm.clearStatus()
it('Initializes a reply form', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
expect(wrapper.vm.newStatus).to.eql(initial)
})
it('Initializes a reply form', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus,
},
}),
)
useInstanceCapabilitiesStore().quotingAvailable = true
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.refId).to.equal('status-1')
expect(wrapper.vm.quotable).to.equal(true)
expect(wrapper.vm.inReplyToStatusId).to.equal('status-1')
expect(wrapper.vm.newStatus.quote).to.be.null
expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('public')
})
it('Copies scope and subject line, disables quoting for locked posts', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
},
}),
)
useInstanceCapabilitiesStore().quotingAvailable = true
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.quotable).to.equal(false)
expect(wrapper.vm.newStatus.quote).to.be.null
expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('re: subject')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('private')
expect(wrapper.vm.postingOptions.status).to.eql('@replied ')
expect(wrapper.vm.postingOptions.spoilerText).to.eql('re: subject')
expect(wrapper.vm.postingOptions.visibility).to.eql('private')
expect(wrapper.vm.postingOptions.sensitive).to.eql(false)
expect(wrapper.vm.postingOptions.media).to.eql([])
expect(wrapper.vm.postingOptions.inReplyToStatusId).to.eql('status-2')
expect(wrapper.vm.postingOptions.quoteId).to.be.null
expect(wrapper.vm.postingOptions.contentType).to.eql('text/plain')
expect(wrapper.vm.postingOptions.poll).to.be.null
})
it('Forces direct mode when replying to a DM, mastodon style subject handling', () => {
// We need to initialize pinia first which is happening here...
const options = mountOpts({
props: {
repliedStatus: repliedStatus,
repliedStatus: { ...repliedStatus2, visibility: 'direct' },
},
}),
)
})
useInstanceCapabilitiesStore().quotingAvailable = true
// ...set our settings...
useMergedConfigStore().mergedConfig = {
...useMergedConfigStore().mergedConfig,
subjectLineBehavior: 'masto',
}
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.refId).to.equal('status-1')
expect(wrapper.vm.quotable).to.equal(true)
expect(wrapper.vm.inReplyToStatusId).to.equal('status-1')
expect(wrapper.vm.newStatus.quote).to.be.null
expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('public')
// ...and only then mount our component
const wrapper = mount(PostStatusForm, options)
// Otherwise we get multiple instances of pinia that don't talk to each other
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.quotable).to.equal(false)
expect(wrapper.vm.newStatus.quote).to.be.null
expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('subject')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('direct')
})
it('Sets status to statusText without mentions if mentions line is enabled', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
statusText: 'testing',
mentionsLine: true,
},
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('testing')
})
it('Sets mention when asked for it', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
profileMention: repliedUser,
},
}),
)
expect(wrapper.vm.statusType).to.equal('mention')
expect(wrapper.vm.isReply).to.equal(false)
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
})
it('Initializes quote when reply/quote toggled to quote', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
},
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.quoteThreadToggled = true
expect(wrapper.vm.newStatus.quote).to.eql({
thread: true,
id: 'status-2',
})
})
it('Resets quote when reply/quote toggled to reply', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
},
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.quoteThreadToggled = true
wrapper.vm.quoteThreadToggled = false
expect(wrapper.vm.newStatus.quote).to.be.null
})
it('Initializes and reset quote when toggling quote attachment', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
},
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.toggleQuoteForm()
expect(wrapper.vm.newStatus.quote).to.eql({
thread: false,
id: null,
url: '',
})
wrapper.vm.toggleQuoteForm()
expect(wrapper.vm.newStatus.quote).to.be.null
})
it('Status editing', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
statusId: 'edited',
statusText: 'text',
statusSubject: 'heading',
statusIsSensitive: true,
statusPoll: {},
statusQuote: {},
statusFiles: [],
statusMediaDescriptions: {},
statusVisibility: 'unlisted',
statusContentType: 'text/markdown',
},
}),
)
expect(wrapper.vm.statusType).to.equal('edit')
expect(wrapper.vm.isReply).to.equal(false) // edits don't support changing reply-to so it's pretty much ignored
expect(wrapper.vm.isEdit).to.equal(true)
expect(wrapper.vm.newStatus.quote).to.eql({})
expect(wrapper.vm.newStatus.poll).to.eql({})
expect(wrapper.vm.newStatus.spoilerText).to.eql('heading')
expect(wrapper.vm.newStatus.mentions).to.eql('')
expect(wrapper.vm.newStatus.status).to.eql('text')
expect(wrapper.vm.newStatus.visibility).to.eql('unlisted')
expect(wrapper.vm.newStatus.contentType).to.eql('text/markdown')
expect(wrapper.vm.newStatus.nsfw).to.equal(true)
expect(wrapper.vm.newStatus.files).to.eql([])
})
it('Posting should reset idempotency key', async () => {
vi.setSystemTime(new Date(2027, 1, 1, 13))
const wrapper = mount(PostStatusForm, mountOpts())
const oldIdempotency = wrapper.vm.idempotencyKey
vi.setSystemTime(new Date(2028, 1, 1, 13))
wrapper.vm.newStatus.status = 'Testing'
await wrapper.vm.postStatus()
expect(wrapper.vm.idempotencyKey).to.not.eql(oldIdempotency)
})
})
it('Copies scope and subject line, disables quoting for locked posts', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
},
}),
)
describe('Attachments', () => {
beforeEach(() => {
vi.useFakeTimers()
setActivePinia(createTestingPinia())
useUsersStore().currentUser = currentUser
useStatusesStore().allStatuses = new Map([
[repliedStatus.id, repliedStatus],
])
})
useInstanceCapabilitiesStore().quotingAvailable = true
afterEach(() => {
vi.useRealTimers()
})
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.quotable).to.equal(false)
expect(wrapper.vm.newStatus.quote).to.be.null
expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('re: subject')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('private')
// TODO Probably better to separate attachment upload/manipulation into its own component?
// we need to upload-on-submit for compression setting anyway
it('Attachments manipulations (moving, adding, removing)', () => {
const wrapper = mount(PostStatusForm, mountOpts())
expect(wrapper.vm.postingOptions.status).to.eql('@replied ')
expect(wrapper.vm.postingOptions.spoilerText).to.eql('re: subject')
expect(wrapper.vm.postingOptions.visibility).to.eql('private')
expect(wrapper.vm.postingOptions.sensitive).to.eql(false)
expect(wrapper.vm.postingOptions.media).to.eql([])
expect(wrapper.vm.postingOptions.inReplyToStatusId).to.eql('status-2')
expect(wrapper.vm.postingOptions.quoteId).to.be.null
expect(wrapper.vm.postingOptions.contentType).to.eql('text/plain')
expect(wrapper.vm.postingOptions.poll).to.be.null
const i1 = { id: '1', url: 'a' }
const i2 = { id: '2', url: 'b' }
const i3 = { id: '3', url: 'c' }
const i4 = { id: '4', url: 'd' }
const iX = { id: 'x', url: 'x' }
wrapper.vm.newStatus.files = [i3, i1, iX, i2]
wrapper.vm.removeMediaFile(iX)
expect(wrapper.vm.newStatus.files).to.eql([i3, i1, i2])
wrapper.vm.shiftUpMediaFile(i1)
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
wrapper.vm.shiftUpMediaFile(i1) // should ignore
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
wrapper.vm.shiftDnMediaFile(i3)
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
wrapper.vm.shiftDnMediaFile(i3) // should ignore
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
wrapper.vm.addMediaFile(i4)
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3, i4])
})
it('Attachment descriptions', () => {
const wrapper = mount(PostStatusForm, mountOpts())
const i1 = { id: '1', url: 'a' }
wrapper.vm.addMediaFile(i1)
expect(wrapper.vm.newStatus.files).to.eql([i1])
wrapper.vm.editAttachment(i1, 'description')
expect(wrapper.vm.newStatus.mediaDescriptions['1']).to.eql('description')
})
})
it('Forces direct mode when replying to a DM, mastodon style subject handling', () => {
// We need to initialize pinia first which is happening here...
const options = mountOpts({
props: {
repliedStatus: { ...repliedStatus2, visibility: 'direct' },
describe('Draft saving', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
useUsersStore().currentUser = currentUser
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
autoSaveOrNot(
it,
'should save when the button is clicked',
async (autoSave) => {
const wrapper = mount(PostStatusForm, mountOpts())
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: autoSave,
}
expect(useDraftsStore().draftsCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
await saveManually(wrapper)
expect(useDraftsStore().draftsCount).to.equal(1)
expect(useDraftsStore().draftsArray[0].status).to.equal('mew mew')
},
)
it('should auto-save if it is enabled', async function () {
vi.useFakeTimers()
const wrapper = mount(PostStatusForm, mountOpts())
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: true,
}
expect(useDraftsStore().draftsCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
expect(useDraftsStore().draftsCount).to.equal(0)
await vi.advanceTimersByTimeAsync(waitSaveTime)
expect(useDraftsStore().draftsCount).to.equal(1)
expect(useDraftsStore().draftsArray[0].status).to.equal('mew mew')
})
// ...set our settings...
useMergedConfigStore().mergedConfig = {
...useMergedConfigStore().mergedConfig,
subjectLineBehavior: 'masto',
}
// ...and only then mount our component
const wrapper = mount(PostStatusForm, options)
// Otherwise we get multiple instances of pinia that don't talk to each other
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.quotable).to.equal(false)
expect(wrapper.vm.newStatus.quote).to.be.null
expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('subject')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('direct')
})
it('Sets status to statusText without mentions if mentions line is enabled', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
statusText: 'testing',
mentionsLine: true,
},
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('testing')
})
it('Sets mention when asked for it', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
profileMention: repliedUser,
},
}),
)
expect(wrapper.vm.statusType).to.equal('mention')
expect(wrapper.vm.isReply).to.equal(false)
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
})
it('Initializes quote when reply/quote toggled to quote', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
},
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.quoteThreadToggled = true
expect(wrapper.vm.newStatus.quote).to.eql({ thread: true, id: 'status-2' })
})
it('Resets quote when reply/quote toggled to reply', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
},
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.quoteThreadToggled = true
wrapper.vm.quoteThreadToggled = false
expect(wrapper.vm.newStatus.quote).to.be.null
})
it('Initializes and reset quote when toggling quote attachment', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
repliedStatus: repliedStatus2,
},
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.toggleQuoteForm()
expect(wrapper.vm.newStatus.quote).to.eql({
thread: false,
id: null,
url: '',
it('should auto-save when close if auto-save is on', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: true,
}
expect(useDraftsStore().draftsCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
expect(useDraftsStore().draftsCount).to.equal(1)
await waitForEvent(wrapper, 'close-accepted')
})
it('should save when close if auto-save is off, and unsavedPostAction is save', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'save',
}
expect(useDraftsStore().draftsCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
expect(useDraftsStore().draftsCount).to.equal(1)
await waitForEvent(wrapper, 'close-accepted')
})
it('should discard when close if auto-save is off, and unsavedPostAction is discard', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'discard',
}
expect(useDraftsStore().draftsCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
await waitForEvent(wrapper, 'close-accepted')
expect(useDraftsStore().draftsCount).to.equal(0)
})
it('should confirm when close if auto-save is off, and unsavedPostAction is confirm', async () => {
const store = useMergedConfigStore()
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'confirm',
}
expect(useDraftsStore().draftsCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
await nextTick()
await flushPromises()
const saveButton = await vi.waitFor(() => {
const button = wrapper.findByText(
'button',
$t('post_status.close_confirm_save_button'),
)
if (!button) throw new Error('Save button not present')
return button
})
expect(saveButton).to.be.ok
await saveButton.trigger('click')
console.info('clicked')
expect(useDraftsStore().draftsCount).to.equal(1)
await flushPromises()
await waitForEvent(wrapper, 'close-accepted')
})
wrapper.vm.toggleQuoteForm()
expect(wrapper.vm.newStatus.quote).to.be.null
})
it('Status editing', () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
statusId: 'edited',
statusText: 'text',
statusSubject: 'heading',
statusIsSensitive: true,
statusPoll: {},
statusQuote: {},
statusFiles: [],
statusMediaDescriptions: {},
statusVisibility: 'unlisted',
statusContentType: 'text/markdown',
},
}),
)
expect(wrapper.vm.statusType).to.equal('edit')
expect(wrapper.vm.isReply).to.equal(false) // edits don't support changing reply-to so it's pretty much ignored
expect(wrapper.vm.isEdit).to.equal(true)
expect(wrapper.vm.newStatus.quote).to.eql({})
expect(wrapper.vm.newStatus.poll).to.eql({})
expect(wrapper.vm.newStatus.spoilerText).to.eql('heading')
expect(wrapper.vm.newStatus.mentions).to.eql('')
expect(wrapper.vm.newStatus.status).to.eql('text')
expect(wrapper.vm.newStatus.visibility).to.eql('unlisted')
expect(wrapper.vm.newStatus.contentType).to.eql('text/markdown')
expect(wrapper.vm.newStatus.nsfw).to.equal(true)
expect(wrapper.vm.newStatus.files).to.eql([])
})
it('Posting should reset idempotency key', async () => {
vi.setSystemTime(new Date(2027, 1, 1, 13))
const wrapper = mount(PostStatusForm, mountOpts())
const oldIdempotency = wrapper.vm.idempotencyKey
vi.setSystemTime(new Date(2028, 1, 1, 13))
wrapper.vm.newStatus.status = 'Testing'
await wrapper.vm.postStatus()
expect(wrapper.vm.idempotencyKey).to.not.eql(oldIdempotency)
})
// TODO Probably better to separate attachment upload/manipulation into its own component?
// we need to upload-on-submit for compression setting anyway
it('Attachments manipulations (moving, adding, removing)', () => {
const wrapper = mount(PostStatusForm, mountOpts())
const i1 = { id: '1', url: 'a' }
const i2 = { id: '2', url: 'b' }
const i3 = { id: '3', url: 'c' }
const i4 = { id: '4', url: 'd' }
const iX = { id: 'x', url: 'x' }
wrapper.vm.newStatus.files = [i3, i1, iX, i2]
wrapper.vm.removeMediaFile(iX)
expect(wrapper.vm.newStatus.files).to.eql([i3, i1, i2])
wrapper.vm.shiftUpMediaFile(i1)
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
wrapper.vm.shiftUpMediaFile(i1) // should ignore
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
wrapper.vm.shiftDnMediaFile(i3)
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
wrapper.vm.shiftDnMediaFile(i3) // should ignore
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
wrapper.vm.addMediaFile(i4)
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3, i4])
})
it('Attachment descriptions', () => {
const wrapper = mount(PostStatusForm, mountOpts())
const i1 = { id: '1', url: 'a' }
wrapper.vm.addMediaFile(i1)
expect(wrapper.vm.newStatus.files).to.eql([i1])
wrapper.vm.editAttachment(i1, 'description')
expect(wrapper.vm.newStatus.mediaDescriptions['1']).to.eql('description')
})
// TODO: Drafts (needs vuex to pinia migration)
})

View file

@ -1,9 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { mount, shallowMount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { mountOpts } from 'test/fixtures/setup_test.js'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { mountOpts } from '../../../fixtures/setup_test'
const attentions = []

View file

@ -37,10 +37,7 @@ describe('The SyncConfig store', () => {
it('should initialize storage if none present', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({ ...user })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
@ -50,10 +47,7 @@ describe('The SyncConfig store', () => {
it('should initialize storage with proper flags for new users if none present', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({ ...user, created_at: new Date() })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
@ -63,10 +57,7 @@ describe('The SyncConfig store', () => {
it('should merge flags even if remote timestamp is older', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
@ -96,10 +87,7 @@ describe('The SyncConfig store', () => {
it('should trim journal to 500 entries', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
@ -138,10 +126,7 @@ describe('The SyncConfig store', () => {
it('should reset local timestamp to remote if contents are the same', async () => {
const store = useSyncConfigStore()
store.cache = null
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({
...user,
@ -161,10 +146,7 @@ describe('The SyncConfig store', () => {
it('should use remote version if local missing', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig(store, user)
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
@ -208,9 +190,7 @@ describe('The SyncConfig store', () => {
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({
...user,
@ -240,9 +220,7 @@ describe('The SyncConfig store', () => {
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({
...user,
@ -282,9 +260,7 @@ describe('The SyncConfig store', () => {
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
const setPreference = vi.spyOn(store, 'setPreference')
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({
...user,
@ -318,9 +294,7 @@ describe('The SyncConfig store', () => {
})
vi.spyOn(storage, 'setItem').mockResolvedValue()
const store = useSyncConfigStore()
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({
...user,
@ -357,9 +331,7 @@ describe('The SyncConfig store', () => {
const localStore = useLocalConfigStore()
localStore.set({ path: 'fontInterface', value: 'Current interface' })
const store = useSyncConfigStore()
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
await store.initSyncConfig({ ...user })
@ -372,10 +344,7 @@ describe('The SyncConfig store', () => {
describe('setPreference', () => {
it('should set preference and update journal log accordingly', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
store.setPreference({ path: 'simple.palette', value: '1' })
expect(store.prefsStorage.simple.palette).to.eql('1')
expect(store.prefsStorage._journal).to.have.length(1)
@ -390,10 +359,7 @@ describe('The SyncConfig store', () => {
it('should keep journal to a minimum', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
store.setPreference({ path: 'simple.palette', value: 1 })
store.setPreference({ path: 'simple.palette', value: 2 })
store.addCollectionPreference({ path: 'collections.palette', value: 2 })
@ -423,10 +389,7 @@ describe('The SyncConfig store', () => {
it('should remove duplicate entries from journal', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
store.setPreference({ path: 'simple.palette', value: 1 })
store.setPreference({ path: 'simple.palette', value: 1 })
store.addCollectionPreference({ path: 'collections.palette', value: 2 })
@ -440,10 +403,7 @@ describe('The SyncConfig store', () => {
// TODO We need a proper test for object-based stores
it.skip('should remove depth = 3 set/unset entries from journal', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
store.setPreference({ path: 'simple.fontInput', value: 'test' })
store.unsetPreference({ path: 'simple.fontInput' })
store.updateCache(store, { username: 'test' })
@ -455,10 +415,7 @@ describe('The SyncConfig store', () => {
it('should not allow unsetting depth <= 2', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
store.setPreference({ path: 'simple.object.foo', value: 1 })
expect(() => store.unsetPreference({ path: 'simple' })).to.throw()
expect(() =>
@ -468,10 +425,7 @@ describe('The SyncConfig store', () => {
it('should not allow (un)setting depth > 3', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.pushSyncConfig = vi.fn()
store.setPreference({ path: 'simple.object', value: {} })
expect(() =>
store.setPreference({ path: 'simple.object.lv3', value: 1 }),

View file

@ -2,6 +2,7 @@ import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vite'
import eslint from 'vite-plugin-eslint2'
@ -120,6 +121,7 @@ export default defineConfig(async ({ mode, command }) => {
const swDest = 'sw-pleroma.js'
const alias = {
src: '/src',
test: '/test',
components: '/src/components',
...(mode === 'test' ? { vue: 'vue/dist/vue.esm-bundler.js' } : {}),
}
@ -141,6 +143,7 @@ export default defineConfig(async ({ mode, command }) => {
},
},
}),
vueDevTools(),
vueJsx(),
buildSwPlugin({ swSrc, swDest }),
swMessagesPlugin(),

4542
yarn.lock

File diff suppressed because it is too large Load diff