Page MenuHomePhorge

No OneTemporary

Size
34 KB
Referenced Files
None
Subscribers
None
diff --git a/src/stores/statuses.js b/src/stores/statuses.js
index 9df26465fb..890dd4002d 100644
--- a/src/stores/statuses.js
+++ b/src/stores/statuses.js
@@ -1,548 +1,551 @@
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,
} 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(),
statusesPerUser: new Map(),
timestamps: new WeakMap(),
scrobblesNextFetch: {},
conversations: new Map(),
favorites: new Set(),
socket: null,
favs: new Map(),
repeats: new Map(),
})
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 = {
name: 'statuses',
et,
handlers: {
handleUpdate,
handleDelete,
},
}
et.addEventListener('update', handleUpdate)
et.addEventListener('status.update', handleUpdate)
et.addEventListener('delete', handleDelete)
useStreamingStore().addSubscriber(socket)
this.socket = socket
},
resetStatuses() {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
if (key === 'socket') return
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)
let userSet = this.statusesPerUser.get(status.user.id)
if (userSet === undefined) {
userSet = new Set()
this.statusesPerUser.set(status.user.id, userSet)
}
userSet.add(status.id)
// 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) => {
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,
})
const { in_reply_to_user_id } = status
if (in_reply_to_user_id) {
useUsersStore().fetchUserIfMissing({ id: in_reply_to_user_id })
}
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,
...Object.fromEntries(
Object.entries(neu).filter(([, v]) => v !== undefined),
),
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)
+ }).then(({ data, timestamp }) => {
+ data.forEach((reaction) => {
+ const users = useUsersStore().addNewUsers({ timestamp, data: reaction.accounts })
+ })
+ this.addEmojiReactionsBy(id, data)
})
},
fetchFavs(id) {
return fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then((result) => {
const users = useUsersStore().addNewUsers(result)
return this.addFavs(id, new Set(users.map(({ id }) => id)))
})
},
fetchRepeats(id) {
return fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then((result) => {
const users = useUsersStore().addNewUsers(result)
return this.addRepeats(id, new Set(users.map(({ id }) => id)))
})
},
fetchFavsAndRepeats(id) {
return Promise.all([this.fetchFavs(id), this.fetchRepeats(id)])
},
// Updates
addRepeats(id, users) {
const currentUser = useUsersStore().currentUser
const newStatus = this.allStatuses.get(id)
this.repeats.set(id, users)
// repeats stats can be incorrect based on polling
// condition, let's update them using the most recent data
newStatus.repeat_num = users.size
newStatus.repeated = users.has(currentUser?.id)
},
addFavs(id, users) {
const currentUser = useUsersStore().currentUser
const newStatus = this.allStatuses.get(id)
this.favs.set(id, users)
// favorites stats can be incorrect based on polling
// condition, let's update them using the most recent data
newStatus.fave_num = users.size
newStatus.favorited = users.has(currentUser?.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 = this.statusesPerUser.get(userId) ?? new Set()
removed.forEach((statusId) => {
const status = this.allStatuses.get(statusId)
this.allStatuses.delete(statusId)
const conversationSet = this.conversations.get(
status.statusnet_conversation_id,
)
conversationSet.delete(statusId)
if (conversationSet.size === 0) {
this.conversations.delete(status.statusnet_conversation_id)
}
})
this.statusesPerUser.delete(userId)
return removed
},
},
})
diff --git a/test/unit/specs/stores/statuses.spec.js b/test/unit/specs/stores/statuses.spec.js
index 9be47145cc..31bf517d25 100644
--- a/test/unit/specs/stores/statuses.spec.js
+++ b/test/unit/specs/stores/statuses.spec.js
@@ -1,634 +1,651 @@
import { createTestingPinia } from '@pinia/testing'
import { snakeCase } from 'lodash'
import { setActivePinia } from 'pinia'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js'
import * as PUBLIC_API from 'src/api/public.js'
import * as USER_API from 'src/api/user.js'
const userId = '1'
const userScreenName = 'user'
const userName = 'Guy'
const userUrl = 'http://localhost/user'
const mockMastoAPIUser = ({
screen_name = userScreenName,
name = userName,
url = userUrl,
id = userId,
} = {}) => ({
id,
acct: screen_name,
display_name: name,
fields: [],
avatar: '',
url,
pleroma: {
emoji_reactions: [],
},
})
const mockUser = ({
screen_name = userScreenName,
id = userId,
name = userName,
url = userUrl,
} = {}) => ({
_original: mockMastoAPIUser({
screen_name,
id,
name,
url,
}),
id,
name,
screen_name,
url,
relationship: undefined,
})
const mockStatus = ({
id = '1',
text,
type = 'status',
statusUser = mockUser(),
} = {}) => ({
id,
user: statusUser,
name: 'status',
text: text ?? `Text number ${id}`,
uri: '',
type,
attentions: [],
statusnet_conversation_id: 'c1',
emoji_reactions: [],
})
const mockMastoAPIStatus = ({
id = '1',
text,
type = 'status',
statusUser = mockMastoAPIUser(),
} = {}) => ({
id,
account: statusUser,
name: 'status',
content: text ?? `Text number ${id}`,
uri: '',
type,
attentions: [],
statusnet_conversation_id: 'c1',
})
const DEFAULT_OPTIONS = (method = 'GET') => ({
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
describe('Statuses store', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('init', () => {
const store = useStatusesStore()
const sub = vi.fn()
useStreamingStore().addSubscriber = sub
store.attachSocket()
expect(store.socket).to.not.be.null
expect(sub).to.have.been.called
})
it('resetStatuses', () => {
const store = useStatusesStore()
const statuses = [...new Array(20)].map((empty, index) =>
mockStatus({
id: 's' + index,
statusUser: mockUser({ id: 'u' + index }),
}),
)
store.attachSocket()
store.addNewStatuses({
statuses,
timestamp: 1,
})
store.resetStatuses()
expect(store.allStatuses).to.have.length(0)
})
describe('addNewStatuses', () => {
beforeEach(() => {
const usersStore = useUsersStore()
usersStore.addNewUsers = vi.fn().mockReturnValue([mockUser()])
})
it('adds the status to allStatuses', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
expect(store.conversations).to.eql(new Map([['c1', new Set(['1'])]]))
expect(usersStore.addNewUsers).to.have.callCount(1)
})
it('splits retweets from their status and links them', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const retweet = mockStatus({
id: '2',
type: 'retweet',
user: mockUser({ id: '2' }),
})
retweet.type = 'retweet'
retweet.retweeted_status = status
store.addNewStatuses({
statuses: [retweet],
timestamp: 1,
})
expect(store.allStatuses).to.eql(
new Map([
['1', status],
['2', retweet],
]),
)
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('splits quotes from their status and links them', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const quote = mockStatus({ id: '2' })
quote.quote = status
store.addNewStatuses({
statuses: [quote],
timestamp: 1,
})
expect(store.allStatuses).to.eql(
new Map([
['1', status],
['2', quote],
]),
)
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('replaces existing statuses with the same id', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const modStatus = mockStatus({ id: '1', text: 'something else' })
store.addNewStatuses({
statuses: [status],
timestamp: 1991,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
store.addNewStatuses({
statuses: [modStatus],
timestamp: 2000,
})
expect(store.allStatuses).to.eql(new Map([['1', modStatus]]))
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('handles edits', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const modStatus = mockStatus({
id: '1',
text: 'something else',
type: 'edit',
})
store.addNewStatuses({
statuses: [status],
timestamp: 1991,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
store.addNewStatuses({
statuses: [modStatus],
timestamp: 2000,
})
expect(store.allStatuses).to.eql(new Map([['1', modStatus]]))
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('ignores updates with older timestamp', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const modStatus = mockStatus({ id: '1', text: 'something else' })
store.addNewStatuses({
statuses: [status],
timestamp: 2000,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
store.addNewStatuses({
statuses: [modStatus],
timestamp: 1991,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('calls useUsersStore().addNewUsers() even on older timestamp', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const modStatus = mockStatus({ id: '1', text: 'something else' })
store.addNewStatuses({
statuses: [status],
timestamp: 2000,
})
store.addNewStatuses({
statuses: [modStatus],
timestamp: 1991,
})
expect(usersStore.addNewUsers).to.have.callCount(2)
})
})
describe('fetchers', () => {
it.each([
[
'StatusSource',
{
content_type: 'text/plain',
text: 'Text',
spoiler_text: 'Text',
},
],
[
'EmojiReactions',
[
{
- accounts: [mockMastoAPIUser()],
+ accounts: [mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })],
count: 1,
me: false,
name: 'cofe',
url: '',
},
],
],
- ['Favs', [mockMastoAPIUser()]],
- ['Repeats', [mockMastoAPIUser()]],
+ ['Favs', [mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })]],
+ ['Repeats', [mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })]],
])('fetch%s', async (group, mockedResponse) => {
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockedResponse), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
+ const addNewUsers = vi.spyOn(useUsersStore(), 'addNewUsers')
let urlKey
let prefix = 'MASTODON'
if (group === 'Favs') {
urlKey = 'STATUS_FAVORITEDBY'
} else if (group === 'Repeats') {
urlKey = 'STATUS_REBLOGGEDBY'
} else {
urlKey = snakeCase(group).toUpperCase()
}
if (group === 'EmojiReactions') {
prefix = 'PLEROMA'
}
const url = PUBLIC_API[`${prefix}_${urlKey}_URL`]('id')
const store = useStatusesStore()
const status = mockStatus({ id: 'id' })
store.addNewStatuses({
statuses: [status],
timestamp: 2000,
})
const result = await store[`fetch${group}`]('id')
const updated = store.allStatuses.get('id')
+ // Fetch called
expect(mockFetch).to.have.been.calledWith(url, DEFAULT_OPTIONS())
+
+ // Users updated
+ if (group !== 'StatusSource') {
+ // first call is the one for the status
+ expect(addNewUsers).to.have.been.calledTwice
+ const secondCallData = addNewUsers.mock.calls[1][0].data
+ expect(secondCallData).to.have.length(2)
+ expect(secondCallData[0]).to.have.property('id', 'u1')
+ expect(secondCallData[1]).to.have.property('id', 'u2')
+ }
+
if (group === 'Favs') {
- expect(updated.favoritedBy).to.have.length(1)
- expect(updated.fave_num).to.eql(1)
+ expect(store.favs).to.have.length(1)
+ expect(store.favs.get('id')).to.have.length(2)
+ expect(store.favs.get('id')).to.eql(new Set(['u1', 'u2']))
+ expect(updated.fave_num).to.eql(2)
} else if (group === 'Repeats') {
- expect(updated.rebloggedBy).to.have.length(1)
- expect(updated.repeat_num).to.eql(1)
+ expect(store.repeats).to.have.length(1)
+ expect(store.repeats.get('id')).to.have.length(2)
+ expect(store.repeats.get('id')).to.eql(new Set(['u1', 'u2']))
+ expect(updated.repeat_num).to.eql(2)
} else if (group === 'EmojiReactions') {
expect(updated.emoji_reactions).to.have.length(mockedResponse.length)
expect(updated.emoji_reactions[0].name).to.eql(mockedResponse[0].name)
} else {
expect(result).to.eql(mockedResponse)
}
})
it('fetchFavsAndRepeats', async () => {
const store = useStatusesStore()
store.fetchFavs = vi.fn().mockResolvedValue(async () => {
/* no-op */
})
store.fetchRepeats = vi.fn().mockResolvedValue(async () => {
/* no-op */
})
await store.fetchFavsAndRepeats('id')
expect(store.fetchFavs).to.have.been.calledWith('id')
expect(store.fetchRepeats).to.have.been.calledWith('id')
})
it('fetchStatus', async () => {
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIStatus()), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useStatusesStore()
await store.fetchStatus('id')
expect(mockFetch).to.have.been.calledWith(
PUBLIC_API.MASTODON_STATUS_URL('id'),
DEFAULT_OPTIONS(),
)
expect(store.allStatuses).to.have.length(1)
})
})
describe('interactions', () => {
it.each([
['favorite', 'MASTODON_FAVORITE_URL'],
['unfavorite', 'MASTODON_UNFAVORITE_URL'],
['retweet', 'MASTODON_RETWEET_URL'],
['unretweet', 'MASTODON_UNRETWEET_URL'],
['reactWithEmoji', 'PLEROMA_EMOJI_REACT_URL', 'PUT'],
['unreactWithEmoji', 'PLEROMA_EMOJI_UNREACT_URL', 'DELETE'],
[
'bookmark',
'MASTODON_BOOKMARK_STATUS_URL',
undefined,
{ folder_id: 'argument' },
],
['unbookmark', 'MASTODON_UNBOOKMARK_STATUS_URL'],
['pinStatus', 'MASTODON_PIN_OWN_STATUS_URL'],
['unpinStatus', 'MASTODON_UNPIN_OWN_STATUS_URL'],
['muteConversation', 'MASTODON_MUTE_CONVERSATION_URL'],
['unmuteConversation', 'MASTODON_UNMUTE_CONVERSATION_URL'],
['deleteStatus', 'MASTODON_DELETE_URL', 'DELETE'],
])('%s - api call', async (interaction, urlKey, method = 'POST', body) => {
const url = USER_API[urlKey]('1', 'argument')
const status = mockStatus()
const store = useStatusesStore()
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
await store[interaction]('1', 'argument')
const expectedArg = DEFAULT_OPTIONS(method)
if (body) {
expectedArg.body = JSON.stringify(body)
}
expect(mockFetch).to.have.been.calledWith(url, expectedArg)
if (interaction === 'deleteStatus') {
expect(store.allStatuses.get('1').deleted).to.be.true
} else {
expect(store.allStatuses.get('1').text).to.eql('Updated')
}
})
const optimismInteractions = [
['favorite', 'favorited', 'fave_num'],
['retweet', 'repeated', 'repeat_num'],
['bookmark', 'bookmarked'],
['muteConversation', 'thread_muted'],
]
.map(([method, property, count]) => [
[method, property, count],
['un' + method, property, count],
])
.flat()
it.each(
optimismInteractions,
)('%s - optimism call', async (method, property, count) => {
// Prepare our status
const status = mockStatus()
const negate = method.startsWith('un')
const oldCount = 9
const newCount = negate ? 8 : 10
status[property] = negate
if (count) {
status[count] = oldCount
}
if (method === 'unbookmark') {
status.bookmark_folder_id = 'argument'
}
// Insert it
const store = useStatusesStore()
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), {
headers: { 'Content-Type': 'application/json' },
}),
)
expect(store.allStatuses.get('1')).to.have.property(property, negate)
if (count) {
expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
}
vi.stubGlobal('fetch', mockFetch)
store[method]('1', 'argument')
expect(store.allStatuses.get('1')).to.have.property(property, !negate)
if (count) {
expect(store.allStatuses.get('1')).to.have.property(count, newCount)
}
if (property === 'bookmarked') {
expect(store.allStatuses.get('1')).to.have.property(
'bookmark_folder_id',
'argument',
)
}
})
it.each(
optimismInteractions,
)('%s - optimism fail', async (method, property, count) => {
// Prepare our status
const status = mockStatus()
const negate = method.startsWith('un')
const oldCount = 9
status[property] = negate
if (count) {
status[count] = oldCount
}
if (method === 'unbookmark') {
status.bookmark_folder_id = 'argument'
}
// Insert it
const store = useStatusesStore()
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
const mockFetch = vi.fn()
mockFetch.mockRejectedValueOnce(new Error('Failure!'))
expect(store.allStatuses.get('1')).to.have.property(property, negate)
if (count) {
expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
}
vi.stubGlobal('fetch', mockFetch)
await store[method]('1', 'argument')
expect(store.allStatuses.get('1')).to.have.property(property, negate)
if (count) {
expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
}
// Failing 'bookmark' method SHOULD clear folder id
if (method === 'unbookmark') {
expect(store.allStatuses.get('1')).to.have.property(
'bookmark_folder_id',
'argument',
)
}
})
it.each([
['reactWithEmoji', 0],
['unreactWithEmoji', 1],
['reactWithEmoji', 1],
['unreactWithEmoji', 2],
])('%s count: %s - optimism call', async (method, oldCount) => {
// Prepare our status
const status = mockStatus()
const negate = method.startsWith('un')
const newCount = negate ? oldCount - 1 : oldCount + 1
useUsersStore().currentUser = mockUser()
if (oldCount > 0) {
const reactors = [...new Array(oldCount)].map((empty, index) =>
mockUser({ id: 'o' + index }),
)
if (negate) {
// Replace one of reactors with ourselves
reactors[0] = useUsersStore().currentUser
}
status.emoji_reactions = [
{
name: 'hyperlol',
count: oldCount,
accounts: reactors,
},
]
} else {
status.emoji_reactions = []
}
// Insert it
const store = useStatusesStore()
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const expected = store.allStatuses.get('1')
expect(expected.emoji_reactions).to.have.length(oldCount === 0 ? 0 : 1)
if (oldCount !== 0) {
expect(expected.emoji_reactions[0].name).to.eql('hyperlol')
expect(expected.emoji_reactions[0].count).to.eql(oldCount)
expect(expected.emoji_reactions[0].accounts).to.have.length(oldCount)
}
store[method]('1', 'hyperlol')
expect(expected.emoji_reactions).to.have.length(newCount === 0 ? 0 : 1)
if (newCount !== 0) {
expect(expected.emoji_reactions[0].name).to.eql('hyperlol')
expect(expected.emoji_reactions[0].count).to.eql(newCount)
expect(expected.emoji_reactions[0].accounts).to.have.length(newCount)
}
})
})
it('wipeUserStatuses', () => {
const store = useStatusesStore()
const statuses = [...new Array(20)].map((empty, index) =>
mockStatus({
id: 's' + index,
statusUser: mockUser({ id: 'u' + index }),
}),
)
store.addNewStatuses({
statuses,
timestamp: 1,
})
const result = store.wipeUserStatuses('u19')
expect(store.allStatuses).to.have.length(19)
expect(result).to.eql(new Set(['s19']))
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sun, Aug 30, 9:41 AM (1 d, 20 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1737889
Default Alt Text
(34 KB)

Event Timeline