Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
230 KB
Referenced Files
None
Subscribers
None
diff --git a/src/api/public.js b/src/api/public.js
index 740a55ca25..ef799566df 100644
--- a/src/api/public.js
+++ b/src/api/public.js
@@ -1,290 +1,295 @@
import { paramsString, promisedRequest } from './helpers.js'
import { MASTODON_USER_TIMELINE_URL } from './timelines.js'
import {
parseSource,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_SUGGESTIONS_URL = '/api/v1/suggestions'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const MASTODON_PASSWORD_RESET_URL = ({ email }) =>
`/auth/password${paramsString({ email })}`
export const MASTODON_FOLLOWING_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/following${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
export const MASTODON_FOLLOWERS_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
export const MASTODON_STATUS_URL = (id) => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = (id) => `/api/v1/statuses/${id}/context`
-const MASTODON_STATUS_SOURCE_URL = (id) => `/api/v1/statuses/${id}/source`
-const MASTODON_STATUS_HISTORY_URL = (id) => `/api/v1/statuses/${id}/history`
+export const MASTODON_STATUS_SOURCE_URL = (id) =>
+ `/api/v1/statuses/${id}/source`
+export const MASTODON_STATUS_HISTORY_URL = (id) =>
+ `/api/v1/statuses/${id}/history`
const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_LOOKUP_URL = ({ acct }) =>
`/api/v1/accounts/lookup${paramsString({ acct })}`
const MASTODON_POLL_URL = (id = '') => `/api/v1/polls/${id}`
-const MASTODON_STATUS_FAVORITEDBY_URL = (id) =>
+export const MASTODON_STATUS_FAVORITEDBY_URL = (id) =>
`/api/v1/statuses/${id}/favourited_by`
-const MASTODON_STATUS_REBLOGGEDBY_URL = (id) =>
+export const MASTODON_STATUS_REBLOGGEDBY_URL = (id) =>
`/api/v1/statuses/${id}/reblogged_by`
const MASTODON_SEARCH_2 = ({
q,
resolve,
limit,
offset,
following,
type,
withRelationships,
accountId,
excludeUnreviewed,
}) =>
`/api/v2/search${paramsString({ q, resolve, limit, offset, following, type, withRelationships, accountId, excludeUnreviewed })}`
const MASTODON_USER_SEARCH_URL = ({ q, resolve }) =>
`/api/v1/accounts/search${paramsString({ q, resolve })}`
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
-const PLEROMA_EMOJI_REACTIONS_URL = (id) =>
+export const PLEROMA_EMOJI_REACTIONS_URL = (id) =>
`/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_SCROBBLES_URL = (id, { maxId, sinceId, minId, limit, offset }) =>
`/api/v1/pleroma/accounts/${id}/scrobbles${paramsString({ maxId, sinceId, minId, limit, offset })}`
const EMOJI_PACKS_URL = (page, pageSize) =>
`/api/v1/pleroma/emoji/packs${paramsString({ page, pageSize })}`
// Params needed:
// nickname
// email
// fullname
// password
// password_confirm
//
// Optional
// bio
// homepage
// location
// token
// language
export const register = ({ params, credentials }) => {
const { nickname, ...rest } = params
return promisedRequest({
url: MASTODON_REGISTRATION_URL,
method: 'POST',
credentials,
payload: {
nickname,
locale: 'en_US',
agreement: true,
...rest,
},
})
}
export const getCaptcha = () =>
promisedRequest({
url: '/api/pleroma/captcha',
})
export const fetchUser = ({ id, credentials }) =>
promisedRequest({
url: `${MASTODON_USER_URL}/${id}`,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const fetchUserByName = ({ name, credentials }) =>
promisedRequest({
url: MASTODON_USER_LOOKUP_URL({ acct: name }),
credentials,
})
.then(({ data }) => data.id)
.catch((error) => {
if (error?.statusCode === 404) {
// Either the backend does not support lookup endpoint,
// or there is no user with such name. Fallback and treat name as id.
return name
} else {
throw error
}
})
.then((id) => fetchUser({ id, credentials }))
export const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) =>
promisedRequest({
url: MASTODON_FOLLOWING_URL(id, {
maxId,
sinceId,
limit,
withRelationships: true,
}),
credentials,
}).then(({ data, ...rest }) => ({
...rest,
data: data.map(parseUser),
}))
export const fetchFollowers = ({
id,
maxId,
sinceId,
limit = 20,
credentials,
}) =>
promisedRequest({
url: MASTODON_FOLLOWERS_URL(id, {
maxId,
sinceId,
limit,
withRelationships: true,
}),
credentials,
}).then(({ data, ...rest }) => ({
...rest,
data: data.map(parseUser),
}))
export const fetchConversation = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_CONTEXT_URL(id),
credentials,
}).then((result) => ({
...result,
data: {
...result.data,
ancestors: result.data.ancestors.map(parseStatus),
descendants: result.data.descendants.map(parseStatus),
},
}))
export const fetchStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_URL(id),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const fetchStatusSource = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_SOURCE_URL(id),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseSource(data) }))
-export const fetchStatusHistory = ({ status, credentials }) =>
+export const fetchStatusHistory = ({ id, credentials }) =>
promisedRequest({
- url: MASTODON_STATUS_HISTORY_URL(status.id),
+ url: MASTODON_STATUS_HISTORY_URL(id),
credentials,
}).then(({ data, ...rest }) => {
- return [...data].reverse().map((item) => {
- item.originalStatus = status
- return { ...rest, data: parseStatus(item) }
- })
+ return {
+ ...rest,
+ data: [...data].reverse().map((item) => {
+ item.originalStatus = status
+ return parseStatus(item)
+ }),
+ }
})
export const listEmojiPacks = ({ page, pageSize, credentials }) =>
promisedRequest({
url: EMOJI_PACKS_URL(page, pageSize),
})
export const fetchPinnedStatuses = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_USER_TIMELINE_URL(id, { pinned: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseStatus) }))
export const verifyCredentials = ({ credentials }) =>
promisedRequest({
url: MASTODON_LOGIN_URL,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const resetPassword = ({ email }) => {
return promisedRequest({
url: MASTODON_PASSWORD_RESET_URL({ email }),
method: 'POST',
})
}
export const suggestions = ({ credentials }) =>
promisedRequest({
url: MASTODON_SUGGESTIONS_URL,
credentials,
})
export const fetchPoll = ({ pollId, credentials }) =>
promisedRequest({
url: MASTODON_POLL_URL(encodeURIComponent(pollId)),
method: 'GET',
credentials,
})
export const fetchFavoritedByUsers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_FAVORITEDBY_URL(id),
method: 'GET',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchRebloggedByUsers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_REBLOGGEDBY_URL(id),
method: 'GET',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchEmojiReactions = ({ id, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_REACTIONS_URL(id),
credentials,
}).then(({ data, ...rest }) => ({
...rest,
data: data.map((r) => {
r.accounts = r.accounts.map(parseUser)
return r
}),
}))
export const searchUsers = ({ credentials, query }) =>
promisedRequest({
url: MASTODON_USER_SEARCH_URL({ q: query, resolve: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const search2 = ({
credentials,
q,
resolve,
limit,
offset,
following,
type,
}) => {
return promisedRequest({
url: MASTODON_SEARCH_2({
q,
resolve,
limit,
offset,
following,
type,
withRelationships: true,
}),
credentials,
}).then(({ data, ...rest }) => {
data.accounts = data.accounts.slice(0, limit).map((u) => parseUser(u))
data.statuses = data.statuses.slice(0, limit).map((s) => parseStatus(s))
return { ...rest, data }
})
}
export const fetchKnownDomains = ({ credentials }) =>
promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials })
export const fetchScrobbles = ({ accountId, limit = 1 }) =>
promisedRequest({
url: PLEROMA_SCROBBLES_URL(accountId, { limit }),
})
diff --git a/src/api/user.js b/src/api/user.js
index 0802d8c8b0..cb7195f49f 100644
--- a/src/api/user.js
+++ b/src/api/user.js
@@ -1,921 +1,926 @@
import { last } from 'lodash'
import { paramsString, promisedRequest } from './helpers.js'
import { fetchFriends, MASTODON_STATUS_URL } from './public.js'
import {
parseAttachment,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MUTES_IMPORT_URL = '/api/pleroma/mutes_import'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
const ALIASES_URL = '/api/pleroma/aliases'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'
const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes'
const MFA_SETUP_OTP_URL = '/api/pleroma/accounts/mfa/setup/totp'
const MFA_CONFIRM_OTP_URL = '/api/pleroma/accounts/mfa/confirm/totp'
const MFA_DISABLE_OTP_URL = '/api/pleroma/accounts/mfa/totp'
const MASTODON_DISMISS_NOTIFICATION_URL = (id) =>
`/api/v1/notifications/${id}/dismiss`
-const MASTODON_FAVORITE_URL = (id) => `/api/v1/statuses/${id}/favourite`
-const MASTODON_UNFAVORITE_URL = (id) => `/api/v1/statuses/${id}/unfavourite`
-const MASTODON_RETWEET_URL = (id) => `/api/v1/statuses/${id}/reblog`
-const MASTODON_UNRETWEET_URL = (id) => `/api/v1/statuses/${id}/unreblog`
-const MASTODON_DELETE_URL = (id) => `/api/v1/statuses/${id}`
+export const MASTODON_FAVORITE_URL = (id) => `/api/v1/statuses/${id}/favourite`
+export const MASTODON_UNFAVORITE_URL = (id) =>
+ `/api/v1/statuses/${id}/unfavourite`
+export const MASTODON_RETWEET_URL = (id) => `/api/v1/statuses/${id}/reblog`
+export const MASTODON_UNRETWEET_URL = (id) => `/api/v1/statuses/${id}/unreblog`
+export const MASTODON_DELETE_URL = (id) => `/api/v1/statuses/${id}`
export const MASTODON_FOLLOW_URL = (id) => `/api/v1/accounts/${id}/follow`
export const MASTODON_UNFOLLOW_URL = (id) => `/api/v1/accounts/${id}/unfollow`
const MASTODON_FOLLOW_REQUESTS_URL = '/api/v1/follow_requests'
const MASTODON_APPROVE_USER_URL = (id) =>
`/api/v1/follow_requests/${id}/authorize`
const MASTODON_DENY_USER_URL = (id) => `/api/v1/follow_requests/${id}/reject`
const MASTODON_USER_RELATIONSHIPS_URL = ({ id, withSuspended }) =>
`/api/v1/accounts/relationships/${paramsString({ id, withSuspended })}`
export const MASTODON_USER_IN_LISTS = (id) => `/api/v1/accounts/${id}/lists`
export const MASTODON_LIST_URL = (id = '') => `/api/v1/lists/${id}`
export const MASTODON_LIST_ACCOUNTS_URL = (id) => `/api/v1/lists/${id}/accounts`
export const MASTODON_USER_BLOCKS_URL = ({
maxId,
sinceId,
limit,
withRelationships,
}) =>
`/api/v1/blocks/${paramsString({ maxId, sinceId, limit, withRelationships })}`
export const MASTODON_USER_MUTES_URL = ({
maxId,
sinceId,
limit,
withRelationships,
}) =>
`/api/v1/mutes/${paramsString({ maxId, sinceId, limit, withRelationships })}`
export const MASTODON_BLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/block`
export const MASTODON_UNBLOCK_USER_URL = (id) =>
`/api/v1/accounts/${id}/unblock`
export const MASTODON_MUTE_USER_URL = (id) => `/api/v1/accounts/${id}/mute`
export const MASTODON_UNMUTE_USER_URL = (id) => `/api/v1/accounts/${id}/unmute`
export const MASTODON_REMOVE_USER_FROM_FOLLOWERS_URL = (id) =>
`/api/v1/accounts/${id}/remove_from_followers`
export const MASTODON_USER_NOTE_URL = (id) => `/api/v1/accounts/${id}/note`
-const MASTODON_BOOKMARK_STATUS_URL = (id) => `/api/v1/statuses/${id}/bookmark`
-const MASTODON_UNBOOKMARK_STATUS_URL = (id) =>
+export const MASTODON_BOOKMARK_STATUS_URL = (id) =>
+ `/api/v1/statuses/${id}/bookmark`
+export const MASTODON_UNBOOKMARK_STATUS_URL = (id) =>
`/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = (id) => `/api/v1/polls/${id}/votes`
const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials'
const MASTODON_REPORT_USER_URL = '/api/v1/reports'
-const MASTODON_PIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/pin`
-const MASTODON_UNPIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/unpin`
-const MASTODON_MUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/mute`
-const MASTODON_UNMUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/unmute`
+export const MASTODON_PIN_OWN_STATUS_URL = (id) => `/api/v1/statuses/${id}/pin`
+export const MASTODON_UNPIN_OWN_STATUS_URL = (id) =>
+ `/api/v1/statuses/${id}/unpin`
+export const MASTODON_MUTE_CONVERSATION_URL = (id) =>
+ `/api/v1/statuses/${id}/mute`
+export const MASTODON_UNMUTE_CONVERSATION_URL = (id) =>
+ `/api/v1/statuses/${id}/unmute`
export const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_ANNOUNCEMENTS_URL = '/api/v1/announcements'
const MASTODON_ANNOUNCEMENTS_DISMISS_URL = (id) =>
`/api/v1/announcements/${id}/dismiss`
-const PLEROMA_EMOJI_REACT_URL = (id, emoji) =>
+export const PLEROMA_EMOJI_REACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
-const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) =>
+export const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const PLEROMA_BOOKMARK_FOLDERS_URL = '/api/v1/pleroma/bookmark_folders'
const PLEROMA_BOOKMARK_FOLDER_URL = (id) =>
`/api/v1/pleroma/bookmark_folders/${id}`
// #Posts
export const favorite = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_FAVORITE_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unfavorite = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNFAVORITE_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const retweet = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_RETWEET_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unretweet = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNRETWEET_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const reactWithEmoji = ({ id, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_REACT_URL(id, emoji),
method: 'PUT',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unreactWithEmoji = ({ id, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_UNREACT_URL(id, emoji),
method: 'DELETE',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const bookmarkStatus = ({ id, credentials, ...options }) =>
promisedRequest({
url: MASTODON_BOOKMARK_STATUS_URL(id),
credentials,
method: 'POST',
payload: {
folder_id: options.folder_id,
},
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unbookmarkStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNBOOKMARK_STATUS_URL(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const pinOwnStatus = ({ id, credentials }) =>
promisedRequest({
- url: MASTODON_PIN_OWN_STATUS(id),
+ url: MASTODON_PIN_OWN_STATUS_URL(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unpinOwnStatus = ({ id, credentials }) =>
promisedRequest({
- url: MASTODON_UNPIN_OWN_STATUS(id),
+ url: MASTODON_UNPIN_OWN_STATUS_URL(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const muteConversation = ({ id, credentials }) =>
promisedRequest({
- url: MASTODON_MUTE_CONVERSATION(id),
+ url: MASTODON_MUTE_CONVERSATION_URL(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unmuteConversation = ({ id, credentials }) =>
promisedRequest({
- url: MASTODON_UNMUTE_CONVERSATION(id),
+ url: MASTODON_UNMUTE_CONVERSATION_URL(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const vote = ({ pollId, choices, credentials }) => {
return promisedRequest({
url: MASTODON_VOTE_URL(encodeURIComponent(pollId)),
method: 'POST',
credentials,
payload: {
choices,
},
})
}
// #Posting
export const postStatus = ({
credentials,
status,
spoilerText,
visibility,
sensitive,
poll,
mediaIds = [],
inReplyToStatusId,
quoteId,
contentType,
preview,
idempotencyKey,
}) => {
const form = new FormData()
const pollOptions = poll?.options || []
form.append('status', status)
form.append('source', 'Pleroma FE')
if (spoilerText) form.append('spoiler_text', spoilerText)
if (visibility) form.append('visibility', visibility)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach((val) => {
form.append('media_ids[]', val)
})
if (pollOptions.some((option) => option !== '')) {
const normalizedPoll = {
expires_in: Number.parseInt(poll.expiresIn, 10),
multiple: poll.multiple,
}
Object.keys(normalizedPoll).forEach((key) => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach((option) => {
form.append('poll[options][]', option)
})
}
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
if (quoteId) {
form.append('quote_id', quoteId)
}
if (preview) {
form.append('preview', 'true')
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: MASTODON_POST_STATUS_URL,
formData: form,
method: 'POST',
credentials,
headers,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
}
export const editStatus = ({
id,
credentials,
status,
spoilerText,
sensitive,
poll,
mediaIds = [],
contentType,
}) => {
const form = new FormData()
const pollOptions = poll?.options || []
form.append('status', status)
if (spoilerText) form.append('spoiler_text', spoilerText)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach((val) => {
form.append('media_ids[]', val)
})
if (pollOptions.some((option) => option !== '')) {
const normalizedPoll = {
expires_in: Number.parseInt(poll.expiresIn, 10),
multiple: poll.multiple,
}
Object.keys(normalizedPoll).forEach((key) => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach((option) => {
form.append('poll[options][]', option)
})
}
return promisedRequest({
url: MASTODON_STATUS_URL(id),
formData: form,
method: 'PUT',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
}
export const deleteStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_DELETE_URL(id),
credentials,
method: 'DELETE',
})
export const uploadMedia = ({ formData, credentials }) =>
promisedRequest({
url: MASTODON_MEDIA_UPLOAD_URL,
formData,
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseAttachment(data) }))
export const setMediaDescription = ({ id, description, credentials }) =>
promisedRequest({
url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
method: 'PUT',
credentials,
payload: {
description,
},
}).then(({ data, ...rest }) => ({ ...rest, data: parseAttachment(data) }))
// #Notifications
export const dismissNotification = ({ credentials, id }) =>
promisedRequest({
url: MASTODON_DISMISS_NOTIFICATION_URL(id),
method: 'POST',
payload: { id },
credentials,
})
export const markNotificationsAsSeen = ({
id,
credentials,
single = false,
}) => {
const formData = new FormData()
if (single) {
formData.append('id', id)
} else {
formData.append('max_id', id)
}
return promisedRequest({
url: NOTIFICATION_READ_URL,
formData,
credentials,
method: 'POST',
})
}
// #Announcements
export const getAnnouncements = ({ credentials }) =>
promisedRequest({ url: MASTODON_ANNOUNCEMENTS_URL, credentials })
export const dismissAnnouncement = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_ANNOUNCEMENTS_DISMISS_URL(id),
credentials,
method: 'POST',
})
// #Imports
export const importMutes = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: MUTES_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: BLOCKS_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const importFollows = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: FOLLOW_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const exportFriends = ({ id, credentials }) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO refactor this
return new Promise(async (resolve, reject) => {
try {
let friends = []
let more = true
while (more) {
const maxId = friends.length > 0 ? last(friends).id : undefined
const users = await fetchFriends({
id,
maxId,
credentials,
})
friends = [...friends, ...users]
if (users.length === 0) {
more = false
}
}
resolve(friends)
} catch (err) {
reject(err)
}
})
}
// #Profile settings
export const updateNotificationSettings = ({ credentials, settings }) => {
return promisedRequest({
url: NOTIFICATION_SETTINGS_URL,
credentials,
method: 'PUT',
payload: settings,
})
}
export const updateProfileImages = ({
credentials,
avatar = null,
avatarName = null,
banner = null,
background = null,
}) => {
const form = new FormData()
if (avatar !== null) {
if (avatarName !== null) {
form.append('avatar', avatar, avatarName)
} else {
form.append('avatar', avatar)
}
}
if (banner !== null) form.append('header', banner)
if (background !== null) form.append('pleroma_background_image', background)
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
method: 'PATCH',
formData: form,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
}
export const updateProfile = ({ credentials, params }) => {
const formData = new FormData()
for (const name in params) {
if (name === 'fields_attributes') {
params[name].forEach((param, i) => {
formData.append(name + `[${i}][name]`, param.name)
formData.append(name + `[${i}][value]`, param.value)
})
} else {
if (typeof params[name] === 'object') {
console.warn(
'Object detected in updateProfile API call. This will not work, use updateProfileJSON instead.',
)
console.warn('Object:\n' + JSON.stringify(params[name], null, 2))
}
formData.append(name, params[name])
}
}
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
method: 'PATCH',
formData,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
}
export const updateProfileJSON = ({ credentials, params }) =>
promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
payload: params,
method: 'PATCH',
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const changeEmail = ({ credentials, email, password }) => {
const form = new FormData()
form.append('email', email)
form.append('password', password)
return promisedRequest({
url: CHANGE_EMAIL_URL,
formData: form,
method: 'POST',
credentials,
})
}
export const moveAccount = ({ credentials, password, targetAccount }) => {
const form = new FormData()
form.append('password', password)
form.append('target_account', targetAccount)
return promisedRequest({
url: MOVE_ACCOUNT_URL,
formData: form,
method: 'POST',
credentials,
})
}
export const changePassword = ({
credentials,
password,
newPassword,
newPasswordConfirmation,
}) => {
const form = new FormData()
form.append('password', password)
form.append('new_password', newPassword)
form.append('new_password_confirmation', newPasswordConfirmation)
return promisedRequest({
url: CHANGE_PASSWORD_URL,
formData: form,
method: 'POST',
credentials,
})
}
// #MFA
export const settingsMFA = ({ credentials }) =>
promisedRequest({
url: MFA_SETTINGS_URL,
credentials,
method: 'GET',
})
export const mfaDisableOTP = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return promisedRequest({
url: MFA_DISABLE_OTP_URL,
formData: form,
method: 'DELETE',
credentials,
})
}
export const mfaConfirmOTP = ({ credentials, password, token }) => {
const form = new FormData()
form.append('password', password)
form.append('code', token)
return promisedRequest({
url: MFA_CONFIRM_OTP_URL,
formData: form,
credentials,
method: 'POST',
})
}
export const mfaSetupOTP = ({ credentials }) =>
promisedRequest({
url: MFA_SETUP_OTP_URL,
credentials,
method: 'GET',
})
export const generateMfaBackupCodes = ({ credentials }) =>
promisedRequest({
url: MFA_BACKUP_CODES_URL,
credentials,
method: 'GET',
})
// #Aliases
export const addAlias = ({ credentials, alias }) =>
promisedRequest({
url: ALIASES_URL,
method: 'PUT',
credentials,
payload: { alias },
})
export const deleteAlias = ({ credentials, alias }) =>
promisedRequest({
url: ALIASES_URL,
method: 'DELETE',
credentials,
payload: { alias },
})
export const listAliases = ({ credentials }) =>
promisedRequest({
url: ALIASES_URL,
method: 'GET',
credentials,
params: {
_cacheBooster: Date.now(),
},
})
// User manipulation
export const fetchUserRelationship = ({ id, withSuspended, credentials }) =>
promisedRequest({
url: MASTODON_USER_RELATIONSHIPS_URL({ id, withSuspended }),
credentials,
})
export const followUser = ({ id, credentials, ...options }) => {
const payload = {}
if (options.reblogs !== undefined) {
payload.reblogs = options.reblogs
}
if (options.notify !== undefined) {
payload.notify = options.notify
}
return promisedRequest({
url: MASTODON_FOLLOW_URL(id),
payload,
credentials,
method: 'POST',
})
}
export const unfollowUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNFOLLOW_URL(id),
credentials,
method: 'POST',
})
export const fetchUserInLists = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_USER_IN_LISTS(id),
credentials,
})
export const removeUserFromFollowers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_REMOVE_USER_FROM_FOLLOWERS_URL(id),
credentials,
method: 'POST',
})
export const fetchFollowRequests = ({ credentials }) =>
promisedRequest({
url: MASTODON_FOLLOW_REQUESTS_URL,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const approveUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_APPROVE_USER_URL(id),
credentials,
method: 'POST',
})
export const denyUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_DENY_USER_URL(id),
credentials,
method: 'POST',
})
export const editUserNote = ({ id, credentials, comment }) =>
promisedRequest({
url: MASTODON_USER_NOTE_URL(id),
credentials,
payload: {
comment,
},
method: 'POST',
})
export const fetchMutes = ({ maxId, credentials }) =>
promisedRequest({
url: MASTODON_USER_MUTES_URL({ maxId, withRelationships: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const muteUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.expires_in = expiresIn
}
return promisedRequest({
url: MASTODON_MUTE_USER_URL(id),
credentials,
method: 'POST',
payload,
})
}
export const unmuteUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNMUTE_USER_URL(id),
credentials,
method: 'POST',
})
export const fetchBlocks = ({ maxId, credentials }) =>
promisedRequest({
url: MASTODON_USER_BLOCKS_URL({ maxId, withRelationships: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const blockUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.duration = expiresIn
}
return promisedRequest({
url: MASTODON_BLOCK_USER_URL(id),
credentials,
method: 'POST',
payload,
})
}
export const unblockUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNBLOCK_USER_URL(id),
credentials,
method: 'POST',
})
export const reportUser = ({
credentials,
userId,
statusIds,
comment,
forward,
}) =>
promisedRequest({
url: MASTODON_REPORT_USER_URL,
method: 'POST',
payload: {
account_id: userId,
status_ids: statusIds,
comment,
forward,
},
credentials,
})
// #Domain mutes
export const fetchDomainMutes = ({ credentials }) =>
promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials })
export const muteDomain = ({ domain, credentials }) =>
promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'POST',
payload: { domain },
credentials,
})
export const unmuteDomain = ({ domain, credentials }) =>
promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'DELETE',
payload: { domain },
credentials,
})
// #Backups
export const addBackup = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'POST',
credentials,
})
export const listBackups = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'GET',
credentials,
params: {
_cacheBooster: Date.now(),
},
})
// #OAuth
export const fetchOAuthTokens = ({ credentials }) =>
promisedRequest({
url: '/api/oauth_tokens.json',
credentials,
})
export const revokeOAuthToken = ({ id, credentials }) =>
promisedRequest({
url: `/api/oauth_tokens/${id}`,
credentials,
method: 'DELETE',
})
// #Lists
export const fetchLists = ({ credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(),
credentials,
})
export const createList = ({ title, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(),
credentials,
method: 'POST',
payload: { title },
})
export const getList = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
credentials,
})
export const updateList = ({ listId, title, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
credentials,
method: 'PUT',
payload: { title },
})
export const getListAccounts = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
}).then((data) => data.map(({ id }) => id))
export const addAccountsToList = ({ listId, accountIds, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
method: 'POST',
payload: { account_ids: accountIds },
})
export const removeAccountsFromList = ({ listId, accountIds, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
method: 'DELETE',
payload: { account_ids: accountIds },
})
export const deleteList = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
method: 'DELETE',
credentials,
})
// #Bookmarks
export const fetchBookmarkFolders = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDERS_URL,
credentials,
})
export const createBookmarkFolder = ({ name, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDERS_URL,
credentials,
method: 'POST',
payload: { name, emoji },
})
export const updateBookmarkFolder = ({ folderId, name, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDER_URL(folderId),
credentials,
method: 'PATCH',
payload: { name, emoji },
})
export const deleteBookmarkFolder = ({ folderId, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDER_URL(folderId),
method: 'DELETE',
credentials,
})
// #So long and thanks for all the fish
export const deleteAccount = ({ credentials, password }) => {
const formData = new FormData()
formData.append('password', password)
return promisedRequest({
url: DELETE_ACCOUNT_URL,
formData,
method: 'POST',
credentials,
})
}
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 63657017c5..f5a441d543 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,622 +1,626 @@
import { get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const conversation = {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
collapsable: {
// Whether conversation can be collapsed
// i.e. when it's not a page
type: Boolean,
default: false,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
pinnedStatusIdsObject: {
// Used for user profile, map of pinned statuses
type: Object,
default: null,
},
inProfile: {
// Whether conversation is rendered in a user profile
// used for overriding muted status
type: Boolean,
default: false,
},
profileUserId: {
// used with inProfile, user id of the profile
type: String,
default: null,
},
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
},
data() {
return {
focused: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
inlineDivePosition: null,
loadStatusError: null,
unsuspendibleIds: new Set(),
}
},
created() {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
+ status() {
+ console.log(this.statusId)
+ console.log(useStatusesStore().allStatuses.get(this.statusId))
+ return useStatusesStore().allStatuses.get(this.statusId)
+ },
maxDepthToShowByDefault() {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
displayStyle() {
return this.mergedConfig.conversationDisplay
},
treeViewIsSimple() {
return !this.mergedConfig.conversationTreeAdvanced
},
isTreeView() {
return this.displayStyle === 'tree'
},
isLinearView() {
return this.displayStyle !== 'tree'
},
shouldFadeAncestors() {
return this.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition() {
return this.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus() {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus() {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable() {
return this.unsuspendibleIds.size > 0
},
hideStatus() {
return this.virtualHidden && this.suspendable
},
- status() {
- return useStatusesStore().allStatuses.get(this.statusId)
- },
originalStatusId() {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId() {
return this.getConversationId(this.statusId)
},
conversation() {
if (!this.status) {
return []
}
if (!this.isExpanded) {
return [this.status]
}
const conversation = useStatusesStore().conversations.get(
this.conversationId,
)
- return [...conversation.values()].toSorted(sortById)
+ return [...conversation.keys()]
+ .map((k) => useStatusesStore().allStatuses.get(k))
+ .toSorted(sortById)
},
statusMap() {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{},
)
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
return a
},
{
forest: {},
},
)
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth,
},
walk(forest, forest[id], depth + 1, processed),
].flat()
})
.flat()
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id),
)
return linearized
},
replyIds() {
return this.conversation
.map((k) => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map((k) => k.id)
return res
}, {})
},
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth() {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map((k) => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths() {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
tl.filter(
(k) =>
!this.getReplies(cur.id)
.map((v) => v.id)
.includes(k.id),
),
this.conversation,
)
return topLevel
},
otherTopLevelCount() {
return this.topLevel.length - 1
},
showingTopLevel() {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot() {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth() {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode() {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton() {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
},
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
},
replies() {
let i = 1
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id,
})
}
i++
return result
},
{},
)
},
isExpanded() {
return !!(this.expanded || this.isPage)
},
hiddenStyle() {
const height = this.status?.virtualHeight || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus() {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
canDive() {
return this.isTreeView && this.isExpanded
},
maybeFocused() {
return this.isExpanded ? this.focused : null
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
}),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
RichContent,
},
watch: {
statusId(newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
this.setFocused(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded(value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden() {
useStatusesStore().setVirtualHeight({
statusId: this.statusId,
height: `${this.$el.clientHeight}px`,
})
},
},
methods: {
fetchConversation() {
if (this.status) {
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
}).then(({ data: { ancestors, descendants }, timestamp }) => {
useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp })
useStatusesStore().addNewStatuses({
statuses: descendants,
timestamp,
})
this.setFocused(this.originalStatusId)
})
} else {
this.loadStatusError = null
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
})
.then(({ data: status }) => {
useStatusesStore().addNewStatuses({ statuses: [status] })
this.fetchConversation()
})
.catch((error) => {
console.error(error)
this.loadStatusError = error
})
}
},
getReplies(id) {
return this.replies[id] || []
},
setFocused(id) {
if (!id) return
this.focused = id
if (!this.streamingEnabled) {
useStatusesStore().fetchStatus(id)
}
useStatusesStore().fetchFavsAndRepeats(id)
- useStatusesStore().fetchEmojiReactionsBy(id)
+ useStatusesStore().fetchEmojiReactions(id)
},
toggleExpanded() {
this.expanded = !this.expanded
},
getConversationId(statusId) {
const status = useStatusesStore().allStatuses.get(statusId)
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus,
}
},
toggleThreadDisplay(id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively(id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively(id) {
this.setThreadDisplayRecursively(id, 'showing')
},
leastVisibleAncestor(id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
}
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus(id) {
this.tryScrollTo(id)
},
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id,
)
},
// only used when we are not on a page
undive() {
this.inlineDivePosition = null
this.setFocused(this.statusId)
},
tryScrollTo(id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = id
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `focused` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `focused` watcher.
this.$nextTick(() => {
this.setFocused(id)
})
},
goToCurrent() {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById(id) {
return this.statusMap[id]
},
parentOf(id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
return parentId
},
parentOrSelf(id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf(id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId(id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState() {
this.undive()
this.threadDisplayStatusObject = {}
},
onStatusSuspendStateChange({ id, suspend }) {
if (!suspend) {
this.unsuspendibleIds.add(id)
} else {
this.unsuspendibleIds.delete(id)
}
},
onPosted(data) {
if (this.isPage) {
this.$router.push({ name: 'conversation', params: { id: data.id } })
}
},
},
}
export default conversation
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 0833c4f89e..ae6e3a0ce5 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,216 +1,216 @@
<template>
<div
v-if="!hideStatus"
:style="hiddenStyle"
class="Conversation"
:class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
>
<h1 class="title">
<RichContent
v-if="conversation[0]?.summary_raw_html"
:html="conversation[0].summary_raw_html"
:emoji="conversation[0].emojis"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</h1>
<button
v-if="collapsable"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="!collapsable && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="!collapsable"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !status"
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadStatusError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadStatusError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="status.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
scope="global"
@click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $t('status.show_all_conversation', { numStatus: otherTopLevelCount }, otherTopLevelCount) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
class="thread-ancestors"
>
<article
v-for="status in ancestorsOf(diveRoot)"
:key="status.id"
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
>
<Status
ref="statusComponent"
class="conversation-status status-fadein panel-body"
- :statusoid="status"
+ :status-id="status.id"
:replies="getReplies(status.id)"
:expandable="!isExpanded"
:focused="maybeFocused === status.id"
:inline-expanded="collapsable && isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-profile="inProfile"
:in-conversation="isExpanded"
:profile-user-id="profileUserId"
:simple-tree="treeViewIsSimple"
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
can-dive
@goto="setFocused"
@dive="() => diveIntoStatus(status.id)"
@suspendable-state-change="onStatusSuspendStateChange"
/>
<div
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
class="thread-ancestor-dive-box"
>
<div
class="thread-ancestor-dive-box-inner"
>
<i18n-t
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="diveIntoStatus(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).length - 1).length - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
</article>
</div>
<ThreadTree
v-for="status in showingTopLevel"
:key="status.id"
ref="statusComponent"
:depth="0"
- :status="status"
+ :status-id="status.id"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:get-replies="getReplies"
:focused="maybeFocused"
:toggle-expanded="toggleExpanded"
:simple="treeViewIsSimple"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:can-dive="canDive"
@goto="setFocused"
@dive="diveIntoStatus"
@suspendable-state-change="onStatusSuspendStateChange"
/>
</div>
<div
v-else-if="isLinearView"
class="thread-body"
>
<article>
<Status
v-for="status in conversation"
:key="status.id"
ref="statusComponent"
class="conversation-status status-fadein panel-body"
- :statusoid="status"
+ :status-id="status.id"
:replies="getReplies(status.id)"
:expandable="!isExpanded"
:focused="maybeFocused === status.id || maybeFocused === status.retweeted_status?.id"
:inline-expanded="collapsable && isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-profile="inProfile"
:in-conversation="isExpanded"
:profile-user-id="profileUserId"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="onStatusSuspendStateChange"
/>
</article>
</div>
</div>
</div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js
index f881036df1..3077ed2126 100644
--- a/src/components/emoji_reactions/emoji_reactions.js
+++ b/src/components/emoji_reactions/emoji_reactions.js
@@ -1,104 +1,104 @@
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCheck, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'
library.add(faPlus, faMinus, faCheck)
const EMOJI_REACTION_COUNT_CUTOFF = 12
const EmojiReactions = {
name: 'EmojiReactions',
components: {
UserAvatar,
UserListPopover,
},
props: ['status'],
data: () => ({
showAll: false,
}),
computed: {
tooManyReactions() {
return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF
},
emojiReactions() {
return this.showAll
? this.status.emoji_reactions
: this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF)
},
showMoreString() {
return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}`
},
accountsForEmoji() {
return this.status.emoji_reactions.reduce((acc, reaction) => {
acc[reaction.name] = reaction.accounts || []
return acc
}, {})
},
loggedIn() {
return !!useUsersStore().currentUser
},
remoteInteractionLink() {
return useInstanceStore().getRemoteInteractionLink({
statusId: this.status.id,
})
},
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
methods: {
toggleShowAll() {
this.showAll = !this.showAll
},
reactedWith(emoji) {
return this.status.emoji_reactions.find((r) => r.name === emoji).me
},
- async fetchEmojiReactionsByIfMissing() {
+ async fetchEmojiReactionsIfMissing() {
const hasNoAccounts = this.status.emoji_reactions.find((r) => !r.accounts)
- if (hasNoAccounts) {
- return await useStatusesStore().fetchEmojiReactionsBy(this.status.id)
+ if (!hasNoAccounts) {
+ return await useStatusesStore().fetchEmojiReactions(this.status.id)
}
},
reactWith(emoji) {
useStatusesStore().reactWithEmoji(this.status.id, emoji)
},
unreact(emoji) {
useStatusesStore().unreactWithEmoji(this.status.id, emoji)
},
async emojiOnClick(emoji) {
if (!this.loggedIn) return
- await this.fetchEmojiReactionsByIfMissing()
+ await this.fetchEmojiReactionsIfMissing()
if (this.reactedWith(emoji)) {
this.unreact(emoji)
} else {
this.reactWith(emoji)
}
},
counterTriggerAttrs(reaction) {
return {
class: [
'emoji-reaction-count-button',
{
'-picked-reaction': this.reactedWith(reaction.name),
toggled: this.reactedWith(reaction.name),
},
],
'aria-label': this.$t(
'status.reaction_count_label',
{ num: reaction.count },
reaction.count,
),
}
},
},
}
export default EmojiReactions
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index 16c69fa40e..a8100c0d49 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -1,77 +1,77 @@
<template>
<div class="EmojiReactions">
<span
v-for="(reaction) in emojiReactions"
:key="reaction.url || reaction.name"
class="emoji-reaction-container btn-group"
>
<component
:is="loggedIn ? 'button' : 'a'"
v-bind="!loggedIn ? { href: remoteInteractionLink } : {}"
role="button"
class="emoji-reaction btn button-default"
:class="{ '-picked-reaction': reactedWith(reaction.name), toggled: reactedWith(reaction.name) }"
:title="reaction.url ? reaction.name : undefined"
:aria-pressed="reactedWith(reaction.name)"
@click="emojiOnClick(reaction.name, $event)"
>
<span
class="reaction-emoji"
:class="{ ['-wide']: allowNonSquareEmoji }"
>
<StillImage
v-if="reaction.url"
:src="reaction.url"
class="reaction-emoji-content"
:class="{ ['-wide']: allowNonSquareEmoji }"
/>
<span
v-else
class="reaction-emoji reaction-emoji-content"
>{{ reaction.name }}</span>
</span>
<FALayers>
<FAIcon
v-if="reactedWith(reaction.name)"
class="active-marker"
transform="shrink-6 up-9"
icon="check"
/>
<FAIcon
v-if="!reactedWith(reaction.name)"
class="focus-marker"
transform="shrink-6 up-9"
icon="plus"
/>
<FAIcon
v-else
class="focus-marker"
transform="shrink-6 up-9"
icon="minus"
/>
</FALayers>
</component>
<UserListPopover
:users="accountsForEmoji[reaction.name]"
class="emoji-reaction-popover"
:normal-button="true"
:trigger-attrs="counterTriggerAttrs(reaction)"
- @show="fetchEmojiReactionsByIfMissing()"
+ @show="fetchEmojiReactionsIfMissing()"
>
<span class="emoji-reaction-counts">{{ reaction.count }}</span>
</UserListPopover>
</span>
<a
v-if="tooManyReactions"
class="emoji-reaction-expand faint"
href="javascript:void(0)"
@click="toggleShowAll"
>
{{ showAll ? $t('general.show_less') : showMoreString }}
</a>
</div>
</template>
<script src="./emoji_reactions.js"></script>
<style src="./emoji_reactions.scss" lang="scss" />
diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
index 7ad91f2366..e4d243ff99 100644
--- a/src/components/follow_button/follow_button.js
+++ b/src/components/follow_button/follow_button.js
@@ -1,89 +1,88 @@
import { defineAsyncComponent } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUsersStore } from 'src/stores/users.js'
export default {
props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
components: {
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data() {
return {
inProgress: false,
showingConfirmUnfollow: false,
}
},
computed: {
shouldConfirmUnfollow() {
return useMergedConfigStore().mergedConfig.modalOnUnfollow
},
isPressed() {
return this.inProgress || this.relationship.following
},
title() {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.relationship.requested) {
return this.$t('user_card.follow_cancel')
} else {
return this.$t('user_card.follow')
}
},
label() {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
} else if (this.relationship.following) {
return this.labelFollowing || this.$t('user_card.following')
} else if (this.relationship.requested) {
return this.$t('user_card.follow_sent')
} else {
return this.$t('user_card.follow')
}
},
disabled() {
return this.inProgress || this.user.deactivated
},
},
methods: {
showConfirmUnfollow() {
this.showingConfirmUnfollow = true
},
hideConfirmUnfollow() {
this.showingConfirmUnfollow = false
},
onClick() {
this.relationship.following || this.relationship.requested
? this.unfollow()
: this.follow()
},
follow() {
this.inProgress = true
useUsersStore()
.followUser(this.relationship.id)
.finally(() => {
this.inProgress = false
})
},
unfollow() {
if (this.shouldConfirmUnfollow) {
this.showConfirmUnfollow()
} else {
this.doUnfollow()
}
},
doUnfollow() {
- const store = this.$store
this.inProgress = true
useUsersStore()
.unfollowUser(this.relationship.id)
.finally(() => {
this.inProgress = false
})
this.hideConfirmUnfollow()
},
},
}
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 44c07383df..1004f50c9a 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,243 +1,244 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Report from 'src/components/report/report.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils_sw.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import { approveUser, denyUser } from 'src/api/user.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCheck,
faCompressAlt,
faExpandAlt,
faEyeSlash,
faRetweet,
faStar,
faSuitcaseRolling,
faTimes,
faUser,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faCheck,
faTimes,
faStar,
faRetweet,
faUserPlus,
faUser,
faEyeSlash,
faSuitcaseRolling,
faExpandAlt,
faCompressAlt,
)
const Notification = {
data() {
return {
selecting: false,
statusExpanded: false,
unmuted: false,
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false,
}
},
props: ['notification'],
emits: ['interacted'],
components: {
StatusContent,
UserAvatar,
Timeago,
Report,
UserPopover,
UserLink,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
mounted() {
document.addEventListener('selectionchange', this.onContentSelect)
},
unmounted() {
document.removeEventListener('selectionchange', this.onContentSelect)
},
methods: {
toggleStatusExpanded() {
if (!this.expandable) return
this.statusExpanded = !this.statusExpanded
},
onContentSelect() {
const { isCollapsed, anchorNode, offsetNode } = document.getSelection()
if (isCollapsed) {
this.selecting = false
return
}
const within =
this.$refs.root.contains(anchorNode) ||
this.$refs.root.contains(offsetNode)
if (within) {
this.selecting = true
} else {
this.selecting = false
}
},
onContentClick(e) {
if (
!this.selecting &&
!e.target.closest('a') &&
!e.target.closest('button')
) {
this.toggleStatusExpanded()
}
},
generateUserProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
getUser(notification) {
return this.$store.state.users.usersObject[notification.from_profile.id]
},
interacted() {
this.$emit('interacted')
},
toggleMute() {
this.unmuted = !this.unmuted
},
showApproveConfirmDialog() {
this.showingApproveConfirmDialog = true
},
hideApproveConfirmDialog() {
this.showingApproveConfirmDialog = false
},
showDenyConfirmDialog() {
this.showingDenyConfirmDialog = true
},
hideDenyConfirmDialog() {
this.showingDenyConfirmDialog = false
},
approveUser() {
if (this.shouldConfirmApprove) {
this.showApproveConfirmDialog()
} else {
this.doApprove()
}
},
doApprove() {
approveUser({
id: this.user.id,
credentials: useOAuthStore().token,
})
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('markSingleNotificationAsSeen', {
id: this.notification.id,
})
this.$store.dispatch('updateNotification', {
id: this.notification.id,
updater: (notification) => {
notification.type = 'follow'
},
})
this.hideApproveConfirmDialog()
},
denyUser() {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny() {
denyUser({
id: this.user.id,
credentials: useOAuthStore().token,
}).then(() => {
this.$store.dispatch('dismissNotificationLocal', {
id: this.notification.id,
})
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
},
},
computed: {
status() {
+ // Used for StatusContent
if (this.notification.status) {
return useStatusesStore().allStatuses.get(this.notification.status.id)
}
},
userClass() {
return highlightClass(this.notification.from_profile)
},
userStyle() {
const user = this.notification.from_profile.screen_name
return highlightStyle(useUserHighlightStore().get(user))
},
expandable() {
return new Set(['like', 'pleroma:emoji_reaction', 'repeat', 'poll']).has(
this.notification.type,
)
},
user() {
return useUsersStore().findUser(this.notification.from_profile.id)
},
userProfileLink() {
return this.generateUserProfileLink(this.user)
},
targetUser() {
return useUsersStore().findUser(this.notification.target.id)
},
targetUserProfileLink() {
return this.generateUserProfileLink(this.targetUser)
},
needMute() {
return useUsersStore().relationship(this.user.id).muting
},
isStatusNotification() {
return isStatusNotification(this.notification.type)
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny() {
return this.mergedConfig.modalOnDenyFollow
},
...mapState(useUsersStore, ['currentUser']),
},
}
export default Notification
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 3fc486127f..d2f3709280 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,297 +1,297 @@
<template>
<article
v-if="notification.type === 'mention' || notification.type === 'status'"
ref="root"
>
<Status
class="Notification panel-body"
:compact="true"
- :statusoid="status"
+ :status-id="notification.status?.id"
@click="interacted"
/>
</article>
<article
v-else
ref="root"
class="NotificationParent panel-body"
:class="{ '-expandable': expandable }"
>
<div
v-if="needMute && !unmuted"
:id="'notif-' +notification.id"
:aria-expanded="statusExpanded"
:aria-controls="'notif-' +notification.id"
class="Notification container -muted"
>
<small>
<user-link
:user="notification.from_profile"
:at="false"
/>
</small>
<button
class="button-unstyled unmute"
@click.prevent="toggleMute"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="eye-slash"
/>
</button>
</div>
<div
v-else
class="Notification non-mention"
:class="[userClass, { highlighted: userStyle }, '-type--' + notification.type]"
:style="[ userStyle ]"
>
<a
class="avatar-container"
:href="$router.resolve(userProfileLink).href"
@click.prevent
>
<UserPopover
:user-id="notification.from_profile.id"
:overlay-centers="true"
>
<UserAvatar
class="post-avatar"
:compact="true"
:user-id="notification.from_profile.id"
/>
</UserPopover>
</a>
<div class="notification-right">
<span class="notification-details">
<div class="name-and-action">
<!-- eslint-disable vue/no-v-html -->
<bdi v-if="!!notification.from_profile.name_html">
<RichContent
class="username"
:title="'@'+notification.from_profile.screen_name_ui"
:html="notification.from_profile.name_html"
:emoji="notification.from_profile.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
:is-local="notification.from_profile.is_local"
/>
</bdi>
<!-- eslint-enable vue/no-v-html -->
<span
v-else
class="username"
:title="'@'+notification.from_profile.screen_name_ui"
>
{{ notification.from_profile.name }}
</span>
{{ ' ' }}
<span v-if="notification.type === 'like'">
<FAIcon
class="type-icon"
icon="star"
/>
{{ ' ' }}
<small>{{ $t('notifications.favorited_you') }}</small>
</span>
<span v-if="notification.type === 'repeat'">
<FAIcon
class="type-icon"
icon="retweet"
:title="$t('tool_tip.repeat')"
/>
{{ ' ' }}
<small>{{ $t('notifications.repeated_you') }}</small>
</span>
<span v-if="notification.type === 'follow'">
<FAIcon
class="type-icon"
icon="user-plus"
/>
{{ ' ' }}
<small>{{ $t('notifications.followed_you') }}</small>
</span>
<span v-if="notification.type === 'follow_request'">
<FAIcon
class="type-icon"
icon="user"
/>
{{ ' ' }}
<small>{{ $t('notifications.follow_request') }}</small>
</span>
<span v-if="notification.type === 'move'">
<FAIcon
class="type-icon"
icon="suitcase-rolling"
/>
{{ ' ' }}
<small>{{ $t('notifications.migrated_to') }}</small>
</span>
<span v-if="notification.type === 'pleroma:emoji_reaction'">
<small>
<i18n-t
scope="global"
keypath="notifications.reacted_with"
>
<img
v-if="notification.emoji_url"
class="emoji-reaction-emoji emoji-reaction-emoji-image"
:src="notification.emoji_url"
:alt="notification.emoji"
:title="notification.emoji"
:class="{ ['-wide']: allowNonSquareEmoji }"
>
<span
v-else
class="emoji-reaction-emoji"
>{{ notification.emoji }}</span>
</i18n-t>
</small>
</span>
<span v-if="notification.type === 'pleroma:report'">
<small>{{ $t('notifications.submitted_report') }}</small>
</span>
<span v-if="notification.type === 'poll'">
<FAIcon
class="type-icon"
icon="poll-h"
/>
{{ ' ' }}
<small>{{ $t('notifications.poll_ended') }}</small>
</span>
</div>
<div
v-if="isStatusNotification"
class="timeago"
>
<router-link
v-if="notification.status"
:to="{ name: 'conversation', params: { id: notification.status.id } }"
class="timeago-link faint"
>
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</router-link>
<button
class="button-unstyled expand-icon"
:title="$t('tool_tip.toggle_expand')"
:aria-expanded="statusExpanded"
@click.prevent="toggleStatusExpanded"
>
<FAIcon
class="fa-scale-110"
fixed-width
:icon="statusExpanded ? 'compress-alt' : 'expand-alt'"
/>
</button>
</div>
<div
v-else
class="timeago"
>
<span class="faint">
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span>
</div>
<button
v-if="needMute"
class="button-unstyled"
:title="$t('tool_tip.toggle_mute')"
:aria-expanded="!unmuted"
@click.prevent="toggleMute"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="eye-slash"
/>
</button>
</span>
<div
v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text"
>
<user-link
class="follow-name"
:user="notification.from_profile"
/>
<div
v-if="notification.type === 'follow_request'"
style="white-space: nowrap;"
>
<button
class="button-unstyled"
:title="$t('tool_tip.accept_follow_request')"
@click="approveUser()"
>
<FAIcon
icon="check"
class="fa-scale-110 fa-old-padding follow-request-accept"
/>
</button>
<button
class="button-unstyled"
:title="$t('tool_tip.reject_follow_request')"
@click="denyUser()"
>
<FAIcon
icon="times"
class="fa-scale-110 fa-old-padding follow-request-reject"
/>
</button>
</div>
</div>
<div
v-else-if="notification.type === 'move'"
class="move-text"
>
<user-link
:user="notification.target"
/>
</div>
<Report
v-else-if="notification.type === 'pleroma:report'"
:report-id="notification.report.id"
/>
<template v-else>
<StatusContent
class="status-content"
:compact="!statusExpanded"
:status="status"
:collapse="!statusExpanded"
@click="onContentClick"
/>
</template>
</div>
</div>
<teleport to="#modal">
<ConfirmModal
v-if="showingApproveConfirmDialog"
:title="$t('user_card.approve_confirm_title')"
:confirm-text="$t('user_card.approve_confirm_accept_button')"
:cancel-text="$t('user_card.approve_confirm_cancel_button')"
@accepted="doApprove"
@cancelled="hideApproveConfirmDialog"
>
{{ $t('user_card.approve_confirm', { user: user.screen_name_ui }) }}
</ConfirmModal>
<ConfirmModal
v-if="showingDenyConfirmDialog"
:title="$t('user_card.deny_confirm_title')"
:confirm-text="$t('user_card.deny_confirm_accept_button')"
:cancel-text="$t('user_card.deny_confirm_cancel_button')"
@accepted="doDeny"
@cancelled="hideDenyConfirmDialog"
>
{{ $t('user_card.deny_confirm', { user: user.screen_name_ui }) }}
</ConfirmModal>
</teleport>
</article>
</template>
<script src="./notification.js"></script>
<style src="./notification.scss" lang="scss"></style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index c88d120db1..8fc7db75a8 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,584 +1,587 @@
import { uniqBy } from 'lodash'
import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.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 { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
+ statusId: String,
statusoid: Object,
replies: Array,
expandable: Boolean,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inlineExpanded: Boolean,
showPinned: Boolean,
inProfile: Boolean,
inConversation: Boolean,
inQuote: Boolean,
profileUserId: String,
simpleTree: Boolean,
showOtherRepliesAsButton: Boolean,
canDive: Boolean,
ignoreMute: Boolean,
threadDisplayStatus: String,
},
emits: ['goto', 'dive', 'toggleExpanded', 'suspendableStateChange'],
data() {
return {
replying: false,
unmuted: false,
userExpanded: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
computed: {
+ status() {
+ return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
+ },
showReasonMutedThread() {
return (
- (this.status.thread_muted || this.status.reblog?.thread_muted) &&
+ (this.mainStatus.thread_muted ||
+ this.smainSatus.reblog?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
- const user = this.statusoid.user
+ const user = this.status.user
return highlightClass(user)
},
userClass() {
const user = this.retweet
- ? this.statusoid.retweeted_status.user
- : this.statusoid.user
+ ? this.status.retweeted_status.user
+ : this.status.user
return highlightClass(user)
},
deleted() {
- return this.statusoid.deleted
+ return this.status.deleted
},
repeaterStyle() {
- const user = this.statusoid.user
+ const user = this.status.user
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userStyle() {
if (this.noHeading) return
const user = this.retweet
- ? this.statusoid.retweeted_status.user
- : this.statusoid.user
+ ? this.status.retweeted_status.user
+ : this.status.user
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userProfileLink() {
return this.generateUserProfileLink(
this.status.user.id,
this.status.user.screen_name,
)
},
replyProfileLink() {
if (this.isReply) {
const user = useUsersStore().findUser(this.status.in_reply_to_user_id)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet() {
- return !!this.statusoid.retweeted_status
+ return !!this.status.retweeted_status
},
retweeterUser() {
- return this.statusoid.user
+ return this.status.user
},
retweeter() {
- return this.statusoid.user.name || this.statusoid.user.screen_name_ui
+ return this.status.user.name || this.status.user.screen_name_ui
},
retweeterHtml() {
- return this.statusoid.user.name
+ return this.status.user.name
},
retweeterProfileLink() {
return this.generateUserProfileLink(
- this.statusoid.user.id,
- this.statusoid.user.screen_name,
+ this.status.user.id,
+ this.status.user.screen_name,
)
},
- status() {
+ mainStatus() {
if (this.retweet) {
- return this.statusoid.retweeted_status
+ return this.status.retweeted_status
} else {
- return this.statusoid
+ return this.status
}
},
statusFromGlobalRepository() {
// NOTE: Consider to replace status with statusFromGlobalRepository
return useStatusesStore().allStatuses.get(this.status.id)
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
- return this.status.attentions
+ return this.mainStatus.attentions
.filter((attn) => {
// no reply user
return (
- attn.id !== this.status.in_reply_to_user_id &&
+ attn.id !== this.mainStatus.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
- this.status.user.statusnet_profile_url &&
+ this.mainStatus.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
- this.status.thread_muted ? 'thread' : null,
+ this.mainStatus.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
].filter(Boolean)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.ignoreMute) return false
- if (this.statusoid.user.id === this.currentUser?.id) return false
+ if (this.status.user.id === this.currentUser?.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
- if (this.statusoid.user.id === this.currentUser?.id) return false
- const { status } = this
- const { reblog } = status
- const relationship = useUsersStore().relationship(status.user.id)
+ if (this.status.user.id === this.currentUser?.id) return false
+ const { reblog } = this.status
+ const relationship = useUsersStore().relationship(this.status.user.id)
const relationshipReblog =
reblog && useUsersStore().relationship(reblog.user.id)
return (
(status.muted && !status.thread_muted) ||
// Reprööt of a muted post according to BE
(reblog?.muted && !reblog.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
relationshipReblog?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) return true
- const { status } = this
- const { reblog } = status
+ const { reblog } = this.status
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
reblog?.user.id === this.profileUserId)) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isReply() {
return !!(
this.status.in_reply_to_status_id && this.status.in_reply_to_user_id
)
},
replyToName() {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(this.status.in_reply_to_user_id)
return user?.screen_name_ui
}
},
combinedFavsAndRepeatsUsers() {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy,
)
return uniqBy(combinedUsers, 'id')
},
tags() {
return this.status.tags
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.focused &&
(this.combinedFavsAndRepeatsUsers.length > 0 ||
this.statusFromGlobalRepository.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return useUsersStore().currentUser
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
inThreadForest() {
return !!this.threadDisplayStatus
},
threadShowing() {
return this.threadDisplayStatus === 'showing'
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.status.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.status.quote_id
},
quoteUrl() {
return this.status.quote_url
},
quoteVisible() {
return this.status.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
showError(error) {
this.error = error
},
clearError() {
this.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
gotoOriginal(id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded() {
this.$emit('toggleExpanded')
},
toggleMute() {
this.unmuted = !this.unmuted
},
toggleUserExpanded() {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay() {
this.controlledToggleThreadDisplay()
},
scrollIfFocused(focused) {
if (this.$el.getBoundingClientRect == null) return
if (focused) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= window.innerHeight - 50) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
},
watch: {
focused: function (id) {
this.scrollIfFocused(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.focused &&
this.statusFromGlobalRepository.rebloggedBy &&
this.statusFromGlobalRepository.rebloggedBy.length !== num
) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (
this.focused &&
this.statusFromGlobalRepository.favoritedBy &&
this.statusFromGlobalRepository.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (suspend) {
- this.$emit('suspendableStateChange', { id: this.statusoid.id, suspend })
+ this.$emit('suspendableStateChange', { id: this.status.id, suspend })
},
},
}
export default Status
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
index 47b1baaf2b..399fad3957 100644
--- a/src/components/status_action_buttons/buttons_definitions.js
+++ b/src/components/status_action_buttons/buttons_definitions.js
@@ -1,349 +1,349 @@
import { useEditStatusStore } from 'src/stores/editStatus.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
const PRIVATE_SCOPES = new Set(['private', 'direct'])
const PUBLIC_SCOPES = new Set(['public', 'unlisted'])
export const BUTTONS = [
{
// =========
// REPLY
// =========
name: 'reply',
label: 'tool_tip.reply',
icon: 'reply',
active: ({ replying }) => replying,
counter: ({ status }) => status.replies_count,
anon: true,
anonLink: true,
toggleable: true,
closeIndicator: 'times',
activeIndicator: null,
action({ emit }) {
emit('toggleReplying')
return Promise.resolve()
},
},
{
// =========
// REPEAT
// =========
name: 'retweet',
label: ({ status }) =>
status.repeated ? 'tool_tip.unrepeat' : 'tool_tip.repeat',
icon({ status, loggedIn, currentUser }) {
if (
loggedIn &&
status.user.id !== currentUser.id &&
PRIVATE_SCOPES.has(status.visibility)
) {
return 'lock'
}
return 'retweet'
},
animated: true,
active: ({ status }) => status.repeated,
counter: ({ status }) => status.repeat_num,
anonLink: true,
interactive: ({ status, currentUser }) =>
!!currentUser &&
(currentUser.id === status.user.id ||
!PRIVATE_SCOPES.has(status.visibility)),
toggleable: true,
confirm: ({ status }) =>
!status.repeated && useMergedConfigStore().mergedConfig.modalOnRepeat,
confirmStrings: {
title: 'status.repeat_confirm_title',
body: 'status.repeat_confirm',
confirm: 'status.repeat_confirm_accept_button',
cancel: 'status.repeat_confirm_cancel_button',
},
action({ status }) {
if (!status.repeated) {
return useStatusesStore().retweet(status.id)
} else {
return useStatusesStore().unretweet(status.id)
}
},
},
{
// =========
// FAVORITE
// =========
name: 'favorite',
label: ({ status }) =>
status.favorited ? 'tool_tip.unfavorite' : 'tool_tip.favorite',
icon: ({ status }) =>
status.favorited ? ['fas', 'star'] : ['far', 'star'],
animated: true,
active: ({ status }) => status.favorited,
counter: ({ status }) => status.fave_num,
anonLink: true,
toggleable: true,
action({ status }) {
if (!status.favorited) {
return useStatusesStore().favorite(status.id)
} else {
return useStatusesStore().unfavorite(status.id)
}
},
},
{
// =========
// EMOJI REACTIONS
// =========
name: 'emoji',
label: 'tool_tip.add_reaction',
icon: ['far', 'face-smile-beam'],
interactive: () => true,
active: ({ emojiPickerShown }) => emojiPickerShown,
toggleable: true,
anonLink: true,
},
{
// =========
// MUTE
// =========
name: 'mute',
icon: 'eye-slash',
label: 'status.mute_ellipsis',
if: ({ loggedIn }) => loggedIn,
toggleable: false,
dropdown: true,
action({ status, emit }) {
/* prevent hiding */
},
},
{
// =========
// PIN STATUS
// =========
name: 'pin',
icon: 'thumbtack',
label: ({ status }) => (status.pinned ? 'status.unpin' : 'status.pin'),
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
status.user.id === currentUser.id &&
PUBLIC_SCOPES.has(status.visibility)
)
},
action({ status }) {
if (status.pinned) {
return useStatusesStore().unpinStatus(status.id)
} else {
return useStatusesStore().pinStatus(status.id)
}
},
},
{
// =========
// BOOKMARK
// =========
name: 'bookmark',
icon: ({ status }) =>
status.bookmarked ? ['fas', 'bookmark'] : ['far', 'bookmark'],
toggleable: true,
active: ({ status }) => status.bookmarked,
label: ({ status }) =>
status.bookmarked ? 'status.unbookmark' : 'status.bookmark',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
if (status.bookmarked) {
return useStatusesStore().unbookmark(status.id)
} else {
return useStatusesStore().bookmark(status.id)
}
},
},
{
// =========
// EDIT HISTORY
// =========
name: 'editHistory',
icon: 'history',
label: 'status.status_history',
if({ status }) {
return (
useInstanceCapabilitiesStore().editingAvailable &&
status.edited_at !== null
)
},
action({ status }) {
const originalStatus = { ...status }
const stripFieldsList = [
'attachments',
'created_at',
'emojis',
'text',
'raw_html',
'nsfw',
'poll',
'summary',
'summary_raw_html',
]
stripFieldsList.forEach((p) => delete originalStatus[p])
- useStatusHistoryStore().openStatusHistoryModal(originalStatus)
+ useStatusHistoryStore().openModal(originalStatus.id)
return Promise.resolve()
},
},
{
// =========
// EDIT
// =========
name: 'edit',
icon: 'pen',
label: 'status.edit',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
useInstanceCapabilitiesStore().editingAvailable &&
status.user.id === currentUser.id
)
},
action({ status }) {
return useStatusesStore()
.fetchStatusSource(status.id)
.then((data) =>
useEditStatusStore().openEditStatusModal({
statusId: status.id,
statusSubject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: status.nsfw,
statusPoll: status.poll,
statusFiles: [...status.attachments],
statusVisibility: status.visibility,
statusContentType: data.content_type,
}),
)
},
},
{
// =========
// OPEN IN CHAT VIEW
// =========
name: 'chat_view',
icon: 'comments',
label: 'status.open_in_chat_view',
if({ chatView }) {
return !chatView
},
action({ router, status }) {
router.push({ name: 'conversation2', params: { statusId: status.id } })
},
},
{
// =========
// OPEN IN THREAD VIEW
// =========
name: 'thread_view',
icon: 'list',
label: 'status.open_in_thread_view',
if({ chatView }) {
return chatView
},
action({ router, status }) {
router.push({ name: 'conversation', params: { id: status.id } })
},
},
{
// =========
// DELETE
// =========
name: 'delete',
icon: 'times',
label: 'status.delete',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
confirm: () => useMergedConfigStore().mergedConfig.modalOnDelete,
confirmStrings: {
title: 'status.delete_confirm_title',
body: 'status.delete_confirm',
confirm: 'status.delete_confirm_accept_button',
cancel: 'status.delete_confirm_cancel_button',
},
action({ status }) {
return useStatusesStore().deleteStatus(status.id)
},
},
{
// =========
// CHANGE SCOPE
// =========
name: 'changeScope',
icon: 'eye',
label: 'status.admin_change_scope',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
toggleable: false,
dropdown: true,
action({ status, emit }) {
/* prevent hiding */
},
},
{
// =========
// SHARE/COPY
// =========
name: 'share',
icon: 'share-alt',
label: 'status.copy_link',
action({ status, router }) {
navigator.clipboard.writeText(
[
useInstanceStore().server,
router.resolve({ name: 'conversation', params: { id: status.id } })
.href,
].join(''),
)
return Promise.resolve()
},
},
{
// =========
// EXTERNAL
// =========
name: 'external',
icon: 'external-link-alt',
label: 'status.external_source',
link: ({ status }) => status.external_url,
},
{
// =========
// REPORT
// =========
name: 'report',
icon: 'flag',
label: 'user_card.report',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
useReportsStore().openUserReportingModal({
userId: status.user.id,
statusIds: [status.id],
})
return Promise.resolve()
},
},
].map((button) => {
return Object.fromEntries(
Object.entries(button).map(([k, v]) => [
k,
typeof v === 'function' || k === 'name' ? v : () => v,
]),
)
})
diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js
index b27c19b73d..5a3fc2a415 100644
--- a/src/components/status_history_modal/status_history_modal.js
+++ b/src/components/status_history_modal/status_history_modal.js
@@ -1,66 +1,31 @@
-import { get } from 'lodash'
+import { mapState } from 'pinia'
import Modal from 'src/components/modal/modal.vue'
+import StatusContent from 'src/components/status_content/status_content.vue'
-import { useStatusesStore } from 'src/stores/statuses.js'
import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
const StatusHistoryModal = {
components: {
Modal,
+ StatusContent,
},
data() {
return {
statuses: [],
}
},
computed: {
- modalActivated() {
- return useStatusHistoryStore().modalActivated
- },
- params() {
- return useStatusHistoryStore().params
- },
- statusId() {
- return this.params.id
- },
historyCount() {
- return this.statuses.length
- },
- history() {
- return this.statuses
- },
- },
- watch: {
- params(newVal, oldVal) {
- const newStatusId = get(newVal, 'id') !== get(oldVal, 'id')
- if (newStatusId) {
- this.resetHistory()
- }
-
- if (
- newStatusId ||
- get(newVal, 'edited_at') !== get(oldVal, 'edited_at')
- ) {
- this.fetchStatusHistory()
- }
+ return this.history.length
},
+ ...mapState(useStatusHistoryStore, ['modalActivated', 'history']),
},
methods: {
- resetHistory() {
- this.statuses = []
- },
- fetchStatusHistory() {
- useStatusesStore()
- .fetchStatusHistory(this.params)
- .then((data) => {
- this.statuses = data
- })
- },
closeModal() {
- useStatusHistoryStore().closeStatusHistoryModal()
+ useStatusHistoryStore().closeModal()
},
},
}
export default StatusHistoryModal
diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue
index 282878b525..a78e04c96f 100644
--- a/src/components/status_history_modal/status_history_modal.vue
+++ b/src/components/status_history_modal/status_history_modal.vue
@@ -1,49 +1,49 @@
<template>
<Modal
v-if="modalActivated"
class="status-history-modal-view"
@backdrop-clicked="closeModal"
>
<div class="status-history-modal-panel panel">
<div class="panel-heading">
<h1 class="title">
{{ $t('status.status_history') }} ({{ historyCount }})
</h1>
</div>
<div class="panel-body">
<div
v-if="historyCount > 0"
class="history-body"
>
- <status
+ <StatusContent
v-for="status in history"
:key="status.id"
- :statusoid="status"
+ :status="status"
:is-preview="true"
class="conversation-status status-fadein panel-body"
/>
</div>
</div>
</div>
</Modal>
</template>
<script src="./status_history_modal.js"></script>
<style lang="scss">
.modal-view.status-history-modal-view {
align-items: flex-start;
}
.status-history-modal-panel {
flex-shrink: 0;
margin-top: 25%;
margin-bottom: 2em;
width: 100%;
max-width: 700px;
@media (orientation: landscape) {
margin-top: 8%;
}
}
</style>
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index e9fc2398d9..30b43be2c9 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -1,49 +1,44 @@
import Popover from 'src/components/popover/popover.vue'
import { useStatusesStore } from 'src/stores/statuses.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const StatusPopover = {
name: 'StatusPopover',
props: ['statusId'],
data() {
return {
error: false,
}
},
- computed: {
- status() {
- return useStatusesStore().allStatuses.get(this.statusId)
- },
- },
components: {
Popover,
},
methods: {
enter() {
if (!this.status) {
if (!this.statusId) {
this.error = true
return
}
useStatusesStore()
.fetchStatus(this.statusId)
.then(() => (this.error = false))
.catch(() => (this.error = true))
}
},
},
watch: {
status(newStatus, oldStatus) {
if (newStatus !== oldStatus) {
this.$nextTick(() => this.$refs.popover.updateStyles())
}
},
},
}
export default StatusPopover
diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index ccfb06c02f..a17b416752 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -1,68 +1,68 @@
<template>
<Popover
ref="popover"
trigger="hover"
:stay-on-click="true"
popover-class="popover-default status-popover"
:bound-to="{ x: 'container' }"
@show="enter"
>
<template #trigger>
<slot />
</template>
<template #content>
<Status
v-if="status"
:is-preview="true"
- :statusoid="status"
+ :status-id="statusId"
:compact="true"
:ignore-mute="true"
/>
<div
v-else-if="error"
class="status-preview-no-content faint"
>
{{ $t('status.status_unavailable') }}
</div>
<div
v-else
class="status-preview-no-content"
>
<FAIcon
icon="circle-notch"
spin
size="2x"
/>
</div>
</template>
</Popover>
</template>
<script src="./status_popover.js"></script>
<style lang="scss">
/* popover styles load on-demand, so we need to override */
.status-popover.popover {
font-size: 1rem;
min-width: 15em;
max-width: 95%;
border-color: var(--border);
border-style: solid;
border-width: 1px;
/* TODO cleanup this */
.Status.Status {
border: none;
}
.status-preview-no-content {
padding: 1em;
text-align: center;
i {
font-size: 2em;
}
}
}
</style>
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js
index 75232a259f..e5352fba13 100644
--- a/src/components/thread_tree/thread_tree.js
+++ b/src/components/thread_tree/thread_tree.js
@@ -1,62 +1,60 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleRight,
} from '@fortawesome/free-solid-svg-icons'
library.add(faAngleDoubleDown, faAngleDoubleRight)
const ThreadTree = {
components: {},
name: 'ThreadTree',
props: {
depth: Number,
- status: Object,
+ statusId: String,
inProfile: Boolean,
conversation: Array,
collapsable: Boolean,
isExpanded: Boolean,
pinnedStatusIdsObject: Object,
profileUserId: String,
focused: String,
getReplies: Function,
toggleExpanded: Function,
simple: Boolean,
canDive: Boolean,
threadDisplayStatus: Object,
showThreadRecursively: Function,
totalReplyCount: Object,
totalReplyDepth: Object,
},
emits: ['suspendableStateChange', 'goto', 'dive'],
computed: {
reverseLookupTable() {
return this.conversation.reduce(
- (table, status, index) => {
- table[status.id] = index
+ (table, statusId, index) => {
+ table[statusId] = index
return table
},
{
/* no-op */
},
)
},
currentReplies() {
- return this.getReplies(this.status.id).map(({ id }) =>
- this.statusById(id),
- )
+ return this.getReplies(this.statusId).map(({ id }) => this.statusById(id))
},
threadShowing() {
- return this.threadDisplayStatus[this.status.id] === 'showing'
+ return this.threadDisplayStatus[this.statusId] === 'showing'
},
},
methods: {
statusById(id) {
return this.conversation[this.reverseLookupTable[id]]
},
},
}
export default ThreadTree
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
index 556858ad7a..113119fa66 100644
--- a/src/components/thread_tree/thread_tree.vue
+++ b/src/components/thread_tree/thread_tree.vue
@@ -1,122 +1,122 @@
<template>
<article class="thread-tree">
<Status
- :key="status.id"
+ :key="statusId"
ref="statusComponent"
- :statusoid="status"
- :replies="getReplies(status.id)"
+ :status-id="statusId"
+ :replies="getReplies(statusId)"
:inline-expanded="collapsable && isExpanded"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-conversation="isExpanded"
- :focused="focused === status.id || focused === status.retweeted_status?.id"
+ :focused="focused === statusId"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status conversation-status-treeview status-fadein panel-body"
:simple-tree="simple"
- :thread-display-status="threadDisplayStatus[status.id]"
+ :thread-display-status="threadDisplayStatus[statusId]"
:can-dive="canDive"
- @dive="$emit('dive', status.id)"
- @goto="$emit('goto', status.id)"
+ @dive="$emit('dive', statusId)"
+ @goto="$emit('goto', statusId)"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="e => $emit('suspendableStateChange', e)"
/>
<div
v-if="currentReplies.length > 0 && threadShowing"
class="thread-tree-replies"
>
<ThreadTree
v-for="replyStatus in currentReplies"
- :key="replyStatus.id"
+ :key="replyStatusId"
ref="childComponent"
:depth="depth + 1"
- :status="replyStatus"
+ :status-id="replyStatusId"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:get-replies="getReplies"
:focused="focused"
:toggle-expanded="toggleExpanded"
:simple="simple"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:can-dive="canDive"
@goto="(e) => $emit('goto', e)"
@dive="(e) => $emit('dive', e)"
@suspendable-state-change="e => $emit('suspendableStateChange', e)"
/>
</div>
<div
v-if="currentReplies.length && !threadShowing"
class="thread-tree-replies thread-tree-replies-hidden"
>
<i18n-t
v-if="simple"
scope="global"
tag="button"
keypath="status.thread_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
- @click.prevent="$emit('dive', status.id)"
+ @click.prevent="$emit('dive', statusId)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
- {{ $t('status.thread_follow', { numStatus: totalReplyCount[status.id] }, totalReplyCount[status.id]) }}
+ {{ $t('status.thread_follow', { numStatus: totalReplyCount[statusId] }, totalReplyCount[statusId]) }}
</span>
</template>
</i18n-t>
<i18n-t
v-else
scope="global"
tag="button"
keypath="status.thread_show_full_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
- @click.prevent="showThreadRecursively(status.id)"
+ @click.prevent="showThreadRecursively(statusId)"
>
<template #icon>
<FAIcon
icon="angle-double-down"
/>
</template>
<template #text>
<span>
- {{ $t('status.thread_show_full', { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }, totalReplyCount[status.id]) }}
+ {{ $t('status.thread_show_full', { numStatus: totalReplyCount[statusId], depth: totalReplyDepth[statusId] }, totalReplyCount[statusId]) }}
</span>
</template>
</i18n-t>
</div>
</article>
</template>
<script src="./thread_tree.js"></script>
<style lang="scss">
.thread-tree-replies {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-tree-replies-hidden {
padding: var(--status-margin);
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
</style>
diff --git a/src/stores/notifications.js b/src/stores/notifications.js
index 078b66b89c..bc516d3bdc 100644
--- a/src/stores/notifications.js
+++ b/src/stores/notifications.js
@@ -1,263 +1,263 @@
import { defineStore } from 'pinia'
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'
import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js'
export const defaultState = () => ({
desktopNotificationSilence: true,
maxId: '',
minId: '',
data: [],
statusNotificationRelations: new WeakMap(),
idStore: new Map(),
loading: false,
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
updateNotificationsMinMaxId(id) {
this.maxId = id > this.maxId ? id : this.maxId
this.minId = id < this.minId ? id : this.minId
},
updateNotification({ id, updater }) {
const notification = this.idStore.get(id)
notification && updater(notification)
},
addNewNotifications(result) {
const { timestamp, data: notifications } = result
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.updateNotificationsMinMaxId(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().fetchEmojiReactionsBy(notification.status.id)
+ 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.updateNotificationsMinMaxId(notification.id)
notifications.forEach((notification) => {
this.data.push(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),
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: useUsersStore().currentUser.credentials,
}).then(() => {
closeAllDesktopNotifications()
})
},
markSingleNotificationAsSeen({ id }) {
const notification = this.idStore.get(id)
if (notification) notification.seen = true
markNotificationsAsSeen({
single: true,
id,
credentials: useUsersStore().currentUser.credentials,
}).then(() => {
closeDesktopNotification(id)
})
},
dismissNotificationLocal(id) {
this.data = this.data.filter((n) => n.id !== id)
delete this.idStore.delete(id)
},
dismissNotification(id) {
this.dismissNotificationLocal(id)
dismissNotification({
id,
credentials: useOAuthStore().token,
})
},
// Misc
setLoading(value) {
this.loading = value
},
},
})
diff --git a/src/stores/statusHistory.js b/src/stores/statusHistory.js
index 9868b22c5a..e2a69c36d9 100644
--- a/src/stores/statusHistory.js
+++ b/src/stores/statusHistory.js
@@ -1,17 +1,34 @@
import { defineStore } from 'pinia'
+import { useOAuthStore } from 'src/stores/oauth.js'
+
+import { fetchStatusHistory } from 'src/api/public.js'
+
export const useStatusHistoryStore = defineStore('statusHistory', {
state: () => ({
- params: {},
+ id: null,
modalActivated: false,
+ history: null,
}),
actions: {
- openStatusHistoryModal(params) {
- this.params = params
- this.modalActivated = true
+ openModal(id) {
+ this.fetchStatusHistory(id).then(() => {
+ this.id = id
+ this.modalActivated = true
+ })
},
- closeStatusHistoryModal() {
+ closeModal() {
+ this.id = null
this.modalActivated = false
+ this.history = null
+ },
+ fetchStatusHistory(id) {
+ return fetchStatusHistory({
+ id,
+ credentials: useOAuthStore().token,
+ }).then(({ data }) => {
+ this.history = data
+ })
},
},
})
diff --git a/src/stores/statuses.js b/src/stores/statuses.js
index 9605556f16..878515687b 100644
--- a/src/stores/statuses.js
+++ b/src/stores/statuses.js
@@ -1,642 +1,610 @@
import { defineStore } from 'pinia'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js'
import {
fetchEmojiReactions,
fetchFavoritedByUsers,
fetchRebloggedByUsers,
fetchScrobbles,
fetchStatus,
- fetchStatusHistory,
fetchStatusSource,
search2,
} from 'src/api/public.js'
import {
bookmarkStatus,
deleteStatus,
favorite,
muteConversation,
pinOwnStatus,
reactWithEmoji,
retweet,
unbookmarkStatus,
unfavorite,
unmuteConversation,
unpinOwnStatus,
unreactWithEmoji,
unretweet,
} from 'src/api/user.js'
export const defaultState = () => ({
allStatuses: new Map(),
timestamps: new WeakMap(),
scrobblesNextFetch: {},
conversations: new Map(),
favorites: new Set(),
socket: null,
})
const getLatestScrobble = (user) => {
const scrobblesSupport =
useInstanceCapabilitiesStore().pleromaScrobblesAvailable
if (!scrobblesSupport || !user.name || user.id === 'undefined') {
return
}
if (
this.scrobblesNextFetch[user.id] &&
this.scrobblesNextFetch[user.id] > Date.now()
) {
return
}
this.scrobblesNextFetch[user.id] = Date.now() + 24 * 60 * 60 * 1000
if (!scrobblesSupport) return
fetchScrobbles({ accountId: user.id })
.then(({ data: scrobbles }) => {
if (scrobbles?.error) {
useInstanceCapabilitiesStore().set('pleromaScrobblesAvailable', false)
return
}
if (scrobbles.length > 0) {
user.latestScrobble = scrobbles[0]
this.scrobblesNextFetch[user.id] = Date.now() + 60 * 1000
}
})
.catch((e) => {
console.warn('cannot fetch scrobbles', e)
})
}
export const useStatusesStore = defineStore('statuses', {
state: defaultState,
actions: {
// Init
attachSocket() {
const et = new EventTarget()
const handleUpdate = ({ data, timestamp }) =>
this.addNewStatuses({ statuses: data, timestamp })
const handleDelete = ({ data }) =>
data.forEach((id) => this.setDeleted(id))
const socket = {
et,
handlers: {
handleUpdate,
handleDelete,
},
}
et.addEventListener('update', handleUpdate)
et.addEventListener('status.update', handleUpdate)
et.addEventListener('delete', handleDelete)
useStreamingStore().addSubscriber(socket)
this.socket = socket
},
resetStatuses() {
this.socket.et.removeEventListener('update', this.socket.handleUpdate)
this.socket.et.removeEventListener(
'status.update',
this.socket.handleUpdate,
)
this.socket.et.removeEventListener('delete', this.socket.handleDelete)
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
this[key] = value
})
},
addNewStatuses({ statuses, timestamp }) {
// Sanity check
if (!Array.isArray(statuses)) {
throw new TypeError("Statuses aren't an array!")
}
// addStatus should always return "main" status,
// not "sub-status" i.e. retweeted/quoted/liked status
// in case of likes (which are not statuses) it should return null
const addStatus = (data) => {
getLatestScrobble(data.user)
- const [status] = this.mergeOrAdd(this.allStatuses, data)
+ const [status] = this.mergeOrAdd(this.allStatuses, data, timestamp)
// Add to conversation
const conversations = this.conversations
const conversationId = status.statusnet_conversation_id
if (conversations.has(conversationId)) {
- conversations.get(conversationId).set(status.id, status)
+ conversations.get(conversationId).add(status.id)
} else {
- conversations.set(conversationId, new Map([[status.id, status]]))
+ conversations.set(conversationId, new Set([status.id]))
}
// Work on quote
if (status.quote) {
status.quote = addStatus(status.quote)
}
return status
}
const processors = {
status: (status) => {
return addStatus(status)
},
edit: (status) => {
return addStatus(status)
},
retweet: (status) => {
// RetweetedStatuses are never shown immediately
if (status.retweeted_status) addStatus(status.retweeted_status)
return addStatus(status)
},
- favorite: (favorite) => {
- // Only update if this is a new favorite.
- // Ignore our own favorites because we get info about likes as response to like request
- if (!this.favorites.has(favorite.id)) {
- this.favorites.add(favorite.id)
-
- const status = this.allStatuses.get(favorite.in_reply_to_status_id)
-
- if (status) {
- // This is our favorite, so the relevant bit.
- if (favorite.user.id === useUsersStore().currentUser?.id) {
- status.favorited = true
- } else {
- status.fave_num += 1
- }
- }
- return status
- }
- return null
- },
- follow: () => {
- // NOOP, it is known status but we don't do anything about it for now
- return null
- },
default: (unknown) => {
console.warn('unknown status type', unknown)
return null
},
}
return statuses.map((status) => {
const type = status.type
const processor = processors[type] ?? processors.default
return processor(status)
})
},
mergeOrAdd(map, status, timestamp) {
const existing = map.get(status.id) ?? {}
const oldTimestamp = this.timestamps.get(existing)
const { user: unused0, ...old } = existing
const { user: statusUser, ...neu } = status
const [user] = useUsersStore().addNewUsers({
data: statusUser,
timestamp,
})
existing.user = user // reactive update in case we return old
// implicit: if oldTimestamp is undefined this will still be false
if (oldTimestamp > timestamp) return [existing, false] // not overwriting old data with new
const newStatus = {
...old,
...neu,
user,
}
map.set(newStatus.id, newStatus)
this.timestamps.set(newStatus, timestamp)
return [map.get(newStatus.id), true]
},
// Fetches
fetchStatus(id) {
- return fetchStatus({ id }).then(({ data: status, timestamp }) =>
+ return fetchStatus({
+ id,
+ credentials: useOAuthStore().token,
+ }).then(({ data: status, timestamp }) =>
this.addNewStatuses({ statuses: [status], timestamp }),
)
},
fetchStatusSource(id) {
return fetchStatusSource({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
- fetchStatusHistory(status) {
- return fetchStatusHistory({ status }).then(({ data }) => data)
- },
- fetchEmojiReactionsBy(id) {
+ fetchEmojiReactions(id) {
return fetchEmojiReactions({
id,
credentials: useOAuthStore().token,
}).then(({ data: emojiReactions }) => {
this.addEmojiReactionsBy({
id,
emojiReactions,
})
})
},
fetchFavs(id) {
return fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: favoritedByUsers }) =>
this.addFavs({
id,
favoritedByUsers,
}),
)
},
fetchRepeats(id) {
return fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: rebloggedByUsers }) =>
this.addRepeats({
id,
rebloggedByUsers,
}),
)
},
fetchFavsAndRepeats(id) {
return Promise.all([this.fetchFavs(id), this.fetchRepeats(id)])
},
// Updates
addRepeats({ id, rebloggedByUsers }) {
const currentUser = useUsersStore().currentUser
const newStatus = this.allStatuses.get(id)
newStatus.rebloggedBy = rebloggedByUsers.filter(Boolean)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(
({ id }) => currentUser?.id === id,
)
},
addFavs({ id, favoritedByUsers }) {
const currentUser = useUsersStore().currentUser
const newStatus = this.allStatuses.get(id)
newStatus.favoritedBy = favoritedByUsers.filter(Boolean)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(
({ id }) => currentUser?.id === id,
)
},
addEmojiReactionsBy({ id, emojiReactions }) {
const status = this.allStatuses.get(id)
status.emoji_reactions = emojiReactions
},
// Actions
requestInteract({ name, id, optimisticCall, apiCall, argument, value }) {
const oldValue = !value // Assumption
const apiArgs = (() => {
switch (name) {
case 'emoji':
return { emoji: argument }
case 'bookmark':
return { folder_id: argument }
default:
return {}
}
})()
// Optimistic
optimisticCall(id, value, argument)
return apiCall({
id,
...apiArgs,
credentials: useOAuthStore().token,
})
.then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
.catch((error) => {
optimisticCall(id, oldValue, argument)
console.error('Interact Error', error)
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.interact_error',
messageArgs: [error],
timeout: 5000,
})
})
},
/// Favorite
favorite(id) {
return this.requestInteract({
name: 'favorite',
id,
apiCall: favorite,
optimisticCall: this.setFavorited,
value: true,
})
},
unfavorite(id) {
return this.requestInteract({
name: 'favorite',
id,
apiCall: unfavorite,
optimisticCall: this.setFavorited,
value: false,
})
},
setFavorited(id, value) {
const newStatus = this.allStatuses.get(id)
if (newStatus.favorited !== value) {
if (value) {
newStatus.fave_num++
} else {
newStatus.fave_num--
}
}
newStatus.favorited = value
},
/// Reprööt
retweet(id) {
return this.requestInteract({
name: 'retweet',
id,
apiCall: retweet,
optimisticCall: this.setRetweeted,
value: true,
})
},
unretweet(id) {
return this.requestInteract({
name: 'retweet',
id,
apiCall: unretweet,
optimisticCall: this.setRetweeted,
value: false,
})
},
setRetweeted(id, value) {
const newStatus = this.allStatuses.get(id)
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
// React
reactWithEmoji(id, emoji) {
return this.requestInteract({
name: 'emoji',
id,
apiCall: reactWithEmoji,
optimisticCall: this.setOwnReaction,
argument: emoji,
value: true,
})
},
unreactWithEmoji(id, emoji) {
return this.requestInteract({
name: 'emoji',
id,
apiCall: unreactWithEmoji,
optimisticCall: this.setOwnReaction,
argument: emoji,
value: false,
})
},
setOwnReaction(id, value, emoji) {
const currentUser = useUsersStore().currentUser
const status = this.allStatuses.get(id)
const reactionIndex = status.emoji_reactions.findIndex(
(react) => react.name === emoji,
)
const reactionPresent = reactionIndex >= 0
if (!value && !reactionPresent) return
const reaction = status.emoji_reactions[reactionIndex] || {
name: emoji,
count: 0,
accounts: [],
}
const count = value ? reaction.count + 1 : reaction.count - 1
const accounts = value
? [...reaction.accounts, currentUser]
- : accounts.filter((acc) => acc.id !== currentUser.id)
+ : reaction.accounts.filter((acc) => acc.id !== currentUser.id)
const newReaction = {
...reaction,
count,
me: value,
accounts,
}
if (reactionPresent && count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else if (count === 0) {
status.emoji_reactions = status.emoji_reactions.filter(
(r) => r.name !== emoji,
)
} else {
status.emoji_reactions.push(newReaction)
}
},
/// Bookmark
bookmark(id, bookmark_folder_id) {
return this.requestInteract({
name: 'bookmark',
id,
apiCall: bookmarkStatus,
optimisticCall: this.setBookmarked,
argument: bookmark_folder_id,
- value: false,
+ value: true,
})
},
unbookmark(id) {
return this.requestInteract({
name: 'bookmark',
id,
apiCall: unbookmarkStatus,
optimisticCall: this.setBookmarked,
value: false,
})
},
setBookmarked(id, value, bookmark_folder_id) {
const status = this.allStatuses.get(id)
status.bookmarked = value
// When unbookmarking we don't specify folder so we wanna keep
// reference to the folder even when setting bookmarked to false
// the proper reference will be updated when api call resolves
if (bookmark_folder_id) {
status.bookmark_folder_id = value ? bookmark_folder_id : null
}
},
/// Mute
muteConversation(id) {
return this.requestInteract({
name: 'mute',
id,
apiCall: muteConversation,
optimisticCall: this.setMutedStatus,
- value: false,
+ value: true,
})
},
unmuteConversation(id) {
return this.requestInteract({
name: 'mute',
id,
apiCall: unmuteConversation,
optimisticCall: this.setMutedStatus,
value: false,
})
},
setMutedStatus(id, value) {
// Setting thread_muted flag on all other known statuses
// belonging to same conversation
const newStatus = this.allStatuses.get(id)
newStatus.thread_muted = value
if (newStatus.thread_muted !== undefined) {
this.conversations
.get(newStatus.statusnet_conversation_id)
- .forEach((status) => {
- status.thread_muted = value
+ .forEach((statusId) => {
+ this.allStatuses.get(statusId).thread_muted = value
})
}
},
/// Pin
pinStatus(id) {
return this.requestInteract({
name: 'pin',
id,
apiCall: pinOwnStatus,
optimisticCall: () => {
/* no-op */
},
value: true,
})
},
unpinStatus(id) {
return this.requestInteract({
name: 'pin',
id,
apiCall: unpinOwnStatus,
optimisticCall: () => {
/* no-op */
},
value: false,
})
},
/// Delete
deleteStatus(id) {
- deleteStatus({
+ return deleteStatus({
id,
credentials: useOAuthStore().token,
})
.then(() => {
this.setDeleted(id)
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.delete_error',
messageArgs: [e.message],
timeout: 5000,
})
})
},
setDeleted(id) {
const newStatus = this.allStatuses.get(id)
if (newStatus) newStatus.deleted = true
},
- setManyDeleted(condition) {
- this.allStatuses.values().forEach((status) => {
- if (condition(status)) {
- status.deleted = true
- }
- })
- },
// For when blocking a user
wipeUserStatuses(userId) {
- this.allStatuses.values().forEach((status) => {
+ this.allStatuses.forEach((status) => {
if (status.user.id === userId) {
this.allStatuses.delete(status.id)
}
})
},
// Search
search({ q, resolve, limit, offset, following, type }) {
return search2({
q,
resolve,
limit,
offset,
following,
type,
credentials: useOAuthStore().token,
}).then((result) => {
const { data, ...rest } = result
useUsersStore().addNewUsers({
...rest,
data: data.accounts,
})
useUsersStore().addNewUsers({
...rest,
data: data.statuses.map((s) => s.user).filter(Boolean),
})
this.addNewStatuses({
statuses: data.statuses,
})
data.statuses = data.statuses.map((s) => this.allStatuses.get(s.id))
return data
})
},
setVirtualHeight({ statusId, height }) {
this.allStatuses.get(statusId).virtualHeight = height
},
updateStatusWithPoll({ id, poll }) {
const status = this.allStatuses.get(id)
status.poll = poll
},
},
})
diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js
deleted file mode 100644
index 04bd07ffec..0000000000
--- a/test/unit/specs/modules/statuses.spec.js
+++ /dev/null
@@ -1,447 +0,0 @@
-import { createTestingPinia } from '@pinia/testing'
-
-import {
- defaultState,
- mutations,
- prepareStatus,
-} from '../../../../src/modules/statuses.js'
-
-createTestingPinia()
-
-const makeMockStatus = ({ id, text, type = 'status' }) => {
- return {
- id,
- user: { id: '0' },
- name: 'status',
- text: text || `Text number ${id}`,
- fave_num: 0,
- uri: '',
- type,
- attentions: [],
- }
-}
-
-describe('Statuses module', () => {
- describe('prepareStatus', () => {
- it('sets deleted flag to false', () => {
- const aStatus = makeMockStatus({ id: '1', text: 'Hello oniichan' })
- expect(prepareStatus(aStatus).deleted).to.eq(false)
- })
- })
-
- describe('addNewStatuses', () => {
- it('adds the status to allStatuses and to the given timeline', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- timeline: 'public',
- })
-
- expect(state.allStatuses).to.eql([status])
- expect(state.timelines.public.statuses).to.eql([status])
- expect(state.timelines.public.visibleStatuses).to.eql([])
- expect(state.timelines.public.newStatusCount).to.equal(1)
- })
-
- it('counts the status as new if it has not been seen on this timeline', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- timeline: 'public',
- })
- mutations.addNewStatuses(state, {
- statuses: [status],
- timeline: 'friends',
- })
-
- expect(state.allStatuses).to.eql([status])
- expect(state.timelines.public.statuses).to.eql([status])
- expect(state.timelines.public.visibleStatuses).to.eql([])
- expect(state.timelines.public.newStatusCount).to.equal(1)
-
- expect(state.allStatuses).to.eql([status])
- expect(state.timelines.friends.statuses).to.eql([status])
- expect(state.timelines.friends.visibleStatuses).to.eql([])
- expect(state.timelines.friends.newStatusCount).to.equal(1)
- })
-
- it('add the statuses to allStatuses if no timeline is given', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
-
- mutations.addNewStatuses(state, { statuses: [status] })
-
- expect(state.allStatuses).to.eql([status])
- expect(state.timelines.public.statuses).to.eql([])
- expect(state.timelines.public.visibleStatuses).to.eql([])
- expect(state.timelines.public.newStatusCount).to.equal(0)
- })
-
- it('adds the status to allStatuses and to the given timeline, directly visible', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
-
- expect(state.allStatuses).to.eql([status])
- expect(state.timelines.public.statuses).to.eql([status])
- expect(state.timelines.public.visibleStatuses).to.eql([status])
- expect(state.timelines.public.newStatusCount).to.equal(0)
- })
-
- it('does not update the maxId when the noIdUpdate flag is set', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
- const secondStatus = makeMockStatus({ id: '2' })
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- expect(state.timelines.public.maxId).to.equal('1')
-
- mutations.addNewStatuses(state, {
- statuses: [secondStatus],
- showImmediately: true,
- timeline: 'public',
- noIdUpdate: true,
- })
- expect(state.timelines.public.statuses).to.eql([secondStatus, status])
- expect(state.timelines.public.visibleStatuses).to.eql([
- secondStatus,
- status,
- ])
- expect(state.timelines.public.maxId).to.equal('1')
- })
-
- it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
- const state = defaultState()
- const nonVisibleStatus = makeMockStatus({ id: '1' })
- const status = makeMockStatus({ id: '3' })
- const statusTwo = makeMockStatus({ id: '2' })
- const statusThree = makeMockStatus({ id: '4' })
-
- mutations.addNewStatuses(state, {
- statuses: [nonVisibleStatus],
- showImmediately: false,
- timeline: 'public',
- })
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- mutations.addNewStatuses(state, {
- statuses: [statusTwo],
- showImmediately: true,
- timeline: 'public',
- })
-
- expect(state.timelines.public.minVisibleId).to.equal('2')
-
- mutations.addNewStatuses(state, {
- statuses: [statusThree],
- showImmediately: true,
- timeline: 'public',
- })
-
- expect(state.timelines.public.statuses).to.eql([
- statusThree,
- status,
- statusTwo,
- nonVisibleStatus,
- ])
- expect(state.timelines.public.visibleStatuses).to.eql([
- statusThree,
- status,
- statusTwo,
- ])
- })
-
- it('splits retweets from their status and links them', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
- const retweet = makeMockStatus({ id: '2', type: 'retweet' })
- const modStatus = makeMockStatus({ id: '1', text: 'something else' })
-
- retweet.retweeted_status = status
-
- // It adds both statuses, but only the retweet to visible.
- mutations.addNewStatuses(state, {
- statuses: [retweet],
- timeline: 'public',
- showImmediately: true,
- })
- expect(state.timelines.public.visibleStatuses).to.have.length(1)
- expect(state.timelines.public.statuses).to.have.length(1)
- expect(state.allStatuses).to.have.length(2)
- expect(state.allStatuses[0].id).to.equal('1')
- expect(state.allStatuses[1].id).to.equal('2')
-
- // It refers to the modified status.
- mutations.addNewStatuses(state, {
- statuses: [modStatus],
- timeline: 'public',
- })
- expect(state.allStatuses).to.have.length(2)
- expect(state.allStatuses[0].id).to.equal('1')
- expect(state.allStatuses[0].text).to.equal(modStatus.text)
- expect(state.allStatuses[1].id).to.equal('2')
- expect(retweet.retweeted_status.text).to.eql(modStatus.text)
- })
-
- it('replaces existing statuses with the same id', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
- const modStatus = makeMockStatus({ id: '1', text: 'something else' })
-
- // Add original status
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- expect(state.timelines.public.visibleStatuses).to.have.length(1)
- expect(state.allStatuses).to.have.length(1)
-
- // Add new version of status
- mutations.addNewStatuses(state, {
- statuses: [modStatus],
- showImmediately: true,
- timeline: 'public',
- })
- expect(state.timelines.public.visibleStatuses).to.have.length(1)
- expect(state.allStatuses).to.have.length(1)
- expect(state.allStatuses[0].text).to.eql(modStatus.text)
- })
-
- it('replaces existing statuses with the same id, coming from a retweet', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
- const modStatus = makeMockStatus({ id: '1', text: 'something else' })
- const retweet = makeMockStatus({ id: '2', type: 'retweet' })
- retweet.retweeted_status = modStatus
-
- // Add original status
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- expect(state.timelines.public.visibleStatuses).to.have.length(1)
- expect(state.allStatuses).to.have.length(1)
-
- // Add new version of status
- mutations.addNewStatuses(state, {
- statuses: [retweet],
- showImmediately: false,
- timeline: 'public',
- })
- expect(state.timelines.public.visibleStatuses).to.have.length(1)
- // Don't add the retweet itself if the tweet is visible
- expect(state.timelines.public.statuses).to.have.length(1)
- expect(state.allStatuses).to.have.length(2)
- expect(state.allStatuses[0].text).to.eql(modStatus.text)
- })
-
- it('handles favorite actions', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
-
- const favorite = {
- id: '2',
- type: 'favorite',
- in_reply_to_status_id: '1', // The API uses strings here...
- uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
- text: 'a favorited something by b',
- user: { id: '99' },
- }
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- mutations.addNewStatuses(state, {
- statuses: [favorite],
- showImmediately: true,
- timeline: 'public',
- })
-
- expect(state.timelines.public.visibleStatuses).to.have.length(1)
- expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
- expect(state.timelines.public.maxId).to.eq(favorite.id)
-
- // Adding it again does nothing
- mutations.addNewStatuses(state, {
- statuses: [favorite],
- showImmediately: true,
- timeline: 'public',
- })
-
- expect(state.timelines.public.visibleStatuses).to.have.length(1)
- expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
- expect(state.timelines.public.maxId).to.eq(favorite.id)
-
- // If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
- const user = {
- id: '1',
- }
-
- const ownFavorite = {
- id: '3',
- type: 'favorite',
- in_reply_to_status_id: '1', // The API uses strings here...
- uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
- text: 'a favorited something by b',
- user,
- }
-
- mutations.addNewStatuses(state, {
- statuses: [ownFavorite],
- showImmediately: true,
- timeline: 'public',
- user,
- })
-
- expect(state.timelines.public.visibleStatuses).to.have.length(1)
- expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
- expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true)
- })
- })
-
- describe('emojiReactions', () => {
- it('increments count in existing reaction', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
- status.emoji_reactions = [{ name: '😂', count: 1, accounts: [] }]
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- mutations.addOwnReaction(state, {
- id: '1',
- emoji: '😂',
- currentUser: { id: 'me' },
- })
- expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(2)
- expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true)
- expect(
- state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id,
- ).to.equal('me')
- })
-
- it('adds a new reaction', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
- status.emoji_reactions = []
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- mutations.addOwnReaction(state, {
- id: '1',
- emoji: '😂',
- currentUser: { id: 'me' },
- })
- expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1)
- expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true)
- expect(
- state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id,
- ).to.equal('me')
- })
-
- it('decreases count in existing reaction', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
- status.emoji_reactions = [
- { name: '😂', count: 2, accounts: [{ id: 'me' }] },
- ]
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- mutations.removeOwnReaction(state, {
- id: '1',
- emoji: '😂',
- currentUser: { id: 'me' },
- })
- expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1)
- expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(false)
- expect(state.allStatusesObject['1'].emoji_reactions[0].accounts).to.eql(
- [],
- )
- })
-
- it('removes a reaction', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '1' })
- status.emoji_reactions = [
- { name: '😂', count: 1, accounts: [{ id: 'me' }] },
- ]
-
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- mutations.removeOwnReaction(state, {
- id: '1',
- emoji: '😂',
- currentUser: { id: 'me' },
- })
- expect(state.allStatusesObject['1'].emoji_reactions).to.have.length(0)
- })
- })
-
- describe('showNewStatuses', () => {
- it('resets the minId to the min of the visible statuses when adding new to visible statuses', () => {
- const state = defaultState()
- const status = makeMockStatus({ id: '10' })
- mutations.addNewStatuses(state, {
- statuses: [status],
- showImmediately: true,
- timeline: 'public',
- })
- const newStatus = makeMockStatus({ id: '20' })
- mutations.addNewStatuses(state, {
- statuses: [newStatus],
- showImmediately: false,
- timeline: 'public',
- })
- state.timelines.public.minId = '5'
- mutations.showNewStatuses(state, { timeline: 'public' })
-
- expect(state.timelines.public.visibleStatuses).to.have.length(2)
- expect(state.timelines.public.minVisibleId).to.equal('10')
- expect(state.timelines.public.minId).to.equal('10')
- })
- })
-
- describe('clearTimeline', () => {
- it('keeps userId when clearing user timeline when excludeUserId param is true', () => {
- const state = defaultState()
- state.timelines.user.userId = 123
-
- mutations.clearTimeline(state, { timeline: 'user', excludeUserId: true })
-
- expect(state.timelines.user.userId).to.eql(123)
- })
- })
-})
diff --git a/test/unit/specs/stores/statuses.spec.js b/test/unit/specs/stores/statuses.spec.js
new file mode 100644
index 0000000000..01795b25d5
--- /dev/null
+++ b/test/unit/specs/stores/statuses.spec.js
@@ -0,0 +1,603 @@
+import { createTestingPinia } from '@pinia/testing'
+import { snakeCase } from 'lodash'
+import { setActivePinia } from 'pinia'
+
+import { useStatusesStore } from 'src/stores/statuses.js'
+import { useUsersStore } from 'src/stores/users.js'
+
+import * as PUBLIC_API from 'src/api/public.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,
+ type = 'status',
+ statusUser = mockUser(),
+} = {}) => ({
+ id,
+ user: statusUser,
+ name: 'status',
+ text: text ?? `Text number ${id}`,
+ uri: '',
+ type,
+ attentions: [],
+ statusnet_conversation_id: 'c1',
+ emoji_reactions: [],
+})
+
+const mockMastoAPIStatus = ({
+ id = '1',
+ text,
+ type = 'status',
+ statusUser = mockMastoAPIUser(),
+} = {}) => ({
+ id,
+ account: statusUser,
+ name: 'status',
+ content: text ?? `Text number ${id}`,
+ uri: '',
+ type,
+ attentions: [],
+ statusnet_conversation_id: 'c1',
+})
+
+const DEFAULT_OPTIONS = (method = 'GET') => ({
+ method,
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+})
+
+describe('Statuses store', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ })
+
+ describe('addNewStatuses', () => {
+ beforeEach(() => {
+ const usersStore = useUsersStore()
+ usersStore.addNewUsers = vi.fn().mockReturnValue([mockUser()])
+ })
+
+ it('adds the status to allStatuses', () => {
+ const store = useStatusesStore()
+ const usersStore = useUsersStore()
+ const status = mockStatus({ id: '1' })
+
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 1,
+ })
+
+ expect(store.allStatuses).to.eql(new Map([['1', status]]))
+ expect(store.conversations).to.eql(new Map([['c1', new Set(['1'])]]))
+ expect(usersStore.addNewUsers).to.have.callCount(1)
+ })
+
+ it('splits retweets from their status and links them', () => {
+ const store = useStatusesStore()
+ const usersStore = useUsersStore()
+ const status = mockStatus({ id: '1' })
+ const retweet = mockStatus({
+ id: '2',
+ type: 'retweet',
+ user: mockUser({ id: '2' }),
+ })
+ retweet.type = 'retweet'
+ retweet.retweeted_status = status
+
+ store.addNewStatuses({
+ statuses: [retweet],
+ timestamp: 1,
+ })
+
+ expect(store.allStatuses).to.eql(
+ new Map([
+ ['1', status],
+ ['2', retweet],
+ ]),
+ )
+ expect(usersStore.addNewUsers).to.have.callCount(2)
+ })
+
+ it('splits quotes from their status and links them', () => {
+ const store = useStatusesStore()
+ const usersStore = useUsersStore()
+ const status = mockStatus({ id: '1' })
+ const quote = mockStatus({ id: '2' })
+ quote.quote = status
+
+ store.addNewStatuses({
+ statuses: [quote],
+ timestamp: 1,
+ })
+
+ expect(store.allStatuses).to.eql(
+ new Map([
+ ['1', status],
+ ['2', quote],
+ ]),
+ )
+ expect(usersStore.addNewUsers).to.have.callCount(2)
+ })
+
+ it('replaces existing statuses with the same id', () => {
+ const store = useStatusesStore()
+ const usersStore = useUsersStore()
+ const status = mockStatus({ id: '1' })
+ const modStatus = mockStatus({ id: '1', text: 'something else' })
+
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 1991,
+ })
+ expect(store.allStatuses).to.eql(new Map([['1', status]]))
+
+ store.addNewStatuses({
+ statuses: [modStatus],
+ timestamp: 2000,
+ })
+ expect(store.allStatuses).to.eql(new Map([['1', modStatus]]))
+ expect(usersStore.addNewUsers).to.have.callCount(2)
+ })
+
+ it('handles edits', () => {
+ const store = useStatusesStore()
+ const usersStore = useUsersStore()
+ const status = mockStatus({ id: '1' })
+ const modStatus = mockStatus({
+ id: '1',
+ text: 'something else',
+ type: 'edit',
+ })
+
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 1991,
+ })
+ expect(store.allStatuses).to.eql(new Map([['1', status]]))
+
+ store.addNewStatuses({
+ statuses: [modStatus],
+ timestamp: 2000,
+ })
+ expect(store.allStatuses).to.eql(new Map([['1', modStatus]]))
+ expect(usersStore.addNewUsers).to.have.callCount(2)
+ })
+
+ it('ignores updates with older timestamp', () => {
+ const store = useStatusesStore()
+ const usersStore = useUsersStore()
+ const status = mockStatus({ id: '1' })
+ const modStatus = mockStatus({ id: '1', text: 'something else' })
+
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 2000,
+ })
+ expect(store.allStatuses).to.eql(new Map([['1', status]]))
+
+ store.addNewStatuses({
+ statuses: [modStatus],
+ timestamp: 1991,
+ })
+ expect(store.allStatuses).to.eql(new Map([['1', status]]))
+ expect(usersStore.addNewUsers).to.have.callCount(2)
+ })
+
+ it('calls useUsersStore().addNewUsers() even on older timestamp', () => {
+ const store = useStatusesStore()
+ const usersStore = useUsersStore()
+ const status = mockStatus({ id: '1' })
+ const modStatus = mockStatus({ id: '1', text: 'something else' })
+
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 2000,
+ })
+ store.addNewStatuses({
+ statuses: [modStatus],
+ timestamp: 1991,
+ })
+ expect(usersStore.addNewUsers).to.have.callCount(2)
+ })
+ })
+
+ describe('fetchers', () => {
+ it.each([
+ [
+ 'StatusSource',
+ {
+ content_type: 'text/plain',
+ text: 'Text',
+ spoiler_text: 'Text',
+ },
+ ],
+ [
+ 'EmojiReactions',
+ [
+ {
+ accounts: [mockMastoAPIUser()],
+ count: 1,
+ me: false,
+ name: 'cofe',
+ url: '',
+ },
+ ],
+ ],
+ ['Favs', [mockMastoAPIUser()]],
+ ['Repeats', [mockMastoAPIUser()]],
+ ])('fetch%s', async (group, mockedResponse) => {
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify(mockedResponse), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ vi.stubGlobal('fetch', mockFetch)
+
+ let urlKey
+ let prefix = 'MASTODON'
+ if (group === 'Favs') {
+ urlKey = 'STATUS_FAVORITEDBY'
+ } else if (group === 'Repeats') {
+ urlKey = 'STATUS_REBLOGGEDBY'
+ } else {
+ urlKey = snakeCase(group).toUpperCase()
+ }
+ if (group === 'EmojiReactions') {
+ prefix = 'PLEROMA'
+ }
+
+ const url = PUBLIC_API[`${prefix}_${urlKey}_URL`]('id')
+
+ const store = useStatusesStore()
+ const status = mockStatus({ id: 'id' })
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 2000,
+ })
+
+ const result = await store[`fetch${group}`]('id')
+ const updated = store.allStatuses.get('id')
+
+ expect(mockFetch).to.have.been.calledWith(url, DEFAULT_OPTIONS())
+ if (group === 'Favs') {
+ expect(updated.favoritedBy).to.have.length(1)
+ expect(updated.fave_num).to.eql(1)
+ } else if (group === 'Repeats') {
+ expect(updated.rebloggedBy).to.have.length(1)
+ expect(updated.repeat_num).to.eql(1)
+ } else if (group === 'EmojiReactions') {
+ expect(updated.emoji_reactions).to.have.length(mockedResponse.length)
+ expect(updated.emoji_reactions[0].name).to.eql(mockedResponse[0].name)
+ } else {
+ expect(result).to.eql(mockedResponse)
+ }
+ })
+
+ it('fetchFavsAndRepeats', async () => {
+ const store = useStatusesStore()
+ store.fetchFavs = vi.fn().mockResolvedValue(async () => {
+ /* no-op */
+ })
+ store.fetchRepeats = vi.fn().mockResolvedValue(async () => {
+ /* no-op */
+ })
+
+ await store.fetchFavsAndRepeats('id')
+ expect(store.fetchFavs).to.have.been.calledWith('id')
+ expect(store.fetchRepeats).to.have.been.calledWith('id')
+ })
+
+ it('fetchStatus', async () => {
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify(mockMastoAPIStatus()), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ vi.stubGlobal('fetch', mockFetch)
+
+ const store = useStatusesStore()
+ await store.fetchStatus('id')
+
+ expect(mockFetch).to.have.been.calledWith(
+ PUBLIC_API.MASTODON_STATUS_URL('id'),
+ DEFAULT_OPTIONS(),
+ )
+ expect(store.allStatuses).to.have.length(1)
+ })
+ })
+
+ describe('interactions', () => {
+ it.each([
+ ['favorite', 'MASTODON_FAVORITE_URL'],
+ ['unfavorite', 'MASTODON_UNFAVORITE_URL'],
+ ['retweet', 'MASTODON_RETWEET_URL'],
+ ['unretweet', 'MASTODON_UNRETWEET_URL'],
+ ['reactWithEmoji', 'PLEROMA_EMOJI_REACT_URL', 'PUT'],
+ ['unreactWithEmoji', 'PLEROMA_EMOJI_UNREACT_URL', 'DELETE'],
+ [
+ 'bookmark',
+ 'MASTODON_BOOKMARK_STATUS_URL',
+ undefined,
+ { folder_id: 'argument' },
+ ],
+ ['unbookmark', 'MASTODON_UNBOOKMARK_STATUS_URL'],
+ ['pinStatus', 'MASTODON_PIN_OWN_STATUS_URL'],
+ ['unpinStatus', 'MASTODON_UNPIN_OWN_STATUS_URL'],
+ ['muteConversation', 'MASTODON_MUTE_CONVERSATION_URL'],
+ ['unmuteConversation', 'MASTODON_UNMUTE_CONVERSATION_URL'],
+ ['deleteStatus', 'MASTODON_DELETE_URL', 'DELETE'],
+ ])('%s - api call', async (interaction, urlKey, method = 'POST', body) => {
+ const url = USER_API[urlKey]('1', 'argument')
+
+ const status = mockStatus()
+ const store = useStatusesStore()
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 1,
+ })
+
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ vi.stubGlobal('fetch', mockFetch)
+ await store[interaction]('1', 'argument')
+ const expectedArg = DEFAULT_OPTIONS(method)
+
+ if (body) {
+ expectedArg.body = JSON.stringify(body)
+ }
+
+ expect(mockFetch).to.have.been.calledWith(url, expectedArg)
+ if (interaction === 'deleteStatus') {
+ expect(store.allStatuses.get('1').deleted).to.be.true
+ } else {
+ expect(store.allStatuses.get('1').text).to.eql('Updated')
+ }
+ })
+
+ const optimismInteractions = [
+ ['favorite', 'favorited', 'fave_num'],
+ ['retweet', 'repeated', 'repeat_num'],
+ ['bookmark', 'bookmarked'],
+ ['muteConversation', 'thread_muted'],
+ ]
+ .map(([method, property, count]) => [
+ [method, property, count],
+ ['un' + method, property, count],
+ ])
+ .flat()
+
+ it.each(
+ optimismInteractions,
+ )('%s - optimism call', async (method, property, count) => {
+ // Prepare our status
+ const status = mockStatus()
+ const negate = method.startsWith('un')
+ const oldCount = 9
+ const newCount = negate ? 8 : 10
+ status[property] = negate
+ if (count) {
+ status[count] = oldCount
+ }
+ if (method === 'unbookmark') {
+ status.bookmark_folder_id = 'argument'
+ }
+
+ // Insert it
+ const store = useStatusesStore()
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 1,
+ })
+
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ expect(store.allStatuses.get('1')).to.have.property(property, negate)
+ if (count) {
+ expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
+ }
+ vi.stubGlobal('fetch', mockFetch)
+
+ store[method]('1', 'argument')
+ expect(store.allStatuses.get('1')).to.have.property(property, !negate)
+ if (count) {
+ expect(store.allStatuses.get('1')).to.have.property(count, newCount)
+ }
+ if (property === 'bookmarked') {
+ expect(store.allStatuses.get('1')).to.have.property(
+ 'bookmark_folder_id',
+ 'argument',
+ )
+ }
+ })
+
+ it.each(
+ optimismInteractions,
+ )('%s - optimism fail', async (method, property, count) => {
+ // Prepare our status
+ const status = mockStatus()
+ const negate = method.startsWith('un')
+ const oldCount = 9
+ status[property] = negate
+ if (count) {
+ status[count] = oldCount
+ }
+ if (method === 'unbookmark') {
+ status.bookmark_folder_id = 'argument'
+ }
+
+ // Insert it
+ const store = useStatusesStore()
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 1,
+ })
+
+ const mockFetch = vi.fn()
+ mockFetch.mockRejectedValueOnce(new Error('Failure!'))
+
+ expect(store.allStatuses.get('1')).to.have.property(property, negate)
+ if (count) {
+ expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
+ }
+ vi.stubGlobal('fetch', mockFetch)
+
+ await store[method]('1', 'argument')
+ expect(store.allStatuses.get('1')).to.have.property(property, negate)
+ if (count) {
+ expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
+ }
+
+ // Failing 'bookmark' method SHOULD clear folder id
+ if (method === 'unbookmark') {
+ expect(store.allStatuses.get('1')).to.have.property(
+ 'bookmark_folder_id',
+ 'argument',
+ )
+ }
+ })
+
+ it.each([
+ ['reactWithEmoji', 0],
+ ['unreactWithEmoji', 1],
+ ['reactWithEmoji', 1],
+ ['unreactWithEmoji', 2],
+ ])('%s count: %s - optimism call', async (method, oldCount) => {
+ // Prepare our status
+ const status = mockStatus()
+ const negate = method.startsWith('un')
+ const newCount = negate ? oldCount - 1 : oldCount + 1
+
+ useUsersStore().currentUser = mockUser()
+
+ if (oldCount > 0) {
+ const reactors = [...new Array(oldCount)].map((empty, index) =>
+ mockUser({ id: 'o' + index }),
+ )
+ if (negate) {
+ // Replace one of reactors with ourselves
+ reactors[0] = useUsersStore().currentUser
+ }
+ status.emoji_reactions = [
+ {
+ name: 'hyperlol',
+ count: oldCount,
+ accounts: reactors,
+ },
+ ]
+ } else {
+ status.emoji_reactions = []
+ }
+
+ // Insert it
+ const store = useStatusesStore()
+ store.addNewStatuses({
+ statuses: [status],
+ timestamp: 1,
+ })
+
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ vi.stubGlobal('fetch', mockFetch)
+ const expected = store.allStatuses.get('1')
+
+ expect(expected.emoji_reactions).to.have.length(oldCount === 0 ? 0 : 1)
+ if (oldCount !== 0) {
+ expect(expected.emoji_reactions[0].name).to.eql('hyperlol')
+ expect(expected.emoji_reactions[0].count).to.eql(oldCount)
+ expect(expected.emoji_reactions[0].accounts).to.have.length(oldCount)
+ }
+
+ store[method]('1', 'hyperlol')
+
+ expect(expected.emoji_reactions).to.have.length(newCount === 0 ? 0 : 1)
+ if (newCount !== 0) {
+ expect(expected.emoji_reactions[0].name).to.eql('hyperlol')
+ expect(expected.emoji_reactions[0].count).to.eql(newCount)
+ expect(expected.emoji_reactions[0].accounts).to.have.length(newCount)
+ }
+ })
+ })
+
+ it('wipeUserStatuses', () => {
+ const store = useStatusesStore()
+ const statuses = [...new Array(20)].map((empty, index) =>
+ mockStatus({
+ id: 's' + index,
+ statusUser: mockUser({ id: 'u' + index }),
+ }),
+ )
+
+ store.addNewStatuses({
+ statuses,
+ timestamp: 1,
+ })
+
+ store.wipeUserStatuses('u19')
+
+ expect(store.allStatuses).to.have.length(19)
+ })
+})
diff --git a/test/unit/specs/stores/users.spec.js b/test/unit/specs/stores/users.spec.js
index 879d2b0a9a..b234a0ffe1 100644
--- a/test/unit/specs/stores/users.spec.js
+++ b/test/unit/specs/stores/users.spec.js
@@ -1,1255 +1,1257 @@
import { createTestingPinia } from '@pinia/testing'
import { snakeCase } from 'lodash'
import { setActivePinia } 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 { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
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 { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import * as PUBLIC_API from 'src/api/public.js'
import * as USER_API from 'src/api/user.js'
const DEFAULT_OPTIONS = (method = 'POST') => ({
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
const actionKeys = (action) => {
const result = {}
if (action === 'removeUserFromFollowers') {
result.storeAction = action
} else {
result.storeAction = action + 'User'
}
result.apiUrl = `MASTODON_${snakeCase(result.storeAction).toUpperCase()}_URL`
return result
}
-describe('The users store', () => {
- beforeEach(() => {
- setActivePinia(createTestingPinia({ stubActions: false }))
- })
-
- const userId = '1'
- const userScreenName = 'user'
- const userName = 'Guy'
- const userUrl = 'http://localhost/user'
-
- const mastoApiUser = ({
- screen_name = userScreenName,
- name = userName,
- url = userUrl,
- id = userId,
- } = {}) => ({
- id,
- acct: screen_name,
- display_name: name,
- fields: [],
- avatar: '',
- url,
- })
+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,
+})
- const user = ({
- screen_name = userScreenName,
- id = userId,
- name = userName,
- url = userUrl,
- } = {}) => ({
- _original: mastoApiUser({
- screen_name,
- id,
- name,
- url,
- }),
+const mockUser = ({
+ screen_name = userScreenName,
+ id = userId,
+ name = userName,
+ url = userUrl,
+} = {}) => ({
+ _original: mockMastoAPIUser({
+ screen_name,
id,
name,
- screen_name,
url,
- relationship: undefined,
+ }),
+ id,
+ name,
+ screen_name,
+ url,
+ relationship: undefined,
+})
+
+describe('Users store', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('addNewUsers', () => {
describe('users', () => {
it('adds new users to the set, merging in new information for old users', () => {
const store = useUsersStore()
- const modUser = user({ name: 'Dude' })
+ const modUser = mockUser({ name: 'Dude' })
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
expect(store.users).to.have.length(1)
expect(store.users).to.have.all.keys(userId)
store.addNewUsers({ data: [modUser], timestamp: 2 })
expect(store.users).to.have.length(1)
expect(store.users).to.have.all.keys(userId)
expect(store.users.get(userId).name).to.eql('Dude')
})
it('ignores new users if timestamp is older', () => {
const store = useUsersStore()
- const modUser = user({ name: 'Old guy' })
+ const modUser = mockUser({ name: 'Old guy' })
- store.addNewUsers({ data: [user()], timestamp: 2000 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 2000 })
expect(store.users).to.have.length(1)
expect(store.users).to.have.all.keys(userId)
expect(store.users.get(userId).name).to.eql('Guy')
store.addNewUsers({ data: [modUser], timestamp: 1991 })
expect(store.users).to.have.length(1)
expect(store.users).to.have.all.keys(userId)
expect(store.users.get(userId).name).to.eql('Guy')
})
it('merging array field in new information for old users', () => {
const store = useUsersStore()
const userFields = {
- ...user(),
+ ...mockUser(),
fields: [{ name: 'Label 1', value: 'Content 1' }],
}
const firstModUser = {
- ...user(),
+ ...mockUser(),
fields: [
{ name: 'Label 2', value: 'Content 2' },
{ name: 'Label 3', value: 'Content 3' },
],
}
const secondModUser = {
- ...user(),
+ ...mockUser(),
fields: [{ name: 'Label 4', value: 'Content 4' }],
}
store.addNewUsers({ data: [userFields], timestamp: 1 })
const reactive = store.users.get(userId)
expect(reactive.fields).to.have.length(1)
expect(reactive.fields[0].name).to.eql('Label 1')
store.addNewUsers({ data: [firstModUser], timestamp: 2 })
expect(reactive.fields).to.have.length(2)
expect(reactive.fields[0].name).to.eql('Label 2')
expect(reactive.fields[1].name).to.eql('Label 3')
store.addNewUsers({ data: [secondModUser], timestamp: 3 })
expect(reactive.fields).to.have.length(1)
expect(reactive.fields[0].name).to.eql('Label 4')
})
})
describe('relationships', () => {
it('updates relationship information if present', () => {
const store = useUsersStore()
const modUser = {
- ...user(),
+ ...mockUser(),
relationship: {
id: userId,
following: true,
},
}
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
store.addNewUsers({ data: [modUser], timestamp: 2 })
expect(store.relationships).to.have.length(1)
expect(store.relationships).to.have.all.keys(userId)
expect(store.relationship(userId).following).to.eql(true)
expect(store.findUser(userId).relationship.following).to.eql(true)
})
it('updates relationship information if present even if user timestamp is older', () => {
const store = useUsersStore()
const modUser = {
- ...user({ name: 'Old Dude' }),
+ ...mockUser({ name: 'Old Dude' }),
relationship: {
id: userId,
following: true,
},
}
- store.addNewUsers({ data: [user()], timestamp: 2000 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 2000 })
store.addNewUsers({ data: [modUser], timestamp: 1991 })
expect(store.relationships).to.have.length(1)
expect(store.relationships).to.have.all.keys(userId)
expect(store.relationship(userId).following).to.eql(true)
expect(store.findUser(userId).relationship.following).to.eql(true)
})
it("doesn't erase relationship information if new data has it missing", () => {
const store = useUsersStore()
- const modUser = user({ name: 'Dude' })
+ const modUser = mockUser({ name: 'Dude' })
store.addNewUsers({
- data: [{ ...user(), relationship: { id: userId, following: true } }],
+ data: [
+ { ...mockUser(), relationship: { id: userId, following: true } },
+ ],
timestamp: 1,
})
store.addNewUsers({ data: [modUser], timestamp: 2 })
expect(store.relationships).to.have.length(1)
expect(store.relationships).to.have.all.keys(userId)
expect(store.relationship(userId).following).to.eql(true)
expect(store.findUser(userId).relationship.following).to.eql(true)
})
})
})
describe('updateUserRelationships', () => {
it('updates existing user relationship', () => {
const store = useUsersStore()
const relationship = { id: userId, following: true }
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
store.updateUserRelationships({ data: relationship, timestamp: 2 })
expect(store.relationship(userId)).to.eql({ id: userId, following: true })
expect(store.findUser(userId).relationship.following).to.eql(true)
})
it('stores relationship for missing user', () => {
const store = useUsersStore()
const relationship = { id: userId, following: true }
store.updateUserRelationships({ data: relationship, timestamp: 1 })
expect(store.relationship(userId)).to.eql({ id: userId, following: true })
})
it('assigns relationship for missing user when it becomes available', () => {
const store = useUsersStore()
const relationship = { id: userId, following: true }
store.updateUserRelationships({ data: relationship, timestamp: 1 })
- store.addNewUsers({ data: [user()], timestamp: 2 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 2 })
expect(store.relationship(userId)).to.eql({ id: userId, following: true })
expect(store.findUser(userId).relationship).to.eql({
id: userId,
following: true,
})
})
it('ignores relationship update if timestamp is older than existing', () => {
const store = useUsersStore()
const oldRelationship = { id: userId, following: true }
const newRelationship = { id: userId, following: false }
store.addNewUsers({
- data: [{ ...user(), relationship: newRelationship }],
+ data: [{ ...mockUser(), relationship: newRelationship }],
timestamp: 2000,
})
store.updateUserRelationships({ data: oldRelationship, timestamp: 1991 })
expect(store.relationship(userId)).to.eql({
id: userId,
following: false,
})
expect(store.findUser(userId).relationship.following).to.eql(false)
})
})
describe('fetchers', () => {
describe('fetchUserIfMissing', () => {
it('should fetch requested user, add it to store and return it', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
- new Response(JSON.stringify(mastoApiUser()), {
+ new Response(JSON.stringify(mockMastoAPIUser()), {
headers: { 'Content-Type': 'application/json' },
}),
),
)
- const expected = user()
+ const expected = mockUser()
const store = useUsersStore()
const resultUser = await store.fetchUserIfMissing({ id: '1' })
expect(resultUser).to.deep.include(expected)
expect(store.findUser(userId)).to.deep.include(expected)
})
it('Should re-use existing promise for other fetches', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: '1' }), {
headers: { 'Content-Type': 'application/json' },
}),
)
// fetch by name yields user id which we request next
.mockResolvedValueOnce(
- new Response(JSON.stringify(mastoApiUser()), {
+ new Response(JSON.stringify(mockMastoAPIUser()), {
headers: { 'Content-Type': 'application/json' },
}),
)
.mockThrowOnce(new Error("Shouldn't be called more than once")),
)
- const expected = user()
+ const expected = mockUser()
const store = useUsersStore()
const resultUser1 = await store.fetchUserIfMissing({ name: 'user' })
const resultUser2 = await store.fetchUserIfMissing({ id: '1' })
expect(resultUser1).to.deep.include(expected)
expect(resultUser2).to.deep.include(expected)
expect(store.findUser(userId)).to.deep.include(expected)
})
it('Should use cached data if present', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockThrowOnce(new Error("Shouldn't be called at all")),
)
const store = useUsersStore()
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
const resultUser1 = await store.fetchUserIfMissing({ id: '1' })
const resultUser2 = await store.fetchUserIfMissing({ name: 'user' })
- const expected = user()
+ const expected = mockUser()
expect(resultUser1).to.deep.include(expected)
expect(resultUser2).to.deep.include(expected)
expect(store.findUser(userId)).to.deep.include(expected)
})
it('Should handle 404 gracefully', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
- new Response(JSON.stringify(mastoApiUser()), {
+ new Response(JSON.stringify(mockMastoAPIUser()), {
status: 404,
statusText: 'Not Found',
headers: { 'Content-Type': 'application/json' },
}),
),
)
const store = useUsersStore()
const resultUser = await store.fetchUserIfMissing({ id: '1' })
expect(resultUser).to.be.null
})
it('Should throw if no identifier is provided', async () => {
const store = useUsersStore()
await expect(
async () => await store.fetchUserIfMissing({}),
).rejects.to.throw(TypeError)
await expect(
async () => await store.fetchUserIfMissing(),
).rejects.to.throw(TypeError)
})
})
describe('relationships', () => {
it.each(['Friends', 'Followers'])('fetch%s', async (group) => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify([
- mastoApiUser({ screen_name: 'snake', name: 'John', id: '2' }),
- mastoApiUser({
+ mockMastoAPIUser({
+ screen_name: 'snake',
+ name: 'John',
+ id: '2',
+ }),
+ mockMastoAPIUser({
screen_name: 'zero',
name: 'David Oh',
id: '3',
}),
]),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify([
- mastoApiUser({
+ mockMastoAPIUser({
screen_name: 'sigint',
name: 'Mr.Anderson',
id: '4',
}),
- mastoApiUser({
+ mockMastoAPIUser({
screen_name: 'paramedic',
name: 'Dr.Clark',
id: '5',
}),
]),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
- store.addNewUsers({ timestamp: 1, data: user() })
+ store.addNewUsers({ timestamp: 1, data: mockUser() })
const urlGroup = group === 'Friends' ? 'Following' : group
const us = store.users.get(userId)
await store[`fetch${group}`](userId)
expect(mockFetch).to.have.been.calledWith(
PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, {
limit: 20,
withRelationships: true,
}),
DEFAULT_OPTIONS('GET'),
)
expect(
store.relationshipsLists[group.toLowerCase()].get(us),
).to.have.length(2)
await store[`fetch${group}`](userId)
expect(mockFetch).to.have.been.calledWith(
PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, {
maxId: '3',
limit: 20,
withRelationships: true,
}),
DEFAULT_OPTIONS('GET'),
)
expect(
store.relationshipsLists[group.toLowerCase()].get(us),
).to.have.length(4)
})
it.each(['Mutes', 'Blocks'])('fetch%s', async (group) => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify([
- mastoApiUser({ screen_name: 'snake', name: 'John', id: '2' }),
- mastoApiUser({
+ mockMastoAPIUser({
+ screen_name: 'snake',
+ name: 'John',
+ id: '2',
+ }),
+ mockMastoAPIUser({
screen_name: 'zero',
name: 'David Oh',
id: '3',
}),
]),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify([
- mastoApiUser({
+ mockMastoAPIUser({
screen_name: 'sigint',
name: 'Mr.Anderson',
id: '4',
}),
- mastoApiUser({
+ mockMastoAPIUser({
screen_name: 'paramedic',
name: 'Dr.Clark',
id: '5',
}),
]),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
- store.addNewUsers({ timestamp: 1, data: user() })
+ store.addNewUsers({ timestamp: 1, data: mockUser() })
const us = store.users.get(userId)
store.currentUser = us
const ids = group === 'Mutes' ? 'muteIds' : 'blockIds'
await store[`fetch${group}`]({ reset: true })
expect(mockFetch).to.have.been.calledWith(
USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({
withRelationships: true,
}),
DEFAULT_OPTIONS('GET'),
)
expect(us[ids]).to.have.length(2)
await store[`fetch${group}`]()
expect(mockFetch).to.have.been.calledWith(
USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({
withRelationships: true,
}),
DEFAULT_OPTIONS('GET'),
)
expect(us[ids]).to.have.length(4)
})
it('fetchDomainMutes', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(['example.com', 'example.org']), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
- store.addNewUsers({ timestamp: 1, data: user() })
+ store.addNewUsers({ timestamp: 1, data: mockUser() })
const us = store.users.get(userId)
store.currentUser = us
await store.fetchDomainMutes()
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_DOMAIN_BLOCKS_URL,
DEFAULT_OPTIONS('GET'),
)
expect(us.domainMutes).to.have.eql(
new Set(['example.com', 'example.org']),
)
})
it('fetchInLists', async () => {
const inLists = [
{ exclusive: false, id: '1', title: 'Operatives' },
{ exclusive: true, id: '2', title: 'Agents' },
]
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(inLists), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
store.addNewUsers({
timestamp: 1,
data: [
- user(),
- { ...user({ name: 'John', screen_name: 'snake', id: '2' }) },
- { ...user({ name: 'David Oh', screen_name: 'zero', id: '3' }) },
+ mockUser(),
+ { ...mockUser({ name: 'John', screen_name: 'snake', id: '2' }) },
+ { ...mockUser({ name: 'David Oh', screen_name: 'zero', id: '3' }) },
],
})
const us = store.users.get(userId)
store.currentUser = us
await store.fetchUserInLists('2')
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_USER_IN_LISTS('2'),
DEFAULT_OPTIONS('GET'),
)
expect(store.users.get('2').inLists).to.have.eql(inLists)
})
})
})
describe('misc updates', () => {
it('updateUserAdminData', () => {
const store = useUsersStore()
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
const adminData = { is_active: true, tags: ['one', 'two'] }
store.updateUserAdminData(userId, adminData)
const userData = store.users.get(userId)
expect(userData.deactivated).to.eql(false)
expect(userData.tags).to.eql(new Set(['one', 'two']))
})
it('updateRight', () => {
const store = useUsersStore()
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
store.updateRight(userId, 'right1', true)
store.updateRight(userId, 'right2', false)
const userData = store.users.get(userId)
expect(userData.rights.right1).to.eql(true)
expect(userData.rights.right2).to.eql(false)
})
it('clearFollowLists', () => {
const store = useUsersStore()
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
const userData = store.users.get(userId)
store.relationshipsLists.friends.get(userData).add('2')
store.relationshipsLists.friends.get(userData).add('3')
store.relationshipsLists.followers.get(userData).add('4')
store.relationshipsLists.followers.get(userData).add('5')
store.clearFollowLists(userId)
expect(store.relationshipsLists.friends.get(userData)).to.have.length(0)
expect(store.relationshipsLists.followers.get(userData)).to.have.length(0)
})
})
describe('login/logout', () => {
describe('login', () => {
it('normal login', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
- new Response(JSON.stringify(mastoApiUser()), {
+ new Response(JSON.stringify(mockMastoAPIUser()), {
headers: { 'Content-Type': 'application/json' },
}),
),
)
const spies = [
// Misc initialization
vi.spyOn(useSyncConfigStore(), 'initSyncConfig'),
vi.spyOn(useUserHighlightStore(), 'initUserHighlight'),
vi.spyOn(useInterfaceStore(), 'applyTheme'),
vi.spyOn(useInterfaceStore(), 'onLogin'),
vi.spyOn(useEmojiStore(), 'fetchEmoji'),
// Timeline / Notifications
vi.spyOn(useNotificationsStore(), 'activate'),
vi.spyOn(useTimelinesStore(), 'activatePersistents'),
// Fetchers
vi.spyOn(useChatsStore(), 'startFetching'),
vi.spyOn(useListsStore(), 'startFetching'),
vi.spyOn(useAnnouncementsStore(), 'startFetching'),
vi.spyOn(useBookmarkFoldersStore(), 'startFetching'),
vi.spyOn(useStreamingStore(), 'initSocket'),
]
spies.forEach((spy) => {
spy.mockImplementation(async () => {
/* no-op */
})
})
useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true
useMergedConfigStore().mergedConfig = { useStreamingApi: true }
const store = useUsersStore()
// Adding some users to verify they are getting cleaned afterwards
store.addNewUsers({
data: [
- user(),
- { ...user({ name: 'John', screen_name: 'snake' }) },
- { ...user({ name: 'David Oh', screen_name: 'zero' }) },
+ mockUser(),
+ { ...mockUser({ name: 'John', screen_name: 'snake' }) },
+ { ...mockUser({ name: 'David Oh', screen_name: 'zero' }) },
],
timestamp: 2000,
})
expect(store.loggedIn).to.eql(false)
await store.loginUser('ACCESS_TOKEN')
expect(store.loggedIn).to.eql(true)
// We should be in the store ourselves
expect(store.users).to.have.length(1)
expect(store.usersByName).to.have.length(1)
expect(store.usersByURL).to.have.length(1)
expect(store.relationships).to.have.length(0)
expect(store.lastLoginName).to.eql(userScreenName)
spies.forEach((spy, index) => {
expect(spy, `Spy ${index} has failed`).to.have.been.called
})
})
it('bad credentials', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
- new Response(JSON.stringify(mastoApiUser()), {
+ new Response(JSON.stringify(mockMastoAPIUser()), {
status: 403,
statusText: 'Forbidden',
headers: { 'Content-Type': 'application/json' },
}),
),
)
const spies = [vi.spyOn(useOAuthStore(), 'clearToken')]
spies.forEach((spy) => {
spy.mockImplementation(async () => {
/* no-op */
})
})
const store = useUsersStore()
const exec = async () => {
await store.loginUser('ACCESS_TOKEN')
}
await expect(exec).rejects.to.throw(Error)
expect(store.loggedIn).to.eql(false)
spies.forEach((spy, index) => {
expect(spy, `Spy ${index} has failed`).to.have.been.called
})
})
})
describe('logout', () => {
it('normal logout', async () => {
const revokeApi = vi
.fn()
.mockResolvedValueOnce(
// Ensure APP
new Response(JSON.stringify('ok'), {
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
// Revoke Token
new Response(JSON.stringify('ok'), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', revokeApi)
const spies = [
// Misc initialization
vi.spyOn(useStatusesStore(), 'resetStatuses'),
vi.spyOn(useInterfaceStore(), 'onLogout'),
// Timeline / Notifications
vi.spyOn(useNotificationsStore(), 'deactivate'),
vi.spyOn(useTimelinesStore(), 'deactivateAll'),
// Fetchers
vi.spyOn(useChatsStore(), 'resetChats'),
vi.spyOn(useListsStore(), 'stopFetching'),
vi.spyOn(useAnnouncementsStore(), 'stopFetching'),
vi.spyOn(useBookmarkFoldersStore(), 'stopFetching'),
vi.spyOn(useStreamingStore(), 'stopSocket'),
]
spies.forEach((spy) => {
spy.mockImplementation(async () => {
/* no-op */
})
})
useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true
useMergedConfigStore().mergedConfig = { useStreamingApi: true }
const store = useUsersStore()
- store.currentUser = user()
+ store.currentUser = mockUser()
// Adding some users to verify they are getting cleaned afterwards
store.addNewUsers({
data: [
- user(),
+ mockUser(),
{
- ...user({ name: 'John', screen_name: 'snake' }),
+ ...mockUser({ name: 'John', screen_name: 'snake' }),
relationship: { id: userId, following: true },
},
- { ...user({ name: 'David Oh', screen_name: 'zero' }) },
+ { ...mockUser({ name: 'David Oh', screen_name: 'zero' }) },
],
timestamp: 2000,
})
expect(store.loggedIn).to.eql(true)
await store.logout()
expect(store.loggedIn).to.eql(false)
expect(store.lastLoginName).to.eql(null)
expect(revokeApi).to.have.been.called
expect(store.users).to.have.length(0)
expect(store.usersByName).to.have.length(0)
expect(store.usersByURL).to.have.length(0)
expect(store.relationships).to.have.length(0)
spies.forEach((spy, index) => {
expect(spy, `Spy ${index} has failed`).to.have.been.called
})
})
})
})
describe('actions', () => {
describe('follow', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('instant follow case', async () => {
const followApi = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: true,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', followApi)
const store = useUsersStore()
await store.followUser(userId)
expect(followApi).to.have.been.called
expect(store.followPollers).to.have.length(0)
expect(store.followPollersAttempts).to.have.length(0)
expect(followApi).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: '{}',
...DEFAULT_OPTIONS(),
},
)
})
it('delayed follow case', async () => {
const followApi = vi
.fn()
.mockResolvedValueOnce(
// Follow
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
.mockResolvedValueOnce(
// Check relationship
new Response(
JSON.stringify({
id: userId,
following: true,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', followApi)
const store = useUsersStore()
await store.followUser(userId)
expect(followApi).to.have.been.called
expect(store.followPollers).to.have.length(1)
expect(store.followPollersAttempts).to.have.length(1)
expect(store.followPollersAttempts.get(userId)).to.eql(3)
await vi.runAllTimersAsync()
expect(followApi).to.have.been.called
expect(store.followPollers).to.have.length(0)
expect(store.followPollersAttempts).to.have.length(0)
})
it('unresolved follow case', async () => {
const followApi = vi.fn().mockResolvedValueOnce(
// Follow
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
followApi // mockImplementation because we need to re-create Response
.mockImplementation(
() =>
// Check relationship
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', followApi)
const store = useUsersStore()
await store.followUser(userId)
expect(store.followPollers).to.have.length(1)
expect(store.followPollersAttempts).to.have.length(1)
expect(store.followPollersAttempts.get(userId)).to.eql(3)
await vi.runAllTimersAsync()
expect(store.followPollersAttempts.get(userId)).to.eql(2)
await vi.runAllTimersAsync()
expect(store.followPollersAttempts.get(userId)).to.eql(1)
await vi.runAllTimersAsync()
expect(store.followPollers).to.have.length(0)
expect(store.followPollersAttempts).to.have.length(0)
expect(followApi).to.have.callCount(4) // 1 follow + 3 retries
})
})
it('unfollow', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.unfollowUser(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_UNFOLLOW_URL(userId),
DEFAULT_OPTIONS(),
)
})
it('subscribe', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
subscribing: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.subscribeUser(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: JSON.stringify({ notify: true }),
...DEFAULT_OPTIONS(),
},
)
})
it('unsubscribe', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
subscribing: false,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.unsubscribeUser(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: JSON.stringify({ notify: false }),
...DEFAULT_OPTIONS(),
},
)
})
it('showReblogs', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
showing_reblogs: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.showReblogs(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: JSON.stringify({ reblogs: true }),
...DEFAULT_OPTIONS(),
},
)
})
it('hideReblogs', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
showing_reblogs: false,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.hideReblogs(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: JSON.stringify({ reblogs: false }),
...DEFAULT_OPTIONS(),
},
)
})
it.each([
'unmute',
'unblock',
'removeUserFromFollowers',
])('%s', async (action) => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: userId }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
const { storeAction, apiUrl } = actionKeys(action)
await store[storeAction](userId)
console.log(apiUrl)
expect(mockFetch).to.have.been.calledWith(
USER_API[apiUrl](userId),
DEFAULT_OPTIONS(),
)
})
describe.each(['mute', 'block'])('%s', (action) => {
it('normal', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: userId }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation(
async () => {
/* no-op */
},
)
vi.spyOn(useTimelinesStore(), 'wipeUserStatuses').mockImplementation(
async () => {
/* no-op */
},
)
const store = useUsersStore()
const { storeAction, apiUrl } = actionKeys(action)
await store[storeAction](userId)
expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), {
body: '{}',
...DEFAULT_OPTIONS(),
})
})
it('with expiration', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: userId }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation(
async () => {
/* no-op */
},
)
vi.spyOn(useTimelinesStore(), 'wipeUserStatuses').mockImplementation(
async () => {
/* no-op */
},
)
const store = useUsersStore()
const { storeAction, apiUrl } = actionKeys(action)
await store[storeAction](userId, 20)
const argument = action === 'mute' ? 'expires_in' : 'duration'
expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), {
body: `{"${argument}":20}`,
...DEFAULT_OPTIONS(),
})
})
})
it.each(['mute', 'unmute'])('%s domain', async (action) => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: userId }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
- store.currentUser = user()
+ store.currentUser = mockUser()
store.currentUser.domainMutes = new Set()
if (action === 'unmute') {
store.currentUser.domainMutes.add('example.com')
}
await store[action + 'Domain']('example.com')
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_DOMAIN_BLOCKS_URL,
{
body: JSON.stringify({ domain: 'example.com' }),
...DEFAULT_OPTIONS(action === 'mute' ? 'POST' : 'DELETE'),
},
)
if (action === 'mute') {
expect(store.currentUser.domainMutes).to.include('example.com')
} else {
expect(store.currentUser.domainMutes).to.not.include('example.com')
}
})
it.each([
'muteDomain',
'unmuteDomain',
'muteUser',
'unmuteUser',
'blockUser',
'unblockUser',
])('%ss', async (action) => {
- const mockFetch = vi.fn().mockResolvedValueOnce(
- new Response(JSON.stringify({ id: userId }), {
- headers: { 'Content-Type': 'application/json' },
- }),
- )
-
- vi.stubGlobal('fetch', mockFetch)
-
const store = useUsersStore()
store[action] = vi.fn().mockResolvedValue(async () => {
/* no-op */
})
store[action + 's'](['1', '2', '3'])
expect(store[action]).to.have.been.calledWith('1')
expect(store[action]).to.have.been.calledWith('2')
expect(store[action]).to.have.been.calledWith('3')
})
})
describe('getters', () => {
it('relationship returns a placeholder if relationship info is missing', () => {
const store = useUsersStore()
expect(store.relationship(userId)).to.eql({ id: userId, loading: true })
})
it('relationship returns a placeholder if relationship info is missing while user is present', () => {
const store = useUsersStore()
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
expect(store.relationship(userId)).to.eql({ id: userId, loading: true })
})
it('findUser returns user with matching id', () => {
const store = useUsersStore()
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
expect(store.findUser(userId).id).to.eql(userId)
})
it('findUserByName returns user with matching screen_name', () => {
const store = useUsersStore()
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
- expect(store.findUserByName(user().screen_name).id).to.eql(userId)
+ expect(store.findUserByName(mockUser().screen_name).id).to.eql(userId)
})
it('findUserByName returns user with matching url', () => {
const store = useUsersStore()
- store.addNewUsers({ data: [user()], timestamp: 1 })
+ store.addNewUsers({ data: [mockUser()], timestamp: 1 })
- expect(store.findUserByUrl(user().url).id).to.eql(userId)
+ expect(store.findUserByUrl(mockUser().url).id).to.eql(userId)
})
})
})

File Metadata

Mime Type
text/x-diff
Expires
Mon, Nov 2, 2:26 AM (15 h, 16 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1803513
Default Alt Text
(230 KB)

Event Timeline