Merge branch 'favorites' into 'develop'

Add Favorites TL to user profile, add some initial support for MastoAPI

Closes #265 and #262

See merge request pleroma/pleroma-fe!462
This commit is contained in:
HJ 2019-01-22 17:46:08 +00:00
commit 3a3cf1d48a
19 changed files with 2361 additions and 252 deletions

View file

@ -1,5 +1,5 @@
import Conversation from '../conversation/conversation.vue' import Conversation from '../conversation/conversation.vue'
import { find, toInteger } from 'lodash' import { find } from 'lodash'
const conversationPage = { const conversationPage = {
components: { components: {
@ -7,7 +7,7 @@ const conversationPage = {
}, },
computed: { computed: {
statusoid () { statusoid () {
const id = toInteger(this.$route.params.id) const id = this.$route.params.id
const statuses = this.$store.state.statuses.allStatuses const statuses = this.$store.state.statuses.allStatuses
const status = find(statuses, {id}) const status = find(statuses, {id})

View file

@ -1,9 +1,8 @@
import { reduce, filter, sortBy } from 'lodash' import { reduce, filter, sortBy } from 'lodash'
import { statusType } from '../../modules/statuses.js'
import Status from '../status/status.vue' import Status from '../status/status.vue'
const sortAndFilterConversation = (conversation) => { const sortAndFilterConversation = (conversation) => {
conversation = filter(conversation, (status) => statusType(status) !== 'retweet') conversation = filter(conversation, (status) => status.type !== 'retweet')
return sortBy(conversation, 'id') return sortBy(conversation, 'id')
} }
@ -18,10 +17,12 @@ const conversation = {
'collapsable' 'collapsable'
], ],
computed: { computed: {
status () { return this.statusoid }, status () {
return this.statusoid
},
conversation () { conversation () {
if (!this.status) { if (!this.status) {
return false return []
} }
const conversationId = this.status.statusnet_conversation_id const conversationId = this.status.statusnet_conversation_id
@ -32,7 +33,9 @@ const conversation = {
replies () { replies () {
let i = 1 let i = 1
return reduce(this.conversation, (result, {id, in_reply_to_status_id}) => { return reduce(this.conversation, (result, {id, in_reply_to_status_id}) => {
const irid = Number(in_reply_to_status_id) /* eslint-disable camelcase */
const irid = in_reply_to_status_id
/* eslint-enable camelcase */
if (irid) { if (irid) {
result[irid] = result[irid] || [] result[irid] = result[irid] || []
result[irid].push({ result[irid].push({
@ -69,7 +72,6 @@ const conversation = {
} }
}, },
getReplies (id) { getReplies (id) {
id = Number(id)
return this.replies[id] || [] return this.replies[id] || []
}, },
focused (id) { focused (id) {
@ -80,7 +82,7 @@ const conversation = {
} }
}, },
setHighlight (id) { setHighlight (id) {
this.highlight = Number(id) this.highlight = id
} }
} }
} }

View file

@ -129,7 +129,7 @@ const Status = {
if (this.status.user.id === this.$store.state.users.currentUser.id) { if (this.status.user.id === this.$store.state.users.currentUser.id) {
return false return false
} }
if (this.status.activity_type === 'repeat') { if (this.status.type === 'retweet') {
return false return false
} }
var checkFollowing = this.$store.state.config.replyVisibility === 'following' var checkFollowing = this.$store.state.config.replyVisibility === 'following'
@ -258,7 +258,7 @@ const Status = {
}, },
replyEnter (id, event) { replyEnter (id, event) {
this.showPreview = true this.showPreview = true
const targetId = Number(id) const targetId = id
const statuses = this.$store.state.statuses.allStatuses const statuses = this.$store.state.statuses.allStatuses
if (!this.preview) { if (!this.preview) {
@ -283,7 +283,6 @@ const Status = {
}, },
watch: { watch: {
'highlight': function (id) { 'highlight': function (id) {
id = Number(id)
if (this.status.id === id) { if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect() let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) { if (rect.top < 100) {

View file

@ -6,18 +6,26 @@ export default Vue.component('tab-switcher', {
name: 'TabSwitcher', name: 'TabSwitcher',
data () { data () {
return { return {
active: 0 active: this.$slots.default.findIndex(_ => _.tag)
} }
}, },
methods: { methods: {
activateTab(index) { activateTab (index) {
return () => this.active = index; return () => {
this.active = index
}
} }
}, },
render(h) { beforeUpdate () {
const currentSlot = this.$slots.default[this.active]
if (!currentSlot.tag) {
this.active = this.$slots.default.findIndex(_ => _.tag)
}
},
render (h) {
const tabs = this.$slots.default const tabs = this.$slots.default
.filter(slot => slot.data)
.map((slot, index) => { .map((slot, index) => {
if (!slot.tag) return
const classesTab = ['tab'] const classesTab = ['tab']
const classesWrapper = ['tab-wrapper'] const classesWrapper = ['tab-wrapper']
@ -25,20 +33,24 @@ export default Vue.component('tab-switcher', {
classesTab.push('active') classesTab.push('active')
classesWrapper.push('active') classesWrapper.push('active')
} }
return ( return (
<div class={ classesWrapper.join(' ')}> <div class={ classesWrapper.join(' ')}>
<button onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button> <button onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button>
</div> </div>
) )
}); })
const contents = this.$slots.default.filter(_=>_.data).map(( slot, index ) => {
const contents = this.$slots.default.map((slot, index) => {
if (!slot.tag) return
const active = index === this.active const active = index === this.active
return ( return (
<div class={active ? 'active' : 'hidden'}> <div class={active ? 'active' : 'hidden'}>
{slot} {slot}
</div> </div>
) )
}); })
return ( return (
<div class="tab-switcher"> <div class="tab-switcher">
<div class="tabs"> <div class="tabs">

View file

@ -5,24 +5,33 @@ import Timeline from '../timeline/timeline.vue'
const UserProfile = { const UserProfile = {
created () { created () {
this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' })
this.$store.dispatch('startFetching', ['user', this.fetchBy]) this.$store.dispatch('startFetching', ['user', this.fetchBy])
this.$store.dispatch('startFetching', ['favorites', this.fetchBy])
if (!this.user.id) { if (!this.user.id) {
this.$store.dispatch('fetchUser', this.fetchBy) this.$store.dispatch('fetchUser', this.fetchBy)
} }
}, },
destroyed () { destroyed () {
this.$store.dispatch('stopFetching', 'user') this.$store.dispatch('stopFetching', 'user')
this.$store.dispatch('stopFetching', 'favorites')
}, },
computed: { computed: {
timeline () { timeline () {
return this.$store.state.statuses.timelines.user return this.$store.state.statuses.timelines.user
}, },
favorites () {
return this.$store.state.statuses.timelines.favorites
},
userId () { userId () {
return this.$route.params.id || this.user.id return this.$route.params.id || this.user.id
}, },
userName () { userName () {
return this.$route.params.name || this.user.screen_name return this.$route.params.name || this.user.screen_name
}, },
isUs () {
return this.userId === this.$store.state.users.currentUser.id
},
friends () { friends () {
return this.user.friends return this.user.friends
}, },
@ -62,21 +71,28 @@ const UserProfile = {
} }
}, },
watch: { watch: {
// TODO get rid of this copypasta
userName () { userName () {
if (this.isExternal) { if (this.isExternal) {
return return
} }
this.$store.dispatch('stopFetching', 'user') this.$store.dispatch('stopFetching', 'user')
this.$store.dispatch('stopFetching', 'favorites')
this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' })
this.$store.dispatch('startFetching', ['user', this.fetchBy]) this.$store.dispatch('startFetching', ['user', this.fetchBy])
this.$store.dispatch('startFetching', ['favorites', this.fetchBy])
}, },
userId () { userId () {
if (!this.isExternal) { if (!this.isExternal) {
return return
} }
this.$store.dispatch('stopFetching', 'user') this.$store.dispatch('stopFetching', 'user')
this.$store.dispatch('stopFetching', 'favorites')
this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' })
this.$store.dispatch('startFetching', ['user', this.fetchBy]) this.$store.dispatch('startFetching', ['user', this.fetchBy])
this.$store.dispatch('startFetching', ['favorites', this.fetchBy])
}, },
user () { user () {
if (this.user.id && !this.user.followers) { if (this.user.id && !this.user.followers) {

View file

@ -20,6 +20,7 @@
<i class="icon-spin3 animate-spin"></i> <i class="icon-spin3 animate-spin"></i>
</div> </div>
</div> </div>
<Timeline v-if="isUs" :label="$t('user_card.favorites')" :embedded="true" :title="$t('user_profile.favorites_title')" timeline-name="favorites" :timeline="favorites"/>
</tab-switcher> </tab-switcher>
</div> </div>
<div v-else class="panel user-profile-placeholder"> <div v-else class="panel user-profile-placeholder">

View file

@ -324,6 +324,7 @@
"block": "Block", "block": "Block",
"blocked": "Blocked!", "blocked": "Blocked!",
"deny": "Deny", "deny": "Deny",
"favorites": "Favorites",
"follow": "Follow", "follow": "Follow",
"follow_sent": "Request sent!", "follow_sent": "Request sent!",
"follow_progress": "Requesting…", "follow_progress": "Requesting…",

View file

@ -279,6 +279,7 @@
"user_card": { "user_card": {
"block": "Заблокировать", "block": "Заблокировать",
"blocked": "Заблокирован", "blocked": "Заблокирован",
"favorites": "Понравившиеся",
"follow": "Читать", "follow": "Читать",
"follow_sent": "Запрос отправлен!", "follow_sent": "Запрос отправлен!",
"follow_progress": "Запрашиваем…", "follow_progress": "Запрашиваем…",

View file

@ -1,8 +1,8 @@
import { includes, remove, slice, sortBy, toInteger, each, find, flatten, maxBy, minBy, merge, last, isArray } from 'lodash' import { remove, slice, each, find, maxBy, minBy, merge, last, isArray } from 'lodash'
import apiService from '../services/api/api.service.js' import apiService from '../services/api/api.service.js'
// import parse from '../services/status_parser/status_parser.js' // import parse from '../services/status_parser/status_parser.js'
const emptyTl = (userId = 0) => ({ const emptyTl = () => ({
statuses: [], statuses: [],
statusesObject: {}, statusesObject: {},
faves: [], faves: [],
@ -14,8 +14,8 @@ const emptyTl = (userId = 0) => ({
loading: false, loading: false,
followers: [], followers: [],
friends: [], friends: [],
flushMarker: 0, userId: 0,
userId flushMarker: 0
}) })
export const defaultState = { export const defaultState = {
@ -36,6 +36,7 @@ export const defaultState = {
mentions: emptyTl(), mentions: emptyTl(),
public: emptyTl(), public: emptyTl(),
user: emptyTl(), user: emptyTl(),
favorites: emptyTl(),
publicAndExternal: emptyTl(), publicAndExternal: emptyTl(),
friends: emptyTl(), friends: emptyTl(),
tag: emptyTl(), tag: emptyTl(),
@ -43,20 +44,7 @@ export const defaultState = {
} }
} }
const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return includes(status.tags, 'nsfw') || !!status.text.match(nsfwRegex)
}
export const prepareStatus = (status) => { export const prepareStatus = (status) => {
// Parse nsfw tags
if (status.nsfw === undefined) {
status.nsfw = isNsfw(status)
if (status.retweeted_status) {
status.nsfw = status.retweeted_status.nsfw
}
}
// Set deleted flag // Set deleted flag
status.deleted = false status.deleted = false
@ -75,35 +63,6 @@ const visibleNotificationTypes = (rootState) => {
].filter(_ => _) ].filter(_ => _)
} }
export const statusType = (status) => {
if (status.is_post_verb) {
return 'status'
}
if (status.retweeted_status) {
return 'retweet'
}
if ((typeof status.uri === 'string' && status.uri.match(/(fave|objectType=Favourite)/)) ||
(typeof status.text === 'string' && status.text.match(/favorited/))) {
return 'favorite'
}
if (status.text.match(/deleted notice {{tag/) || status.qvitter_delete_notice) {
return 'deletion'
}
if (status.text.match(/started following/) || status.activity_type === 'follow') {
return 'follow'
}
return 'unknown'
}
export const findMaxId = (...args) => {
return (maxBy(flatten(args), 'id') || {}).id
}
const mergeOrAdd = (arr, obj, item) => { const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id] const oldItem = obj[item.id]
@ -122,9 +81,11 @@ const mergeOrAdd = (arr, obj, item) => {
} }
} }
const sortById = (a, b) => a.id > b.id ? -1 : 1
const sortTimeline = (timeline) => { const sortTimeline = (timeline) => {
timeline.visibleStatuses = sortBy(timeline.visibleStatuses, ({id}) => -id) timeline.visibleStatuses = timeline.visibleStatuses.sort(sortById)
timeline.statuses = sortBy(timeline.statuses, ({id}) => -id) timeline.statuses = timeline.statuses.sort(sortById)
timeline.minVisibleId = (last(timeline.visibleStatuses) || {}).id timeline.minVisibleId = (last(timeline.visibleStatuses) || {}).id
return timeline return timeline
} }
@ -153,13 +114,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
return return
} }
const addStatus = (status, showImmediately, addToTimeline = true) => { const addStatus = (data, showImmediately, addToTimeline = true) => {
const result = mergeOrAdd(allStatuses, allStatusesObject, status) const result = mergeOrAdd(allStatuses, allStatusesObject, data)
status = result.item const status = result.item
if (result.new) { if (result.new) {
// We are mentioned in a post // We are mentioned in a post
if (statusType(status) === 'status' && find(status.attentions, { id: user.id })) { if (status.type === 'status' && find(status.attentions, { id: user.id })) {
const mentions = state.timelines.mentions const mentions = state.timelines.mentions
// Add the mention to the mentions timeline // Add the mention to the mentions timeline
@ -200,7 +161,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
} }
const favoriteStatus = (favorite, counter) => { const favoriteStatus = (favorite, counter) => {
const status = find(allStatuses, { id: toInteger(favorite.in_reply_to_status_id) }) const status = find(allStatuses, { id: favorite.in_reply_to_status_id })
if (status) { if (status) {
// This is our favorite, so the relevant bit. // This is our favorite, so the relevant bit.
if (favorite.user.id === user.id) { if (favorite.user.id === user.id) {
@ -263,6 +224,9 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
remove(timelineObject.visibleStatuses, { uri }) remove(timelineObject.visibleStatuses, { uri })
} }
}, },
'follow': (follow) => {
// NOOP, it is known status but we don't do anything about it for now
},
'default': (unknown) => { 'default': (unknown) => {
console.log('unknown status type') console.log('unknown status type')
console.log(unknown) console.log(unknown)
@ -270,7 +234,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
} }
each(statuses, (status) => { each(statuses, (status) => {
const type = statusType(status) const type = status.type
const processor = processors[type] || processors['default'] const processor = processors[type] || processors['default']
processor(status) processor(status)
}) })
@ -288,42 +252,36 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
const allStatuses = state.allStatuses const allStatuses = state.allStatuses
const allStatusesObject = state.allStatusesObject const allStatusesObject = state.allStatusesObject
each(notifications, (notification) => { each(notifications, (notification) => {
const result = mergeOrAdd(allStatuses, allStatusesObject, notification.notice) notification.action = mergeOrAdd(allStatuses, allStatusesObject, notification.action).item
const action = result.item notification.status = notification.status && mergeOrAdd(allStatuses, allStatusesObject, notification.status).item
// Only add a new notification if we don't have one for the same action // Only add a new notification if we don't have one for the same action
if (!find(state.notifications.data, (oldNotification) => oldNotification.action.id === action.id)) { if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
state.notifications.maxId = Math.max(notification.id, state.notifications.maxId) state.notifications.maxId = notification.id > state.notifications.maxId
state.notifications.minId = Math.min(notification.id, state.notifications.minId) ? notification.id
: state.notifications.maxId
state.notifications.minId = notification.id < state.notifications.minId
? notification.id
: state.notifications.minId
const fresh = !notification.is_seen state.notifications.data.push(notification)
const status = notification.ntype === 'like' state.notifications.idStore[notification.id] = notification
? action.favorited_status
: action
const result = {
type: notification.ntype,
status,
action,
seen: !fresh
}
state.notifications.data.push(result)
state.notifications.idStore[notification.id] = result
if ('Notification' in window && window.Notification.permission === 'granted') { if ('Notification' in window && window.Notification.permission === 'granted') {
const notifObj = {}
const action = notification.action
const title = action.user.name const title = action.user.name
const result = {} notifObj.icon = action.user.profile_image_url
result.icon = action.user.profile_image_url notifObj.body = action.text // there's a problem that it doesn't put a space before links tho
result.body = action.text // there's a problem that it doesn't put a space before links tho
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow... // Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (action.attachments && action.attachments.length > 0 && !action.nsfw && if (action.attachments && action.attachments.length > 0 && !action.nsfw &&
action.attachments[0].mimetype.startsWith('image/')) { action.attachments[0].mimetype.startsWith('image/')) {
result.image = action.attachments[0].url notifObj.image = action.attachments[0].url
} }
if (fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) { if (notification.fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) {
let notification = new window.Notification(title, result) let notification = new window.Notification(title, notifObj)
// Chrome is known for not closing notifications automatically // Chrome is known for not closing notifications automatically
// according to MDN, anyway. // according to MDN, anyway.
setTimeout(notification.close.bind(notification), 5000) setTimeout(notification.close.bind(notification), 5000)
@ -346,7 +304,7 @@ export const mutations = {
each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status }) each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status })
}, },
clearTimeline (state, { timeline }) { clearTimeline (state, { timeline }) {
state.timelines[timeline] = emptyTl(state.timelines[timeline].userId) state.timelines[timeline] = emptyTl()
}, },
setFavorited (state, { status, value }) { setFavorited (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id] const newStatus = state.allStatusesObject[status.id]

View file

@ -68,6 +68,7 @@ export const mutations = {
}, },
setUserForNotification (state, notification) { setUserForNotification (state, notification) {
notification.action.user = state.usersObject[notification.action.user.id] notification.action.user = state.usersObject[notification.action.user.id]
notification.from_profile = state.usersObject[notification.action.user.id]
}, },
setColor (state, { user: { id }, highlighted }) { setColor (state, { user: { id }, highlighted }) {
const user = state.usersObject[id] const user = state.usersObject[id]
@ -149,8 +150,8 @@ const users = {
}) })
}, },
addNewNotifications (store, { notifications }) { addNewNotifications (store, { notifications }) {
const users = compact(map(notifications, 'from_profile')) const users = map(notifications, 'from_profile')
const notificationIds = compact(notifications.map(_ => String(_.id))) const notificationIds = notifications.map(_ => _.id)
store.commit('addNewUsers', users) store.commit('addNewUsers', users)
const notificationsObject = store.rootState.statuses.notifications.idStore const notificationsObject = store.rootState.statuses.notifications.idStore
@ -206,10 +207,9 @@ const users = {
const commit = store.commit const commit = store.commit
commit('beginLogin') commit('beginLogin')
store.rootState.api.backendInteractor.verifyCredentials(accessToken) store.rootState.api.backendInteractor.verifyCredentials(accessToken)
.then((response) => { .then((data) => {
if (response.ok) { if (!data.error) {
response.json() const user = data
.then((user) => {
// user.credentials = userCredentials // user.credentials = userCredentials
user.credentials = accessToken user.credentials = accessToken
commit('setCurrentUser', user) commit('setCurrentUser', user)
@ -237,8 +237,8 @@ const users = {
// Fetch our friends // Fetch our friends
store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends)) .then((friends) => commit('addNewUsers', friends))
})
} else { } else {
const response = data.error
// Authentication failed // Authentication failed
commit('endLogin') commit('endLogin')
if (response.status === 401) { if (response.status === 401) {

View file

@ -41,7 +41,10 @@ const APPROVE_USER_URL = '/api/pleroma/friendships/approve'
const DENY_USER_URL = '/api/pleroma/friendships/deny' const DENY_USER_URL = '/api/pleroma/friendships/deny'
const SUGGESTIONS_URL = '/api/v1/suggestions' const SUGGESTIONS_URL = '/api/v1/suggestions'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
import { each, map } from 'lodash' import { each, map } from 'lodash'
import { parseStatus, parseUser, parseNotification } from '../entity_normalizer/entity_normalizer.service.js'
import 'whatwg-fetch' import 'whatwg-fetch'
const oldfetch = window.fetch const oldfetch = window.fetch
@ -70,6 +73,7 @@ const updateAvatar = ({credentials, params}) => {
form.append(key, value) form.append(key, value)
} }
}) })
return fetch(url, { return fetch(url, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST', method: 'POST',
@ -87,6 +91,7 @@ const updateBg = ({credentials, params}) => {
form.append(key, value) form.append(key, value)
} }
}) })
return fetch(url, { return fetch(url, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST', method: 'POST',
@ -110,6 +115,7 @@ const updateBanner = ({credentials, params}) => {
form.append(key, value) form.append(key, value)
} }
}) })
return fetch(url, { return fetch(url, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST', method: 'POST',
@ -237,24 +243,28 @@ const fetchUser = ({id, credentials}) => {
let url = `${USER_URL}?user_id=${id}` let url = `${USER_URL}?user_id=${id}`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => parseUser(data))
} }
const fetchFriends = ({id, credentials}) => { const fetchFriends = ({id, credentials}) => {
let url = `${FRIENDS_URL}?user_id=${id}` let url = `${FRIENDS_URL}?user_id=${id}`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseUser))
} }
const fetchFollowers = ({id, credentials}) => { const fetchFollowers = ({id, credentials}) => {
let url = `${FOLLOWERS_URL}?user_id=${id}` let url = `${FOLLOWERS_URL}?user_id=${id}`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseUser))
} }
const fetchAllFollowing = ({username, credentials}) => { const fetchAllFollowing = ({username, credentials}) => {
const url = `${ALL_FOLLOWING_URL}/${username}.json` const url = `${ALL_FOLLOWING_URL}/${username}.json`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseUser))
} }
const fetchFollowRequests = ({credentials}) => { const fetchFollowRequests = ({credentials}) => {
@ -266,13 +276,27 @@ const fetchFollowRequests = ({credentials}) => {
const fetchConversation = ({id, credentials}) => { const fetchConversation = ({id, credentials}) => {
let url = `${CONVERSATION_URL}/${id}.json?count=100` let url = `${CONVERSATION_URL}/${id}.json?count=100`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseStatus))
} }
const fetchStatus = ({id, credentials}) => { const fetchStatus = ({id, credentials}) => {
let url = `${STATUS_URL}/${id}.json` let url = `${STATUS_URL}/${id}.json`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json()) .then((data) => data.json())
.then((data) => parseStatus(data))
} }
const setUserMute = ({id, credentials, muted = true}) => { const setUserMute = ({id, credentials, muted = true}) => {
@ -300,13 +324,14 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use
notifications: QVITTER_USER_NOTIFICATIONS_URL, notifications: QVITTER_USER_NOTIFICATIONS_URL,
'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL, 'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL,
user: QVITTER_USER_TIMELINE_URL, user: QVITTER_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: TAG_TIMELINE_URL tag: TAG_TIMELINE_URL
} }
const isNotifications = timeline === 'notifications'
const params = []
let url = timelineUrls[timeline] let url = timelineUrls[timeline]
let params = []
if (since) { if (since) {
params.push(['since_id', since]) params.push(['since_id', since])
} }
@ -330,9 +355,10 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use
if (data.ok) { if (data.ok) {
return data return data
} }
throw new Error('Error fetching timeline') throw new Error('Error fetching timeline', data)
}) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(isNotifications ? parseNotification : parseStatus))
} }
const verifyCredentials = (user) => { const verifyCredentials = (user) => {
@ -340,6 +366,16 @@ const verifyCredentials = (user) => {
method: 'POST', method: 'POST',
headers: authHeaders(user) headers: authHeaders(user)
}) })
.then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response
}
}
})
.then((data) => data.error ? data : parseUser(data))
} }
const favorite = ({ id, credentials }) => { const favorite = ({ id, credentials }) => {
@ -391,6 +427,16 @@ const postStatus = ({credentials, status, spoilerText, visibility, sensitive, me
method: 'POST', method: 'POST',
headers: authHeaders(credentials) headers: authHeaders(credentials)
}) })
.then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response
}
}
})
.then((data) => data.error ? data : parseStatus(data))
} }
const deleteStatus = ({ id, credentials }) => { const deleteStatus = ({ id, credentials }) => {

View file

@ -0,0 +1,263 @@
const qvitterStatusType = (status) => {
if (status.is_post_verb) {
return 'status'
}
if (status.retweeted_status) {
return 'retweet'
}
if ((typeof status.uri === 'string' && status.uri.match(/(fave|objectType=Favourite)/)) ||
(typeof status.text === 'string' && status.text.match(/favorited/))) {
return 'favorite'
}
if (status.text.match(/deleted notice {{tag/) || status.qvitter_delete_notice) {
return 'deletion'
}
if (status.text.match(/started following/) || status.activity_type === 'follow') {
return 'follow'
}
return 'unknown'
}
export const parseUser = (data) => {
const output = {}
const masto = data.hasOwnProperty('acct')
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = masto && !data.hasOwnProperty('avatar')
output.id = String(data.id)
if (masto) {
output.screen_name = data.acct
// There's nothing else to get
if (mastoShort) {
return output
}
output.name = null // missing
output.name_html = data.display_name
output.description = null // missing
output.description_html = data.note
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
output.statusnet_profile_url = data.url
if (data.pleroma) {
const pleroma = data.pleroma
output.follows_you = pleroma.follows_you
output.statusnet_blocking = pleroma.statusnet_blocking
output.muted = pleroma.muted
}
// Missing, trying to recover
output.is_local = !output.screen_name.includes('@')
} else {
output.screen_name = data.screen_name
output.name = data.name
output.name_html = data.name_html
output.description = data.description
output.description_html = data.description_html
output.profile_image_url = data.profile_image_url
output.profile_image_url_original = data.profile_image_url_original
output.cover_photo = data.cover_photo
output.friends_count = data.friends_count
output.bot = null // missing
output.statusnet_profile_url = data.statusnet_profile_url
output.statusnet_blocking = data.statusnet_blocking
output.is_local = data.is_local
output.follows_you = data.follows_you
output.muted = data.muted
// QVITTER ONLY FOR NOW
// Really only applies to logged in user, really.. I THINK
output.rights = data.rights
output.no_rich_text = data.no_rich_text
output.default_scope = data.default_scope
output.hide_network = data.hide_network
output.background_image = data.background_image
}
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.followers_count = data.followers_count
output.statuses_count = data.statuses_count
return output
}
const parseAttachment = (data) => {
const output = {}
const masto = !data.hasOwnProperty('oembed')
if (masto) {
// Not exactly same...
output.mimetype = data.type
output.meta = data.meta // not present in BE yet
} else {
output.mimetype = data.mimetype
output.meta = null // missing
}
output.url = data.url
output.description = data.description
return output
}
export const parseStatus = (data) => {
const output = {}
const masto = data.hasOwnProperty('account')
if (masto) {
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.statusnet_html = data.content
// Not exactly the same but works?
output.text = data.content
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
// Missing!! fix in UI?
output.in_reply_to_screen_name = null
// Not exactly the same but works
output.statusnet_conversation_id = data.id
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary = data.spoiler_text
output.external_url = data.url
// FIXME missing!!
output.is_local = false
} else {
output.favorited = data.favorited
output.fave_num = data.fave_num
output.repeated = data.repeated
output.repeat_num = data.repeat_num
// catchall, temporary
// Object.assign(output, data)
output.type = qvitterStatusType(data)
if (data.nsfw === undefined) {
output.nsfw = isNsfw(data)
if (data.retweeted_status) {
output.nsfw = data.retweeted_status.nsfw
}
} else {
output.nsfw = data.nsfw
}
output.statusnet_html = data.statusnet_html
output.text = data.text
output.in_reply_to_status_id = data.in_reply_to_status_id
output.in_reply_to_user_id = data.in_reply_to_user_id
output.in_reply_to_screen_name = data.in_reply_to_screen_name
output.statusnet_conversation_id = data.statusnet_conversation_id
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.retweeted_status)
}
output.summary = data.summary
output.external_url = data.external_url
output.is_local = data.is_local
}
output.id = String(data.id)
output.visibility = data.visibility
output.created_at = new Date(data.created_at)
output.user = parseUser(masto ? data.account : data.user)
output.attentions = ((masto ? data.mentions : data.attentions) || []).map(parseUser)
output.attachments = ((masto ? data.media_attachments : data.attachments) || [])
.map(parseAttachment)
const retweetedStatus = masto ? data.reblog : data.retweeted_status
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
return output
}
export const parseNotification = (data) => {
const mastoDict = {
'favourite': 'like',
'reblog': 'repeat'
}
const masto = !data.hasOwnProperty('ntype')
const output = {}
if (masto) {
output.type = mastoDict[data.type] || data.type
output.seen = null // missing
output.status = parseStatus(data.status)
output.action = output.status // not sure
output.from_profile = parseUser(data.account)
} else {
const parsedNotice = parseStatus(data.notice)
output.type = data.ntype
output.seen = Boolean(data.is_seen)
output.status = output.type === 'like'
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
output.from_profile = parseUser(data.from_profile)
}
output.created_at = new Date(data.created_at)
output.id = String(data.id)
return output
}
const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!status.text.match(nsfwRegex)
}

View file

@ -10,8 +10,8 @@ export const visibleTypes = store => ([
].filter(_ => _)) ].filter(_ => _))
export const visibleNotificationsFromStore = store => { export const visibleNotificationsFromStore = store => {
// Don't know why, but sortBy([seen, -action.id]) doesn't work. // map is just to clone the array since sort mutates it and it causes some issues
let sortedNotifications = sortBy(notificationsFromStore(store), ({action}) => -action.id) let sortedNotifications = notificationsFromStore(store).map(_ => _).sort((a, b) => a.action.id > b.action.id ? -1 : 1)
sortedNotifications = sortBy(sortedNotifications, 'seen') sortedNotifications = sortBy(sortedNotifications, 'seen')
return sortedNotifications.filter((notification) => visibleTypes(store).includes(notification.type)) return sortedNotifications.filter((notification) => visibleTypes(store).includes(notification.type))
} }

View file

@ -5,7 +5,6 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, media =
const mediaIds = map(media, 'id') const mediaIds = map(media, 'id')
return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType, noAttachmentLinks: store.state.instance.noAttachmentLinks}) return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType, noAttachmentLinks: store.state.instance.noAttachmentLinks})
.then((data) => data.json())
.then((data) => { .then((data) => {
if (!data.error) { if (!data.error) {
store.dispatch('addNewStatuses', { store.dispatch('addNewStatuses', {

1582
test/fixtures/mastoapi.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,76 +1,31 @@
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { defaultState, mutations, findMaxId, prepareStatus, statusType } from '../../../../src/modules/statuses.js' import { defaultState, mutations, prepareStatus } from '../../../../src/modules/statuses.js'
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
const makeMockStatus = ({id, text, is_post_verb = true}) => { const makeMockStatus = ({id, text, type = 'status'}) => {
return { return {
id, id,
user: {id: 0}, user: {id: '0'},
name: 'status', name: 'status',
text: text || `Text number ${id}`, text: text || `Text number ${id}`,
fave_num: 0, fave_num: 0,
uri: '', uri: '',
is_post_verb, type,
attentions: [] attentions: []
} }
} }
describe('Statuses.statusType', () => {
it('identifies favorites', () => {
const fav = {
uri: 'tag:soykaf.com,2016-08-21:fave:2558:note:339495:2016-08-21T16:54:04+00:00'
}
const mastoFav = {
uri: 'tag:mastodon.social,2016-11-27:objectId=73903:objectType=Favourite'
}
expect(statusType(fav)).to.eql('favorite')
expect(statusType(mastoFav)).to.eql('favorite')
})
})
describe('Statuses.prepareStatus', () => { describe('Statuses.prepareStatus', () => {
it('sets nsfw for statuses with the #nsfw tag', () => {
const safe = makeMockStatus({id: 1, text: 'Hello oniichan'})
const nsfw = makeMockStatus({id: 1, text: 'Hello oniichan #nsfw'})
expect(prepareStatus(safe).nsfw).to.eq(false)
expect(prepareStatus(nsfw).nsfw).to.eq(true)
})
it('leaves existing nsfw settings alone', () => {
const nsfw = makeMockStatus({id: 1, text: 'Hello oniichan #nsfw'})
nsfw.nsfw = false
expect(prepareStatus(nsfw).nsfw).to.eq(false)
})
it('sets deleted flag to false', () => { it('sets deleted flag to false', () => {
const aStatus = makeMockStatus({id: 1, text: 'Hello oniichan'}) const aStatus = makeMockStatus({id: '1', text: 'Hello oniichan'})
expect(prepareStatus(aStatus).deleted).to.eq(false) expect(prepareStatus(aStatus).deleted).to.eq(false)
}) })
}) })
describe('Statuses.findMaxId', () => {
it('returns the largest id in any of the given arrays', () => {
const statusesOne = [{ id: 100 }, { id: 2 }]
const statusesTwo = [{ id: 3 }]
const maxId = findMaxId(statusesOne, statusesTwo)
expect(maxId).to.eq(100)
})
it('returns undefined for empty arrays', () => {
const maxId = findMaxId([], [])
expect(maxId).to.eq(undefined)
})
})
describe('The Statuses module', () => { describe('The Statuses module', () => {
it('adds the status to allStatuses and to the given timeline', () => { it('adds the status to allStatuses and to the given timeline', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
@ -82,7 +37,7 @@ describe('The Statuses module', () => {
it('counts the status as new if it has not been seen on this timeline', () => { it('counts the status as new if it has not been seen on this timeline', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], timeline: 'friends' }) mutations.addNewStatuses(state, { statuses: [status], timeline: 'friends' })
@ -100,7 +55,7 @@ describe('The Statuses module', () => {
it('add the statuses to allStatuses if no timeline is given', () => { it('add the statuses to allStatuses if no timeline is given', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status] }) mutations.addNewStatuses(state, { statuses: [status] })
@ -112,7 +67,7 @@ describe('The Statuses module', () => {
it('adds the status to allStatuses and to the given timeline, directly visible', () => { it('adds the status to allStatuses and to the given timeline, directly visible', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
@ -124,10 +79,10 @@ describe('The Statuses module', () => {
it('removes statuses by tag on deletion', () => { it('removes statuses by tag on deletion', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
const otherStatus = makeMockStatus({id: 3}) const otherStatus = makeMockStatus({id: '3'})
status.uri = 'xxx' status.uri = 'xxx'
const deletion = makeMockStatus({id: 2, is_post_verb: false}) const deletion = makeMockStatus({id: '2', type: 'deletion'})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.' deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx' deletion.uri = 'xxx'
@ -137,36 +92,36 @@ describe('The Statuses module', () => {
expect(state.allStatuses).to.eql([otherStatus]) expect(state.allStatuses).to.eql([otherStatus])
expect(state.timelines.public.statuses).to.eql([otherStatus]) expect(state.timelines.public.statuses).to.eql([otherStatus])
expect(state.timelines.public.visibleStatuses).to.eql([otherStatus]) expect(state.timelines.public.visibleStatuses).to.eql([otherStatus])
expect(state.timelines.public.maxId).to.eql(3) expect(state.timelines.public.maxId).to.eql('3')
}) })
it('does not update the maxId when the noIdUpdate flag is set', () => { it('does not update the maxId when the noIdUpdate flag is set', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
const secondStatus = makeMockStatus({id: 2}) const secondStatus = makeMockStatus({id: '2'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.maxId).to.eql(1) expect(state.timelines.public.maxId).to.eql('1')
mutations.addNewStatuses(state, { statuses: [secondStatus], showImmediately: true, timeline: 'public', noIdUpdate: true }) mutations.addNewStatuses(state, { statuses: [secondStatus], showImmediately: true, timeline: 'public', noIdUpdate: true })
expect(state.timelines.public.statuses).to.eql([secondStatus, status]) expect(state.timelines.public.statuses).to.eql([secondStatus, status])
expect(state.timelines.public.visibleStatuses).to.eql([secondStatus, status]) expect(state.timelines.public.visibleStatuses).to.eql([secondStatus, status])
expect(state.timelines.public.maxId).to.eql(1) expect(state.timelines.public.maxId).to.eql('1')
}) })
it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => { it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const nonVisibleStatus = makeMockStatus({id: 1}) const nonVisibleStatus = makeMockStatus({id: '1'})
const status = makeMockStatus({id: 3}) const status = makeMockStatus({id: '3'})
const statusTwo = makeMockStatus({id: 2}) const statusTwo = makeMockStatus({id: '2'})
const statusThree = makeMockStatus({id: 4}) const statusThree = makeMockStatus({id: '4'})
mutations.addNewStatuses(state, { statuses: [nonVisibleStatus], showImmediately: false, timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [nonVisibleStatus], showImmediately: false, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [statusTwo], showImmediately: true, timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [statusTwo], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.minVisibleId).to.equal(2) expect(state.timelines.public.minVisibleId).to.equal('2')
mutations.addNewStatuses(state, { statuses: [statusThree], showImmediately: true, timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [statusThree], showImmediately: true, timeline: 'public' })
@ -176,9 +131,9 @@ describe('The Statuses module', () => {
it('splits retweets from their status and links them', () => { it('splits retweets from their status and links them', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
const retweet = makeMockStatus({id: 2, is_post_verb: false}) const retweet = makeMockStatus({id: '2', type: 'retweet'})
const modStatus = makeMockStatus({id: 1, text: 'something else'}) const modStatus = makeMockStatus({id: '1', text: 'something else'})
retweet.retweeted_status = status retweet.retweeted_status = status
@ -187,22 +142,22 @@ describe('The Statuses module', () => {
expect(state.timelines.public.visibleStatuses).to.have.length(1) expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.statuses).to.have.length(1) expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2) expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal(1) expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[1].id).to.equal(2) expect(state.allStatuses[1].id).to.equal('2')
// It refers to the modified status. // It refers to the modified status.
mutations.addNewStatuses(state, { statuses: [modStatus], timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [modStatus], timeline: 'public' })
expect(state.allStatuses).to.have.length(2) expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal(1) expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[0].text).to.equal(modStatus.text) expect(state.allStatuses[0].text).to.equal(modStatus.text)
expect(state.allStatuses[1].id).to.equal(2) expect(state.allStatuses[1].id).to.equal('2')
expect(retweet.retweeted_status.text).to.eql(modStatus.text) expect(retweet.retweeted_status.text).to.eql(modStatus.text)
}) })
it('replaces existing statuses with the same id', () => { it('replaces existing statuses with the same id', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: 1, text: 'something else'}) const modStatus = makeMockStatus({id: '1', text: 'something else'})
// Add original status // Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
@ -218,9 +173,9 @@ describe('The Statuses module', () => {
it('replaces existing statuses with the same id, coming from a retweet', () => { it('replaces existing statuses with the same id, coming from a retweet', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: 1, text: 'something else'}) const modStatus = makeMockStatus({id: '1', text: 'something else'})
const retweet = makeMockStatus({id: 2, is_post_verb: false}) const retweet = makeMockStatus({id: '2', type: 'retweet'})
retweet.retweeted_status = modStatus retweet.retweeted_status = modStatus
// Add original status // Add original status
@ -239,15 +194,15 @@ describe('The Statuses module', () => {
it('handles favorite actions', () => { it('handles favorite actions', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
const favorite = { const favorite = {
id: 2, id: '2',
is_post_verb: false, type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here... in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00', uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b', text: 'a favorited something by b',
user: { id: 99 } user: { id: '99' }
} }
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
@ -266,12 +221,12 @@ describe('The Statuses module', () => {
// If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request. // If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
const user = { const user = {
id: 1 id: '1'
} }
const ownFavorite = { const ownFavorite = {
id: 3, id: '3',
is_post_verb: false, type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here... in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00', uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b', text: 'a favorited something by b',
@ -287,16 +242,16 @@ describe('The Statuses module', () => {
describe('notifications', () => { describe('notifications', () => {
it('removes a notification when the notice gets removed', () => { it('removes a notification when the notice gets removed', () => {
const user = { id: 1 } const user = { id: '1' }
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1}) const status = makeMockStatus({id: '1'})
const otherStatus = makeMockStatus({id: 3}) const otherStatus = makeMockStatus({id: '3'})
const mentionedStatus = makeMockStatus({id: 2}) const mentionedStatus = makeMockStatus({id: '2'})
mentionedStatus.attentions = [user] mentionedStatus.attentions = [user]
mentionedStatus.uri = 'xxx' mentionedStatus.uri = 'xxx'
otherStatus.attentions = [user] otherStatus.attentions = [user]
const deletion = makeMockStatus({id: 4, is_post_verb: false}) const deletion = makeMockStatus({id: '4', type: 'deletion'})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.' deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx' deletion.uri = 'xxx'
@ -305,10 +260,12 @@ describe('The Statuses module', () => {
state, state,
{ {
notifications: [{ notifications: [{
ntype: 'mention', from_profile: { id: '2' },
id: '998',
type: 'mention',
status: otherStatus, status: otherStatus,
notice: otherStatus, action: otherStatus,
is_seen: false seen: false
}] }]
}) })
@ -317,10 +274,12 @@ describe('The Statuses module', () => {
state, state,
{ {
notifications: [{ notifications: [{
ntype: 'mention', from_profile: { id: '2' },
id: '999',
type: 'mention',
status: mentionedStatus, status: mentionedStatus,
notice: mentionedStatus, action: mentionedStatus,
is_seen: false seen: false
}] }]
}) })

View file

@ -6,8 +6,8 @@ describe('The users module', () => {
describe('mutations', () => { describe('mutations', () => {
it('adds new users to the set, merging in new information for old users', () => { it('adds new users to the set, merging in new information for old users', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const user = { id: 1, name: 'Guy' } const user = { id: '1', name: 'Guy' }
const modUser = { id: 1, name: 'Dude' } const modUser = { id: '1', name: 'Dude' }
mutations.addNewUsers(state, [user]) mutations.addNewUsers(state, [user])
expect(state.users).to.have.length(1) expect(state.users).to.have.length(1)
@ -21,7 +21,7 @@ describe('The users module', () => {
it('sets a mute bit on users', () => { it('sets a mute bit on users', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const user = { id: 1, name: 'Guy' } const user = { id: '1', name: 'Guy' }
mutations.addNewUsers(state, [user]) mutations.addNewUsers(state, [user])
mutations.setMuted(state, {user, muted: true}) mutations.setMuted(state, {user, muted: true})
@ -38,11 +38,11 @@ describe('The users module', () => {
it('returns user with matching screen_name', () => { it('returns user with matching screen_name', () => {
const state = { const state = {
users: [ users: [
{ screen_name: 'Guy', id: 1 } { screen_name: 'Guy', id: '1' }
] ]
} }
const name = 'Guy' const name = 'Guy'
const expected = { screen_name: 'Guy', id: 1 } const expected = { screen_name: 'Guy', id: '1' }
expect(getters.userByName(state)(name)).to.eql(expected) expect(getters.userByName(state)(name)).to.eql(expected)
}) })
}) })
@ -51,11 +51,11 @@ describe('The users module', () => {
it('returns user with matching id', () => { it('returns user with matching id', () => {
const state = { const state = {
users: [ users: [
{ screen_name: 'Guy', id: 1 } { screen_name: 'Guy', id: '1' }
] ]
} }
const id = 1 const id = '1'
const expected = { screen_name: 'Guy', id: 1 } const expected = { screen_name: 'Guy', id: '1' }
expect(getters.userById(state)(id)).to.eql(expected) expect(getters.userById(state)(id)).to.eql(expected)
}) })
}) })

View file

@ -0,0 +1,270 @@
import { parseStatus, parseUser, parseNotification } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
import mastoapidata from '../../../../fixtures/mastoapi.json'
import qvitterapidata from '../../../../fixtures/statuses.json'
const makeMockStatusQvitter = (overrides = {}) => {
return Object.assign({
activity_type: 'post',
attachments: [],
attentions: [],
created_at: 'Tue Jan 15 13:57:56 +0000 2019',
external_url: 'https://ap.example/whatever',
fave_num: 1,
favorited: false,
id: '10335970',
in_reply_to_ostatus_uri: null,
in_reply_to_profileurl: null,
in_reply_to_screen_name: null,
in_reply_to_status_id: null,
in_reply_to_user_id: null,
is_local: false,
is_post_verb: true,
possibly_sensitive: false,
repeat_num: 0,
repeated: false,
statusnet_conversation_id: '16300488',
statusnet_html: '<p>haha benis</p>',
summary: null,
tags: [],
text: 'haha benis',
uri: 'https://ap.example/whatever',
user: makeMockUserQvitter(),
visibility: 'public'
}, overrides)
}
const makeMockUserQvitter = (overrides = {}) => {
return Object.assign({
background_image: null,
cover_photo: '',
created_at: 'Mon Jan 14 13:56:51 +0000 2019',
default_scope: 'public',
description: 'ebin',
description_html: '<p>ebin</p>',
favourites_count: 0,
fields: [],
followers_count: 1,
following: true,
follows_you: true,
friends_count: 1,
id: '60717',
is_local: false,
locked: false,
name: 'Spurdo :ebin:',
name_html: 'Spurdo <img src="whatever">',
no_rich_text: false,
pleroma: { confirmation_pending: false, tags: [] },
profile_image_url: 'https://ap.example/whatever',
profile_image_url_https: 'https://ap.example/whatever',
profile_image_url_original: 'https://ap.example/whatever',
profile_image_url_profile_size: 'https://ap.example/whatever',
rights: { delete_others_notice: false },
screen_name: 'spurdo@ap.example',
statuses_count: 46,
statusnet_blocking: false,
statusnet_profile_url: ''
}, overrides)
}
const makeMockUserMasto = (overrides = {}) => {
return Object.assign({
acct: 'hj',
avatar:
'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
avatar_static:
'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
bot: false,
created_at: '2017-12-17T21:54:14.000Z',
display_name: 'whatever whatever whatever witch',
emojis: [],
fields: [],
followers_count: 705,
following_count: 326,
header:
'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
header_static:
'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
id: '1',
locked: false,
note:
'Volatile Internet Weirdo. Name pronounced as Hee Jay. JS and Java dark arts mage, Elixir trainee. I love sampo and lain. Matrix is <span><a data-user="1" href="https://shigusegubu.club/users/hj">@<span>hj</span></a></span>:matrix.heldscal.la Pronouns are whatever. Do not DM me unless it\'s truly private matter and you\'re instance\'s admin or you risk your DM to be reposted publicly.Wish i was Finnish girl.',
pleroma: { confirmation_pending: false, tags: null },
source: { note: '', privacy: 'public', sensitive: false },
statuses_count: 41775,
url: 'https://shigusegubu.club/users/hj',
username: 'hj'
}, overrides)
}
const makeMockStatusMasto = (overrides = {}) => {
return Object.assign({
account: makeMockUserMasto(),
application: { name: 'Web', website: null },
content:
'<span><a data-user="14660" href="https://pleroma.soykaf.com/users/sampo">@<span>sampo</span></a></span> god i wish i was there',
created_at: '2019-01-17T16:29:23.000Z',
emojis: [],
favourited: false,
favourites_count: 1,
id: '10423476',
in_reply_to_account_id: '14660',
in_reply_to_id: '10423197',
language: null,
media_attachments: [],
mentions: [
{
acct: 'sampo@pleroma.soykaf.com',
id: '14660',
url: 'https://pleroma.soykaf.com/users/sampo',
username: 'sampo'
}
],
muted: false,
reblog: null,
reblogged: false,
reblogs_count: 0,
replies_count: 0,
sensitive: false,
spoiler_text: '',
tags: [],
uri: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
url: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
visibility: 'public'
}, overrides)
}
const makeMockNotificationQvitter = (overrides = {}) => {
return Object.assign({
notice: makeMockStatusQvitter(),
ntype: 'follow',
from_profile: makeMockUserQvitter(),
is_seen: 0,
id: 123
}, overrides)
}
parseNotification
parseUser
parseStatus
makeMockStatusQvitter
makeMockUserQvitter
describe('API Entities normalizer', () => {
describe('parseStatus', () => {
describe('QVitter preprocessing', () => {
it('doesn\'t blow up', () => {
const parsed = qvitterapidata.map(parseStatus)
expect(parsed.length).to.eq(qvitterapidata.length)
})
it('identifies favorites', () => {
const fav = {
uri: 'tag:soykaf.com,2016-08-21:fave:2558:note:339495:2016-08-21T16:54:04+00:00',
is_post_verb: false
}
const mastoFav = {
uri: 'tag:mastodon.social,2016-11-27:objectId=73903:objectType=Favourite',
is_post_verb: false
}
expect(parseStatus(makeMockStatusQvitter(fav))).to.have.property('type', 'favorite')
expect(parseStatus(makeMockStatusQvitter(mastoFav))).to.have.property('type', 'favorite')
})
it('processes repeats correctly', () => {
const post = makeMockStatusQvitter({ retweeted_status: null, id: 'deadbeef' })
const repeat = makeMockStatusQvitter({ retweeted_status: post, is_post_verb: false, id: 'foobar' })
const parsedPost = parseStatus(post)
const parsedRepeat = parseStatus(repeat)
expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet')
expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef')
})
it('sets nsfw for statuses with the #nsfw tag', () => {
const safe = makeMockStatusQvitter({id: '1', text: 'Hello oniichan'})
const nsfw = makeMockStatusQvitter({id: '1', text: 'Hello oniichan #nsfw'})
expect(parseStatus(safe).nsfw).to.eq(false)
expect(parseStatus(nsfw).nsfw).to.eq(true)
})
it('leaves existing nsfw settings alone', () => {
const nsfw = makeMockStatusQvitter({id: '1', text: 'Hello oniichan #nsfw', nsfw: false})
expect(parseStatus(nsfw).nsfw).to.eq(false)
})
})
describe('Mastoapi preprocessing and converting', () => {
it('doesn\'t blow up', () => {
const parsed = mastoapidata.map(parseStatus)
expect(parsed.length).to.eq(mastoapidata.length)
})
it('processes repeats correctly', () => {
const post = makeMockStatusMasto({ reblog: null, id: 'deadbeef' })
const repeat = makeMockStatusMasto({ reblog: post, id: 'foobar' })
const parsedPost = parseStatus(post)
const parsedRepeat = parseStatus(repeat)
expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet')
expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef')
})
})
})
// Statuses generally already contain some info regarding users and there's nearly 1:1 mapping, so very little to test
describe('parseUsers (MastoAPI)', () => {
it('sets correct is_local for users depending on their screen_name', () => {
const local = makeMockUserMasto({ acct: 'foo' })
const remote = makeMockUserMasto({ acct: 'foo@bar.baz' })
expect(parseUser(local)).to.have.property('is_local', true)
expect(parseUser(remote)).to.have.property('is_local', false)
})
})
// We currently use QvitterAPI notifications only, and especially due to MastoAPI lacking is_seen, support for MastoAPI
// is more of an afterthought
describe('parseNotifications (QvitterAPI)', () => {
it('correctly normalizes data to FE\'s format', () => {
const notif = makeMockNotificationQvitter({
id: 123,
notice: makeMockStatusQvitter({ id: 444 }),
from_profile: makeMockUserQvitter({ id: 'spurdo' })
})
expect(parseNotification(notif)).to.have.property('id', '123')
expect(parseNotification(notif)).to.have.property('seen', false)
expect(parseNotification(notif)).to.have.deep.property('status.id', '444')
expect(parseNotification(notif)).to.have.deep.property('action.id', '444')
expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo')
})
it('correctly normalizes favorite notifications', () => {
const notif = makeMockNotificationQvitter({
id: 123,
ntype: 'like',
notice: makeMockStatusQvitter({
id: 444,
favorited_status: makeMockStatusQvitter({ id: 4412 })
}),
is_seen: 1,
from_profile: makeMockUserQvitter({ id: 'spurdo' })
})
expect(parseNotification(notif)).to.have.property('id', '123')
expect(parseNotification(notif)).to.have.property('type', 'like')
expect(parseNotification(notif)).to.have.property('seen', true)
expect(parseNotification(notif)).to.have.deep.property('status.id', '4412')
expect(parseNotification(notif)).to.have.deep.property('action.id', '444')
expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo')
})
})
})

View file

@ -9,15 +9,15 @@ describe('NotificationUtils', () => {
notifications: { notifications: {
data: [ data: [
{ {
action: { id: 1 }, action: { id: '1' },
type: 'like' type: 'like'
}, },
{ {
action: { id: 2 }, action: { id: '2' },
type: 'mention' type: 'mention'
}, },
{ {
action: { id: 3 }, action: { id: '3' },
type: 'repeat' type: 'repeat'
} }
] ]
@ -34,11 +34,11 @@ describe('NotificationUtils', () => {
} }
const expected = [ const expected = [
{ {
action: { id: 3 }, action: { id: '3' },
type: 'repeat' type: 'repeat'
}, },
{ {
action: { id: 1 }, action: { id: '1' },
type: 'like' type: 'like'
} }
] ]
@ -54,12 +54,12 @@ describe('NotificationUtils', () => {
notifications: { notifications: {
data: [ data: [
{ {
action: { id: 1 }, action: { id: '1' },
type: 'like', type: 'like',
seen: false seen: false
}, },
{ {
action: { id: 2 }, action: { id: '2' },
type: 'mention', type: 'mention',
seen: true seen: true
} }
@ -77,7 +77,7 @@ describe('NotificationUtils', () => {
} }
const expected = [ const expected = [
{ {
action: { id: 1 }, action: { id: '1' },
type: 'like', type: 'like',
seen: false seen: false
} }