diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/boot/after_store.js | 82 | ||||
| -rw-r--r-- | src/components/attachment/attachment.js | 2 | ||||
| -rw-r--r-- | src/components/attachment/attachment.vue | 2 | ||||
| -rw-r--r-- | src/components/conversation-page/conversation-page.js | 4 | ||||
| -rw-r--r-- | src/components/conversation/conversation.js | 36 | ||||
| -rw-r--r-- | src/components/status/status.js | 23 | ||||
| -rw-r--r-- | src/components/status/status.vue | 12 | ||||
| -rw-r--r-- | src/components/tab_switcher/tab_switcher.jsx | 28 | ||||
| -rw-r--r-- | src/components/user_profile/user_profile.js | 16 | ||||
| -rw-r--r-- | src/components/user_profile/user_profile.vue | 1 | ||||
| -rw-r--r-- | src/components/who_to_follow_panel/who_to_follow_panel.js | 6 | ||||
| -rw-r--r-- | src/i18n/en.json | 1 | ||||
| -rw-r--r-- | src/i18n/ru.json | 1 | ||||
| -rw-r--r-- | src/modules/statuses.js | 134 | ||||
| -rw-r--r-- | src/modules/users.js | 66 | ||||
| -rw-r--r-- | src/services/api/api.service.js | 57 | ||||
| -rw-r--r-- | src/services/entity_normalizer/entity_normalizer.service.js | 273 | ||||
| -rw-r--r-- | src/services/notification_utils/notification_utils.js | 20 | ||||
| -rw-r--r-- | src/services/status_poster/status_poster.service.js | 1 |
19 files changed, 565 insertions, 200 deletions
diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 08c00c64..50500582 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -24,10 +24,6 @@ const afterStoreSetup = ({ store, i18n }) => { store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) }) } - if (data.nsfwCensorImage) { - store.dispatch('setInstanceOption', { name: 'nsfwCensorImage', value: data.nsfwCensorImage }) - } - if (vapidPublicKey) { store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) } @@ -54,46 +50,44 @@ const afterStoreSetup = ({ store, i18n }) => { config = Object.assign({}, staticConfig, apiConfig) } - var theme = (config.theme) - var background = (config.background) - var hidePostStats = (config.hidePostStats) - var hideUserStats = (config.hideUserStats) - var logo = (config.logo) - var logoMask = (typeof config.logoMask === 'undefined' ? true : config.logoMask) - var logoMargin = (typeof config.logoMargin === 'undefined' ? 0 : config.logoMargin) - var redirectRootNoLogin = (config.redirectRootNoLogin) - var redirectRootLogin = (config.redirectRootLogin) - var chatDisabled = (config.chatDisabled) - var showInstanceSpecificPanel = (config.showInstanceSpecificPanel) - var scopeOptionsEnabled = (config.scopeOptionsEnabled) - var formattingOptionsEnabled = (config.formattingOptionsEnabled) - var collapseMessageWithSubject = (config.collapseMessageWithSubject) - var loginMethod = (config.loginMethod) - var scopeCopy = (config.scopeCopy) - var subjectLineBehavior = (config.subjectLineBehavior) - var alwaysShowSubjectInput = (config.alwaysShowSubjectInput) - var noAttachmentLinks = (config.noAttachmentLinks) - - store.dispatch('setInstanceOption', { name: 'theme', value: theme }) - store.dispatch('setInstanceOption', { name: 'background', value: background }) - store.dispatch('setInstanceOption', { name: 'hidePostStats', value: hidePostStats }) - store.dispatch('setInstanceOption', { name: 'hideUserStats', value: hideUserStats }) - store.dispatch('setInstanceOption', { name: 'logo', value: logo }) - store.dispatch('setInstanceOption', { name: 'logoMask', value: logoMask }) - store.dispatch('setInstanceOption', { name: 'logoMargin', value: logoMargin }) - store.dispatch('setInstanceOption', { name: 'redirectRootNoLogin', value: redirectRootNoLogin }) - store.dispatch('setInstanceOption', { name: 'redirectRootLogin', value: redirectRootLogin }) - store.dispatch('setInstanceOption', { name: 'showInstanceSpecificPanel', value: showInstanceSpecificPanel }) - store.dispatch('setInstanceOption', { name: 'scopeOptionsEnabled', value: scopeOptionsEnabled }) - store.dispatch('setInstanceOption', { name: 'formattingOptionsEnabled', value: formattingOptionsEnabled }) - store.dispatch('setInstanceOption', { name: 'collapseMessageWithSubject', value: collapseMessageWithSubject }) - store.dispatch('setInstanceOption', { name: 'loginMethod', value: loginMethod }) - store.dispatch('setInstanceOption', { name: 'scopeCopy', value: scopeCopy }) - store.dispatch('setInstanceOption', { name: 'subjectLineBehavior', value: subjectLineBehavior }) - store.dispatch('setInstanceOption', { name: 'alwaysShowSubjectInput', value: alwaysShowSubjectInput }) - store.dispatch('setInstanceOption', { name: 'noAttachmentLinks', value: noAttachmentLinks }) - - if (chatDisabled) { + const copyInstanceOption = (name) => { + store.dispatch('setInstanceOption', {name, value: config[name]}) + } + + copyInstanceOption('nsfwCensorImage') + copyInstanceOption('theme') + copyInstanceOption('background') + copyInstanceOption('hidePostStats') + copyInstanceOption('hideUserStats') + copyInstanceOption('logo') + + store.dispatch('setInstanceOption', { + name: 'logoMask', + value: typeof config.logoMask === 'undefined' + ? true + : config.logoMask + }) + + store.dispatch('setInstanceOption', { + name: 'logoMargin', + value: typeof config.logoMargin === 'undefined' + ? 0 + : config.logoMargin + }) + + copyInstanceOption('redirectRootNoLogin') + copyInstanceOption('redirectRootLogin') + copyInstanceOption('showInstanceSpecificPanel') + copyInstanceOption('scopeOptionsEnabled') + copyInstanceOption('formattingOptionsEnabled') + copyInstanceOption('collapseMessageWithSubject') + copyInstanceOption('loginMethod') + copyInstanceOption('scopeCopy') + copyInstanceOption('subjectLineBehavior') + copyInstanceOption('alwaysShowSubjectInput') + copyInstanceOption('noAttachmentLinks') + + if ((config.chatDisabled)) { store.dispatch('disableChat') } diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index 0ae8f71a..47939852 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -14,7 +14,7 @@ const Attachment = { ], data () { return { - nsfwImage: this.$store.state.config.nsfwCensorImage || nsfwImage, + nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, hideNsfwLocal: this.$store.state.config.hideNsfw, preloadImage: this.$store.state.config.preloadImage, loading: false, diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index 56a49d2d..5a80db8a 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -27,7 +27,7 @@ :href="attachment.url" target="_blank" :title="attachment.description" > - <StillImage referrerpolicy="referrerPolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/> + <StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/> </a> <a class="video-container" diff --git a/src/components/conversation-page/conversation-page.js b/src/components/conversation-page/conversation-page.js index beffa5bb..8f1ac3d9 100644 --- a/src/components/conversation-page/conversation-page.js +++ b/src/components/conversation-page/conversation-page.js @@ -1,5 +1,5 @@ import Conversation from '../conversation/conversation.vue' -import { find, toInteger } from 'lodash' +import { find } from 'lodash' const conversationPage = { components: { @@ -7,7 +7,7 @@ const conversationPage = { }, computed: { statusoid () { - const id = toInteger(this.$route.params.id) + const id = this.$route.params.id const statuses = this.$store.state.statuses.allStatuses const status = find(statuses, {id}) diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 9d9f7bbe..c18781de 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,10 +1,25 @@ -import { reduce, filter, sortBy } from 'lodash' -import { statusType } from '../../modules/statuses.js' +import { reduce, filter } from 'lodash' import Status from '../status/status.vue' +const sortById = (a, b) => { + const seqA = Number(a.id) + const seqB = Number(b.id) + const isSeqA = !Number.isNaN(seqA) + const isSeqB = !Number.isNaN(seqB) + if (isSeqA && isSeqB) { + return seqA < seqB ? -1 : 1 + } else if (isSeqA && !isSeqB) { + return 1 + } else if (!isSeqA && isSeqB) { + return -1 + } else { + return a.id < b.id ? -1 : 1 + } +} + const sortAndFilterConversation = (conversation) => { - conversation = filter(conversation, (status) => statusType(status) !== 'retweet') - return sortBy(conversation, 'id') + conversation = filter(conversation, (status) => status.type !== 'retweet') + return conversation.filter(_ => _).sort(sortById) } const conversation = { @@ -18,10 +33,12 @@ const conversation = { 'collapsable' ], computed: { - status () { return this.statusoid }, + status () { + return this.statusoid + }, conversation () { if (!this.status) { - return false + return [] } const conversationId = this.status.statusnet_conversation_id @@ -32,7 +49,9 @@ const conversation = { replies () { let i = 1 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) { result[irid] = result[irid] || [] result[irid].push({ @@ -69,7 +88,6 @@ const conversation = { } }, getReplies (id) { - id = Number(id) return this.replies[id] || [] }, focused (id) { @@ -80,7 +98,7 @@ const conversation = { } }, setHighlight (id) { - this.highlight = Number(id) + this.highlight = id } } } diff --git a/src/components/status/status.js b/src/components/status/status.js index 1db074e2..2d485616 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -76,6 +76,14 @@ const Status = { return (this.$store.state.config.hideAttachments && !this.inConversation) || (this.$store.state.config.hideAttachmentsInConv && this.inConversation) }, + userProfileLink () { + return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name) + }, + replyProfileLink () { + if (this.isReply) { + return this.generateUserProfileLink(this.status.in_reply_to_status_id, this.replyToName) + } + }, retweet () { return !!this.statusoid.retweeted_status }, retweeter () { return this.statusoid.user.name }, retweeterHtml () { return this.statusoid.user.name_html }, @@ -122,6 +130,14 @@ const Status = { isReply () { return !!this.status.in_reply_to_status_id }, + replyToName () { + const user = this.$store.state.users.usersObject[this.status.in_reply_to_user_id] + if (user) { + return user.screen_name + } else { + return this.status.in_reply_to_screen_name + } + }, hideReply () { if (this.$store.state.config.replyVisibility === 'all') { return false @@ -132,7 +148,7 @@ const Status = { if (this.status.user.id === this.$store.state.users.currentUser.id) { return false } - if (this.status.activity_type === 'repeat') { + if (this.status.type === 'retweet') { return false } var checkFollowing = this.$store.state.config.replyVisibility === 'following' @@ -281,7 +297,7 @@ const Status = { }, replyEnter (id, event) { this.showPreview = true - const targetId = Number(id) + const targetId = id const statuses = this.$store.state.statuses.allStatuses if (!this.preview) { @@ -300,7 +316,7 @@ const Status = { replyLeave () { this.showPreview = false }, - userProfileLink (id, name) { + generateUserProfileLink (id, name) { return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) }, setMedia () { @@ -310,7 +326,6 @@ const Status = { }, watch: { 'highlight': function (id) { - id = Number(id) if (this.status.id === id) { let rect = this.$el.getBoundingClientRect() if (rect.top < 100) { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index ba3b9a4a..c1800d64 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -3,7 +3,7 @@ <template v-if="muted && !noReplyLinks"> <div class="media status container muted"> <small> - <router-link :to="userProfileLink(status.user.id, status.user.screen_name)"> + <router-link :to="userProfileLink"> {{status.user.screen_name}} </router-link> </small> @@ -38,16 +38,16 @@ <h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4> <h4 class="user-name" v-else>{{status.user.name}}</h4> <span class="links"> - <router-link :to="userProfileLink(status.user.id, status.user.screen_name)"> + <router-link :to="userProfileLink"> {{status.user.screen_name}} </router-link> - <span v-if="status.in_reply_to_screen_name" class="faint reply-info"> + <span v-if="isReply" class="faint reply-info"> <i class="icon-right-open"></i> - <router-link :to="userProfileLink(status.in_reply_to_user_id, status.in_reply_to_screen_name)"> - {{status.in_reply_to_screen_name}} + <router-link :to="replyProfileLink"> + {{replyToName}} </router-link> </span> - <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" :title="$t('tool_tip.reply')"> + <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" :aria-label="$t('tool_tip.reply')"> <i class="button-icon icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i> </a> </span> diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx index 2f362c4d..9038733c 100644 --- a/src/components/tab_switcher/tab_switcher.jsx +++ b/src/components/tab_switcher/tab_switcher.jsx @@ -6,18 +6,26 @@ export default Vue.component('tab-switcher', { name: 'TabSwitcher', data () { return { - active: 0 + active: this.$slots.default.findIndex(_ => _.tag) } }, methods: { - activateTab(index) { - return () => this.active = index; + activateTab (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 - .filter(slot => slot.data) .map((slot, index) => { + if (!slot.tag) return const classesTab = ['tab'] const classesWrapper = ['tab-wrapper'] @@ -25,20 +33,24 @@ export default Vue.component('tab-switcher', { classesTab.push('active') classesWrapper.push('active') } + return ( <div class={ classesWrapper.join(' ')}> <button onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button> </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 return ( <div class={active ? 'active' : 'hidden'}> {slot} </div> ) - }); + }) + return ( <div class="tab-switcher"> <div class="tabs"> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index bde20707..c9197a1c 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -5,24 +5,33 @@ import Timeline from '../timeline/timeline.vue' const UserProfile = { created () { this.$store.commit('clearTimeline', { timeline: 'user' }) + this.$store.commit('clearTimeline', { timeline: 'favorites' }) this.$store.dispatch('startFetching', ['user', this.fetchBy]) + this.$store.dispatch('startFetching', ['favorites', this.fetchBy]) if (!this.user.id) { this.$store.dispatch('fetchUser', this.fetchBy) } }, destroyed () { this.$store.dispatch('stopFetching', 'user') + this.$store.dispatch('stopFetching', 'favorites') }, computed: { timeline () { return this.$store.state.statuses.timelines.user }, + favorites () { + return this.$store.state.statuses.timelines.favorites + }, userId () { return this.$route.params.id || this.user.id }, userName () { return this.$route.params.name || this.user.screen_name }, + isUs () { + return this.userId === this.$store.state.users.currentUser.id + }, friends () { return this.user.friends }, @@ -62,21 +71,28 @@ const UserProfile = { } }, watch: { + // TODO get rid of this copypasta userName () { if (this.isExternal) { return } this.$store.dispatch('stopFetching', 'user') + this.$store.dispatch('stopFetching', 'favorites') this.$store.commit('clearTimeline', { timeline: 'user' }) + this.$store.commit('clearTimeline', { timeline: 'favorites' }) this.$store.dispatch('startFetching', ['user', this.fetchBy]) + this.$store.dispatch('startFetching', ['favorites', this.fetchBy]) }, userId () { if (!this.isExternal) { return } this.$store.dispatch('stopFetching', 'user') + this.$store.dispatch('stopFetching', 'favorites') this.$store.commit('clearTimeline', { timeline: 'user' }) + this.$store.commit('clearTimeline', { timeline: 'favorites' }) this.$store.dispatch('startFetching', ['user', this.fetchBy]) + this.$store.dispatch('startFetching', ['favorites', this.fetchBy]) }, user () { if (this.user.id && !this.user.followers) { diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 50619026..d64ce277 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -20,6 +20,7 @@ <i class="icon-spin3 animate-spin"></i> </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> </div> <div v-else class="panel user-profile-placeholder"> diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js index 5e204001..a56a27ea 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.js +++ b/src/components/who_to_follow_panel/who_to_follow_panel.js @@ -1,12 +1,12 @@ import apiService from '../../services/api/api.service.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' -import _ from 'lodash' +import { shuffle } from 'lodash' function showWhoToFollow (panel, reply) { - _.shuffle(reply) + const shuffled = shuffle(reply) panel.usersToFollow.forEach((toFollow, index) => { - let user = reply[index] + let user = shuffled[index] let img = user.avatar || '/images/avi.png' let name = user.acct diff --git a/src/i18n/en.json b/src/i18n/en.json index 6008287f..2c6887bc 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -327,6 +327,7 @@ "block": "Block", "blocked": "Blocked!", "deny": "Deny", + "favorites": "Favorites", "follow": "Follow", "follow_sent": "Request sent!", "follow_progress": "Requesting…", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 249eab69..0887bb59 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -279,6 +279,7 @@ "user_card": { "block": "Заблокировать", "blocked": "Заблокирован", + "favorites": "Понравившиеся", "follow": "Читать", "follow_sent": "Запрос отправлен!", "follow_progress": "Запрашиваем…", diff --git a/src/modules/statuses.js b/src/modules/statuses.js index f92239a9..20a8d2eb 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -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 parse from '../services/status_parser/status_parser.js' -const emptyTl = (userId = 0) => ({ +const emptyTl = () => ({ statuses: [], statusesObject: {}, faves: [], @@ -14,8 +14,8 @@ const emptyTl = (userId = 0) => ({ loading: false, followers: [], friends: [], - flushMarker: 0, - userId + userId: 0, + flushMarker: 0 }) export const defaultState = { @@ -36,6 +36,7 @@ export const defaultState = { mentions: emptyTl(), public: emptyTl(), user: emptyTl(), + favorites: emptyTl(), publicAndExternal: emptyTl(), friends: 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) => { - // Parse nsfw tags - if (status.nsfw === undefined) { - status.nsfw = isNsfw(status) - if (status.retweeted_status) { - status.nsfw = status.retweeted_status.nsfw - } - } - // Set deleted flag status.deleted = false @@ -75,35 +63,6 @@ const visibleNotificationTypes = (rootState) => { ].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 oldItem = obj[item.id] @@ -122,9 +81,25 @@ const mergeOrAdd = (arr, obj, item) => { } } +const sortById = (a, b) => { + const seqA = Number(a.id) + const seqB = Number(b.id) + const isSeqA = !Number.isNaN(seqA) + const isSeqB = !Number.isNaN(seqB) + if (isSeqA && isSeqB) { + return seqA > seqB ? -1 : 1 + } else if (isSeqA && !isSeqB) { + return 1 + } else if (!isSeqA && isSeqB) { + return -1 + } else { + return a.id > b.id ? -1 : 1 + } +} + const sortTimeline = (timeline) => { - timeline.visibleStatuses = sortBy(timeline.visibleStatuses, ({id}) => -id) - timeline.statuses = sortBy(timeline.statuses, ({id}) => -id) + timeline.visibleStatuses = timeline.visibleStatuses.sort(sortById) + timeline.statuses = timeline.statuses.sort(sortById) timeline.minVisibleId = (last(timeline.visibleStatuses) || {}).id return timeline } @@ -153,13 +128,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us return } - const addStatus = (status, showImmediately, addToTimeline = true) => { - const result = mergeOrAdd(allStatuses, allStatusesObject, status) - status = result.item + const addStatus = (data, showImmediately, addToTimeline = true) => { + const result = mergeOrAdd(allStatuses, allStatusesObject, data) + const status = result.item if (result.new) { // 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 // Add the mention to the mentions timeline @@ -200,7 +175,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } 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) { // This is our favorite, so the relevant bit. if (favorite.user.id === user.id) { @@ -263,6 +238,9 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us remove(timelineObject.visibleStatuses, { uri }) } }, + 'follow': (follow) => { + // NOOP, it is known status but we don't do anything about it for now + }, 'default': (unknown) => { console.log('unknown status type') console.log(unknown) @@ -270,12 +248,12 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } each(statuses, (status) => { - const type = statusType(status) + const type = status.type const processor = processors[type] || processors['default'] processor(status) }) - // Keep the visible statuses sorted + // Keep the visible statuses sorted if (timeline) { sortTimeline(timelineObject) if ((older || timelineObject.minVisibleId <= 0) && statuses.length > 0) { @@ -288,42 +266,36 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot const allStatuses = state.allStatuses const allStatusesObject = state.allStatusesObject each(notifications, (notification) => { - const result = mergeOrAdd(allStatuses, allStatusesObject, notification.notice) - const action = result.item + notification.action = mergeOrAdd(allStatuses, allStatusesObject, notification.action).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 - if (!find(state.notifications.data, (oldNotification) => oldNotification.action.id === action.id)) { - state.notifications.maxId = Math.max(notification.id, state.notifications.maxId) - state.notifications.minId = Math.min(notification.id, state.notifications.minId) - - const fresh = !notification.is_seen - const status = notification.ntype === 'like' - ? action.favorited_status - : action - - const result = { - type: notification.ntype, - status, - action, - seen: !fresh - } + if (!state.notifications.idStore.hasOwnProperty(notification.id)) { + state.notifications.maxId = notification.id > state.notifications.maxId + ? notification.id + : state.notifications.maxId + state.notifications.minId = notification.id < state.notifications.minId + ? notification.id + : state.notifications.minId - state.notifications.data.push(result) - state.notifications.idStore[notification.id] = result + state.notifications.data.push(notification) + state.notifications.idStore[notification.id] = notification if ('Notification' in window && window.Notification.permission === 'granted') { + const notifObj = {} + const action = notification.action const title = action.user.name - const result = {} - result.icon = action.user.profile_image_url - result.body = action.text // there's a problem that it doesn't put a space before links tho + notifObj.icon = action.user.profile_image_url + notifObj.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... if (action.attachments && action.attachments.length > 0 && !action.nsfw && 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)) { - let notification = new window.Notification(title, result) + if (notification.fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) { + let notification = new window.Notification(title, notifObj) // Chrome is known for not closing notifications automatically // according to MDN, anyway. setTimeout(notification.close.bind(notification), 5000) @@ -346,7 +318,7 @@ export const mutations = { each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status }) }, clearTimeline (state, { timeline }) { - state.timelines[timeline] = emptyTl(state.timelines[timeline].userId) + state.timelines[timeline] = emptyTl() }, setFavorited (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] diff --git a/src/modules/users.js b/src/modules/users.js index adbd37dd..d83f0dd8 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -68,6 +68,7 @@ export const mutations = { }, setUserForNotification (state, notification) { notification.action.user = state.usersObject[notification.action.user.id] + notification.from_profile = state.usersObject[notification.action.user.id] }, setColor (state, { user: { id }, highlighted }) { const user = state.usersObject[id] @@ -149,8 +150,8 @@ const users = { }) }, addNewNotifications (store, { notifications }) { - const users = compact(map(notifications, 'from_profile')) - const notificationIds = compact(notifications.map(_ => String(_.id))) + const users = map(notifications, 'from_profile') + const notificationIds = notifications.map(_ => _.id) store.commit('addNewUsers', users) const notificationsObject = store.rootState.statuses.notifications.idStore @@ -206,39 +207,38 @@ const users = { const commit = store.commit commit('beginLogin') store.rootState.api.backendInteractor.verifyCredentials(accessToken) - .then((response) => { - if (response.ok) { - response.json() - .then((user) => { - // user.credentials = userCredentials - user.credentials = accessToken - commit('setCurrentUser', user) - commit('addNewUsers', [user]) + .then((data) => { + if (!data.error) { + const user = data + // user.credentials = userCredentials + user.credentials = accessToken + commit('setCurrentUser', user) + commit('addNewUsers', [user]) - getNotificationPermission() - .then(permission => commit('setNotificationPermission', permission)) + getNotificationPermission() + .then(permission => commit('setNotificationPermission', permission)) - // Set our new backend interactor - commit('setBackendInteractor', backendInteractorService(accessToken)) + // Set our new backend interactor + commit('setBackendInteractor', backendInteractorService(accessToken)) - if (user.token) { - store.dispatch('initializeSocket', user.token) - } + if (user.token) { + store.dispatch('initializeSocket', user.token) + } - // Start getting fresh tweets. - store.dispatch('startFetching', 'friends') + // Start getting fresh tweets. + store.dispatch('startFetching', 'friends') - // Get user mutes and follower info - store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => { - each(mutedUsers, (user) => { user.muted = true }) - store.commit('addNewUsers', mutedUsers) - }) + // Get user mutes and follower info + store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => { + each(mutedUsers, (user) => { user.muted = true }) + store.commit('addNewUsers', mutedUsers) + }) - // Fetch our friends - store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) - .then((friends) => commit('addNewUsers', friends)) - }) + // Fetch our friends + store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) + .then((friends) => commit('addNewUsers', friends)) } else { + const response = data.error // Authentication failed commit('endLogin') if (response.status === 401) { @@ -250,11 +250,11 @@ const users = { commit('endLogin') resolve() }) - .catch((error) => { - console.log(error) - commit('endLogin') - reject('Failed to connect to server, try again') - }) + .catch((error) => { + console.log(error) + commit('endLogin') + reject('Failed to connect to server, try again') + }) }) } } diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 4ee95bd1..776d8dae 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -41,7 +41,10 @@ const APPROVE_USER_URL = '/api/pleroma/friendships/approve' const DENY_USER_URL = '/api/pleroma/friendships/deny' const SUGGESTIONS_URL = '/api/v1/suggestions' +const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' + import { each, map } from 'lodash' +import { parseStatus, parseUser, parseNotification } from '../entity_normalizer/entity_normalizer.service.js' import 'whatwg-fetch' const oldfetch = window.fetch @@ -70,6 +73,7 @@ const updateAvatar = ({credentials, params}) => { form.append(key, value) } }) + return fetch(url, { headers: authHeaders(credentials), method: 'POST', @@ -87,6 +91,7 @@ const updateBg = ({credentials, params}) => { form.append(key, value) } }) + return fetch(url, { headers: authHeaders(credentials), method: 'POST', @@ -110,6 +115,7 @@ const updateBanner = ({credentials, params}) => { form.append(key, value) } }) + return fetch(url, { headers: authHeaders(credentials), method: 'POST', @@ -123,13 +129,14 @@ const updateBanner = ({credentials, params}) => { // location // description const updateProfile = ({credentials, params}) => { + // Always include these fields, because they might be empty or false + const fields = ['description', 'locked', 'no_rich_text', 'hide_network'] let url = PROFILE_UPDATE_URL const form = new FormData() each(params, (value, key) => { - /* Always include description, no_rich_text and locked, because it might be empty or false */ - if (key === 'description' || key === 'locked' || key === 'no_rich_text' || value) { + if (fields.includes(key) || value) { form.append(key, value) } }) @@ -237,24 +244,28 @@ const fetchUser = ({id, credentials}) => { let url = `${USER_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => parseUser(data)) } const fetchFriends = ({id, credentials}) => { let url = `${FRIENDS_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => data.map(parseUser)) } const fetchFollowers = ({id, credentials}) => { let url = `${FOLLOWERS_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => data.map(parseUser)) } const fetchAllFollowing = ({username, credentials}) => { const url = `${ALL_FOLLOWING_URL}/${username}.json` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => data.map(parseUser)) } const fetchFollowRequests = ({credentials}) => { @@ -266,13 +277,27 @@ const fetchFollowRequests = ({credentials}) => { const fetchConversation = ({id, credentials}) => { let url = `${CONVERSATION_URL}/${id}.json?count=100` 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.map(parseStatus)) } const fetchStatus = ({id, credentials}) => { let url = `${STATUS_URL}/${id}.json` 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) => parseStatus(data)) } const setUserMute = ({id, credentials, muted = true}) => { @@ -300,13 +325,14 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use notifications: QVITTER_USER_NOTIFICATIONS_URL, 'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL, user: QVITTER_USER_TIMELINE_URL, + favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, tag: TAG_TIMELINE_URL } + const isNotifications = timeline === 'notifications' + const params = [] let url = timelineUrls[timeline] - let params = [] - if (since) { params.push(['since_id', since]) } @@ -330,9 +356,10 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use if (data.ok) { return data } - throw new Error('Error fetching timeline') + throw new Error('Error fetching timeline', data) }) .then((data) => data.json()) + .then((data) => data.map(isNotifications ? parseNotification : parseStatus)) } const verifyCredentials = (user) => { @@ -340,6 +367,16 @@ const verifyCredentials = (user) => { method: 'POST', 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 }) => { @@ -391,6 +428,16 @@ const postStatus = ({credentials, status, spoilerText, visibility, sensitive, me method: 'POST', 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 }) => { diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js new file mode 100644 index 00000000..becbab15 --- /dev/null +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -0,0 +1,273 @@ +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.summary_html = 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.summary_html = data.summary_html + 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) + + // Converting to string, the right way. + output.in_reply_to_status_id = output.in_reply_to_status_id + ? String(output.in_reply_to_status_id) + : null + output.in_reply_to_user_id = output.in_reply_to_user_id + ? String(output.in_reply_to_user_id) + : null + + 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) +} diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js index f5ac0d47..cd8f3f9e 100644 --- a/src/services/notification_utils/notification_utils.js +++ b/src/services/notification_utils/notification_utils.js @@ -9,9 +9,25 @@ export const visibleTypes = store => ([ store.state.config.notificationVisibility.follows && 'follow' ].filter(_ => _)) +const sortById = (a, b) => { + const seqA = Number(a.action.id) + const seqB = Number(b.action.id) + const isSeqA = !Number.isNaN(seqA) + const isSeqB = !Number.isNaN(seqB) + if (isSeqA && isSeqB) { + return seqA > seqB ? -1 : 1 + } else if (isSeqA && !isSeqB) { + return 1 + } else if (!isSeqA && isSeqB) { + return -1 + } else { + return a.action.id > b.action.id ? -1 : 1 + } +} + export const visibleNotificationsFromStore = store => { - // Don't know why, but sortBy([seen, -action.id]) doesn't work. - let sortedNotifications = sortBy(notificationsFromStore(store), ({action}) => -action.id) + // map is just to clone the array since sort mutates it and it causes some issues + let sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById) sortedNotifications = sortBy(sortedNotifications, 'seen') return sortedNotifications.filter((notification) => visibleTypes(store).includes(notification.type)) } diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index 1e20d336..f1932bb6 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -5,7 +5,6 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, media = 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}) - .then((data) => data.json()) .then((data) => { if (!data.error) { store.dispatch('addNewStatuses', { |
