Page MenuHomePhorge

No OneTemporary

Size
107 KB
Referenced Files
None
Subscribers
None
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 4ea6f39e6f..04ad810008 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -1,310 +1,310 @@
import AuthForm from 'src/components/auth_form/auth_form.js'
import ConversationPage from 'src/components/conversation-page/conversation-page.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import RemoteUserResolver from 'src/components/remote_user_resolver/remote_user_resolver.vue'
import Timeline from 'src/components/timeline/timeline.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useUsersStore } from 'src/stores/users.js'
-export default (store) => {
+export default () => {
const validateAuthenticatedRoute = (to, from, next) => {
if (useUsersStore().currentUser) {
next()
} else {
next(
useInstanceStore().instanceIdentity.redirectRootNoLogin || '/main/all',
)
}
}
let routes = [
{
name: 'root',
path: '/',
redirect: () => {
return (
(useUsersStore().currentUser
? useInstanceStore().instanceIdentity.redirectRootLogin
: useInstanceStore().instanceIdentity.redirectRootNoLogin) ||
'/main/all'
)
},
},
{
name: 'public-external-timeline',
path: '/main/all',
component: Timeline,
props: () => ({
timelineRef: { name: 'publicAndExternal' },
}),
},
{
name: 'public-timeline',
path: '/main/public',
component: Timeline,
props: () => ({
timelineRef: { name: 'public' },
}),
},
{
name: 'friends',
path: '/main/friends',
component: Timeline,
beforeEnter: validateAuthenticatedRoute,
props: () => ({
timelineRef: { name: 'friends' },
}),
},
{
name: 'tag-timeline',
path: '/tag/:id',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'tag', argument: route.params.id },
}),
},
{
name: 'bookmarks',
path: '/bookmarks',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'bookmarks', argument: null },
}),
},
{
name: 'bubble',
path: '/bubble',
component: Timeline,
props: () => ({
timelineRef: { name: 'bubble' },
}),
},
{
name: 'conversation',
path: '/notice/:id',
component: ConversationPage,
meta: { dontScroll: true },
},
{
name: 'conversation2',
path: '/conversation/:statusId',
component: () => import('src/components/chat_view/chat_view.vue'),
props: true,
meta: { dontScroll: true },
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'quotes',
path: '/notice/:id/quotes',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'quotes', argument: route.params.id },
}),
},
{
name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'external-user-profile',
path: '/users/$:id',
component: () => import('src/components/user_profile/user_profile.vue'),
},
{
name: 'user-profile-admin-view',
path: '/users/$:id/admin_view',
component: () =>
import('src/components/user_profile/user_profile_admin_view.vue'),
},
{
name: 'interactions',
path: '/users/:username/interactions',
component: () => import('src/components/interactions/interactions.vue'),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'dms',
path: '/users/:username/dms',
component: Timeline,
beforeEnter: validateAuthenticatedRoute,
props: () => ({
timelineRef: { name: 'dms' },
}),
},
{
name: 'registration',
path: '/registration',
component: () => import('src/components/registration/registration.vue'),
},
{
name: 'password-reset',
path: '/password-reset',
component: () =>
import('src/components/password_reset/password_reset.vue'),
props: true,
},
{
name: 'registration-token',
path: '/registration/:token',
component: () => import('src/components/registration/registration.vue'),
},
{
name: 'friend-requests',
path: '/friend-requests',
component: () =>
import('src/components/follow_requests/follow_requests.vue'),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'notifications',
path: '/:username/notifications',
component: () => import('src/components/notifications/notifications.vue'),
props: () => ({ disableTeleport: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'login',
path: '/login',
component: AuthForm,
},
{
name: 'shout-panel',
path: '/shout-panel',
component: () => import('src/components/shout_panel/shout_panel.vue'),
props: () => ({ floating: false }),
},
{
name: 'oauth-callback',
path: '/oauth-callback',
component: () =>
import('src/components/oauth_callback/oauth_callback.vue'),
props: (route) => ({ code: route.query.code }),
},
{
name: 'search',
path: '/search',
component: () => import('src/components/search/search.vue'),
props: (route) => ({ query: route.query.query }),
},
{
name: 'who-to-follow',
path: '/who-to-follow',
component: () => import('src/components/who_to_follow/who_to_follow.vue'),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'about',
path: '/about',
component: () => import('src/components/about/about.vue'),
},
{
name: 'announcements',
path: '/announcements',
component: () =>
import('src/components/announcements_page/announcements_page.vue'),
},
{
name: 'drafts',
path: '/drafts',
component: () => import('src/components/drafts/drafts.vue'),
},
{
name: 'user-profile',
path: '/users/:name',
component: () => import('src/components/user_profile/user_profile.vue'),
},
{
name: 'legacy-user-profile',
path: '/:name',
component: () => import('src/components/user_profile/user_profile.vue'),
},
{
name: 'lists',
path: '/lists',
component: () => import('src/components/lists/lists.vue'),
},
{
name: 'lists-timeline',
path: '/lists/:id',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'list', argument: route.params.id },
}),
},
{
name: 'lists-edit',
path: '/lists/:id/edit',
component: () => import('src/components/lists_edit/lists_edit.vue'),
},
{
name: 'lists-new',
path: '/lists/new',
component: () => import('src/components/lists_edit/lists_edit.vue'),
},
{
name: 'edit-navigation',
path: '/nav-edit',
component: NavPanel,
props: () => ({ forceExpand: true, forceEditMode: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'bookmark-folders',
path: '/bookmark_folders',
component: () =>
import('src/components/bookmark_folders/bookmark_folders.vue'),
},
{
name: 'bookmark-folder-new',
path: '/bookmarks/new-folder',
component: () =>
import('src/components/bookmark_folder_edit/bookmark_folder_edit.vue'),
},
{
name: 'bookmark-folder',
path: '/bookmarks/:id',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'bookmarks', argument: route.params.id },
}),
},
{
name: 'bookmark-folder-edit',
path: '/bookmarks/:id/edit',
component: () =>
import('src/components/bookmark_folder_edit/bookmark_folder_edit.vue'),
},
]
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
routes = routes.concat([
{
name: 'chat',
path: '/users/:username/chats/:chatUserId',
component: () => import('src/components/chat_view/chat_view.vue'),
meta: { dontScroll: false },
props: true,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'chats',
path: '/users/:username/chats',
component: () => import('src/components/chat_list/chat_list.vue'),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
])
}
return routes
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index ca19005b6d..1cea486701 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,1102 +1,1100 @@
import {
debounce,
isEqual,
unescape as ldUnescape,
reject,
uniqBy,
} from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Attachment from 'src/components/attachment/attachment.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Gallery from 'src/components/gallery/gallery.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Popover from 'src/components/popover/popover.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBan,
faChevronDown,
faChevronLeft,
faChevronRight,
faCircleNotch,
faPollH,
faQuoteRight,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faQuoteRight,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const PostStatusForm = {
props: {
// Status editing stuff
statusId: String,
statusText: String,
statusSubject: String,
statusIsSensitive: {
type: Boolean,
required: false,
default: null, // Avoiding automatic conversion null -> false
},
statusPoll: Object,
statusQuote: Object,
statusFiles: Array,
statusMediaDescriptions: Object,
statusVisibility: String,
statusContentType: String,
// Replies/mentions
repliedStatus: Object, // Object of a status replying to
profileMention: Object, // Mentioned user (used in profile page -> mention)
// Draft stuff
hideDraft: Boolean, // Disable drafts functionality
closeable: Boolean, // Whether form can be closed (i.e. in replies)
draftId: String, // ID of the draft to be used
// Chats stuff
chatView: Boolean, // Used for the "submit on enter" user setting
maxHeight: Number,
placeholder: String,
postHandler: Function, // Used to override poster to use chats one instead of status one
preserveFocus: Boolean, // Keep focus on form after posting
autoFocus: Boolean, // Steal focus when form is opened
fileLimit: Number, // Chats only support 1 attachment :(
emojiPickerPlacement: String,
optimisticPosting: Boolean, // Don't wait for confirmation that post is done
// Feature toggles for special cases (mostly chats)
mentionsLine: Boolean, // Use separate field for specifying mentions
mentionsLineReadOnly: Boolean, // Make the said field read-only (for chat conversation view)
disableSubject: Boolean,
disableScopeSelector: Boolean,
disableVisibilitySelector: Boolean,
disableNotice: Boolean,
disableLockWarning: Boolean,
disablePolls: Boolean,
disableQuotes: Boolean,
disableSensitivityCheckbox: Boolean,
disableSubmit: Boolean,
disablePreview: Boolean,
disableDraft: Boolean,
},
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'close-accepted',
'update',
],
data() {
return {
initialized: false,
randomSeed: genRandomSeed(),
// Posting stuff
idempotencyKey: '',
/* Data is initialized first, but we have no access to .computed yet
* which we need for some defaults (i.e. user configration)
* so we pre-fill with stuff meant for status editing and later
* back-fill with defaults in .created()
*/
newStatus: {
status: this.statusText ?? null,
mentions: this.statusMentionLine ?? null,
spoilerText: this.statusSubject ?? null,
quote: this.statusQuote ?? null,
files: this.statusFiles ?? null,
poll: this.statusPoll ?? null,
mediaDescriptions: this.statusMediaDescriptions ?? null,
nsfw: this.statusIsSensitive ?? null,
visibility: this.statusVisibility ?? null,
contentType: this.statusContentType ?? null,
},
// Attachments
dropFiles: [],
uploadingFiles: false,
showDropIcon: 'hide',
dropStopTimeout: null,
// Preview
preview: null,
previewLoading: false,
// Draft
saveInhibited: true,
saveable: false,
// Misc States
emojiInputShown: false,
error: null,
posting: false,
}
},
components: {
MediaUpload,
EmojiInput,
PollForm: defineAsyncComponent(
() => import('src/components/poll/poll_form.vue'),
),
QuoteForm: defineAsyncComponent(
() => import('src/components/quote/quote_form.vue'),
),
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
Popover,
},
created() {
this.updateIdempotencyKey()
// If we are starting a new post, do not associate it with old drafts
const draft =
!this.disableDraft && (this.draftId || this.statusType !== 'new')
? this.getDraft(this.statusType, this.refId)
: null
if (draft) {
// Copying and overriding defaults from the draft for each field
Object.keys(this.newStatus).forEach((key) => {
this.newStatus[key] = draft[key] ?? this.newStatus[key]
})
} else {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
this.newStatus[key] = this.newStatus[key] ?? value
})
}
this.initialized = true
},
mounted() {
this.resize(this.$refs.textarea)
if (this.repliedStatus) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.repliedStatus || this.autoFocus) {
this.$refs.textarea.focus()
}
},
computed: {
// Visibility / expansion state of subcomponents
pollFormVisible() {
return this.hasPoll
},
quoteFormVisible() {
return this.hasQuote && !this.newStatus.quote.thread
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
// Composing stuff
statusType() {
if (this.repliedStatus) {
return 'reply'
} else if (this.profileMention) {
return 'mention'
} else if (this.statusId) {
return 'edit'
} else {
return 'new'
}
},
refId() {
if (this.repliedStatus) {
return this.repliedStatus.id
} else if (this.profileMention) {
return this.profileMention.id
} else if (this.statusId) {
return this.statusId
} else {
return null
}
},
mentionsString() {
if (this.statusType !== 'reply' && this.statusType !== 'mention')
return ''
let allAttentions = [...(this.repliedStatus?.attentions || [])]
const repliedUser = this.repliedStatus?.user || this.profileMention
if (repliedUser) allAttentions.unshift(repliedUser)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: this.currentUser.id })
const mentions = allAttentions.map(
(attention) => `@${attention.screen_name}`,
)
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
},
newStatusContent() {
return this.mentionsLine
? this.mentionsString + this.newStatus.status
: this.newStatus.status
},
defaultNewStatus() {
const defaultNewStatus = {
files: [],
poll: null,
quote: null,
mediaDescriptions: {},
}
const scope = (() => {
if (this.repliedStatus) {
if (this.repliedStatus.visibility === 'direct') return 'direct'
if (this.userDefaultScopeCopy) return this.repliedStatus.visibility
}
return this.userDefaultScope
})()
const preset = this.$route.query.message
const statusText = preset ?? ''
if (this.mentionsLine) {
defaultNewStatus.status = statusText
} else {
defaultNewStatus.status = this.mentionsString + statusText
}
defaultNewStatus.mentions = this.mentionsString.trim()
defaultNewStatus.spoilerText = this.repliedSubjectString ?? ''
defaultNewStatus.nsfw = this.userDefaultSensitive
defaultNewStatus.visibility = scope
defaultNewStatus.contentType = this.userDefaultPostContentType
return defaultNewStatus
},
// -Edit
isEdit() {
return this.statusId !== undefined && this.statusId.trim() !== ''
},
// -Reply
isReply() {
return this.statusType === 'reply'
},
inReplyToStatusId() {
return !this.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.repliedStatus?.id
: undefined
},
repliedSubjectString() {
if (!this.repliedStatus?.summary) return null
const decodedSummary = ldUnescape(this.repliedStatus.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
// -Poll
hasPoll() {
return this.newStatus.poll != null
},
// -Quotes
hasQuote() {
return this.newStatus.quote !== null
},
quotable() {
if (!this.quotingAvailable || !this.isReply) {
return false
}
if (
this.repliedStatus.visibility === 'public' ||
this.repliedStatus.visibility === 'unlisted' ||
this.repliedStatus.visibility === 'local'
) {
return true
} else if (this.repliedStatus.visibility === 'private') {
return this.repliedStatus.user.id === this.currentUser.id
}
return false
},
quoteId() {
return this.newStatus.quote?.id ?? null
},
// This is for "reply/quote" toggle
quoteThreadToggled: {
get() {
return this.newStatus.quote?.thread
},
set(value) {
if (value) {
this.newStatus.quote = {}
this.newStatus.quote.thread = true
this.newStatus.quote.id = this.repliedStatus.id
} else {
this.newStatus.quote = null
}
},
},
postingOptions() {
const poll = this.hasPoll ? pollFormToMasto(this.newStatus.poll) : null
return {
status: this.newStatusContent,
spoilerText: this.newStatus.spoilerText,
visibility: this.newStatus.visibility,
sensitive: this.newStatus.nsfw,
media: this.newStatus.files,
inReplyToStatusId: this.inReplyToStatusId,
quoteId: this.quoteId,
contentType: this.newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
store: this.$store,
}
},
// Emoji stuff
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emoji() {
return useEmojiStore().standardEmojiList
},
customEmoji() {
return useEmojiStore().customEmoji
},
// Length & Limits
statusLength() {
return this.newStatusContent.length
},
spoilerTextLength() {
return this.newStatus.spoilerText.length
},
statusLengthLimit() {
return useInstanceStore().limits.textLimit
},
hasStatusLengthLimit() {
return this.statusLengthLimit > 0
},
charactersLeft() {
return (
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
)
},
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
isEmptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
// Drafts
isDirty() {
return Object.entries(this.defaultNewStatus).some(
([key, defaultValue]) => {
const actualValue = this.newStatus[key]
if (actualValue === null) return false
return !isEqual(actualValue, defaultValue)
},
)
},
shouldAutoSaveDraft() {
return useMergedConfigStore().mergedConfig.autoSaveDraft
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
autoSaveState() {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft() {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote
)
)
},
// Error handling
pollContentError() {
return this.pollFormVisible && this.newStatus.poll?.error
},
// Featureset detection
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
// User configuration
userDefaultScope() {
return this.currentUser.default_scope
},
userDefaultPostContentType() {
return this.mergedConfig.postContentType
},
userDefaultScopeCopy() {
return this.mergedConfig.scopeCopy
},
userDefaultSensitive() {
return this.mergedConfig.sensitiveByDefault
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
minimalScopesMode() {
return this.mergedConfig.minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
hideScopeNotice() {
return (
this.disableNotice ||
useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
submitOnEnter() {
return this.chatView && this.mergedConfig.chatSubmitOnEnter
},
// Global stuff
- currentUser() {
- return useUsersStore().currentUser
- },
+ ...mapState(useUsersStore, ['currentUser']),
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
}),
},
watch: {
newStatus: {
deep: true,
handler() {
this.statusChanged()
},
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
this.addBeforeUnloadListener()
} else {
this.removeBeforeUnloadListener()
}
},
},
methods: {
// Composing
update() {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
if (key === 'status') return
this.newStatus[key] = value
})
},
onMentionsLineUpdate(e) {
if (this.mentionsLineReadOnly) return
this.newStatus.mentions = e
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
clearStatus() {
this.saveInhibited = true
this.newStatus.status = ''
this.newStatus.mentions = ''
this.newStatus.spoilerText = ''
this.newStatus.files = []
this.newStatus.poll = null
this.newStatus.quote = null
this.newStatus.nsfw = this.defaultNewStatus.nsfw
this.newStatus.mediaDescriptions = {}
this.$refs.mediaUpload?.clearFile()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.saveable = false
},
async postStatus(event) {
if (this.posting && !this.optimisticPosting) {
return
}
if (this.disableSubmit) {
return
}
if (this.emojiInputShown) {
return
}
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (
this.optimisticPosting &&
(this.isEmptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.isEmptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
postHandler(this.postingOptions)
.then((data) => {
this.abandonDraft()
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('posted', data)
})
.catch((error) => {
this.error = error
})
.finally(() => {
this.posting = false
})
},
// Preview
previewStatus() {
if (this.isEmptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
this.previewLoading = true
statusPoster
.postStatus({
...this.postingOptions,
media: [],
poll: null,
preview: true,
})
.then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
this.preview = data
})
.catch((error) => {
this.preview = { error }
})
.finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () {
this.previewStatus()
}, 500),
autoPreview() {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview() {
this.preview = null
this.previewLoading = false
},
togglePreview() {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
// Attachments
setMediaDescription(id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({
store: this.$store,
id,
description,
})
},
setAllMediaDescriptions() {
const ids = this.newStatus.files.map((file) => file.id)
return Promise.all(ids.map((id) => this.setMediaDescription(id)))
},
addMediaFile(fileInfo) {
this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
},
removeMediaFile(fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment(fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
if (index === 0) return
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
if (index === files.length - 1) return
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed(errString, templateArgs) {
templateArgs = templateArgs || {}
this.error =
this.$t('upload.error.base') +
' ' +
this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles() {
this.uploadingFiles = true
},
finishedUploadingFiles() {
this.$emit('resize')
this.uploadingFiles = false
},
paste(e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop(e) {
if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop() {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag(e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer?.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
// Auto-sizable input field
// TODO separate into its own component pls
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize(e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr =
window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef =
this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight
? Math.min(heightWithoutPadding, this.maxHeight)
: heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder =
bottomRef.offsetHeight +
findOffset(bottomRef, scrollerRef).top +
bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom =
isBottomObstructed &&
!(
isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !==
this.$refs.textarea.value.length
)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
// Poll
togglePollForm() {
this.newStatus.poll = this.hasPoll ? null : {}
},
setPoll(poll) {
this.newStatus.poll = poll
},
// Quote
toggleQuoteForm() {
// This is for the "attach quote" button
if (this.newStatus.quote?.thread) return
if (!this.hasQuote) {
this.newStatus.quote = {}
this.newStatus.quote.thread = false
this.newStatus.quote.id = null
this.newStatus.quote.url = ''
} else {
this.newStatus.quote = null
}
},
// Drafts
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return this.$store
.dispatch('addOrSaveDraft', {
draft: {
type: this.statusType,
refId: this.refId,
...this.newStatus,
},
})
.then((id) => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('draft-done')
}
})
} else if (this.hasEmptyDraft) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft().then(() => {
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.draftId })
},
getDraft() {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
this.statusType,
this.refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('close-accepted')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('close-accepted')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('close-accepted')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
// Misc
propsToNative(props) {
return propsToNative(props)
},
handleEmojiInputShow(value) {
this.emojiInputShown = value
},
updateIdempotencyKey() {
this.idempotencyKey = Date.now().toString()
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
dismissScopeNotice() {
useSyncConfigStore().setSimplePrefAndSave({
path: 'hideScopeNotice',
value: true,
})
},
clearError() {
this.error = null
},
...mapActions(useMediaViewerStore, ['increment']),
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
}
export default PostStatusForm
diff --git a/src/stores/notifications.js b/src/stores/notifications.js
index cfdccac2cc..99d56e0d01 100644
--- a/src/stores/notifications.js
+++ b/src/stores/notifications.js
@@ -1,287 +1,288 @@
import { defineStore } from 'pinia'
import notificationsFetcher from 'src/stores/fetchers/notifications_fetcher.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { dismissNotification, markNotificationsAsSeen } from 'src/api/user.js'
import {
closeAllDesktopNotifications,
closeDesktopNotification,
} from 'src/services/desktop_notification_utils/desktop_notification_utils.js'
import {
isValidNotification,
maybeShowNotification,
} from 'src/services/notification_utils/notification_utils.js'
import { isStatusNotification } from 'src/services/notification_utils/notification_utils_sw.js'
export const defaultState = () => ({
desktopNotificationSilence: true,
maxId: '',
minId: '',
data: [],
statusNotificationRelations: new WeakMap(),
idStore: new Map(),
statusIdStore: new Set(),
socket: null,
streaming: false,
fetcher: null,
})
export const useNotificationsStore = defineStore('notifications', {
state: defaultState,
actions: {
// Init
attachSocket() {
const et = new EventTarget()
const socket = { et }
et.addEventListener('notification', this.addNewNotifications)
et.addEventListener('open', this.onStreamConnect)
et.addEventListener('close', this.onStreamDisconnect)
useStreamingStore().addSubscriber(socket)
this.socket = socket
},
activate() {
this.attachSocket()
// Initially there's set flag to silence all desktop notifications so
// that there won't spam of them when user just opened up the FE we
// reset that flag after a while to show new notifications once again.
setTimeout(() => (this.desktopNotificationSilence = false), 10000)
if (this.fetcher) throw new Error('Fetcher already exists!')
this.fetcher = notificationsFetcher(useOAuthStore().token)
this.startFetching('Notifications activated')
},
deactivate() {
if (!this.streaming) {
this.stopFetching('Notifications deactivated')
}
useStreamingStore().removeSubscriber(this.socket)
const { et } = this.socket
et.removeEventListener('notification', this.addNewNotifications)
et.removeEventListener('open', this.onStreamConnect)
et.removeEventListener('close', this.onStreamDisconnect)
const blankState = defaultState()
Object.keys(blankState).forEach((k) => {
this[k] = blankState[k]
})
},
// Poll & Push
onStreamConnect() {
console.debug('[Notifications] Notifications stream connected')
this.streaming = true
this.stopFetching('Socket connected')
},
onStreamDisconnect() {
console.debug('[Notifications] Notifications stream disconnected')
this.streaming = false
this.startFetching('Socket disconnected')
},
startFetching(reason) {
console.debug(
'[Notifications] Starting fetching notifications',
'Reason:',
reason,
)
this.fetcher.startFetching()
},
stopFetching(reason) {
console.debug(
'[Notifications] Stopped fetching notifications',
'Reason:',
reason,
)
this.fetcher.stopFetching()
},
// Updates
updateExtremes(id) {
if (this.maxId === '' || id > this.maxId) {
this.maxId = id
}
if (this.minId === '' || id < this.minId) {
this.minId = id
}
},
addNewNotifications(result, older) {
const { timestamp, data } = result
- const notifications = older
- ? data
- : [...data].reverse()
+ const notifications = older ? data : [...data].reverse()
useUsersStore().addNewUsers({
timestamp,
data: notifications.map((n) => n.from_profile),
})
notifications.forEach((n) => {
n.from_profile = useUsersStore().findUser(n.from_profile.id)
})
const validNotifications = notifications.filter((notification) => {
// If invalid notification, update ids but don't add it to store
if (!isValidNotification(notification)) {
console.error('Invalid notification:', notification)
this.updateExtremes(notification.id)
return false
}
return true
})
useUsersStore().addNewUsers({
timestamp,
data: validNotifications.map(
(notification) => notification.from_profile,
),
})
const statusNotifications = validNotifications.filter(
(notification) =>
isStatusNotification(notification.type) && notification.status,
)
// Synchronous commit to add all the statuses
useStatusesStore().addNewStatuses({
timestamp,
statuses: statusNotifications.map(
(notification) => notification.status,
),
})
// Update references to statuses in notifications to ones in the store
statusNotifications.forEach((notification) => {
const id = notification.status.id
const referenceStatus = useStatusesStore().allStatuses.get(id)
if (referenceStatus) {
notification.status = referenceStatus
}
})
validNotifications.forEach((notification) => {
if (notification.type === 'pleroma:report') {
useReportsStore().addReport(notification.report)
}
if (notification.type === 'pleroma:emoji_reaction') {
useStatusesStore().fetchEmojiReactions(notification.status.id)
}
// Only add a new notification if we don't have one for the same action
if (!this.idStore.has(notification.id)) {
this.updateExtremes(notification.id)
if (older) {
this.data.push(notification)
} else {
this.data.unshift(notification)
}
this.idStore.set(notification.id, notification)
this.statusNotificationRelations.set(
notification.status,
this.idStore.get(notification.id),
)
maybeShowNotification(
useMergedConfigStore().mergedConfig.notificationVisibility,
- Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters ?? {}),
+ Object.values(
+ useSyncConfigStore().prefsStorage.simple.muteFilters ?? {},
+ ),
notification,
useI18nStore().i18n,
)
} else if (notification.seen) {
this.idStore.get(notification.id).seen = true
}
})
},
// Seen / Dismiss
notificationClicked(id) {
const notification = this.idStore.get(id)
const { type, seen } = notification
if (!seen) {
switch (type) {
case 'mention':
case 'pleroma:report':
case 'follow_request':
break
default:
this.markSingleNotificationAsSeen({ id })
}
}
},
markNotificationsAsSeen() {
this.data.forEach((notification) => {
notification.seen = true
})
markNotificationsAsSeen({
id: this.maxId,
credentials: useOAuthStore().token,
}).then(() => {
closeAllDesktopNotifications()
})
},
markSingleNotificationAsSeen(id) {
const notification = this.idStore.get(id)
if (notification) notification.seen = true
markNotificationsAsSeen({
single: true,
id,
credentials: useOAuthStore().token,
}).then(() => {
closeDesktopNotification(id)
})
},
dismissNotificationLocal(id) {
this.idStore.delete(id)
this.syncOrder()
},
dismissNotification(id) {
this.dismissNotificationLocal(id)
dismissNotification({
id,
credentials: useOAuthStore().token,
})
},
syncOrder() {
this.minId = ''
this.maxId = ''
this.data = this.data.filter(({ id }) => {
const present = this.idStore.has(id)
if (present) {
this.updateExtremes(id) // Side-effect
}
return present
})
},
wipeStatuses(ids) {
const set = new Set(ids)
this.data.forEach((notification) => {
- const status = isStatusNotification(notification.type) && notification.status
+ const status =
+ isStatusNotification(notification.type) && notification.status
if (status && set.has(status.id)) {
this.idStore.delete(notification.id)
}
})
this.syncOrder()
},
},
})
diff --git a/test/fixtures/setup_test.js b/test/fixtures/setup_test.js
index b3804b102e..d63e639d79 100644
--- a/test/fixtures/setup_test.js
+++ b/test/fixtures/setup_test.js
@@ -1,145 +1,144 @@
import { createTestingPinia } from '@pinia/testing'
import { config } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
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 */
},
} = {}) => ({
global: {
plugins: [
applyAfterStore(makeMockStore(), afterStore),
- createTestingPinia(),
VueVirtualScroller,
createRouter({
history: createMemoryHistory(),
routes: routes({
state: {
users: {
currentUser: {},
},
instance: {},
},
}),
}),
(Vue) => {
Vue.directive('body-scroll-lock', {})
},
],
components: {
RichContent,
Status,
StillImage,
},
stubs: {
I18nT: true,
teleport: true,
FAIcon: true,
FALayers: true,
},
mocks: {
$t,
$i18n,
},
},
})
// https://github.com/vuejs/vue-test-utils/issues/960
const customBehaviors = () => {
const filterByText = (keyword) => {
const match =
keyword instanceof RegExp
? (target) => target && keyword.test(target)
: (target) => keyword === target
return (wrapper) =>
match(wrapper.text()) ||
match(wrapper.attributes('aria-label')) ||
match(wrapper.attributes('title'))
}
return {
findComponentByText(searchedComponent, text) {
return this.findAllComponents(searchedComponent)
.filter(filterByText(text))
.at(0)
},
findByText(searchedElement, text) {
return this.findAll(searchedElement).filter(filterByText(text)).at(0)
},
}
}
config.plugins.VueWrapper.install(customBehaviors)
export const mountOpts = (allOpts = {}) => {
const { afterStore, ...opts } = allOpts
const defaultOpts = getDefaultOpts({ afterStore })
const mergedOpts = {
...opts,
global: {
...defaultOpts.global,
},
}
if (opts.global) {
mergedOpts.global.plugins = mergedOpts.global.plugins.concat(
opts.global.plugins || [],
)
Object.entries(opts.global).forEach(([k, v]) => {
if (k === 'plugins') {
return
}
if (defaultOpts.global[k]) {
mergedOpts.global[k] = {
...defaultOpts.global[k],
...v,
}
} else {
mergedOpts.global[k] = v
}
})
}
return mergedOpts
}
// https://stackoverflow.com/questions/78033718/how-can-i-wait-for-an-emitted-event-of-a-mounted-component-in-vue-test-utils
export const waitForEvent = (
wrapper,
event,
{ timeout = 1000, timesEmitted = 1 } = {},
) => {
const tick = 10
return vi.waitFor(
() => {
const e = wrapper.emitted(event)
if (e?.length >= timesEmitted) {
return
}
throw new Error('event is not emitted')
},
{
timeout,
interval: tick,
},
)
}
diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js
index 98bb9a61dd..a7fab6210d 100644
--- a/test/unit/specs/boot/routes.spec.js
+++ b/test/unit/specs/boot/routes.spec.js
@@ -1,83 +1,74 @@
import { createTestingPinia } from '@pinia/testing'
createTestingPinia()
import { createMemoryHistory, createRouter } from 'vue-router'
import { createStore } from 'vuex'
import routes from 'src/boot/routes'
-const store = createStore({
- state: {
- instance: {},
- },
-})
-
describe('routes', () => {
const router = createRouter({
history: createMemoryHistory(),
- routes: routes(store),
+ routes: routes(),
})
it('root path', async () => {
await router.push('/main/all')
const matchedComponents = router.currentRoute.value.matched
- expect(
- Object.hasOwn(
- matchedComponents[0].components.default.components,
- 'Timeline',
- ),
- ).to.eql(true)
+ expect(matchedComponents[0].components.default.__file).to.contain(
+ '/timeline.vue',
+ )
})
it("user's profile", async () => {
await router.push('/fake-user-name')
const matchedComponents = router.currentRoute.value.matched
expect(matchedComponents[0].components.default.__file).to.contain(
- 'user_profile.vue',
+ '/user_profile.vue',
)
})
it("user's profile at /users", async () => {
await router.push('/users/fake-user-name')
const matchedComponents = router.currentRoute.value.matched
expect(matchedComponents[0].components.default.__file).to.contain(
- 'user_profile.vue',
+ '/user_profile.vue',
)
})
it('list view', async () => {
await router.push('/lists')
const matchedComponents = router.currentRoute.value.matched
expect(matchedComponents[0].components.default.__file).to.contain(
- 'lists.vue',
+ '/lists.vue',
)
})
it('list timeline', async () => {
await router.push('/lists/1')
const matchedComponents = router.currentRoute.value.matched
expect(matchedComponents[0].components.default.__file).to.contain(
- 'lists_timeline.vue',
+ '/timeline.vue',
)
})
it('list edit', async () => {
await router.push('/lists/1/edit')
const matchedComponents = router.currentRoute.value.matched
expect(matchedComponents[0].components.default.__file).to.contain(
- 'lists_edit.vue',
+ '/lists_edit.vue',
)
})
})
diff --git a/test/unit/specs/components/draft.spec.js b/test/unit/specs/components/draft.spec.js
index b69d2323e7..6332eba7ea 100644
--- a/test/unit/specs/components/draft.spec.js
+++ b/test/unit/specs/components/draft.spec.js
@@ -1,185 +1,193 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
+import { useUsersStore } from 'src/stores/users.js'
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'
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
-afterEach(() => {
- vi.useRealTimers()
-})
+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')
})
})
diff --git a/test/unit/specs/components/post_status_form.spec.js b/test/unit/specs/components/post_status_form.spec.js
index 0c0be4143b..981a6d0200 100644
--- a/test/unit/specs/components/post_status_form.spec.js
+++ b/test/unit/specs/components/post_status_form.spec.js
@@ -1,324 +1,338 @@
+import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
+import { setActivePinia } from 'pinia'
import { vi } from 'vitest'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { mountOpts } from '../../../fixtures/setup_test'
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 currentUser = {
id: 'current-user',
default_scope: 'public',
locked: false,
}
const repliedUser = {
id: 'replied-user',
screen_name: 'replied',
}
const repliedStatus = {
id: 'status-1',
visibility: 'public',
user: repliedUser,
}
const repliedStatus2 = {
id: 'status-2',
visibility: 'private',
summary: 'subject',
user: repliedUser,
}
-const replyMountOpts = (props) =>
- mountOpts({
- props,
- afterStore(store) {
- useUsersStore().currentUser = currentUser
- useStatusesStore().allStatuses = {
- [repliedStatus.id]: repliedStatus,
- }
- },
- })
-
describe('PostStatusForm', () => {
beforeEach(() => {
vi.useFakeTimers()
+ setActivePinia(createTestingPinia())
+ useUsersStore().currentUser = currentUser
+ useStatusesStore().allStatuses = new Map([
+ [repliedStatus.id, repliedStatus],
+ ])
})
it('Clean empty initial state', () => {
- const wrapper = mount(PostStatusForm, replyMountOpts())
+ 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('')
})
it('Reset cleans form to pristine state equal to state form was when created', () => {
- const wrapper = mount(PostStatusForm, replyMountOpts())
+ const wrapper = mount(PostStatusForm, mountOpts())
const initial = { ...wrapper.vm.newStatus }
wrapper.vm.clearStatus()
expect(wrapper.vm.newStatus).to.eql(initial)
})
it('Initializes a reply form', () => {
const wrapper = mount(
PostStatusForm,
- replyMountOpts({
- repliedStatus: repliedStatus,
+ 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,
- replyMountOpts({
- repliedStatus: repliedStatus2,
+ 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 = replyMountOpts({
- repliedStatus: { ...repliedStatus2, visibility: 'direct' },
+ const options = mountOpts({
+ props: {
+ repliedStatus: { ...repliedStatus2, visibility: 'direct' },
+ },
})
// ...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,
- replyMountOpts({
- repliedStatus: repliedStatus2,
- statusText: 'testing',
- mentionsLine: true,
+ 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,
- replyMountOpts({
- profileMention: repliedUser,
+ 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,
- replyMountOpts({
- repliedStatus: repliedStatus2,
+ 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,
- replyMountOpts({
- repliedStatus: repliedStatus2,
+ 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,
- replyMountOpts({
- repliedStatus: repliedStatus2,
+ 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,
- replyMountOpts({
- statusId: 'edited',
- statusText: 'text',
- statusSubject: 'heading',
- statusIsSensitive: true,
- statusPoll: {},
- statusQuote: {},
- statusFiles: [],
- statusMediaDescriptions: {},
- statusVisibility: 'unlisted',
- statusContentType: 'text/markdown',
+ 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, replyMountOpts())
+ 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, replyMountOpts())
+ 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, replyMountOpts())
+ 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)
})
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index cc79fe3776..9286d9d735 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -1,547 +1,522 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
import { mount, shallowMount } from '@vue/test-utils'
+import { mountOpts } from '../../../fixtures/setup_test'
import RichContent from 'src/components/rich_content/rich_content.jsx'
const attentions = []
-const global = {
- mocks: {
- $store: {
- state: {},
- getters: {
- mergedConfig: () => ({
- mentionLinkShowTooltip: true,
- }),
- findUserByUrl: () => null,
- },
- },
- },
- stubs: {
- FAIcon: true,
- },
-}
const makeMention = (who, noClass) => {
attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` })
return noClass
? `<span><a href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
: `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
}
const p = (...data) => `<p>${data.join('')}</p>`
const compwrap = (...data) =>
`<span class="RichContent">${data.join('')}</span>`
const mentionsLine = (times) =>
[
'<mentions-line-stub mentions="',
new Array(times).fill('[object Object]').join(','),
'"></mentions-line-stub>',
].join('')
describe('RichContent', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia())
+ })
+
it('renders simple post without exploding', () => {
const html = p('Hello world!')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(html))
})
it('unescapes everything as needed', () => {
const html = [p('Testing &#39;em all'), 'Testing &#39;em all'].join('')
const expected = [p("Testing 'em all"), "Testing 'em all"].join('')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it('replaces mention with mentionsline', () => {
const html = p(makeMention('John'), ' how are you doing today?')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(
compwrap(p(mentionsLine(1), ' how are you doing today?')),
)
})
it('replaces mentions at the end of the hellpost', () => {
const html = [
p('How are you doing today, fine gentlemen?'),
p(makeMention('John'), makeMention('Josh'), makeMention('Jeremy')),
].join('')
const expected = [
p('How are you doing today, fine gentlemen?'),
// TODO fix this extra line somehow?
p(
'<mentions-line-stub mentions="',
'[object Object],',
'[object Object],',
'[object Object]',
'"></mentions-line-stub>',
),
].join('')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it('Does not touch links if link handling is disabled', () => {
const html = [
[makeMention('Jack'), "let's meet up with ", makeMention('Janet')].join(
'',
),
[makeMention('John'), makeMention('Josh'), makeMention('Jeremy')].join(
'',
),
].join('\n')
const strippedHtml = [
[
makeMention('Jack', true),
"let's meet up with ",
makeMention('Janet', true),
].join(''),
[
makeMention('John', true),
makeMention('Josh', true),
makeMention('Jeremy', true),
].join(''),
].join('\n')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: false,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html()).to.eql(compwrap(strippedHtml))
})
it('Adds greentext and cyantext to the post', () => {
const html = ['&gt;preordering videogames', '&gt;any year'].join('\n')
const expected = [
'<span class="greentext">&gt;preordering videogames</span>',
'<span class="greentext">&gt;any year</span>',
].join('\n')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: false,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html()).to.eql(compwrap(expected))
})
it('Does not add greentext and cyantext if setting is set to false', () => {
const html = ['&gt;preordering videogames', '&gt;any year'].join('\n')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: false,
greentext: false,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html()).to.eql(compwrap(html))
})
it('Adds emoji to post', () => {
const html = p('Ebin :DDDD :spurdo:')
const expected = p(
'Ebin :DDDD ',
'<anonymous-stub shortcode="spurdo" islocal="true" class="emoji img" src="about:blank" title=":spurdo:" alt=":spurdo:"></anonymous-stub>',
)
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: false,
greentext: false,
emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it("Doesn't add nonexistent emoji to post", () => {
const html = p('Lol :lol:')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: false,
greentext: false,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(html))
})
it('Greentext + last mentions', () => {
const html = [
'&gt;quote',
makeMention('lol'),
'&gt;quote',
'&gt;quote',
].join('\n')
const expected = [
'<span class="greentext">&gt;quote</span>',
mentionsLine(1),
'<span class="greentext">&gt;quote</span>',
'<span class="greentext">&gt;quote</span>',
].join('\n')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html()).to.eql(compwrap(expected))
})
it('One buggy example', () => {
const html = [
'Bruh',
'Bruh',
[makeMention('foo'), makeMention('bar'), makeMention('baz')].join(''),
'Bruh',
].join('<br>')
const expected = ['Bruh', 'Bruh', mentionsLine(3), 'Bruh'].join('<br>')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it('buggy example/hashtags', () => {
const html = [
'<p>',
'<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg">',
'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
' <a class="hashtag" data-tag="nou" href="https://shitposter.club/tag/nou">',
'#nou</a>',
' <a class="hashtag" data-tag="screencap" href="https://shitposter.club/tag/screencap">',
'#screencap</a>',
' </p>',
].join('')
const expected = [
'<p>',
'<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg" target="_blank">',
'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
' <hashtag-link-stub url="https://shitposter.club/tag/nou" content="#nou" tag="nou">',
'</hashtag-link-stub>',
' <hashtag-link-stub url="https://shitposter.club/tag/screencap" content="#screencap" tag="screencap">',
'</hashtag-link-stub>',
' </p>',
].join('')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it('rich contents of a mention are handled properly', () => {
attentions.push({ statusnet_profile_url: 'lol' })
const html = [
p(
'<a href="lol" class="mention">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
),
p('Testing'),
].join('')
const expected = [
p(
'<span class="MentionsLine">',
'<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
'</span>',
'</span>',
),
p('Testing'),
].join('')
- const wrapper = mount(RichContent, {
- global,
+ const wrapper = mount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(
wrapper
.html()
.replaceAll('\n', '')
.replaceAll(/<!--.*?-->/g, ''),
).to.eql(compwrap(expected))
})
it('rich contents of nested mentions are handled properly', () => {
attentions.push({ statusnet_profile_url: 'lol' })
const html = [
'<span class="poast-style">',
'<a href="lol" class="mention">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
' ',
'<a href="lol" class="mention">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
' ',
'</span>',
'Testing',
].join('')
const expected = [
'<span>',
'<span class="MentionsLine">',
'<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
'</span>',
'<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
'</span>',
'</span>',
' ',
'</span>',
'Testing',
].join('')
- const wrapper = mount(RichContent, {
- global,
+ const wrapper = mount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(
wrapper
.html()
.replaceAll('\n', '')
.replaceAll(/<!--.*?-->/g, ''),
).to.eql(compwrap(expected))
})
it('rich contents of a link are handled properly', () => {
const html = [
'<p>',
'Freenode is dead.</p>',
'<p>',
'<a href="https://isfreenodedeadyet.com/">',
'<span>',
'https://</span>',
'<span>',
'isfreenodedeadyet.com/</span>',
'<span>',
'</span>',
'</a>',
'</p>',
].join('')
const expected = [
'<p>',
'Freenode is dead.</p>',
'<p>',
'<a href="https://isfreenodedeadyet.com/" target="_blank">',
'<span>',
'https://</span>',
'<span>',
'isfreenodedeadyet.com/</span>',
'<span>',
'</span>',
'</a>',
'</p>',
].join('')
- const wrapper = shallowMount(RichContent, {
- global,
+ const wrapper = shallowMount(RichContent, mountOpts({
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
- })
+ }))
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it.skip('[INFORMATIVE] Performance testing, 10 000 simple posts', () => {
const amount = 20
const onePost = p(
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
' i just landed in l a where are you',
)
const TestComponent = {
template: `
<div v-if="!vhtml">
${new Array(amount).fill(`<RichContent html="${onePost}" :greentext="true" :handleLinks="handeLinks" :emoji="[]" :attentions="attentions"/>`)}
</div>
<div v-else="vhtml">
${new Array(amount).fill(`<div v-html="${onePost}"/>`)}
</div>
`,
props: ['handleLinks', 'attentions', 'vhtml'],
}
const ptest = (handleLinks, vhtml) => {
const t0 = performance.now()
- const wrapper = mount(TestComponent, {
- global,
+ const wrapper = mount(TestComponent, mountOpts({
props: {
attentions,
handleLinks,
vhtml,
},
- })
+ }))
const t1 = performance.now()
wrapper.destroy()
const t2 = performance.now()
return `Mount: ${t1 - t0}ms, destroy: ${t2 - t1}ms, avg ${(t1 - t0) / amount}ms - ${(t2 - t1) / amount}ms per item`
}
console.debug(`${amount} items with links handling:`)
console.debug(ptest(true))
console.debug(`${amount} items without links handling:`)
console.debug(ptest(false))
console.debug(`${amount} items plain v-html:`)
console.debug(ptest(false, true))
})
})
diff --git a/test/unit/specs/services/notification_utils/notification_utils.spec.js b/test/unit/specs/services/notification_utils/notification_utils.spec.js
index 20c9379c69..f610fae7f3 100644
--- a/test/unit/specs/services/notification_utils/notification_utils.spec.js
+++ b/test/unit/specs/services/notification_utils/notification_utils.spec.js
@@ -1,102 +1,94 @@
+import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import { useNotificationsStore } from 'src/stores/notifications.js'
import * as NotificationUtils from 'src/services/notification_utils/notification_utils.js'
describe('NotificationUtils', () => {
beforeEach(() => {
- const store = useSyncConfigStore(createTestingPinia())
- store.mergedConfig = {
+ setActivePinia(createTestingPinia())
+ useSyncConfigStore().mergedConfig = {
notificationVisibility: {
likes: true,
repeats: true,
mentions: false,
},
}
})
- describe('filteredNotificationsFromStore', () => {
+ describe('filteredNotifications', () => {
it('should return sorted notifications with configured types', () => {
- const store = {
- state: {
- notifications: {
- data: [
- {
- id: 1,
- action: { id: '1' },
- type: 'like',
- },
- {
- id: 2,
- action: { id: '2' },
- type: 'mention',
- },
- {
- id: 3,
- action: { id: '3' },
- type: 'repeat',
- },
- ],
- },
+ useNotificationsStore().data = [
+ {
+ id: 1,
+ action: { id: '1' },
+ type: 'like',
+ },
+ {
+ id: 2,
+ action: { id: '2' },
+ type: 'mention',
+ },
+ {
+ id: 3,
+ action: { id: '3' },
+ type: 'repeat',
},
- }
+ ]
+
const expected = [
{
action: { id: '3' },
id: 3,
type: 'repeat',
},
{
action: { id: '1' },
id: 1,
type: 'like',
},
]
expect(
- NotificationUtils.filteredNotificationsFromStore(store, {
+ NotificationUtils.filteredNotifications({
mentions: false,
likes: true,
repeats: true,
}),
).to.eql(expected)
})
})
- describe('unseenNotificationsFromStore', () => {
+ describe('unseenNotifications', () => {
it('should return only notifications not marked as seen', () => {
- const store = {
- state: {
- notifications: {
- data: [
- {
- action: { id: '1' },
- type: 'like',
- seen: false,
- },
- {
- action: { id: '2' },
- type: 'mention',
- seen: true,
- },
- ],
- },
+ useNotificationsStore().data = [
+ {
+ action: { id: '1' },
+ type: 'like',
+ seen: false,
+ },
+ {
+ action: { id: '2' },
+ type: 'mention',
+ seen: true,
},
- }
+ ]
+
const expected = [
{
action: { id: '1' },
type: 'like',
seen: false,
},
]
expect(
- NotificationUtils.unseenNotificationsFromStore(store, {
+ NotificationUtils.unseenNotifications({
likes: true,
repeats: true,
mentions: false,
}),
).to.eql(expected)
})
})
})
diff --git a/test/unit/specs/stores/notifications.spec.js b/test/unit/specs/stores/notifications.spec.js
index 6947607fb2..653999cdbe 100644
--- a/test/unit/specs/stores/notifications.spec.js
+++ b/test/unit/specs/stores/notifications.spec.js
@@ -1,379 +1,400 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
+import { useI18nStore } from 'src/stores/i18n.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
-import { useStatusesStore } from 'src/stores/statuses.js'
-import { useUsersStore } from 'src/stores/users.js'
import { useReportsStore } from 'src/stores/reports.js'
+import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
-import { useI18nStore } from 'src/stores/i18n.js'
+import { useUsersStore } from 'src/stores/users.js'
import * as USER_API from 'src/api/user.js'
const userId = '1'
const userScreenName = 'user'
const userName = 'Guy'
const userUrl = 'http://localhost/user'
const mockMastoAPIUser = ({
screen_name = userScreenName,
name = userName,
url = userUrl,
id = userId,
} = {}) => ({
id,
acct: screen_name,
display_name: name,
fields: [],
avatar: '',
url,
pleroma: {
emoji_reactions: [],
},
})
const mockUser = ({
screen_name = userScreenName,
id = userId,
name = userName,
url = userUrl,
} = {}) => ({
_original: mockMastoAPIUser({
screen_name,
id,
name,
url,
}),
id,
name,
screen_name,
url,
relationship: undefined,
})
const mockStatus = ({
id = '1',
text,
summary,
type = 'status',
statusUser = mockUser(),
} = {}) => ({
id,
user: statusUser,
summary: summary ?? `Summary number ${id}`,
name: 'status',
text: text ?? `Text number ${id}`,
uri: '',
type,
attentions: [],
statusnet_conversation_id: 'c1',
emoji_reactions: [],
})
const mockStatusNotification = ({
id = '1',
type = 'like',
status = mockStatus({ id }),
seen = false,
user = mockUser(),
} = {}) => ({
type,
id,
status,
seen,
user,
from_profile: user,
})
const DEFAULT_OPTIONS = (method = 'POST') => ({
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
},
})
describe('Notifications store', () => {
beforeEach(() => {
vi.useFakeTimers()
setActivePinia(createTestingPinia({ stubActions: false }))
- useI18nStore().i18n = { t: () => { /* no-op */} }
+ useI18nStore().i18n = {
+ t: () => {
+ /* no-op */
+ },
+ }
})
afterEach(() => {
vi.useRealTimers()
})
it('activate', () => {
const store = useNotificationsStore()
const sub = vi.fn()
useStreamingStore().addSubscriber = sub
store.activate()
expect(sub).to.have.been.called
expect(store.fetcher).to.not.be.null
expect(store.socket).to.not.be.null
store.deactivate()
})
it('deactivate', () => {
const store = useNotificationsStore()
const unsub = vi.fn()
useStreamingStore().removeSubscriber = unsub
store.activate()
// Checking so that they were set properly before
// since reset changes them to ''
store.maxId = '2'
store.minId = '1'
store.idStore = new Map()
store.idStore.set('1', {})
store.idStore.set('2', {})
store.deactivate()
expect(unsub).to.have.been.called
expect(store.fetcher).to.be.null
expect(store.socket).to.be.null
expect(store.idStore).to.have.length(0)
expect(store).to.have.property('maxId', '')
expect(store).to.have.property('minId', '')
})
it('updateExtremes should update min and max ids', () => {
const store = useNotificationsStore()
store.maxId = '10'
store.minId = '05'
store.updateExtremes('04')
store.updateExtremes('11')
expect(store).to.have.property('maxId', '11')
expect(store).to.have.property('minId', '04')
})
describe('addNewNotifications', () => {
it('adds notifications to the list', () => {
const store = useNotificationsStore()
store.addNewNotifications({
timestamp: 1,
data: [
mockStatusNotification({ id: 'a' }),
mockStatusNotification({ id: 'b' }),
],
})
// must be ordered
expect(store.data.map(({ id }) => id)).to.eql(['a', 'b'])
expect(store.idStore).to.have.keys(['a', 'b'])
expect(store).to.have.property('maxId', 'b')
expect(store).to.have.property('minId', 'a')
})
it('ignores duplicates', () => {
const store = useNotificationsStore()
store.addNewNotifications({
timestamp: 1,
data: [
mockStatusNotification({ id: '2' }),
mockStatusNotification({ id: '1' }),
],
})
store.addNewNotifications({
timestamp: 1,
data: [
mockStatusNotification({ id: '3' }),
mockStatusNotification({ id: '2' }),
],
})
// must be ordered
expect(store.data.map(({ id }) => id)).to.eql(['3', '2', '1'])
expect(store.idStore).to.have.keys(['1', '2', '3'])
expect(store).to.have.property('maxId', '3')
expect(store).to.have.property('minId', '1')
})
it('appends notifications if fetching older', () => {
const store = useNotificationsStore()
store.addNewNotifications({
timestamp: 1,
data: [
mockStatusNotification({ id: '4' }),
mockStatusNotification({ id: '3' }),
],
})
- store.addNewNotifications({
- timestamp: 1,
- data: [
- mockStatusNotification({ id: '2' }),
- mockStatusNotification({ id: '1' }),
- ],
- }, true)
+ store.addNewNotifications(
+ {
+ timestamp: 1,
+ data: [
+ mockStatusNotification({ id: '2' }),
+ mockStatusNotification({ id: '1' }),
+ ],
+ },
+ true,
+ )
// must be ordered
expect(store.data.map(({ id }) => id)).to.eql(['4', '3', '2', '1'])
expect(store.idStore).to.have.keys(['1', '2', '3', '4'])
expect(store).to.have.property('maxId', '4')
expect(store).to.have.property('minId', '1')
})
it('should update usersStore', () => {
const store = useNotificationsStore()
const mock = vi.spyOn(useUsersStore(), 'addNewUsers')
const mockedNotification = mockStatusNotification({ type: 'follow' })
store.addNewNotifications({
timestamp: 1337,
data: [mockedNotification],
})
expect(mock).to.have.been.called
expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337)
- expect(mock.mock.calls[0][0].data[0]).to.eql(mockedNotification.from_profile)
+ expect(mock.mock.calls[0][0].data[0]).to.eql(
+ mockedNotification.from_profile,
+ )
})
it('should update reportsStore', (notificationType) => {
const store = useNotificationsStore()
const mock = vi.spyOn(useReportsStore(), 'addReport')
- const mockedNotification = mockStatusNotification({ type: 'pleroma:report' })
+ const mockedNotification = mockStatusNotification({
+ type: 'pleroma:report',
+ })
mockedNotification.report = { data: '123' }
store.addNewNotifications({
timestamp: 1337,
data: [mockedNotification],
})
expect(mock).to.have.been.calledWith({ data: '123' })
})
it.each([
'like',
'mention',
'status',
'repeat',
'pleroma:emoji_reaction',
'poll',
])('should update statusesStore on %s notification', (notificationType) => {
const store = useNotificationsStore()
const mock = vi.fn()
useStatusesStore().addNewStatuses = mock
- const mockedNotification = mockStatusNotification({ type: notificationType })
+ const mockedNotification = mockStatusNotification({
+ type: notificationType,
+ })
store.addNewNotifications({
timestamp: 1337,
data: [mockedNotification],
})
expect(mock).to.have.been.called
expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337)
- expect(mock.mock.calls[0][0].statuses[0]).to.eql(mockedNotification.status)
+ expect(mock.mock.calls[0][0].statuses[0]).to.eql(
+ mockedNotification.status,
+ )
})
})
describe('wipeStatuses', () => {
it('clears all statuses', () => {
const store = useNotificationsStore()
store.addNewNotifications({
timestamp: 1,
data: [
- mockStatusNotification({ id: 'n2', status: mockStatus({ id: 's2' }) }),
- mockStatusNotification({ id: 'n1', status: mockStatus({ id: 's1' }) }),
+ mockStatusNotification({
+ id: 'n2',
+ status: mockStatus({ id: 's2' }),
+ }),
+ mockStatusNotification({
+ id: 'n1',
+ status: mockStatus({ id: 's1' }),
+ }),
],
})
store.wipeStatuses(['s2'])
expect(store.idStore).to.not.have.members('n2')
expect(store.data.map(({ id }) => id)).to.eql(['n1'])
})
})
describe('read/dismiss', () => {
it('read single', () => {
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useNotificationsStore()
const mockedNotification = mockStatusNotification()
store.addNewNotifications({
timestamp: 1337,
data: [
mockStatusNotification({ id: 'n3' }),
mockStatusNotification({ id: 'n2' }),
mockStatusNotification({ id: 'n1' }),
],
})
expect(store.data[1]).to.have.property('seen', false)
store.markSingleNotificationAsSeen('n2')
expect(store.data[0]).to.have.property('seen', false)
expect(store.data[1]).to.have.property('seen', true)
expect(store.data[0]).to.have.property('seen', false)
const calls = mockFetch.mock.calls
expect(calls).to.have.length(1)
const callOne = calls[0]
expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL)
const formData = Object.fromEntries(callOne[1].body.entries())
expect(formData).to.eql({ id: 'n2' })
})
it('read all', () => {
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useNotificationsStore()
const mockedNotification = mockStatusNotification()
// FormData is weird to test
store.addNewNotifications({
timestamp: 1337,
data: [
mockStatusNotification({ id: 'n3' }),
mockStatusNotification({ id: 'n2' }),
mockStatusNotification({ id: 'n1' }),
],
})
expect(store.data[0]).to.have.property('seen', false)
expect(store.data[1]).to.have.property('seen', false)
expect(store.data[2]).to.have.property('seen', false)
store.markNotificationsAsSeen()
expect(store.data[0]).to.have.property('seen', true)
expect(store.data[1]).to.have.property('seen', true)
expect(store.data[2]).to.have.property('seen', true)
// FormData is weird to test
const calls = mockFetch.mock.calls
expect(calls).to.have.length(1)
const callOne = calls[0]
expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL)
const formData = Object.fromEntries(callOne[1].body.entries())
expect(formData).to.eql({ max_id: 'n3' })
})
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sat, Aug 29, 8:46 AM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1736267
Default Alt Text
(107 KB)

Event Timeline