Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
168 KB
Referenced Files
None
Subscribers
None
diff --git a/src/api/user.js b/src/api/user.js
index cb7195f49f..ec1763bb4a 100644
--- a/src/api/user.js
+++ b/src/api/user.js
@@ -1,926 +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'
+export 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) =>
+export const MASTODON_DISMISS_NOTIFICATION_URL = (id) =>
`/api/v1/notifications/${id}/dismiss`
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`
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'
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`
export const PLEROMA_EMOJI_REACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${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_URL(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unpinOwnStatus = ({ id, credentials }) =>
promisedRequest({
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_URL(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unmuteConversation = ({ id, credentials }) =>
promisedRequest({
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/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js
index f72e3bf6c4..02d3bec343 100644
--- a/src/components/follow_request_card/follow_request_card.js
+++ b/src/components/follow_request_card/follow_request_card.js
@@ -1,103 +1,97 @@
import { defineAsyncComponent } from 'vue'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { approveUser, denyUser } from 'src/api/user.js'
const FollowRequestCard = {
props: ['user'],
components: {
BasicUserCard,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data() {
return {
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false,
}
},
methods: {
findFollowRequestNotificationId() {
const notif = useNotificationsStore().data.find(
(notif) =>
notif.from_profile.id === this.user.id &&
notif.type === 'follow_request',
)
return notif?.id
},
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)
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
- this.$store.dispatch('updateNotification', {
- id: notifId,
- updater: (notification) => {
- notification.type = 'follow'
- },
- })
this.hideApproveConfirmDialog()
},
denyUser() {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny() {
const notifId = this.findFollowRequestNotificationId()
denyUser({
id: this.user.id,
credentials: useOAuthStore().token,
}).then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
},
},
computed: {
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny() {
return this.mergedConfig.modalOnDenyFollow
},
},
}
export default FollowRequestCard
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 1004f50c9a..577a811427 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,244 +1,238 @@
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/notifications/notifications.js b/src/components/notifications/notifications.js
index 92272facf8..3f65cc65b8 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,270 +1,256 @@
import { mapState } from 'pinia'
import { computed } from 'vue'
import ExtraNotifications from 'src/components/extra_notifications/extra_notifications.vue'
import Notification from 'src/components/notification/notification.vue'
import FaviconService from '../../services/favicon_service/favicon_service.js'
import {
ACTIONABLE_NOTIFICATION_TYPES,
countExtraNotifications,
filteredNotifications,
unseenNotifications,
} from '../../services/notification_utils/notification_utils.js'
-import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import NotificationFilters from './notification_filters.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCircleNotch,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faArrowUp, faMinus)
const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30
const Notifications = {
components: {
Notification,
NotificationFilters,
ExtraNotifications,
},
props: {
// Disables panel styles, unread mark, potentially other notification-related actions
// meant for "Interactions" timeline
minimalMode: Boolean,
// Custom filter mode, an array of strings, possible values 'mention', 'status', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline
filterMode: Array,
// Do not show extra notifications
noExtra: {
type: Boolean,
default: false,
},
// Disable teleporting (i.e. for /users/user/notifications)
disableTeleport: Boolean,
},
data() {
return {
showScrollTop: false,
- bottomedOut: false,
// How many seen notifications to display in the list. The more there are,
// the heavier the page becomes. This count is increased when loading
// older notifications, and cut back to default whenever hitting "Read!".
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT,
}
},
provide() {
return {
popoversZLayer: computed(() => this.popoversZLayer),
}
},
computed: {
mainClass() {
return this.minimalMode ? '' : 'panel panel-default'
},
notifications() {
return useNotificationsStore().data
},
error() {
return useNotificationsStore().error
},
unseenNotifications() {
return unseenNotifications(
useMergedConfigStore().mergedConfig.notificationVisibility,
useMergedConfigStore().mergedConfig.ignoreInactionableSeen,
)
},
filteredNotifications() {
if (this.unseenAtTop) {
return [
...filteredNotifications(
useMergedConfigStore().mergedConfig.notificationVisibility,
).filter((n) => this.shouldShowUnseen(n)),
...filteredNotifications(
useMergedConfigStore().mergedConfig.notificationVisibility,
).filter((n) => !this.shouldShowUnseen(n)),
]
} else {
return filteredNotifications(
useMergedConfigStore().mergedConfig.notificationVisibility,
this.filterMode,
)
}
},
unseenCountBadgeText() {
return `${this.unseenCount ? this.unseenCount : ''}${this.extraNotificationsCount ? '*' : ''}`
},
unseenCount() {
return this.unseenNotifications.length
},
ignoreInactionableSeen() {
return useMergedConfigStore().mergedConfig.ignoreInactionableSeen
},
extraNotificationsCount() {
return countExtraNotifications(
this.$store,
useMergedConfigStore().mergedConfig,
useChatsStore().unreadChatsCount,
useAnnouncementsStore().unreadAnnouncementCount,
)
},
unseenCountTitle() {
return (
this.unseenNotifications.length +
this.unreadChatsCount +
this.unreadAnnouncementCount
)
},
loading() {
- return useNotificationsStore().loading
+ return useNotificationsStore().fetcher.loading.value
+ },
+ bottomedOut() {
+ return useNotificationsStore().fetcher.bottomedOut.value
},
noHeading() {
const { layoutType } = useInterfaceStore()
return this.minimalMode || layoutType === 'mobile'
},
teleportTarget() {
const map = {
wide: '#notifs-column',
mobile: '#mobile-notifications',
}
return map[this.layoutType] || '#notifs-sidebar'
},
popoversZLayer() {
const { layoutType } = useInterfaceStore()
return layoutType === 'mobile' ? 'navbar' : null
},
notificationsToDisplay() {
return this.filteredNotifications.slice(
0,
this.unseenCount + this.seenToDisplayCount,
)
},
noSticky() {
return useMergedConfigStore().mergedConfig.disableStickyHeaders
},
unseenAtTop() {
return useMergedConfigStore().mergedConfig.unseenAtTop
},
showExtraNotifications() {
return !this.noExtra
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapState(useChatsStore, ['unreadChatsCount']),
...mapState(useInterfaceStore, ['layoutType']),
},
mounted() {
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
if (!this.scrollerRef) {
this.scrollerRef = this.$refs.root.closest('.mobile-notifications')
}
if (!this.scrollerRef) {
this.scrollerRef = this.$refs.root.closest('.column.main')
}
this.scrollerRef.addEventListener('scroll', this.updateScrollPosition)
},
unmounted() {
if (!this.scrollerRef) return
this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition)
},
watch: {
unseenCountTitle(count) {
if (count > 0) {
FaviconService.drawFaviconBadge()
useInterfaceStore().setPageTitle(`(${count})`)
} else {
FaviconService.clearFaviconBadge()
useInterfaceStore().setPageTitle('')
}
},
teleportTarget() {
// handle scroller change
this.$nextTick(() => {
this.scrollerRef.removeEventListener(
'scroll',
this.updateScrollPosition,
)
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
if (!this.scrollerRef) {
this.scrollerRef = this.$refs.root.closest('.mobile-notifications')
}
this.scrollerRef.addEventListener('scroll', this.updateScrollPosition)
this.updateScrollPosition()
})
},
},
methods: {
scrollToTop() {
const scrollable = this.scrollerRef
scrollable.scrollTo({ top: this.$refs.root.offsetTop })
},
updateScrollPosition() {
this.showScrollTop =
this.$refs.root.offsetTop < this.scrollerRef.scrollTop
},
shouldShowUnseen(notification) {
if (notification.seen) return false
const actionable = ACTIONABLE_NOTIFICATION_TYPES.has(notification.type)
return this.ignoreInactionableSeen ? actionable : true
},
/* "Interacted" really refers to "actionable" notifications that require user input,
* everything else (likes/repeats/reacts) cannot be acted and therefore we just clear
* the "seen" status upon any clicks on them
*/
notificationClicked(notification) {
const { id } = notification
useNotificationsStore().notificationClicked(id)
},
notificationInteracted(notification) {
const { id } = notification
useNotificationsStore().markSingleNotificationAsSeen(id)
},
markAsSeen() {
useNotificationsStore().markNotificationsAsSeen()
this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT
},
fetchOlderNotifications() {
if (this.loading) {
return
}
const seenCount = this.filteredNotifications.length - this.unseenCount
if (this.seenToDisplayCount < seenCount) {
this.seenToDisplayCount = Math.min(
this.seenToDisplayCount + 20,
seenCount,
)
return
} else if (this.seenToDisplayCount > seenCount) {
this.seenToDisplayCount = seenCount
}
- const store = this.$store
- const credentials = useUsersStore().currentUser.credentials
- useNotificationsStore().setNotificationsLoading(true)
- notificationsFetcher
- .fetchAndUpdate({
- store,
- credentials,
- older: true,
- })
- .then((notifs) => {
- useNotificationsStore().setNotificationsLoading(false)
- if (notifs.length === 0) {
- this.bottomedOut = true
- }
- this.seenToDisplayCount += notifs.length
- })
+ useNotificationsStore().fetcher.fetchOlder()
},
},
}
export default Notifications
diff --git a/src/components/status/status.js b/src/components/status/status.js
index f3e3ff1542..7c8400bdce 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,583 +1,586 @@
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 { useScrobblesStore } from 'src/stores/scrobbles.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,
}
},
created() {
useScrobblesStore().getLatestScrobble(this.status.user.id)
},
computed: {
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
},
repeater() {
return useUsersStore().findUser(this.status.user.id)
},
user() {
return useUsersStore().findUser(this.mainStatus.user.id)
},
showReasonMutedThread() {
return (
(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() {
return highlightClass(this.repeater)
},
userClass() {
return highlightClass(this.user)
},
deleted() {
return this.status.deleted
},
repeaterStyle() {
- const user = this.status.user
- return highlightStyle(useUserHighlightStore().get(this.repeater.screen_name))
+ return highlightStyle(
+ useUserHighlightStore().get(this.repeater.screen_name),
+ )
},
userStyle() {
if (this.noHeading) return
return highlightStyle(useUserHighlightStore().get(this.user.screen_name))
},
userProfileLink() {
- return this.generateUserProfileLink(
- this.user.id,
- this.user.screen_name,
- )
+ return this.generateUserProfileLink(this.user.id, this.user.screen_name)
},
replyProfileLink() {
if (this.isReply) {
- const user = useUsersStore().findUser(this.mainStatus.in_reply_to_user_id)
+ const user = useUsersStore().findUser(
+ this.mainStatus.in_reply_to_user_id,
+ )
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet() {
return !!this.status.retweeted_status
},
repeaterName() {
return this.status.user.name || this.status.user.screen_name_ui
},
repeaterHtml() {
return this.status.user.name
},
repeaterProfileLink() {
return this.generateUserProfileLink(
this.repeater.id,
this.repeater.screen_name,
)
},
mainStatus() {
if (this.retweet) {
return this.status.retweeted_status
} else {
return this.status
}
},
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.mainStatus.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.mainStatus.in_reply_to_user_id &&
// no self-replies
attn.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.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.status.user.id === this.currentUser?.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
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 { reblog } = this.mainStatus
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && this.mainStatus.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 && this.mainStatus.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.mainStatus.in_reply_to_status_id && this.mainStatus.in_reply_to_user_id
+ this.mainStatus.in_reply_to_status_id &&
+ this.mainStatus.in_reply_to_user_id
)
},
replyToName() {
if (this.mainStatus.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
- const user = useUsersStore().findUser(this.mainStatus.in_reply_to_user_id)
+ const user = useUsersStore().findUser(
+ this.mainStatus.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.mainStatus.favoritedBy,
this.mainStatus.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.mainStatus.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.mainStatus.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)
},
'mainStatus.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.focused &&
this.mainStatus.rebloggedBy &&
this.mainStatus.rebloggedBy.length !== num
) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'mainStatus.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (
this.focused &&
this.mainStatus.favoritedBy &&
this.mainStatus.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (suspend) {
this.$emit('suspendableStateChange', { id: this.status.id, suspend })
},
},
}
export default Status
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 38bf1387cb..a76438ad0f 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,290 +1,287 @@
import { debounce, throttle } from 'lodash'
import { mapState } from 'pinia'
import Conversation from 'src/components/conversation/conversation.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 ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
-import { useStatusesStore } from 'src/stores/statuses.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useStatusesStore } from 'src/stores/statuses.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: {
timelineRef: Object,
count: Number,
footerSlipgate: Object, // reference to an element where we should put our footer
embedded: Boolean,
inProfile: Boolean,
skipPinned: Boolean,
},
data() {
return {
showScrollTop: false,
paused: false,
unfocused: false,
virtualScrollIndex: 0,
blockingClicks: false,
}
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
computed: {
timeline() {
return useTimelinesStore()[this.timelineRef.name]
},
filteredVisibleStatuses() {
return this.timeline.order
.filter((id) => this.timeline.visibleStatusIds.has(id))
.map((id) => useStatusesStore().allStatuses.get(id))
.filter(({ pinned }) => (this.skipPinned ? !pinned : true))
},
newStatusCount() {
return this.timeline.newStatusCount
},
showLoadButton() {
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
},
loadButtonString() {
if (this.timeline.flushMarker !== 0) {
return this.$t('timeline.reload')
} else {
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
mobileLoadButtonString() {
if (this.timeline.flushMarker !== 0) {
return '+'
} else {
return this.newStatusCount > 99 ? '∞' : this.newStatusCount
}
},
classes() {
let rootClasses = !this.embedded
? ['panel', 'panel-default']
: ['-embedded']
if (this.blockingClicks)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!this.embedded ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!this.embedded ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!this.embedded ? ['panel-footer'] : ['panel-body'],
),
}
},
statusesToDisplay() {
const amount = this.timeline.visibleStatusIds.size
const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
const min = Math.max(0, this.virtualScrollIndex - statusesPerSide)
const max = Math.min(amount, this.virtualScrollIndex + statusesPerSide)
- return new Set(
- this.timeline.order.slice(min, max),
- )
+ return new Set(this.timeline.order.slice(min, max))
},
virtualScrollingEnabled() {
return useMergedConfigStore().mergedConfig.virtualScrolling
},
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
created() {
this.timelineChange(this.timelineRef)
},
mounted() {
if (document.hidden !== undefined) {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.unfocused = document.hidden
}
window.addEventListener('keydown', this.handleShortKey)
window.addEventListener('scroll', this.handleScroll)
setTimeout(this.determineVisibleStatuses, 250)
},
unmounted() {
this.timelineChange(null, this.timelineRef)
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('keydown', this.handleShortKey)
if (document.hidden !== undefined)
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
},
methods: {
timelineChange(newTimeline, oldTimeline) {
const sameName = newTimeline?.name === oldTimeline?.name
const sameArgument = newTimeline?.argument === oldTimeline?.argument
if (sameName && sameArgument) return
if (oldTimeline) {
useTimelinesStore().deactivate(oldTimeline.name)
}
if (newTimeline) {
useTimelinesStore().activate(newTimeline.name, newTimeline.argument)
}
},
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
blockClicksTemporarily() {
if (!this.blockingClicks) {
this.blockingClicks = true
}
this.stopBlockingClicks()
},
handleShortKey(e) {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses() {
if (this.timeline.flushMarker !== 0) {
useTimelinesStore().clearTimeline(this.timelineRef.name)
useTimelinesStore().queueFlush(this.timelineRef.name, '')
this.fetchOlderStatuses()
} else {
this.blockClicksTemporarily()
useTimelinesStore().showNewStatuses(this.timelineRef.name)
this.paused = false
}
window.scrollTo({ top: 0 })
},
fetchOlderStatuses: throttle(
function () {
this.timeline.fetcher.fetchOlder()
},
1000,
this,
),
determineVisibleStatuses() {
if (!this.$refs.timeline) return
if (!this.virtualScrollingEnabled) return
const statuses = this.$refs.timeline.children
const cappedScrollIndex = Math.max(
0,
Math.min(this.virtualScrollIndex, statuses.length - 1),
)
if (statuses.length === 0) return
const height = Math.max(document.body.offsetHeight, window.pageYOffset)
const centerOfScreen = window.pageYOffset + window.innerHeight * 0.5
// Start from approximating the index of some visible status by using the
// the center of the screen on the timeline.
let approxIndex = Math.min(
Math.floor(statuses.length * (centerOfScreen / height)),
statuses.length - 1,
)
let err = statuses[approxIndex].getBoundingClientRect().y
// if we have a previous scroll index that can be used, test if it's
-
const virtualScrollIndexY =
statuses[cappedScrollIndex].getBoundingClientRect().y
if (Math.abs(err) > virtualScrollIndexY) {
approxIndex = cappedScrollIndex
err = virtualScrollIndexY
}
// if the status is too far from viewport, check the next/previous ones if
// they happen to be better
while (err < -20 && approxIndex < statuses.length - 1) {
err += statuses[approxIndex].offsetHeight
approxIndex++
}
while (err > window.innerHeight + 100 && approxIndex > 0) {
approxIndex--
err -= statuses[approxIndex].offsetHeight
}
// this status is now the center point for virtual scrolling and visible
// statuses will be nearby statuses before and after it
this.virtualScrollIndex = approxIndex
},
scrollLoad() {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
!this.timeline.fetcher.loading.value &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchOlderStatuses()
}
},
handleScroll: throttle(function (e) {
this.determineVisibleStatuses()
this.scrollLoad(e)
}, 200),
handleVisibilityChange() {
this.unfocused = document.hidden
},
},
watch: {
timelineRef(newTimeline, oldTimeline) {
this.timelineChange(newTimeline, oldTimeline)
},
filteredVisibleStatuses() {
this.determineVisibleStatuses()
},
newStatusCount(count) {
if (!useMergedConfigStore().mergedConfig.streaming) {
return
}
if (count > 0) {
// only 'stream' them when you're scrolled to the top
const doc = document.documentElement
const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
if (
top < 15 &&
!this.paused &&
!(
this.unfocused &&
useMergedConfigStore().mergedConfig.pauseOnUnfocused
)
) {
this.showNewStatuses()
} else {
this.paused = true
}
}
},
},
}
export default Timeline
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
deleted file mode 100644
index 30f973ea58..0000000000
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ /dev/null
@@ -1,143 +0,0 @@
-import { promiseInterval } from '../promise_interval/promise_interval.js'
-
-import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
-import { useInterfaceStore } from 'src/stores/interface.js'
-import { useMergedConfigStore } from 'src/stores/merged_config.js'
-import { useNotificationsStore } from 'src/stores/notifications.js'
-
-import { fetchTimeline } from 'src/api/timelines.js'
-
-// For using include_types when fetching notifications.
-// Note: chat_mention excluded as pleroma-fe polls them separately
-const mastoApiNotificationTypes = new Set([
- 'mention',
- 'status',
- 'favourite',
- 'reblog',
- 'follow',
- 'follow_request',
- 'move',
- 'poll',
- 'pleroma:emoji_reaction',
- 'pleroma:report',
-])
-
-const fetchAndUpdate = ({ credentials }, { older = false, sinceId }) => {
- useNotificationsStore().setLoading(true)
- const args = { credentials }
- const timelineData = useNotificationsStore()
- const hideMutedPosts = useMergedConfigStore().mergedConfig.hideMutedPosts
-
- if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
- mastoApiNotificationTypes.add('pleroma:chat_mention')
- }
-
- args.includeTypes = [...mastoApiNotificationTypes]
- args.withMuted = !hideMutedPosts
-
- args.timeline = 'notifications'
- if (older) {
- if (timelineData.minId !== Number.POSITIVE_INFINITY) {
- args.maxId = timelineData.minId
- }
- return fetchNotifications({ args, older })
- } else {
- // fetch new notifications
- if (
- sinceId === undefined &&
- timelineData.maxId !== Number.POSITIVE_INFINITY
- ) {
- args.sinceId = timelineData.maxId
- } else if (sinceId !== null) {
- args.sinceId = sinceId
- }
- const result = fetchNotifications({ args, older })
-
- // If there's any unread notifications, try fetch notifications since
- // the newest read notification to check if any of the unread notifs
- // have changed their 'seen' state (marked as read in another session), so
- // we can update the state in this session to mark them as read as well.
- // The normal maxId-check does not tell if older notifications have changed
- const notifications = timelineData.data
- const readNotifsIds = notifications.filter((n) => n.seen).map((n) => n.id)
- const unreadNotifsIds = notifications
- .filter((n) => !n.seen)
- .map((n) => n.id)
-
- if (readNotifsIds.length > 0 && unreadNotifsIds.length > 0) {
- const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification
- if (minId !== Infinity) {
- args.sinceId = null // Don't use since_id since it sorta conflicts with min_id
- args.minId = minId - 1 // go beyond
- fetchNotifications({ args, older })
- }
- }
-
- return result
- }
-}
-
-const fetchNotifications = ({ args, older }) => {
- return fetchTimeline(args)
- .then((response) => {
- const notifications = response.data
-
- useNotificationsStore().addNewNotifications(response)
-
- return notifications
- })
- .catch((error) => {
- if (
- error.statusCode === 400 &&
- error.statusText.includes('Invalid value for enum')
- ) {
- error.statusText
- .matchAll(/(\w+) - Invalid value for enum./g)
- .toArray()
- .map((x) => x[1])
- .forEach((x) => mastoApiNotificationTypes.delete(x))
- return fetchNotifications({ args, older })
- }
-
- console.error('Notifications Error', error)
- useInterfaceStore().pushGlobalNotice({
- level: 'error',
- messageKey: 'notifications.error',
- messageArgs: [error.message],
- timeout: 5000,
- })
- })
- .finally(() => {
- useNotificationsStore().setLoading(false)
- })
-}
-
-const notificationsFetcher = (credentials) => {
- const state = {
- interval: null,
- }
-
- const boundFetchAndUpdate = ({ older = false, sinceId } = {}) =>
- fetchAndUpdate({ credentials }, { older, sinceId })
-
- const startFetching = () => {
- if (state.interval) throw new Error('Interval already exists!')
-
- boundFetchAndUpdate()
-
- state.interval = promiseInterval(boundFetchAndUpdate, 10000)
- }
-
- const stopFetching = () => {
- state.interval.stop()
- state.interval = null
- }
-
- return {
- startFetching,
- stopFetching,
- fetchAndUpdate: boundFetchAndUpdate,
- }
-}
-
-export default notificationsFetcher
diff --git a/src/stores/fetchers/notifications_fetcher.js b/src/stores/fetchers/notifications_fetcher.js
new file mode 100644
index 0000000000..a31877072e
--- /dev/null
+++ b/src/stores/fetchers/notifications_fetcher.js
@@ -0,0 +1,143 @@
+import { ref } from 'vue'
+
+import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useNotificationsStore } from 'src/stores/notifications.js'
+
+import { fetchTimeline } from 'src/api/timelines.js'
+import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
+
+// For using include_types when fetching notifications.
+// Note: chat_mention excluded as pleroma-fe polls them separately
+const mastoApiNotificationTypes = new Set([
+ 'mention',
+ 'status',
+ 'favourite',
+ 'reblog',
+ 'follow',
+ 'follow_request',
+ 'move',
+ 'poll',
+ 'pleroma:emoji_reaction',
+ 'pleroma:report',
+])
+
+const notificationsFetcher = (credentials) => {
+ const interval = ref(null)
+ const loading = ref(false)
+ const bottomedOut = ref(false)
+
+ const fetchNotifications = async ({ args, older }) => {
+ loading.value = true
+
+ try {
+ const response = await fetchTimeline(args)
+ const notifications = response.data
+ if (older && notifications.length === 0) bottomedOut.value = true
+
+ useNotificationsStore().addNewNotifications(response)
+ } catch (error) {
+ if (
+ error.statusCode === 400 &&
+ error.statusText.includes('Invalid value for enum')
+ ) {
+ error.statusText
+ .matchAll(/(\w+) - Invalid value for enum./g)
+ .toArray()
+ .map((x) => x[1])
+ .forEach((x) => mastoApiNotificationTypes.delete(x))
+
+ // Retry
+ return await fetchNotifications({ args, older })
+ }
+
+ console.error('Notifications Error', error)
+ useInterfaceStore().pushGlobalNotice({
+ level: 'error',
+ messageKey: 'notifications.error',
+ messageArgs: [error.message],
+ timeout: 5000,
+ })
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const fetchAndUpdate = async ({ older = false, sinceId } = {}) => {
+ const args = { credentials }
+ const timelineData = useNotificationsStore()
+ const hideMutedPosts = useMergedConfigStore().mergedConfig.hideMutedPosts
+
+ if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
+ mastoApiNotificationTypes.add('pleroma:chat_mention')
+ }
+
+ args.includeTypes = [...mastoApiNotificationTypes]
+ args.withMuted = !hideMutedPosts
+
+ args.timeline = 'notifications'
+ if (older) {
+ if (timelineData.minId !== Number.POSITIVE_INFINITY) {
+ args.maxId = timelineData.minId
+ }
+ return await fetchNotifications({ args, older })
+ } else {
+ // fetch new notifications
+ if (
+ sinceId === undefined &&
+ timelineData.maxId !== Number.POSITIVE_INFINITY
+ ) {
+ args.sinceId = timelineData.maxId
+ } else if (sinceId !== null) {
+ args.sinceId = sinceId
+ }
+ const result = await fetchNotifications({ args, older })
+
+ // If there's any unread notifications, try fetch notifications since
+ // the newest read notification to check if any of the unread notifs
+ // have changed their 'seen' state (marked as read in another session), so
+ // we can update the state in this session to mark them as read as well.
+ // The normal maxId-check does not tell if older notifications have changed
+ const notifications = timelineData.data
+ const readNotifsIds = notifications.filter((n) => n.seen).map((n) => n.id)
+ const unreadNotifsIds = notifications
+ .filter((n) => !n.seen)
+ .map((n) => n.id)
+
+ if (readNotifsIds.length > 0 && unreadNotifsIds.length > 0) {
+ const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification
+ if (minId !== Infinity) {
+ args.sinceId = null // Don't use since_id since it sorta conflicts with min_id
+ args.minId = minId - 1 // go beyond
+ fetchNotifications({ args, older })
+ }
+ }
+
+ return result
+ }
+ }
+
+ const startFetching = () => {
+ if (interval.value) throw new Error('Interval already exists!')
+
+ fetchAndUpdate()
+
+ interval.value = promiseInterval(fetchAndUpdate, 10000)
+ }
+
+ const stopFetching = () => {
+ interval.value.stop()
+ interval.value = null
+ }
+
+ return {
+ loading,
+ bottomedOut,
+ startFetching,
+ stopFetching,
+ fetchOlder: () => fetchAndUpdate({ older: true }),
+ }
+}
+
+export default notificationsFetcher
diff --git a/src/stores/fetchers/timeline_fetcher.js b/src/stores/fetchers/timeline_fetcher.js
index 1b75244430..25e6e895f4 100644
--- a/src/stores/fetchers/timeline_fetcher.js
+++ b/src/stores/fetchers/timeline_fetcher.js
@@ -1,120 +1,115 @@
import { ref } from 'vue'
-import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { ARGUMENT_MAP, useTimelinesStore } from 'src/stores/timelines.js'
import { useUsersStore } from 'src/stores/users.js'
import { fetchTimeline } from 'src/api/timelines.js'
+import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
const REPLY_VISIBILITY_TIMELINES = new Set([
'friends',
'public',
'publicAndExternal',
'bubble',
])
const timelineFetcher = (timeline, argument, credentials) => {
const loading = ref(false)
const bottomedOut = ref(false)
const interval = ref(null)
const fetchAndUpdate = ({ older = false, showImmediately = false } = {}) => {
loading.value = true
const { hideMutedPosts, replyVisibility } =
useMergedConfigStore().mergedConfig
const loggedIn = useUsersStore().loggedIn
const args = { timeline: timeline.name, credentials }
const mainArg = ARGUMENT_MAP[timeline.name]
if (mainArg) args[mainArg] = argument
if (older) {
// When minId = 0 we need to fetch without maxId param
args.maxId = timeline.minId || null
} else {
args.sinceId = timeline.maxId || null
}
args.withMuted = !hideMutedPosts
if (loggedIn && REPLY_VISIBILITY_TIMELINES.has(timeline)) {
args.replyVisibility = replyVisibility
}
const numStatusesBeforeFetch = timeline.statusIds.size
return fetchTimeline(args)
.then(({ data: statuses, pagination, timestamp }) => {
- if (
- !older &&
- statuses.length >= 20 &&
- numStatusesBeforeFetch > 0
- ) {
+ if (!older && statuses.length >= 20 && numStatusesBeforeFetch > 0) {
useTimelinesStore().queueFlush(timeline.name, timeline.maxId)
}
if (older && statuses.length === 0) {
bottomedOut.value = true
}
const processed = useStatusesStore()
.addNewStatuses({ statuses, timestamp })
.map(({ id }) => id)
useTimelinesStore().addStatusesToTimeline(timeline.name, argument, {
statuses: processed,
showImmediately,
older,
pagination,
})
return { statuses, pagination }
})
.catch((error) => {
if (error.statusCode === 403 && timeline === 'favorites') {
useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable = false
return
}
console.error('Timeline Error', error)
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'timeline.error',
messageArgs: [error.message],
timeout: 5000,
})
})
.finally(() => {
loading.value = false
})
}
const startFetching = () => {
if (interval.value) throw new Error('Interval already exists!')
fetchAndUpdate({
showImmediately: timeline.visibleStatusIds.size === 0,
})
interval.value = promiseInterval(fetchAndUpdate, 10000)
}
const stopFetching = () => {
interval.value.stop()
interval.value = null
}
return {
startFetching,
stopFetching,
fetchOlder: () => fetchAndUpdate({ showImmediately: true, older: true }),
- fetchNewer: () => fetchAndUpdate({ showImmediately: true, older: false }),
loading,
bottomedOut,
}
}
export default timelineFetcher
diff --git a/src/stores/notifications.js b/src/stores/notifications.js
index bc516d3bdc..cfdccac2cc 100644
--- a/src/stores/notifications.js
+++ b/src/stores/notifications.js
@@ -1,263 +1,287 @@
import { defineStore } from 'pinia'
+import notificationsFetcher from 'src/stores/fetchers/notifications_fetcher.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { dismissNotification, markNotificationsAsSeen } from 'src/api/user.js'
import {
closeAllDesktopNotifications,
closeDesktopNotification,
} from 'src/services/desktop_notification_utils/desktop_notification_utils.js'
import {
isValidNotification,
maybeShowNotification,
} from 'src/services/notification_utils/notification_utils.js'
import { isStatusNotification } from 'src/services/notification_utils/notification_utils_sw.js'
-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,
+ statusIdStore: new Set(),
socket: null,
streaming: false,
fetcher: null,
})
export const useNotificationsStore = defineStore('notifications', {
state: defaultState,
actions: {
// Init
attachSocket() {
const et = new EventTarget()
const socket = { et }
et.addEventListener('notification', this.addNewNotifications)
et.addEventListener('open', this.onStreamConnect)
et.addEventListener('close', this.onStreamDisconnect)
useStreamingStore().addSubscriber(socket)
this.socket = socket
},
activate() {
this.attachSocket()
// Initially there's set flag to silence all desktop notifications so
// that there won't spam of them when user just opened up the FE we
// reset that flag after a while to show new notifications once again.
setTimeout(() => (this.desktopNotificationSilence = false), 10000)
if (this.fetcher) throw new Error('Fetcher already exists!')
this.fetcher = notificationsFetcher(useOAuthStore().token)
this.startFetching('Notifications activated')
},
deactivate() {
if (!this.streaming) {
this.stopFetching('Notifications deactivated')
}
useStreamingStore().removeSubscriber(this.socket)
const { et } = this.socket
et.removeEventListener('notification', this.addNewNotifications)
et.removeEventListener('open', this.onStreamConnect)
et.removeEventListener('close', this.onStreamDisconnect)
const blankState = defaultState()
Object.keys(blankState).forEach((k) => {
this[k] = blankState[k]
})
},
// Poll & Push
onStreamConnect() {
console.debug('[Notifications] Notifications stream connected')
this.streaming = true
this.stopFetching('Socket connected')
},
onStreamDisconnect() {
console.debug('[Notifications] Notifications stream disconnected')
this.streaming = false
this.startFetching('Socket disconnected')
},
startFetching(reason) {
console.debug(
'[Notifications] Starting fetching notifications',
'Reason:',
reason,
)
this.fetcher.startFetching()
},
stopFetching(reason) {
console.debug(
'[Notifications] Stopped fetching notifications',
'Reason:',
reason,
)
this.fetcher.stopFetching()
},
// Updates
- 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)
+ updateExtremes(id) {
+ if (this.maxId === '' || id > this.maxId) {
+ this.maxId = id
+ }
+ if (this.minId === '' || id < this.minId) {
+ this.minId = id
+ }
},
- addNewNotifications(result) {
- const { timestamp, data: notifications } = result
+ addNewNotifications(result, older) {
+ const { timestamp, data } = result
+
+ const notifications = older
+ ? data
+ : [...data].reverse()
useUsersStore().addNewUsers({
timestamp,
data: notifications.map((n) => n.from_profile),
})
- notifications.forEach(
- (n) => (n.from_profile = useUsersStore().findUser(n.from_profile.id)),
- )
+
+ 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)
+ this.updateExtremes(notification.id)
return false
}
return true
})
useUsersStore().addNewUsers({
timestamp,
data: validNotifications.map(
(notification) => notification.from_profile,
),
})
const statusNotifications = validNotifications.filter(
(notification) =>
isStatusNotification(notification.type) && notification.status,
)
// Synchronous commit to add all the statuses
useStatusesStore().addNewStatuses({
timestamp,
statuses: statusNotifications.map(
(notification) => notification.status,
),
})
// Update references to statuses in notifications to ones in the store
statusNotifications.forEach((notification) => {
const id = notification.status.id
const referenceStatus = useStatusesStore().allStatuses.get(id)
if (referenceStatus) {
notification.status = referenceStatus
}
})
validNotifications.forEach((notification) => {
if (notification.type === 'pleroma:report') {
useReportsStore().addReport(notification.report)
}
if (notification.type === 'pleroma:emoji_reaction') {
useStatusesStore().fetchEmojiReactions(notification.status.id)
}
// Only add a new notification if we don't have one for the same action
if (!this.idStore.has(notification.id)) {
- this.updateNotificationsMinMaxId(notification.id)
+ this.updateExtremes(notification.id)
- notifications.forEach((notification) => {
+ if (older) {
this.data.push(notification)
- this.idStore.set(notification.id, notification)
- })
+ } else {
+ this.data.unshift(notification)
+ }
+ this.idStore.set(notification.id, notification)
this.statusNotificationRelations.set(
notification.status,
this.idStore.get(notification.id),
)
maybeShowNotification(
useMergedConfigStore().mergedConfig.notificationVisibility,
- Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters),
+ Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters ?? {}),
notification,
useI18nStore().i18n,
)
} else if (notification.seen) {
this.idStore.get(notification.id).seen = true
}
})
},
// Seen / Dismiss
notificationClicked(id) {
const notification = this.idStore.get(id)
const { type, seen } = notification
if (!seen) {
switch (type) {
case 'mention':
case 'pleroma:report':
case 'follow_request':
break
default:
this.markSingleNotificationAsSeen({ id })
}
}
},
markNotificationsAsSeen() {
this.data.forEach((notification) => {
notification.seen = true
})
markNotificationsAsSeen({
id: this.maxId,
- credentials: useUsersStore().currentUser.credentials,
+ credentials: useOAuthStore().token,
}).then(() => {
closeAllDesktopNotifications()
})
},
- markSingleNotificationAsSeen({ id }) {
+ markSingleNotificationAsSeen(id) {
const notification = this.idStore.get(id)
if (notification) notification.seen = true
markNotificationsAsSeen({
single: true,
id,
- credentials: useUsersStore().currentUser.credentials,
+ credentials: useOAuthStore().token,
}).then(() => {
closeDesktopNotification(id)
})
},
dismissNotificationLocal(id) {
- this.data = this.data.filter((n) => n.id !== id)
- delete this.idStore.delete(id)
+ this.idStore.delete(id)
+ this.syncOrder()
},
dismissNotification(id) {
this.dismissNotificationLocal(id)
dismissNotification({
id,
credentials: useOAuthStore().token,
})
},
+ syncOrder() {
+ this.minId = ''
+ this.maxId = ''
+ this.data = this.data.filter(({ id }) => {
+ const present = this.idStore.has(id)
+ if (present) {
+ this.updateExtremes(id) // Side-effect
+ }
+ return present
+ })
+ },
+ wipeStatuses(ids) {
+ const set = new Set(ids)
+ this.data.forEach((notification) => {
+ const status = isStatusNotification(notification.type) && notification.status
+ if (status && set.has(status.id)) {
+ this.idStore.delete(notification.id)
+ }
+ })
- // Misc
- setLoading(value) {
- this.loading = value
+ this.syncOrder()
},
},
})
diff --git a/src/stores/search.js b/src/stores/search.js
index 06fe736343..ad85712c4c 100644
--- a/src/stores/search.js
+++ b/src/stores/search.js
@@ -1,55 +1,55 @@
import { defineStore } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUsersStore } from 'src/stores/users.js'
-import { search2 } from 'src/api/public.js'
+import { search2, searchUsers } from 'src/api/public.js'
export const useSearchStore = defineStore('search', {
actions: {
async search({ q, resolve, limit, offset, following, type }) {
const { data, ...rest } = await search2({
q,
resolve,
limit,
offset,
following,
type,
credentials: useOAuthStore().token,
})
const { accounts, statuses } = data
useUsersStore().addNewUsers({
...rest,
data: accounts,
})
useStatusesStore().addNewStatuses({
...rest,
statuses,
})
const output = {}
output.statuses = statuses.map((s) =>
useStatusesStore().allStatuses.get(s.id),
)
output.accounts = accounts.map((s) => useUsersStore().findUser(s.id))
return output
},
// Search
searchUsers({ query }) {
return searchUsers({
query,
credentials: useOAuthStore().token,
}).then((result) => {
const { data } = result
useUsersStore().addNewUsers(result)
return data.map((s) => useUsersStore().findUser(s.id))
})
},
},
})
diff --git a/src/stores/statuses.js b/src/stores/statuses.js
index f63efd35c4..91a3448a80 100644
--- a/src/stores/statuses.js
+++ b/src/stores/statuses.js
@@ -1,546 +1,545 @@
import { defineStore } from 'pinia'
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,
fetchStatus,
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,
})
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) => {
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).add(status.id)
} else {
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)
},
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,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) =>
this.addNewStatuses({ statuses: [status], timestamp }),
)
},
fetchStatusSource(id) {
return fetchStatusSource({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
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
},
updateStatusWithPoll(id, poll) {
const status = this.allStatuses.get(id)
status.poll = poll
},
// 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]
: 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: 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: 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((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) {
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
},
// For when blocking a user
wipeUserStatuses(userId) {
const removed = new Set()
this.allStatuses.forEach((status) => {
if (status.user.id === userId) {
this.allStatuses.delete(status.id)
removed.add(status.id)
}
})
return removed
},
// Misc
setVirtualHeight({ statusId, height }) {
this.allStatuses.get(statusId).virtualHeight = height
},
},
})
diff --git a/src/stores/timelines.js b/src/stores/timelines.js
index 9a329cdff9..df1c41a69b 100644
--- a/src/stores/timelines.js
+++ b/src/stores/timelines.js
@@ -1,316 +1,310 @@
-import { first, last, max, min } from 'lodash'
+import { first, last } from 'lodash'
import { defineStore } from 'pinia'
+import timelineFetcher from 'src/stores/fetchers/timeline_fetcher.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useOAuthStore } from 'src/stores/oauth.js'
-import { useStatusesStore } from 'src/stores/statuses.js'
import { TIMELINE_STREAM_MAP, useStreamingStore } from 'src/stores/streaming.js'
-import { useUsersStore } from 'src/stores/users.js'
-
-import timelineFetcher from 'src/stores/fetchers/timeline_fetcher.js'
const emptyTl = (name, argument = null) => {
const result = {
name,
order: [],
statusIds: new Set(),
visibleStatusIds: new Set(),
newStatusCount: 0,
maxId: '',
minId: '',
streaming: false,
flushMarker: 0,
fetcher: null,
socket: null,
}
const property = ARGUMENT_MAP[name]
if (property) {
result[property] = argument
}
if (name === 'dms' || name === 'friends') {
result.persistent = true
}
return result
}
export const ARGUMENT_MAP = {
tag: 'tag',
list: 'listId',
bookmarks: 'bookmarkFolderId',
quotes: 'statusId',
search: 'query',
user: 'userId',
userPinned: 'userId',
media: 'userId',
}
const TIMELINES = new Set([
'mentions',
'public',
'user',
'userPinned',
'media',
'favorites',
'publicAndExternal',
'friends',
'tag',
'dms',
'bookmarks',
'list',
'bubble',
'quotes',
'search',
])
export const defaultState = () => {
return Object.fromEntries([...TIMELINES].map((name) => [name, emptyTl(name)]))
}
//const CUSTOM_SORT = new Set(['bookmarks', 'favorites'])
export const useTimelinesStore = defineStore('timelines', {
state: defaultState,
actions: {
// (De)Initialization stuff
activate(timelineName, argument, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (
timelineName === 'favourites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
) {
console.warn("Instance doesn't support public favorites timeline")
return
}
const property = ARGUMENT_MAP[timelineName]
if (property) {
timeline[property] = argument
}
timeline.fetcher = timelineFetcher(
timeline,
argument,
useOAuthStore().token,
)
this.startFetchingTimeline(timelineName, argument, 'Timeline activated')
const streamName = TIMELINE_STREAM_MAP[timelineName]
if (streamName) {
const et = new EventTarget()
const openHandler = () => this.onStreamConnect(timelineName, argument)
const closeHandler = () =>
this.onStreamDisconnect(timelineName, argument)
const messageHandler =
() =>
({ detail: message }) =>
this.onStreamMessage(timelineName, argument, message)
et.addEventListener('open', openHandler)
et.addEventListener('close', closeHandler)
et.addEventListener('update', messageHandler)
timeline.socket = {
stream: {
name: streamName,
argument,
},
et,
handlers: {
openHandler,
closeHandler,
messageHandler,
},
}
useStreamingStore().addSubscriber(timeline.socket)
}
},
deactivate(timelineName, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (!timeline.streaming) {
this.stopFetchingTimeline(timelineName, 'Timeline deactivation')
}
if (timeline.socket) {
useStreamingStore().removeSubscriber(timeline.socket)
const { openHandler, closeHandler, messageHandler } =
timeline.socket.handlers
timeline.socket.et.removeEventListener('open', openHandler)
timeline.socket.et.removeEventListener('close', closeHandler)
timeline.socket.et.removeEventListener('message', messageHandler)
}
this[timelineName] = emptyTl(timelineName)
},
activatePersistents() {
TIMELINES.forEach((name) => {
if (this[name].persistent) {
this.activate(name, undefined, true)
}
})
},
deactivateAll() {
TIMELINES.forEach((name) => {
try {
this.deactivate(name, true)
} catch (e) {
- console.error(`Failed to deactivate timeline ${name}`)
+ console.error(`Failed to deactivate timeline ${name}:`, e)
}
})
},
// Update stuff
addStatusesToTimeline(
timelineName,
argument,
{
statuses,
showImmediately = false,
noIdUpdate = false,
pagination = {},
- older = false
+ older = false,
},
) {
if (statuses.length === 0) return
const timeline = this[timelineName]
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
// Same can happen with tags etc.
const property = ARGUMENT_MAP[timelineName]
if (property && timeline[property] !== argument) {
return
}
if (!noIdUpdate) {
- this.updateTimelineExtremes(
- timeline,
- pagination,
- )
+ this.updateTimelineExtremes(timeline, pagination)
}
const filtered = statuses.filter((id) => !timeline.statusIds.has(id))
if (older) {
timeline.order.push(...filtered)
} else {
timeline.order.unshift(...filtered)
}
statuses.forEach((statusId) => {
const isNew = !timeline.statusIds.has(statusId)
timeline.statusIds.add(statusId)
if (isNew) {
if (showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
timeline.visibleStatusIds.add(statusId)
} else {
// Just change newStatuscount
timeline.newStatusCount += 1
}
}
})
},
onStreamMessage(timeline, argument, event) {
this.addStatusesToTimeline(timeline, argument, {
statuses: [event.data.status.id],
})
},
// Poll & Push
onStreamConnect(timeline) {
console.debug('[Timelines] Stream connected', timeline)
this[timeline].streaming = true
this.stopFetchingTimeline(timeline, 'Socket connected')
},
onStreamDisconnect(timeline, argument) {
console.debug('[Timelines] Stream disconnected', timeline, argument)
this[timeline].streaming = false
this.startFetchingTimeline(timeline, argument, 'Socket disconnected')
},
startFetchingTimeline(timelineName, argument, reason) {
console.debug(
'[Timelines] Starting fetching timeline',
timelineName,
argument,
'Reason:',
reason,
)
const timeline = this[timelineName]
timeline.fetcher.startFetching()
},
stopFetchingTimeline(timelineName, reason) {
console.debug(
'[Timelines] Stopped fetching timeline',
timelineName,
'Reason:',
reason,
)
const timeline = this[timelineName]
timeline.fetcher.stopFetching()
},
// Queues & Timeline manip
updateTimelineExtremes(timeline, pagination = {}) {
// Can't use Math.min/max because it doesn't work with string (duh)
const minNew = pagination.maxId ?? last(timeline.order) ?? ''
const maxNew = pagination.minId ?? first(timeline.order) ?? ''
const newer = maxNew > timeline.maxId
const older = minNew < timeline.minId
if (newer || timeline.maxId === '') {
timeline.maxId = maxNew
}
if (older || timeline.minId === '') {
timeline.minId = minNew
}
this.syncOrder(timeline)
},
showNewStatuses(timelineName) {
const timeline = this[timelineName]
timeline.newStatusCount = 0
timeline.visibleStatusIds = new Set([...timeline.statusIds])
},
syncOrder(timeline) {
timeline.order = timeline.order.filter((id) => timeline.statusIds.has(id))
},
queueFlush(timeline, id) {
this[timeline].flushMarker = id
},
queueFlushAll() {
Object.keys(this).forEach((timeline) => {
this[timeline].flushMarker = this[timeline].maxId
})
},
// Misc
wipeStatuses(ids) {
TIMELINES.forEach((timelineName) => {
const timeline = this[timelineName]
ids.forEach((id) => {
timeline.statusIds.delete(id)
timeline.visibleStatusIds.delete(id)
})
this.syncOrder(timeline)
})
},
},
})
diff --git a/src/stores/users.js b/src/stores/users.js
index 98589f0206..10d9222c9e 100644
--- a/src/stores/users.js
+++ b/src/stores/users.js
@@ -1,753 +1,752 @@
import Cookies from 'js-cookie'
import { last } from 'lodash'
import { defineStore } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { 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 { revokeToken } from 'src/api/oauth.js'
import {
fetchFollowers,
fetchFriends,
fetchUser,
fetchUserByName,
- searchUsers,
verifyCredentials,
} from 'src/api/public.js'
import {
blockUser,
editUserNote,
fetchBlocks,
fetchDomainMutes,
fetchMutes,
fetchUserInLists,
fetchUserRelationship,
followUser,
muteDomain,
muteUser,
removeUserFromFollowers,
unblockUser,
unfollowUser,
unmuteDomain,
unmuteUser,
} from 'src/api/user.js'
import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
export const useUsersStore = defineStore('users', {
state: () => ({
loggingIn: false,
lastLoginName: null,
currentUser: null,
users: new Map(),
usersByName: new Map(),
usersByURL: new Map(),
relationships: new Map(),
relationshipsLists: {
friends: new WeakMap(),
followers: new WeakMap(),
},
timestamps: new WeakMap(),
fetchesIds: new Map(),
fetchesNames: new Map(),
followPollers: new Map(),
followPollersAttempts: new Map(),
}),
getters: {
loggedIn: (state) => !!state.currentUser,
findUser: (state) => (query) => {
return state.users.get(query)
},
findUserByName: (state) => (query) => {
return state.usersByName.get(query.toLowerCase())
},
findUserByUrl: (state) => (query) => {
return state.usersByURL.get(query.toLowerCase())
},
relationship: (state) => (id) => {
const rel = id && state.relationships.get(id)
return rel || { id, loading: true }
},
},
actions: {
// Main updates
addNewUsers(response) {
const { data, timestamp } = response
const users = Array.isArray(data) ? data : [data]
return users.map((user) => {
const existing = this.users.get(user.id) ?? {}
const oldTimestamp = this.timestamps.get(existing)
//
// Relationship might have different timestamp and
// might need updating separate from user
const oldRelationship = this.relationships.get(user.id)
const oldRelationshipTimestamp = this.timestamps.get(oldRelationship)
let relationship
// Only need an update if new user data has relationship
if (user.relationship) {
// Only update if there is no old data or if it's outdated
if (
oldRelationship === undefined ||
timestamp > oldRelationshipTimestamp
) {
relationship = this.updateUserRelationships({
timestamp,
data: { id: user.id, ...user.relationship },
})[0]
}
} else {
relationship = oldRelationship
}
// implicit: if oldTimestamp is undefined this will still be false
if (oldTimestamp > timestamp) return existing // not overwriting old data with new
const { relationship: unused1, ...newUser } = user
let reactive = this.users.get(user.id)
// Initializing reactivity & avoiding excessive Map mutation
if (!reactive) {
this.users.set(user.id, existing)
reactive = this.users.get(user.id)
this.usersByName.set(user.screen_name.toLowerCase(), reactive)
this.usersByURL.set(user.url.toLowerCase(), reactive)
}
// Relying on object reactivity to avoid mutating the Map
reactive.relationship = relationship ?? reactive.relationship
Object.entries(newUser).forEach(([k, v]) => {
reactive[k] = v
})
// Updating the timestamp
this.timestamps.set(reactive, timestamp)
if (user.id === this.currentUser?.id) {
this.currentUser = reactive
}
// Initialize some stuff
const { friends, followers } = this.relationshipsLists
if (!friends.has(reactive)) friends.set(reactive, new Set())
if (!followers.has(reactive)) followers.set(reactive, new Set())
return reactive
})
},
updateUserRelationships({ timestamp, optimism, data }) {
const relationships = Array.isArray(data) ? data : [data]
return relationships.map((relationship) => {
const { id } = relationship
const existing = this.relationships.get(id) ?? {}
const oldTimestamp = this.timestamps.get(existing)
// implicit: if oldTimestamp is undefined this will still be false
if (!optimism && oldTimestamp > timestamp) return existing
// Initializing reactivity
if (!this.relationships.has(id)) this.relationships.set(id, existing)
const reactive = this.relationships.get(id)
// Relying on reactivity
Object.entries(relationship).forEach(([k, v]) => {
reactive[k] = v
})
if (timestamp) {
this.timestamps.set(reactive, timestamp)
}
// Updating user property if there is such a user
if (this.users.has(id)) {
this.users.get(id).relationship = reactive
}
// Update block/mute lists
if (this.currentUser && id !== this.currentUser.id) {
;[
['muting', this.currentUser.muteIds],
['blocking', this.currentUser.blockIds],
[
'following',
this.relationshipsLists.friends.get(this.currentUser),
],
[
'followed_by',
this.relationshipsLists.followers.get(this.currentUser),
],
].forEach(([relationshipName, list]) => {
if (relationship[relationshipName]) {
list?.add(id)
} else {
list?.delete(id)
}
})
}
return reactive
})
},
// Misc updates
updateUserAdminData(id, data) {
const user = this.users.get(id)
user.adminData = data
user.deactivated = !data.is_active
user.tags = new Set(data.tags)
},
updateRight(id, right, value) {
const user = this.users.get(id)
const newRights = user.rights ?? {}
newRights[right] = value
user.rights = newRights
},
// Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile.
clearFollowLists(userId) {
const user = this.users.get(userId)
if (user) {
this.relationshipsLists.friends.set(user, new Set())
this.relationshipsLists.followers.set(user, new Set())
}
},
// Fetches
async fetchUserIfMissing({ id, name }) {
let findFunc
let fetchFunc
let map
let identifier
if (id) {
findFunc = this.findUser
fetchFunc = this.fetchUser
map = this.fetchesIds
identifier = id
} else if (name) {
findFunc = this.findUserByName
fetchFunc = this.fetchUserByName
map = this.fetchesNames
identifier = name
} else {
throw new TypeError('No identifier provided')
}
// Search in cache
const user = findFunc(identifier)
if (user) return user
// not found => fetch
let promise
// Did we already search for this user?
if (map.has(identifier)) {
// if so, reuse the promise
promise = map.get(identifier)
} else {
// if not, make a new one
promise = fetchFunc(identifier)
}
map.set(identifier, promise)
const result = await promise
if (result) {
const { id, screen_name } = result
// Save promise for future use
this.fetchesIds.set(id, promise)
this.fetchesNames.set(screen_name, promise)
return this.users.get(id)
} else {
return null
}
},
async fetchUser(id) {
try {
const result = await fetchUser({
id,
credentials: useOAuthStore().token,
})
this.addNewUsers(result)
return this.users.get(result.data.id)
} catch (error) {
if (error.name === 'StatusCodeError' && error.statusCode === 404) {
console.warn(`User ${id} not found`)
return null
} else {
throw error
}
}
},
async fetchUserByName(name) {
try {
const result = await fetchUserByName({
name,
credentials: useOAuthStore().token,
})
this.addNewUsers(result)
return this.users.get(result.data.id)
} catch (error) {
if (error.name === 'StatusCodeError' && error.statusCode === 404) {
console.warn(`User ${name} not found`)
return null
} else {
throw error
}
}
},
fetchUserRelationship(id) {
return fetchUserRelationship({
id,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
fetchFriends(id) {
const user = this.users.get(id)
const maxId = last([...this.relationshipsLists.friends.get(user)])
return fetchFriends({
id,
maxId,
credentials: useOAuthStore().token,
}).then((result) => {
const users = this.addNewUsers(result)
const list = this.relationshipsLists.friends.get(user)
users.forEach(({ id }) => list.add(id))
return result.data
})
},
fetchFollowers(id) {
const user = this.users.get(id)
const maxId = last([...this.relationshipsLists.followers.get(user)])
return fetchFollowers({
id,
maxId,
credentials: useOAuthStore().token,
}).then((result) => {
const users = this.addNewUsers(result)
const list = this.relationshipsLists.followers.get(user)
users.forEach(({ id }) => list.add(id))
return result.data
})
},
fetchUserInLists(id) {
if (this.currentUser) {
return fetchUserInLists({
id,
credentials: useOAuthStore().token,
}).then(({ data: inLists }) => {
this.users.get(id).inLists = inLists
})
}
},
fetchMutes(args) {
const { reset } = args || {}
const maxId = this.currentUser.muteIdsMaxId
return fetchMutes({
maxId,
credentials: useOAuthStore().token,
}).then((result) => {
const { data: mutes } = result
if (reset) {
this.currentUser.muteIds = new Set(mutes.map(({ id }) => id))
} else {
mutes.forEach(({ id }) => this.currentUser.muteIds.add(id))
}
if (mutes.length) {
this.currentUser.muteIdsMaxId = last(mutes).id
}
this.addNewUsers(result)
return mutes
})
},
fetchBlocks(args) {
const { reset } = args || {}
const maxId = this.currentUser.blockIdsMaxId
return fetchBlocks({
maxId,
credentials: useOAuthStore().token,
}).then((result) => {
const { data: blocks } = result
if (reset) {
this.currentUser.blockIds = new Set(blocks.map(({ id }) => id))
} else {
blocks.forEach(({ id }) => this.currentUser.blockIds.add(id))
}
if (blocks.length) {
this.currentUser.blockIdsMaxId = last(blocks).id
}
this.addNewUsers(result)
return blocks
})
},
fetchDomainMutes() {
return fetchDomainMutes({
credentials: useOAuthStore().token,
}).then(({ data: domainMutes }) => {
this.currentUser.domainMutes = new Set(domainMutes)
return domainMutes
})
},
// Actions
/// Follow
async followUser(id) {
// Don't spam follow requests if we are already polling
if (this.followPollers.has(id)) return
const followFunc = () =>
followUser({
id,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
const checker = async (func = () => this.fetchUserRelationship(id)) => {
await func() // Either fetch relationships or follow request
const relationship = this.relationships.get(id)
return (
relationship.following ||
(relationship.locked && relationship.requested)
)
}
const immediate = await checker(followFunc)
if (immediate) return // If follow goes through immediately don'd to looping
const loop = async () => {
const attempts = this.followPollersAttempts.get(id)
const result = await checker()
if (result || attempts === 1) {
this.followPollersAttempts.delete(id)
this.followPollers.get(id).stop()
this.followPollers.delete(id)
} else {
this.followPollersAttempts.set(id, attempts - 1)
}
}
this.followPollersAttempts.set(id, 3)
this.followPollers.set(id, promiseInterval(loop, 500))
},
async unfollowUser(id) {
const result = await unfollowUser({
id,
credentials: useOAuthStore().token,
})
return this.updateUserRelationships(result)
},
/// Subscribe
subscribeUser(id) {
return followUser({
id,
notify: true,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
unsubscribeUser(id) {
return followUser({
id,
notify: false,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
/// User Note
editUserNote(id, comment) {
return editUserNote({ id, comment }).then((result) =>
this.updateUserRelationships(result),
)
},
/// Hide reblogs
hideReblogs(id) {
return followUser({
id,
reblogs: false,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
showReblogs(id) {
return followUser({
id,
reblogs: true,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
/// Remove follower
removeUserFromFollowers(id) {
return removeUserFromFollowers({ id }).then((result) =>
this.updateUserRelationships(result),
)
},
/// Mute
muteUser(id, expiresIn = 0) {
const predictedRelationship = this.relationships[id] || { id }
predictedRelationship.muting = true
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
return muteUser({
id,
expiresIn,
credentials: useOAuthStore().token,
}).then((result) => {
this.updateUserRelationships(result)
})
},
muteUsers(data = []) {
return Promise.all(data.map((d) => this.muteUser(d)))
},
unmuteUser(id) {
const predictedRelationship = this.relationships[id] || { id }
predictedRelationship.muting = false
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
return unmuteUser({ id }).then((result) =>
this.updateUserRelationships(result),
)
},
unmuteUsers(ids = []) {
return Promise.all(ids.map((d) => this.unmuteUser(d)))
},
/// Block
blockUser(id, expiresIn = 0) {
const predictedRelationship = this.relationships[id] || { id }
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
return blockUser({ id, expiresIn }).then((result) => {
this.updateUserRelationships(result)
const ids = useStatusesStore().wipeUserStatuses(id)
useTimelinesStore().wipeStatuses(ids)
})
},
blockUsers(data = []) {
return Promise.all(data.map((d) => this.blockUser(d)))
},
unblockUser(id) {
return unblockUser({ id }).then((result) => {
this.updateUserRelationships(result)
})
},
unblockUsers(data = []) {
return Promise.all(data.map((d) => this.unblockUser(d)))
},
/// Domain Mute
muteDomain(domain) {
return muteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => this.currentUser.domainMutes.add(domain))
},
unmuteDomain(domain) {
return unmuteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => this.currentUser.domainMutes.delete(domain))
},
muteDomains(domains = []) {
return Promise.all(domains.map((domain) => this.muteDomain(domain)))
},
unmuteDomains(domain = []) {
return Promise.all(domain.map((domain) => this.unmuteDomain(domain)))
},
// Login/Logout
async loginUser(accessToken) {
const store = window.vuex
const dispatch =
store?.dispatch ??
(() => {
/* no-op */
}) // for tests
this.loggingIn = true
try {
const { data: user, ...rest } = await verifyCredentials({
credentials: useOAuthStore().token,
})
user.blockIds = new Set()
user.muteIds = new Set()
user.domainMutes = new Set()
this.lastLoginName = user.screen_name
this.currentUser = user
this.users = new Map()
this.usersByName = new Map()
this.usersByURL = new Map()
this.relationships = new Map()
useInterfaceStore().onLogin()
useSyncConfigStore()
.initSyncConfig(user)
.then(() => {
useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
})
useUserHighlightStore().initUserHighlight(user)
this.addNewUsers({ data: user, ...rest })
useEmojiStore().fetchEmoji()
// Do server-side storage migrations
// Debug snippet to clean up storage and reset migrations
/*
// Reset wordfilter
Object.keys(
useSyncConfigStore().prefsStorage.simple.muteFilters
).forEach(key => {
useSyncConfigStore().unsetSimplePrefAndSave({ path: 'muteFilters.' + key, value: null })
})
// Reset flag to 0 to re-run migrations
useSyncConfigStore().setFlag({ flag: 'configMigration', value: 0 })
/**/
if (user.token) {
// Shoutbox
dispatch('setWsToken', user.token)
dispatch('initializeSocket')
}
// DMs and Home
useNotificationsStore().activate()
useTimelinesStore().activatePersistents()
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
// Start fetching chats
useChatsStore().startFetching()
}
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
if (useMergedConfigStore().mergedConfig.useStreamingApi) {
useStreamingStore().initSocket(true)
}
// Start fetching things that don't need to block the UI
useAnnouncementsStore().startFetching()
this.fetchMutes()
dispatch('loadDrafts')
} catch (error) {
console.error(error)
// Authentication failed
this.loggingIn = false
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(error.statusCode)) {
useOAuthStore().clearToken()
}
if (error.tatusCode === 401) {
throw new Error('Wrong username or password', error)
} else {
throw new Error('An error occurred, please try again', error)
}
} finally {
this.loggingIn = false
}
},
logout() {
const store = window.vuex
const oauth = useOAuthStore()
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return oauth
.ensureApp()
.then((app) => {
const params = {
app,
instance: useInstanceStore().server,
token: oauth.userToken,
}
return revokeToken(params)
})
.then(() => {
this.currentUser = null
this.lastLoginName = null
this.users = new Map()
this.usersByName = new Map()
this.usersByURL = new Map()
this.relationships = new Map()
useNotificationsStore().deactivate()
useAnnouncementsStore().stopFetching()
useListsStore().stopFetching()
useBookmarkFoldersStore().stopFetching()
store?.dispatch('stopFetchingFollowRequests')
useTimelinesStore().deactivateAll()
useStatusesStore().resetStatuses()
if (useMergedConfigStore().mergedConfig.useStreamingApi) {
useStreamingStore().stopSocket()
}
useChatsStore().resetChats()
oauth.clearToken()
Cookies.remove('__Host-pleroma_key', { path: '/' })
useInterfaceStore().onLogout()
})
},
},
persist: {
paths: ['lastLoginName'],
},
})
diff --git a/test/unit/specs/stores/notifications.spec.js b/test/unit/specs/stores/notifications.spec.js
new file mode 100644
index 0000000000..6947607fb2
--- /dev/null
+++ b/test/unit/specs/stores/notifications.spec.js
@@ -0,0 +1,379 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+
+import { useNotificationsStore } from 'src/stores/notifications.js'
+import { useStatusesStore } from 'src/stores/statuses.js'
+import { useUsersStore } from 'src/stores/users.js'
+import { useReportsStore } from 'src/stores/reports.js'
+import { useStreamingStore } from 'src/stores/streaming.js'
+import { useI18nStore } from 'src/stores/i18n.js'
+
+import * as USER_API from 'src/api/user.js'
+
+const userId = '1'
+const userScreenName = 'user'
+const userName = 'Guy'
+const userUrl = 'http://localhost/user'
+
+const mockMastoAPIUser = ({
+ screen_name = userScreenName,
+ name = userName,
+ url = userUrl,
+ id = userId,
+} = {}) => ({
+ id,
+ acct: screen_name,
+ display_name: name,
+ fields: [],
+ avatar: '',
+ url,
+ pleroma: {
+ emoji_reactions: [],
+ },
+})
+
+const mockUser = ({
+ screen_name = userScreenName,
+ id = userId,
+ name = userName,
+ url = userUrl,
+} = {}) => ({
+ _original: mockMastoAPIUser({
+ screen_name,
+ id,
+ name,
+ url,
+ }),
+ id,
+ name,
+ screen_name,
+ url,
+ relationship: undefined,
+})
+
+const mockStatus = ({
+ id = '1',
+ text,
+ summary,
+ type = 'status',
+ statusUser = mockUser(),
+} = {}) => ({
+ id,
+ user: statusUser,
+ summary: summary ?? `Summary number ${id}`,
+ name: 'status',
+ text: text ?? `Text number ${id}`,
+ uri: '',
+ type,
+ attentions: [],
+ statusnet_conversation_id: 'c1',
+ emoji_reactions: [],
+})
+
+const mockStatusNotification = ({
+ id = '1',
+ type = 'like',
+ status = mockStatus({ id }),
+ seen = false,
+ user = mockUser(),
+} = {}) => ({
+ type,
+ id,
+ status,
+ seen,
+ user,
+ from_profile: user,
+})
+
+const DEFAULT_OPTIONS = (method = 'POST') => ({
+ method,
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json',
+ },
+})
+
+describe('Notifications store', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ useI18nStore().i18n = { t: () => { /* no-op */} }
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('activate', () => {
+ const store = useNotificationsStore()
+ const sub = vi.fn()
+ useStreamingStore().addSubscriber = sub
+
+ store.activate()
+
+ expect(sub).to.have.been.called
+ expect(store.fetcher).to.not.be.null
+ expect(store.socket).to.not.be.null
+
+ store.deactivate()
+ })
+
+ it('deactivate', () => {
+ const store = useNotificationsStore()
+ const unsub = vi.fn()
+ useStreamingStore().removeSubscriber = unsub
+
+ store.activate()
+ // Checking so that they were set properly before
+ // since reset changes them to ''
+ store.maxId = '2'
+ store.minId = '1'
+ store.idStore = new Map()
+ store.idStore.set('1', {})
+ store.idStore.set('2', {})
+ store.deactivate()
+
+ expect(unsub).to.have.been.called
+ expect(store.fetcher).to.be.null
+ expect(store.socket).to.be.null
+ expect(store.idStore).to.have.length(0)
+ expect(store).to.have.property('maxId', '')
+ expect(store).to.have.property('minId', '')
+ })
+
+ it('updateExtremes should update min and max ids', () => {
+ const store = useNotificationsStore()
+ store.maxId = '10'
+ store.minId = '05'
+
+ store.updateExtremes('04')
+ store.updateExtremes('11')
+
+ expect(store).to.have.property('maxId', '11')
+ expect(store).to.have.property('minId', '04')
+ })
+
+ describe('addNewNotifications', () => {
+ it('adds notifications to the list', () => {
+ const store = useNotificationsStore()
+
+ store.addNewNotifications({
+ timestamp: 1,
+ data: [
+ mockStatusNotification({ id: 'a' }),
+ mockStatusNotification({ id: 'b' }),
+ ],
+ })
+
+ // must be ordered
+ expect(store.data.map(({ id }) => id)).to.eql(['a', 'b'])
+ expect(store.idStore).to.have.keys(['a', 'b'])
+ expect(store).to.have.property('maxId', 'b')
+ expect(store).to.have.property('minId', 'a')
+ })
+
+ it('ignores duplicates', () => {
+ const store = useNotificationsStore()
+
+ store.addNewNotifications({
+ timestamp: 1,
+ data: [
+ mockStatusNotification({ id: '2' }),
+ mockStatusNotification({ id: '1' }),
+ ],
+ })
+
+ store.addNewNotifications({
+ timestamp: 1,
+ data: [
+ mockStatusNotification({ id: '3' }),
+ mockStatusNotification({ id: '2' }),
+ ],
+ })
+
+ // must be ordered
+ expect(store.data.map(({ id }) => id)).to.eql(['3', '2', '1'])
+ expect(store.idStore).to.have.keys(['1', '2', '3'])
+ expect(store).to.have.property('maxId', '3')
+ expect(store).to.have.property('minId', '1')
+ })
+
+ it('appends notifications if fetching older', () => {
+ const store = useNotificationsStore()
+
+ store.addNewNotifications({
+ timestamp: 1,
+ data: [
+ mockStatusNotification({ id: '4' }),
+ mockStatusNotification({ id: '3' }),
+ ],
+ })
+
+ store.addNewNotifications({
+ timestamp: 1,
+ data: [
+ mockStatusNotification({ id: '2' }),
+ mockStatusNotification({ id: '1' }),
+ ],
+ }, true)
+
+ // must be ordered
+ expect(store.data.map(({ id }) => id)).to.eql(['4', '3', '2', '1'])
+ expect(store.idStore).to.have.keys(['1', '2', '3', '4'])
+ expect(store).to.have.property('maxId', '4')
+ expect(store).to.have.property('minId', '1')
+ })
+
+ it('should update usersStore', () => {
+ const store = useNotificationsStore()
+ const mock = vi.spyOn(useUsersStore(), 'addNewUsers')
+ const mockedNotification = mockStatusNotification({ type: 'follow' })
+
+ store.addNewNotifications({
+ timestamp: 1337,
+ data: [mockedNotification],
+ })
+
+ expect(mock).to.have.been.called
+ expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337)
+ expect(mock.mock.calls[0][0].data[0]).to.eql(mockedNotification.from_profile)
+ })
+
+ it('should update reportsStore', (notificationType) => {
+ const store = useNotificationsStore()
+ const mock = vi.spyOn(useReportsStore(), 'addReport')
+ const mockedNotification = mockStatusNotification({ type: 'pleroma:report' })
+ mockedNotification.report = { data: '123' }
+
+ store.addNewNotifications({
+ timestamp: 1337,
+ data: [mockedNotification],
+ })
+
+ expect(mock).to.have.been.calledWith({ data: '123' })
+ })
+
+ it.each([
+ 'like',
+ 'mention',
+ 'status',
+ 'repeat',
+ 'pleroma:emoji_reaction',
+ 'poll',
+ ])('should update statusesStore on %s notification', (notificationType) => {
+ const store = useNotificationsStore()
+ const mock = vi.fn()
+ useStatusesStore().addNewStatuses = mock
+ const mockedNotification = mockStatusNotification({ type: notificationType })
+
+ store.addNewNotifications({
+ timestamp: 1337,
+ data: [mockedNotification],
+ })
+
+ expect(mock).to.have.been.called
+ expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337)
+ expect(mock.mock.calls[0][0].statuses[0]).to.eql(mockedNotification.status)
+ })
+ })
+
+ describe('wipeStatuses', () => {
+ it('clears all statuses', () => {
+ const store = useNotificationsStore()
+
+ store.addNewNotifications({
+ timestamp: 1,
+ data: [
+ mockStatusNotification({ id: 'n2', status: mockStatus({ id: 's2' }) }),
+ mockStatusNotification({ id: 'n1', status: mockStatus({ id: 's1' }) }),
+ ],
+ })
+
+ store.wipeStatuses(['s2'])
+ expect(store.idStore).to.not.have.members('n2')
+ expect(store.data.map(({ id }) => id)).to.eql(['n1'])
+ })
+ })
+
+ describe('read/dismiss', () => {
+ it('read single', () => {
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ ok: 'ok' }), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ vi.stubGlobal('fetch', mockFetch)
+
+ const store = useNotificationsStore()
+ const mockedNotification = mockStatusNotification()
+
+ store.addNewNotifications({
+ timestamp: 1337,
+ data: [
+ mockStatusNotification({ id: 'n3' }),
+ mockStatusNotification({ id: 'n2' }),
+ mockStatusNotification({ id: 'n1' }),
+ ],
+ })
+
+ expect(store.data[1]).to.have.property('seen', false)
+
+ store.markSingleNotificationAsSeen('n2')
+
+ expect(store.data[0]).to.have.property('seen', false)
+ expect(store.data[1]).to.have.property('seen', true)
+ expect(store.data[0]).to.have.property('seen', false)
+
+ const calls = mockFetch.mock.calls
+ expect(calls).to.have.length(1)
+ const callOne = calls[0]
+ expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL)
+ const formData = Object.fromEntries(callOne[1].body.entries())
+ expect(formData).to.eql({ id: 'n2' })
+ })
+
+ it('read all', () => {
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ ok: 'ok' }), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ vi.stubGlobal('fetch', mockFetch)
+
+ const store = useNotificationsStore()
+ const mockedNotification = mockStatusNotification()
+
+ // FormData is weird to test
+ store.addNewNotifications({
+ timestamp: 1337,
+ data: [
+ mockStatusNotification({ id: 'n3' }),
+ mockStatusNotification({ id: 'n2' }),
+ mockStatusNotification({ id: 'n1' }),
+ ],
+ })
+
+ expect(store.data[0]).to.have.property('seen', false)
+ expect(store.data[1]).to.have.property('seen', false)
+ expect(store.data[2]).to.have.property('seen', false)
+
+ store.markNotificationsAsSeen()
+
+ expect(store.data[0]).to.have.property('seen', true)
+ expect(store.data[1]).to.have.property('seen', true)
+ expect(store.data[2]).to.have.property('seen', true)
+
+ // FormData is weird to test
+ const calls = mockFetch.mock.calls
+ expect(calls).to.have.length(1)
+ const callOne = calls[0]
+ expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL)
+ const formData = Object.fromEntries(callOne[1].body.entries())
+ expect(formData).to.eql({ max_id: 'n3' })
+ })
+ })
+})
diff --git a/test/unit/specs/stores/timelines.spec.js b/test/unit/specs/stores/timelines.spec.js
index df71a37a6c..f810c029d5 100644
--- a/test/unit/specs/stores/timelines.spec.js
+++ b/test/unit/specs/stores/timelines.spec.js
@@ -1,358 +1,304 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
-import { useStatusesStore } from 'src/stores/statuses.js'
-import { useTimelinesStore } from 'src/stores/timelines.js'
import { useStreamingStore } from 'src/stores/streaming.js'
+import { useTimelinesStore } from 'src/stores/timelines.js'
describe('Timelines store', () => {
beforeEach(() => {
vi.useFakeTimers()
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
useTimelinesStore().deactivateAll()
vi.useRealTimers()
})
describe('activate', () => {
it('streamable', () => {
const store = useTimelinesStore()
const sub = vi.fn()
useStreamingStore().addSubscriber = sub
console.log(store.activate)
store.activate('friends', undefined, true)
expect(sub).to.have.been.called
expect(store.friends.fetcher).to.not.be.null
expect(store.friends.socket).to.not.be.null
})
it('non-streamable', () => {
const store = useTimelinesStore()
const sub = vi.fn()
useStreamingStore().addSubscriber = sub
console.log(store.activate)
store.activate('user', '1')
expect(sub).to.not.have.been.called
expect(store.user.fetcher).to.not.be.null
expect(store.user.socket).to.be.null
})
})
describe('deactivate', () => {
it('streamable', () => {
const store = useTimelinesStore()
const unsub = vi.fn()
useStreamingStore().removeSubscriber = unsub
+ useStreamingStore().addSubscriber = vi.fn()
store.activate('friends', undefined, true)
// Checking so that they were set properly before
// since reset changes them to ''
store.friends.maxId = '3'
store.friends.minId = '4'
- store.friends.statusIds = new Set(['3','4'])
- store.friends.visibleStausIds = new Set(['3','4'])
+ store.friends.statusIds = new Set(['3', '4'])
+ store.friends.visibleStausIds = new Set(['3', '4'])
store.deactivate('friends', true)
expect(unsub).to.have.been.called
expect(store.friends.fetcher).to.be.null
expect(store.friends.socket).to.be.null
expect(store.friends.statusIds).to.have.length(0)
expect(store.friends.visibleStatusIds).to.have.length(0)
expect(store.friends).to.have.property('maxId', '')
expect(store.friends).to.have.property('minId', '')
})
it('non-streamable', () => {
const store = useTimelinesStore()
const unsub = vi.fn()
useStreamingStore().removeSubscriber = unsub
+ useStreamingStore().addSubscriber = vi.fn()
store.activate('user', '1')
// Checking so that they were set properly before
// since reset changes them to ''
store.user.maxId = '3'
store.user.minId = '4'
- store.user.statusIds = new Set(['3','4'])
- store.user.visibleStausIds = new Set(['3','4'])
+ store.user.statusIds = new Set(['3', '4'])
+ store.user.visibleStausIds = new Set(['3', '4'])
store.deactivate('user')
expect(unsub).to.not.have.been.called
expect(store.user.fetcher).to.be.null
expect(store.user.socket).to.be.null
expect(store.user.statusIds).to.have.length(0)
expect(store.user.visibleStatusIds).to.have.length(0)
expect(store.user).to.have.property('maxId', '')
expect(store.user).to.have.property('minId', '')
})
})
describe('updateTimelineExtremes', () => {
it('should derive extremes from data', () => {
const store = useTimelinesStore()
const timeline = useTimelinesStore().friends
- timeline.order = ['4','1','3','2']
+ timeline.order = ['4', '1', '3', '2']
timeline.statusesIds = new Set(timeline.order)
store.updateTimelineExtremes(timeline)
expect(store.friends).to.have.property('maxId', '4')
expect(store.friends).to.have.property('minId', '2')
})
it('should use extremes from pagination', () => {
const store = useTimelinesStore()
const timeline = useTimelinesStore().friends
- store.updateTimelineExtremes(
- timeline,
- { maxId: '1', minId: '2' }
- )
+ store.updateTimelineExtremes(timeline, { maxId: '1', minId: '2' })
// Min and max are swapped!
expect(store.friends).to.have.property('maxId', '2')
expect(store.friends).to.have.property('minId', '1')
})
})
describe('addStatusesToTimeline', () => {
it('adds the status to the given timeline', () => {
const store = useTimelinesStore()
- const statuses = ['1','2','3']
+ const statuses = ['1', '2', '3']
store.activate('list', '1')
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses,
- pagination: { minId: '1', maxId: '3' }
- }
- )
+ store.addStatusesToTimeline('list', '1', {
+ statuses,
+ pagination: { minId: '1', maxId: '3' },
+ })
expect(store.list.order).to.eql(statuses)
expect(store.list.statusIds).to.eql(new Set(statuses))
expect(store.list.visibleStatusIds).to.eql(new Set())
expect(store.list.newStatusCount).to.equal(3)
expect(store.list).to.have.property('maxId', '1')
expect(store.list).to.have.property('minId', '3')
})
it('ignores duplicates', () => {
const store = useTimelinesStore()
- const statuses = ['1','2','3']
+ const statuses = ['1', '2', '3']
store.activate('list', '1')
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses,
- pagination: { minId: '1', maxId: '3' }
- }
- )
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses,
- pagination: { minId: '1', maxId: '3' }
- }
- )
+ store.addStatusesToTimeline('list', '1', {
+ statuses,
+ pagination: { minId: '1', maxId: '3' },
+ })
+ store.addStatusesToTimeline('list', '1', {
+ statuses,
+ pagination: { minId: '1', maxId: '3' },
+ })
expect(store.list.order).to.eql(statuses)
expect(store.list.statusIds).to.eql(new Set(statuses))
expect(store.list.visibleStatusIds).to.eql(new Set())
expect(store.list.newStatusCount).to.equal(3)
expect(store.list).to.have.property('maxId', '1')
expect(store.list).to.have.property('minId', '3')
})
it('adds the status the given timeline, directly visible', () => {
const store = useTimelinesStore()
- const statuses = ['1','2','3']
+ const statuses = ['1', '2', '3']
store.activate('list', '1')
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses,
- showImmediately: true,
- pagination: { minId: '1', maxId: '3' }
- }
- )
+ store.addStatusesToTimeline('list', '1', {
+ statuses,
+ showImmediately: true,
+ pagination: { minId: '1', maxId: '3' },
+ })
expect(store.list.order).to.eql(statuses)
expect(store.list.statusIds).to.eql(new Set(statuses))
expect(store.list.visibleStatusIds).to.eql(new Set(statuses))
expect(store.list.newStatusCount).to.equal(0)
expect(store.list).to.have.property('maxId', '1')
expect(store.list).to.have.property('minId', '3')
})
it('does not update the maxId when the noIdUpdate flag is set', () => {
const store = useTimelinesStore()
- const statuses = ['1','2','3']
+ const statuses = ['1', '2', '3']
store.activate('list', '1')
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses,
- noIdUpdate: true,
- pagination: { minId: '1', maxId: '3' }
- }
- )
+ store.addStatusesToTimeline('list', '1', {
+ statuses,
+ noIdUpdate: true,
+ pagination: { minId: '1', maxId: '3' },
+ })
expect(store.list.order).to.eql(statuses)
expect(store.list.statusIds).to.eql(new Set(statuses))
expect(store.list.visibleStatusIds).to.eql(new Set())
expect(store.list.newStatusCount).to.equal(3)
expect(store.list).to.have.property('maxId', '')
expect(store.list).to.have.property('minId', '')
})
it('does not update timeline if it belongs to a different arugment', () => {
const store = useTimelinesStore()
- const statuses = ['1','2','3']
+ const statuses = ['1', '2', '3']
store.activate('list', '1')
- store.addStatusesToTimeline(
- 'list',
- '2',
- {
- statuses,
- noIdUpdate: true,
- pagination: { minId: '1', maxId: '3' }
- }
- )
+ store.addStatusesToTimeline('list', '2', {
+ statuses,
+ noIdUpdate: true,
+ pagination: { minId: '1', maxId: '3' },
+ })
expect(store.list.order).to.eql([])
expect(store.list.statusIds).to.eql(new Set())
expect(store.list.visibleStatusIds).to.eql(new Set())
expect(store.list.newStatusCount).to.equal(0)
expect(store.list).to.have.property('maxId', '')
expect(store.list).to.have.property('minId', '')
})
it('prepends timeline with new statuses', () => {
const store = useTimelinesStore()
- const statuses1 = ['3','2','1']
- const statuses2 = ['6','5','4']
+ const statuses1 = ['3', '2', '1']
+ const statuses2 = ['6', '5', '4']
store.activate('list', '1')
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses: statuses1,
- pagination: { minId: '3', maxId: '1' }
- }
- )
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses: statuses2,
- pagination: { minId: '6', maxId: '4' }
- }
- )
+ store.addStatusesToTimeline('list', '1', {
+ statuses: statuses1,
+ pagination: { minId: '3', maxId: '1' },
+ })
+ store.addStatusesToTimeline('list', '1', {
+ statuses: statuses2,
+ pagination: { minId: '6', maxId: '4' },
+ })
const newOrder = [...statuses2, ...statuses1]
expect(store.list.order).to.eql(newOrder)
expect(store.list.statusIds).to.eql(new Set(newOrder))
expect(store.list.visibleStatusIds).to.eql(new Set())
expect(store.list.newStatusCount).to.equal(6)
expect(store.list).to.have.property('maxId', '6')
expect(store.list).to.have.property('minId', '1')
})
it('appends timeline with new statuses if fetching older', () => {
const store = useTimelinesStore()
- const statuses1 = ['6','5','4']
- const statuses2 = ['3','2','1']
+ const statuses1 = ['6', '5', '4']
+ const statuses2 = ['3', '2', '1']
store.activate('list', '1')
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses: statuses1,
- pagination: { minId: '6', maxId: '4' },
- }
- )
- store.addStatusesToTimeline(
- 'list',
- '1',
- {
- statuses: statuses2,
- pagination: { minId: '3', maxId: '1' },
- older: true,
- }
- )
+ store.addStatusesToTimeline('list', '1', {
+ statuses: statuses1,
+ pagination: { minId: '6', maxId: '4' },
+ })
+ store.addStatusesToTimeline('list', '1', {
+ statuses: statuses2,
+ pagination: { minId: '3', maxId: '1' },
+ older: true,
+ })
const newOrder = [...statuses1, ...statuses2]
expect(store.list.order).to.eql(newOrder)
expect(store.list.statusIds).to.eql(new Set(newOrder))
expect(store.list.visibleStatusIds).to.eql(new Set())
expect(store.list.newStatusCount).to.equal(6)
expect(store.list).to.have.property('maxId', '6')
expect(store.list).to.have.property('minId', '1')
})
})
describe('showNewStatuses', () => {
it('resets counter and makes all ids visible', () => {
const store = useTimelinesStore()
- const statuses = ['1','2','3']
+ const statuses = ['1', '2', '3']
store.activate('public')
- store.addStatusesToTimeline(
- 'public',
- undefined,
- {
- statuses,
- pagination: { minId: '1', maxId: '3' }
- }
- )
+ store.addStatusesToTimeline('public', undefined, {
+ statuses,
+ pagination: { minId: '1', maxId: '3' },
+ })
expect(store.public.statusIds).to.eql(new Set(statuses))
expect(store.public.visibleStatusIds).to.eql(new Set())
expect(store.public.newStatusCount).to.equal(3)
store.showNewStatuses('public')
expect(store.public.visibleStatusIds).to.eql(new Set(statuses))
expect(store.public.newStatusCount).to.equal(0)
})
})
describe('wipeStatuses', () => {
it('clears all statuses', () => {
const store = useTimelinesStore()
store.activate('friends')
store.activate('public')
- store.addStatusesToTimeline(
- 'public',
- undefined,
- {
- statuses: ['1','2','3','0']
- }
- )
- store.addStatusesToTimeline(
- 'friends',
- undefined,
- {
- statuses: ['5','0','9','1']
- }
- )
+ store.addStatusesToTimeline('public', undefined, {
+ statuses: ['1', '2', '3', '0'],
+ })
+ store.addStatusesToTimeline('friends', undefined, {
+ statuses: ['5', '0', '9', '1'],
+ })
store.wipeStatuses(['0'])
expect(store.friends.statusIds).to.not.have.members('0')
expect(store.public.statusIds).to.not.have.members('0')
})
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 5:32 AM (17 h, 10 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801079
Default Alt Text
(168 KB)

Event Timeline