Merge remote-tracking branch 'upstream/develop' into mastoapi/emojis

* upstream/develop: (34 commits)
  after store: fix setting postFormats field
  fix user-card avatar falling into permament failed state
  fix flake id users not fetching correctly
  fix console error
  afterStoreSetup: Move log in and theme load to afterStoreSetup.
  afterStoreSetup: Handle 404 cases.
  afterStoreSetup: Emoji and nodeinfo refactor.
  afterStoreSetup: refactor TOS and panel fetching, handle 404s.
  afterStoreSetup: refactor.
  Load persistedStated with async/await.
  whoops
  レインせんぱいにサンキュー
  fix embedded relationship card parsing
  actually use embedded relationship if it's present
  instead of filtering nulls, let's just not have them in the first place
  #434 - fix plain text issue
  Add floating post-status button on mobile
  Update user settings icon to pencil
  I18n: Update Czech translation
  user_card.vue: Copy over .status-content img styling
  ...
This commit is contained in:
Henry Jameson 2019-03-17 13:32:56 +02:00
commit 30c0cafff1
63 changed files with 906 additions and 537 deletions

View File

@ -8,6 +8,7 @@ import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_pan
import ChatPanel from './components/chat_panel/chat_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusModal from './components/mobile_post_status_modal/mobile_post_status_modal.vue'
import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils'
export default {
@ -22,7 +23,8 @@ export default {
WhoToFollowPanel,
ChatPanel,
MediaModal,
SideDrawer
SideDrawer,
MobilePostStatusModal
},
data: () => ({
mobileActivePanel: 'timeline',

View File

@ -671,6 +671,31 @@ nav {
border-radius: var(--inputRadius, $fallback--inputRadius);
}
@keyframes modal-background-fadein {
from {
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.5);
}
}
.modal-view {
z-index: 1000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
animation-duration: 0.2s;
background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein;
}
.button-icon {
font-size: 1.2em;
}

View File

@ -50,6 +50,7 @@
<media-modal></media-modal>
</div>
<chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
<MobilePostStatusModal />
</div>
</template>

View File

@ -4,10 +4,11 @@ import routes from './routes'
import App from '../App.vue'
const afterStoreSetup = ({ store, i18n }) => {
window.fetch('/api/statusnet/config.json')
.then((res) => res.json())
.then((data) => {
const getStatusnetConfig = async ({ store }) => {
try {
const res = await window.fetch('/api/statusnet/config.json')
if (res.ok) {
const data = await res.json()
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site
store.dispatch('setInstanceOption', { name: 'name', value: name })
@ -28,140 +29,167 @@ const afterStoreSetup = ({ store, i18n }) => {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
var apiConfig = data.site.pleromafe
return data.site.pleromafe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load statusnet config, potentially fatal')
console.error(error)
}
}
window.fetch('/static/config.json')
.then((res) => res.json())
.catch((err) => {
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(err)
return {}
})
.then((staticConfig) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
const getStaticConfig = async () => {
try {
const res = await window.fetch('/static/config.json')
if (res.ok) {
return res.json()
} else {
throw (res)
}
} catch (error) {
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(error)
return {}
}
}
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', {name, value: config[name]})
}
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', { name, value: config[name] })
}
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('scopeOptionsEnabled')
copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('loginMethod')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
if ((config.chatDisabled)) {
store.dispatch('disableChat')
} else {
store.dispatch('initializeSocket')
}
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('scopeOptionsEnabled')
copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('loginMethod')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
return store.dispatch('setTheme', config['theme'])
})
.then(() => {
const router = new VueRouter({
mode: 'history',
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { x: 0, y: 0 }
}
})
if ((config.chatDisabled)) {
store.dispatch('disableChat')
} else {
store.dispatch('initializeSocket')
}
/* eslint-disable no-new */
new Vue({
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
})
})
return store.dispatch('setTheme', config['theme'])
}
window.fetch('/static/terms-of-service.html')
.then((res) => res.text())
.then((html) => {
const getTOS = async ({ store }) => {
try {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'tos', value: html })
})
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load TOS")
console.warn(e)
}
}
window.fetch('/api/pleroma/emoji.json')
.then(
(res) => res.json()
.then(
(values) => {
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: values[key] }
})
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
},
(failure) => {
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false })
}
),
(error) => console.log(error)
)
const getInstancePanel = async ({ store }) => {
try {
const res = await window.fetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load instance panel")
console.warn(e)
}
}
window.fetch('/static/emoji.json')
.then((res) => res.json())
.then((values) => {
const getStaticEmoji = async ({ store }) => {
try {
const res = await window.fetch('/static/emoji.json')
if (res.ok) {
const values = await res.json()
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: false, 'utf': values[key] }
})
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
})
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load static emoji")
console.warn(e)
}
}
window.fetch('/instance/panel.html')
.then((res) => res.text())
.then((html) => {
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
})
// This is also used to indicate if we have a 'pleroma backend' or not.
// Somewhat weird, should probably be somewhere else.
const getCustomEmoji = async ({ store }) => {
try {
const res = await window.fetch('/api/pleroma/emoji.json')
if (res.ok) {
const values = await res.json()
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: values[key] }
})
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
} else {
throw (res)
}
} catch (e) {
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false })
console.warn("Can't load custom emojis, maybe not a Pleroma instance?")
console.warn(e)
}
}
window.fetch('/nodeinfo/2.0.json')
.then((res) => res.json())
.then((data) => {
const getNodeInfo = async ({ store }) => {
try {
const res = await window.fetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
@ -169,14 +197,65 @@ const afterStoreSetup = ({ store, i18n }) => {
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
} else {
throw (res)
}
} catch (e) {
console.warn('Could not load nodeinfo')
console.warn(e)
}
}
const afterStoreSetup = async ({ store, i18n }) => {
if (store.state.config.customTheme) {
// This is a hack to deal with async loading of config.json and themes
// See: style_setter.js, setPreset()
window.themeLoaded = true
store.dispatch('setOption', {
name: 'customTheme',
value: store.state.config.customTheme
})
}
const apiConfig = await getStatusnetConfig({ store })
const staticConfig = await getStaticConfig()
await setSettings({ store, apiConfig, staticConfig })
await getTOS({ store })
await getInstancePanel({ store })
await getStaticEmoji({ store })
await getCustomEmoji({ store })
await getNodeInfo({ store })
// Now we have the server settings and can try logging in
if (store.state.oauth.token) {
store.dispatch('loginUser', store.state.oauth.token)
}
const router = new VueRouter({
mode: 'history',
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { x: 0, y: 0 }
}
})
/* eslint-disable no-new */
return new Vue({
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
}
export default afterStoreSetup

View File

@ -13,7 +13,6 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import UserSearch from 'components/user_search/user_search.vue'
import Notifications from 'components/notifications/notifications.vue'
import UserPanel from 'components/user_panel/user_panel.vue'
import LoginForm from 'components/login_form/login_form.vue'
import ChatPanel from 'components/chat_panel/chat_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
@ -43,7 +42,6 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
{ name: 'user-settings', path: '/user-settings', component: UserSettings },
{ name: 'notifications', path: '/:username/notifications', component: Notifications },
{ name: 'new-status', path: '/:username/new-status', component: UserPanel },
{ name: 'login', path: '/login', component: LoginForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },

View File

@ -9,7 +9,7 @@ const BlockCard = {
},
computed: {
user () {
return this.$store.getters.userById(this.userId)
return this.$store.getters.findUser(this.userId)
},
blocked () {
return this.user.statusnet_blocking

View File

@ -1,5 +1,5 @@
<template>
<div class="modal-view" v-if="showing" @click.prevent="hide">
<div class="modal-view media-modal-view" v-if="showing" @click.prevent="hide">
<img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img>
<VideoAttachment
class="modal-image"
@ -32,18 +32,7 @@
<style lang="scss">
@import '../../_variables.scss';
.modal-view {
z-index: 1000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
.media-modal-view {
&:hover {
.modal-view-button-arrow {
opacity: 0.75;

View File

@ -0,0 +1,91 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import { throttle } from 'lodash'
const MobilePostStatusModal = {
components: {
PostStatusForm
},
data () {
return {
hidden: false,
postFormOpen: false,
scrollingDown: false,
inputActive: false,
oldScrollPos: 0,
amountScrolled: 0
}
},
created () {
window.addEventListener('scroll', this.handleScroll)
window.addEventListener('resize', this.handleOSK)
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleOSK)
},
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
isHidden () {
return this.hidden || this.inputActive
}
},
methods: {
openPostForm () {
this.postFormOpen = true
this.hidden = true
const el = this.$el.querySelector('textarea')
this.$nextTick(function () {
el.focus()
})
},
closePostForm () {
this.postFormOpen = false
this.hidden = false
},
handleOSK () {
// This is a big hack: we're guessing from changed window sizes if the
// on-screen keyboard is active or not. This is only really important
// for phones in portrait mode and it's more important to show the button
// in normal scenarios on all phones, than it is to hide it when the
// keyboard is active.
// Guesswork based on https://www.mydevice.io/#compare-devices
// for example, iphone 4 and android phones from the same time period
const smallPhone = window.innerWidth < 350
const smallPhoneKbOpen = smallPhone && window.innerHeight < 345
const biggerPhone = !smallPhone && window.innerWidth < 450
const biggerPhoneKbOpen = biggerPhone && window.innerHeight < 560
if (smallPhoneKbOpen || biggerPhoneKbOpen) {
this.inputActive = true
} else {
this.inputActive = false
}
},
handleScroll: throttle(function () {
const scrollAmount = window.scrollY - this.oldScrollPos
const scrollingDown = scrollAmount > 0
if (scrollingDown !== this.scrollingDown) {
this.amountScrolled = 0
this.scrollingDown = scrollingDown
if (!scrollingDown) {
this.hidden = false
}
} else if (scrollingDown) {
this.amountScrolled += scrollAmount
if (this.amountScrolled > 100 && !this.hidden) {
this.hidden = true
}
}
this.oldScrollPos = window.scrollY
this.scrollingDown = scrollingDown
}, 100)
}
}
export default MobilePostStatusModal

View File

@ -0,0 +1,76 @@
<template>
<div v-if="currentUser">
<div
class="post-form-modal-view modal-view"
v-show="postFormOpen"
@click="closePostForm"
>
<div class="post-form-modal-panel panel" @click.stop="">
<div class="panel-heading">{{$t('post_status.new_status')}}</div>
<PostStatusForm class="panel-body" @posted="closePostForm"/>
</div>
</div>
<button
class="new-status-button"
:class="{ 'hidden': isHidden }"
@click="openPostForm"
>
<i class="icon-edit" />
</button>
</div>
</template>
<script src="./mobile_post_status_modal.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.post-form-modal-view {
max-height: 100%;
display: block;
}
.post-form-modal-panel {
flex-shrink: 0;
margin: 25% 0 4em 0;
width: 100%;
}
.new-status-button {
width: 5em;
height: 5em;
border-radius: 100%;
position: fixed;
bottom: 1.5em;
right: 1.5em;
// TODO: this needs its own color, it has to stand out enough and link color
// is not very optimal for this particular use.
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
z-index: 10;
transition: 0.35s transform;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
&.hidden {
transform: translateY(150%);
}
i {
font-size: 1.5em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
@media all and (min-width: 801px) {
.new-status-button {
display: none;
}
}
</style>

View File

@ -9,7 +9,7 @@ const MuteCard = {
},
computed: {
user () {
return this.$store.getters.userById(this.userId)
return this.$store.getters.findUser(this.userId)
},
muted () {
return this.user.muted

View File

@ -222,6 +222,9 @@ const PostStatusForm = {
this.highlighted = 0
}
},
onKeydown (e) {
e.stopPropagation()
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
},

View File

@ -20,6 +20,7 @@
ref="textarea"
@click="setCaret"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"

View File

@ -15,12 +15,7 @@
</div>
</div>
<ul>
<li v-if="currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'new-status', params: { username: currentUser.screen_name } }">
{{ $t("post_status.new_status") }}
</router-link>
</li>
<li v-else @click="toggleDrawer">
<li v-if="!currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'login' }">
{{ $t("login.login") }}
</router-link>
@ -119,14 +114,14 @@
}
.side-drawer-container-open {
transition-delay: 0.0s;
transition-property: left;
transition: 0.35s;
transition-property: background-color;
background-color: rgba(0, 0, 0, 0.5);
}
.side-drawer-container-closed {
left: -100%;
transition-delay: 0.5s;
transition-property: left;
background-color: rgba(0, 0, 0, 0);
}
.side-drawer-click-outside {

View File

@ -145,11 +145,11 @@ const Status = {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
},
replyToName () {
const user = this.$store.state.users.usersObject[this.status.in_reply_to_user_id]
if (user) {
return user.screen_name
} else {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
return user && user.screen_name
}
},
hideReply () {

View File

@ -23,6 +23,11 @@ const UserAvatar = {
imageLoadError () {
this.showPlaceholder = true
}
},
watch: {
src () {
this.showPlaceholder = false
}
}
}

View File

@ -15,6 +15,9 @@ export default {
betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
created () {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
classes () {
return [{

View File

@ -11,7 +11,7 @@
<div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div>
<div :title="user.name" class='user-name' v-else>{{user.name}}</div>
<router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser">
<i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i>
<i class="button-icon icon-pencil usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link>
<a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local">
<i class="icon-link-ext usersettings"></i>
@ -159,6 +159,18 @@
&-bio {
text-align: center;
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
.emoji {
width: 32px;
height: 32px;
}
}
}
// Modifiers

View File

@ -9,7 +9,7 @@ import withList from '../../hocs/with_list/with_list'
const FollowerList = compose(
withLoadMore({
fetch: (props, $store) => $store.dispatch('addFollowers', props.userId),
select: (props, $store) => get($store.getters.userById(props.userId), 'followers', []),
select: (props, $store) => get($store.getters.findUser(props.userId), 'followers', []),
destory: (props, $store) => $store.dispatch('clearFollowers', props.userId),
childPropName: 'entries',
additionalPropNames: ['userId']
@ -20,7 +20,7 @@ const FollowerList = compose(
const FriendList = compose(
withLoadMore({
fetch: (props, $store) => $store.dispatch('addFriends', props.userId),
select: (props, $store) => get($store.getters.userById(props.userId), 'friends', []),
select: (props, $store) => get($store.getters.findUser(props.userId), 'friends', []),
destory: (props, $store) => $store.dispatch('clearFriends', props.userId),
childPropName: 'entries',
additionalPropNames: ['userId']
@ -31,28 +31,16 @@ const FriendList = compose(
const UserProfile = {
data () {
return {
error: false
error: false,
fetchedUserId: null
}
},
created () {
this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' })
this.$store.commit('clearTimeline', { timeline: 'media' })
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy })
this.startFetchFavorites()
if (!this.user.id) {
this.$store.dispatch('fetchUser', this.fetchBy)
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') { // Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
this.fetchUserId()
.then(() => this.startUp())
} else {
this.startUp()
}
},
destroyed () {
@ -69,7 +57,7 @@ const UserProfile = {
return this.$store.state.statuses.timelines.media
},
userId () {
return this.$route.params.id || this.user.id
return this.$route.params.id || this.user.id || this.fetchedUserId
},
userName () {
return this.$route.params.name || this.user.screen_name
@ -79,10 +67,9 @@ const UserProfile = {
this.userId === this.$store.state.users.currentUser.id
},
userInStore () {
if (this.isExternal) {
return this.$store.getters.userById(this.userId)
}
return this.$store.getters.userByName(this.userName)
const routeParams = this.$route.params
// This needs fetchedUserId so that computed will be refreshed when user is fetched
return this.$store.getters.findUser(this.fetchedUserId || routeParams.name || routeParams.id)
},
user () {
if (this.timeline.statuses[0]) {
@ -93,9 +80,6 @@ const UserProfile = {
}
return {}
},
fetchBy () {
return this.isExternal ? this.userId : this.userName
},
isExternal () {
return this.$route.name === 'external-user-profile'
},
@ -109,14 +93,38 @@ const UserProfile = {
methods: {
startFetchFavorites () {
if (this.isUs) {
this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.fetchBy })
this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.userId })
}
},
fetchUserId () {
let fetchPromise
if (this.userId && !this.$route.params.name) {
fetchPromise = this.$store.dispatch('fetchUser', this.userId)
} else {
fetchPromise = this.$store.dispatch('fetchUser', this.userName)
.then(({ id }) => {
this.fetchedUserId = id
})
}
return fetchPromise
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') { // Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
.then(() => this.startUp())
},
startUp () {
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy })
this.startFetchFavorites()
if (this.userId) {
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.userId })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.userId })
this.startFetchFavorites()
}
},
cleanUp () {
this.$store.dispatch('stopFetching', 'user')
@ -128,19 +136,19 @@ const UserProfile = {
}
},
watch: {
userName () {
if (this.isExternal) {
return
// userId can be undefined if we don't know it yet
userId (newVal) {
if (newVal) {
this.cleanUp()
this.startUp()
}
this.cleanUp()
this.startUp()
},
userId () {
if (!this.isExternal) {
return
userName () {
if (this.$route.params.name) {
this.fetchUserId()
this.cleanUp()
this.startUp()
}
this.cleanUp()
this.startUp()
},
$route () {
this.$refs.tabSwitcher.activateTab(0)()

View File

@ -11,7 +11,7 @@
:title="$t('user_profile.timeline_title')"
:timeline="timeline"
:timeline-name="'user'"
:user-id="fetchBy"
:user-id="userId"
/>
<div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count">
<FriendList :userId="userId" />
@ -25,7 +25,7 @@
:embedded="true" :title="$t('user_card.media')"
timeline-name="media"
:timeline="media"
:user-id="fetchBy"
:user-id="userId"
/>
<Timeline
v-if="isUs"

View File

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "مقفل",
"attachments_sensitive": "اعتبر المرفقات كلها كمحتوى حساس",
"content_type": {
"plain_text": "نص صافٍ"
"text/plain": "نص صافٍ"
},
"content_warning": "الموضوع (اختياري)",
"default": "وصلت للتوّ إلى لوس أنجلس.",

View File

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "bloquejat",
"attachments_sensitive": "Marca l'adjunt com a delicat",
"content_type": {
"plain_text": "Text pla"
"text/plain": "Text pla"
},
"content_warning": "Assumpte (opcional)",
"default": "Em sento…",

View File

@ -71,7 +71,9 @@
"account_not_locked_warning_link": "uzamčen",
"attachments_sensitive": "Označovat přílohy jako citlivé",
"content_type": {
"plain_text": "Prostý text"
"text/plain": "Prostý text",
"text/html": "HTML",
"text/markdown": "Markdown"
},
"content_warning": "Předmět (volitelný)",
"default": "Právě jsem přistál v L.A.",
@ -95,7 +97,7 @@
"new_captcha": "Kliknutím na obrázek získáte novou CAPTCHA",
"username_placeholder": "např. lain",
"fullname_placeholder": "např. Lain Iwakura",
"bio_placeholder": "např.\nNazdar, jsem Lain\nJsem anime dívka a žiji v příměstském Japonsku. Možná mě znáte z Wired.",
"bio_placeholder": "např.\nNazdar, jsem Lain\nJsem anime dívka žijící v příměstském Japonsku. Možná mě znáte z Wired.",
"validations": {
"username_required": "nemůže být prázdné",
"fullname_required": "nemůže být prázdné",
@ -204,7 +206,7 @@
"radii_help": "Nastavit zakulacení rohů rozhraní (v pixelech)",
"replies_in_timeline": "Odpovědi v časové ose",
"reply_link_preview": "Povolit náhledy odkazu pro odpověď při přejetí myši",
"reply_visibility_all": "Zobrazit všechny odpovědiShow all replies",
"reply_visibility_all": "Zobrazit všechny odpovědi",
"reply_visibility_following": "Zobrazit pouze odpovědi směřované na mě nebo uživatele, které sleduji",
"reply_visibility_self": "Zobrazit pouze odpovědi směřované na mě",
"saving_err": "Chyba při ukládání nastavení",
@ -221,7 +223,6 @@
"subject_line_mastodon": "Jako u Mastodonu: zkopírovat tak, jak je",
"subject_line_noop": "Nekopírovat",
"post_status_content_type": "Publikovat typ obsahu příspěvku",
"status_content_type_plain": "Prostý text",
"stop_gifs": "Přehrávat GIFy při přejetí myši",
"streaming": "Povolit automatické streamování nových příspěvků při rolování nahoru",
"text": "Text",
@ -339,7 +340,7 @@
"button": "Tlačítko",
"text": "Spousta dalšího {0} a {1}",
"mono": "obsahu",
"input": "Just landed in L.A.",
"input": "Právě jsem přistál v L.A.",
"faint_link": "pomocný manuál",
"fine_print": "Přečtěte si náš {0} a nenaučte se nic užitečného!",
"header_faint": "Tohle je v pohodě",
@ -361,7 +362,7 @@
"no_statuses": "Žádné příspěvky"
},
"status": {
"reply_to": "Odpovědět uživateli",
"reply_to": "Odpověď uživateli",
"replies_list": "Odpovědi:"
},
@ -413,7 +414,7 @@
"upload":{
"error": {
"base": "Nahrávání selhalo.",
"file_too_big": "Soubor je úříliš velký [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"file_too_big": "Soubor je příliš velký [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Zkuste to znovu později"
},
"file_size_units": {

View File

@ -55,7 +55,7 @@
"account_not_locked_warning_link": "gesperrt",
"attachments_sensitive": "Anhänge als heikel markieren",
"content_type": {
"plain_text": "Nur Text"
"text/plain": "Nur Text"
},
"content_warning": "Betreff (optional)",
"default": "Sitze gerade im Hofbräuhaus.",

View File

@ -71,7 +71,7 @@
"account_not_locked_warning_link": "ŝlosita",
"attachments_sensitive": "Marki kunsendaĵojn kiel konsternajn",
"content_type": {
"plain_text": "Plata teksto"
"text/plain": "Plata teksto"
},
"content_warning": "Temo (malnepra)",
"default": "Ĵus alvenis al la Universala Kongreso!",

View File

@ -61,7 +61,7 @@
"account_not_locked_warning_link": "bloqueada",
"attachments_sensitive": "Contenido sensible",
"content_type": {
"plain_text": "Texto Plano"
"text/plain": "Texto Plano"
},
"content_warning": "Tema (opcional)",
"default": "Acabo de aterrizar en L.A.",

View File

@ -60,7 +60,7 @@
"account_not_locked_warning_link": "lukittu",
"attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
"content_type": {
"plain_text": "Tavallinen teksti"
"text/plain": "Tavallinen teksti"
},
"content_warning": "Aihe (valinnainen)",
"default": "Tulin juuri saunasta.",

View File

@ -51,7 +51,7 @@
"account_not_locked_warning_link": "verrouillé",
"attachments_sensitive": "Marquer le média comme sensible",
"content_type": {
"plain_text": "Texte brut"
"text/plain": "Texte brut"
},
"content_warning": "Sujet (optionnel)",
"default": "Écrivez ici votre prochain statut.",

View File

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "faoi glas",
"attachments_sensitive": "Marcáil ceangaltán mar íogair",
"content_type": {
"plain_text": "Gnáth-théacs"
"text/plain": "Gnáth-théacs"
},
"content_warning": "Teideal (roghnach)",
"default": "Lá iontach anseo i nGaillimh",

View File

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "נעול",
"attachments_sensitive": "סמן מסמכים מצורפים כלא בטוחים לצפייה",
"content_type": {
"plain_text": "טקסט פשוט"
"text/plain": "טקסט פשוט"
},
"content_warning": "נושא (נתון לבחירה)",
"default": "הרגע נחת ב-ל.א.",

View File

@ -175,7 +175,7 @@
"account_not_locked_warning_link": "bloccato",
"attachments_sensitive": "Segna allegati come sensibili",
"content_type": {
"plain_text": "Testo normale"
"text/plain": "Testo normale"
},
"content_warning": "Oggetto (facoltativo)",
"default": "Appena atterrato in L.A.",

View File

@ -61,7 +61,7 @@
"account_not_locked_warning_link": "ロックされたアカウント",
"attachments_sensitive": "ファイルをNSFWにする",
"content_type": {
"plain_text": "プレーンテキスト"
"text/plain": "プレーンテキスト"
},
"content_warning": "せつめい (かかなくてもよい)",
"default": "はねだくうこうに、つきました。",

View File

@ -56,7 +56,7 @@
"account_not_locked_warning_link": "잠김",
"attachments_sensitive": "첨부물을 민감함으로 설정",
"content_type": {
"plain_text": "평문"
"text/plain": "평문"
},
"content_warning": "주제 (필수 아님)",
"default": "LA에 도착!",

View File

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "låst",
"attachments_sensitive": "Merk vedlegg som sensitive",
"content_type": {
"plain_text": "Klar tekst"
"text/plain": "Klar tekst"
},
"content_warning": "Tema (valgfritt)",
"default": "Landet akkurat i L.A.",

View File

@ -57,7 +57,7 @@
"account_not_locked_warning_link": "gesloten",
"attachments_sensitive": "Markeer bijlage als gevoelig",
"content_type": {
"plain_text": "Gewone tekst"
"text/plain": "Gewone tekst"
},
"content_warning": "Onderwerp (optioneel)",
"default": "Tijd voor een pauze!",

View File

@ -71,7 +71,7 @@
"account_not_locked_warning_link": "clavat",
"attachments_sensitive": "Marcar las pèças juntas coma sensiblas",
"content_type": {
"plain_text": "Tèxte brut"
"text/plain": "Tèxte brut"
},
"content_warning": "Avís de contengut (opcional)",
"default": "Escrivètz aquí vòstre estatut.",

View File

@ -71,7 +71,7 @@
"account_not_locked_warning_link": "fechada",
"attachments_sensitive": "Marcar anexos como sensíveis",
"content_type": {
"plain_text": "Texto puro"
"text/plain": "Texto puro"
},
"content_warning": "Assunto (opcional)",
"default": "Acabei de chegar no Rio!",

View File

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "上锁",
"attachments_sensitive": "标记附件为敏感内容",
"content_type": {
"plain_text": "纯文本"
"text/plain": "纯文本"
},
"content_warning": "主题(可选)",
"default": "刚刚抵达上海",

View File

@ -60,18 +60,6 @@ export default function createPersistedState ({
merge({}, store.state, savedState)
)
}
if (store.state.config.customTheme) {
// This is a hack to deal with async loading of config.json and themes
// See: style_setter.js, setPreset()
window.themeLoaded = true
store.dispatch('setOption', {
name: 'customTheme',
value: store.state.config.customTheme
})
}
if (store.state.oauth.token) {
store.dispatch('loginUser', store.state.oauth.token)
}
loaded = true
} catch (e) {
console.log("Couldn't load state")

View File

@ -53,9 +53,10 @@ const persistedStateOptions = {
'users.lastLoginName',
'oauth'
]
}
};
createPersistedState(persistedStateOptions).then((persistedState) => {
(async () => {
const persistedState = await createPersistedState(persistedStateOptions)
const store = new Vuex.Store({
modules: {
interface: interfaceModule,
@ -75,7 +76,7 @@ createPersistedState(persistedStateOptions).then((persistedState) => {
})
afterStoreSetup({ store, i18n })
})
})()
// These are inlined by webpack's DefinePlugin
/* eslint-disable */

View File

@ -1,12 +1,16 @@
const chat = {
state: {
messages: [],
channel: {state: ''}
channel: {state: ''},
socket: null
},
mutations: {
setChannel (state, channel) {
state.channel = channel
},
setSocket (state, socket) {
state.socket = socket
},
addMessage (state, message) {
state.messages.push(message)
state.messages = state.messages.slice(-19, 20)
@ -16,8 +20,12 @@ const chat = {
}
},
actions: {
disconnectFromChat (store) {
store.state.socket.disconnect()
},
initializeChat (store, socket) {
const channel = socket.channel('chat:public')
store.commit('setSocket', socket)
channel.on('new_msg', (msg) => {
store.commit('addMessage', msg)
})

View File

@ -1,4 +1,4 @@
import { remove, slice, each, find, maxBy, minBy, merge, first, last, isArray } from 'lodash'
import { remove, slice, each, find, maxBy, minBy, merge, first, last, isArray, omitBy } from 'lodash'
import apiService from '../services/api/api.service.js'
// import parse from '../services/status_parser/status_parser.js'
@ -72,7 +72,9 @@ const mergeOrAdd = (arr, obj, item) => {
if (oldItem) {
// We already have this, so only merge the new info.
merge(oldItem, item)
// We ignore null values to avoid overwriting existing properties with missing data
// we also skip 'user' because that is handled by users module
merge(oldItem, omitBy(item, (v, k) => v === null || k === 'user'))
// Reactivity fix.
oldItem.attachments.splice(oldItem.attachments.length)
return {item: oldItem, new: false}
@ -333,6 +335,7 @@ export const mutations = {
oldTimeline.newStatusCount = 0
oldTimeline.visibleStatuses = slice(oldTimeline.statuses, 0, 50)
oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id
oldTimeline.minId = oldTimeline.minVisibleId
oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status })
},

View File

@ -18,7 +18,7 @@ export const mergeOrAdd = (arr, obj, item) => {
arr.push(item)
obj[item.id] = item
if (item.screen_name && !item.screen_name.includes('@')) {
obj[item.screen_name] = item
obj[item.screen_name.toLowerCase()] = item
}
return { item, new: true }
}
@ -91,6 +91,17 @@ export const mutations = {
addNewUsers (state, users) {
each(users, (user) => mergeOrAdd(state.users, state.usersObject, user))
},
updateUserRelationship (state, relationships) {
relationships.forEach((relationship) => {
const user = state.usersObject[relationship.id]
if (user) {
user.follows_you = relationship.followed_by
user.following = relationship.following
user.muted = relationship.muting
user.statusnet_blocking = relationship.blocking
}
})
},
saveBlocks (state, blockIds) {
state.currentUser.blockIds = blockIds
},
@ -122,12 +133,14 @@ export const mutations = {
}
export const getters = {
userById: state => id =>
state.users.find(user => user.id === id),
userByName: state => name =>
state.users.find(user => user.screen_name &&
(user.screen_name.toLowerCase() === name.toLowerCase())
)
findUser: state => query => {
const result = state.usersObject[query]
// In case it's a screen_name, we can try searching case-insensitive
if (!result && typeof query === 'string') {
return state.usersObject[query.toLowerCase()]
}
return result
}
}
export const defaultState = {
@ -147,7 +160,14 @@ const users = {
actions: {
fetchUser (store, id) {
return store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => store.commit('addNewUsers', [user]))
.then((user) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship (store, id) {
return store.rootState.api.backendInteractor.fetchUserRelationship({ id })
.then((relationships) => store.commit('updateUserRelationship', relationships))
},
fetchBlocks (store) {
return store.rootState.api.backendInteractor.fetchBlocks()
@ -292,6 +312,7 @@ const users = {
logout (store) {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromChat')
store.commit('setToken', false)
store.dispatch('stopFetching', 'friends')
store.commit('setBackendInteractor', backendInteractorService())
@ -321,6 +342,9 @@ const users = {
if (user.token) {
store.dispatch('setWsToken', user.token)
// Initialize the chat socket.
store.dispatch('initializeSocket')
}
// Start getting fresh posts.

View File

@ -28,12 +28,10 @@ const BG_UPDATE_URL = '/api/qvitter/update_background_image.json'
const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json'
const PROFILE_UPDATE_URL = '/api/account/update_profile.json'
const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json'
const QVITTER_USER_TIMELINE_URL = '/api/qvitter/statuses/user_timeline.json'
const QVITTER_USER_NOTIFICATIONS_URL = '/api/qvitter/statuses/notifications.json'
const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json'
const BLOCKING_URL = '/api/blocks/create.json'
const UNBLOCKING_URL = '/api/blocks/destroy.json'
const USER_URL = '/api/users/show.json'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
@ -43,6 +41,9 @@ const DENY_USER_URL = '/api/pleroma/friendships/deny'
const SUGGESTIONS_URL = '/api/v1/suggestions'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
import { each, map } from 'lodash'
import { parseStatus, parseUser, parseNotification } from '../entity_normalizer/entity_normalizer.service.js'
@ -243,7 +244,7 @@ const denyUser = ({id, credentials}) => {
}
const fetchUser = ({id, credentials}) => {
let url = `${USER_URL}?user_id=${id}`
let url = `${MASTODON_USER_URL}/${id}`
return fetch(url, { headers: authHeaders(credentials) })
.then((response) => {
return new Promise((resolve, reject) => response.json()
@ -257,6 +258,20 @@ const fetchUser = ({id, credentials}) => {
.then((data) => parseUser(data))
}
const fetchUserRelationship = ({id, credentials}) => {
let url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) })
.then((response) => {
return new Promise((resolve, reject) => response.json()
.then((json) => {
if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url }, response))
}
return resolve(json)
}))
})
}
const fetchFriends = ({id, page, credentials}) => {
let url = `${FRIENDS_URL}?user_id=${id}`
if (page) {
@ -347,8 +362,8 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use
dms: DM_TIMELINE_URL,
notifications: QVITTER_USER_NOTIFICATIONS_URL,
'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL,
user: QVITTER_USER_TIMELINE_URL,
media: QVITTER_USER_TIMELINE_URL,
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: TAG_TIMELINE_URL
}
@ -357,15 +372,16 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use
let url = timelineUrls[timeline]
if (timeline === 'user' || timeline === 'media') {
url = url(userId)
}
if (since) {
params.push(['since_id', since])
}
if (until) {
params.push(['max_id', until])
}
if (userId) {
params.push(['user_id', userId])
}
if (tag) {
url += `/${tag}.json`
}
@ -588,6 +604,7 @@ const apiService = {
blockUser,
unblockUser,
fetchUser,
fetchUserRelationship,
favorite,
unfavorite,
retweet,

View File

@ -30,6 +30,10 @@ const backendInteractorService = (credentials) => {
return apiService.fetchUser({id, credentials})
}
const fetchUserRelationship = ({id}) => {
return apiService.fetchUserRelationship({id, credentials})
}
const followUser = (id) => {
return apiService.followUser({credentials, id})
}
@ -92,6 +96,7 @@ const backendInteractorService = (credentials) => {
blockUser,
unblockUser,
fetchUser,
fetchUserRelationship,
fetchAllFollowing,
verifyCredentials: apiService.verifyCredentials,
startFetching,

View File

@ -39,11 +39,11 @@ export const parseUser = (data) => {
return output
}
output.name = null // missing
output.name_html = addEmojis(data.display_name, data.emojis)
// output.name = ??? missing
output.name_html = data.display_name
output.description = null // missing
output.description_html = addEmojis(data.note, data.emojis)
// output.description = ??? missing
output.description_html = data.note
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
@ -59,10 +59,14 @@ export const parseUser = (data) => {
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
const relationship = data.pleroma.relationship
if (relationship) {
output.follows_you = relationship.followed_by
output.following = relationship.following
output.statusnet_blocking = relationship.blocking
output.muted = relationship.muting
}
}
// Missing, trying to recover
@ -83,7 +87,7 @@ export const parseUser = (data) => {
output.friends_count = data.friends_count
output.bot = null // missing
// output.bot = ??? missing
output.statusnet_profile_url = data.statusnet_profile_url
@ -134,7 +138,7 @@ const parseAttachment = (data) => {
output.meta = data.meta // not present in BE yet
} else {
output.mimetype = data.mimetype
output.meta = null // missing
// output.meta = ??? missing
}
output.url = data.url
@ -174,7 +178,7 @@ export const parseStatus = (data) => {
output.in_reply_to_user_id = data.in_reply_to_account_id
// Missing!! fix in UI?
output.in_reply_to_screen_name = null
// output.in_reply_to_screen_name = ???
// Not exactly the same but works
output.statusnet_conversation_id = data.id
@ -187,8 +191,7 @@ export const parseStatus = (data) => {
output.summary_html = addEmojis(data.spoiler_text, data.emojis)
output.external_url = data.url
// FIXME missing!!
output.is_local = false
// output.is_local = ??? missing
} else {
output.favorited = data.favorited
output.fave_num = data.fave_num
@ -267,7 +270,7 @@ export const parseNotification = (data) => {
if (masto) {
output.type = mastoDict[data.type] || data.type
output.seen = null // missing
// output.seen = ??? missing
output.status = parseStatus(data.status)
output.action = output.status // not sure
output.from_profile = parseUser(data.account)

0
static/font/LICENSE.txt Normal file → Executable file
View File

0
static/font/README.txt Normal file → Executable file
View File

6
static/font/config.json Normal file → Executable file
View File

@ -233,6 +233,12 @@
"css": "play-circled",
"code": 61764,
"src": "fontawesome"
},
{
"uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6",
"css": "pencil",
"code": 59416,
"src": "fontawesome"
}
]
}

0
static/font/css/animation.css Normal file → Executable file
View File

1
static/font/css/fontello-codes.css vendored Normal file → Executable file
View File

@ -23,6 +23,7 @@
.icon-plus:before { content: '\e815'; } /* '' */
.icon-adjust:before { content: '\e816'; } /* '' */
.icon-edit:before { content: '\e817'; } /* '' */
.icon-pencil:before { content: '\e818'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */

13
static/font/css/fontello-embedded.css vendored Normal file → Executable file

File diff suppressed because one or more lines are too long

1
static/font/css/fontello-ie7-codes.css vendored Normal file → Executable file
View File

@ -23,6 +23,7 @@
.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe815;&nbsp;'); }
.icon-adjust { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe816;&nbsp;'); }
.icon-edit { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe817;&nbsp;'); }
.icon-pencil { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe818;&nbsp;'); }
.icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe832;&nbsp;'); }
.icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe834;&nbsp;'); }
.icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }

1
static/font/css/fontello-ie7.css vendored Normal file → Executable file
View File

@ -34,6 +34,7 @@
.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe815;&nbsp;'); }
.icon-adjust { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe816;&nbsp;'); }
.icon-edit { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe817;&nbsp;'); }
.icon-pencil { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe818;&nbsp;'); }
.icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe832;&nbsp;'); }
.icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe834;&nbsp;'); }
.icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }

15
static/font/css/fontello.css vendored Normal file → Executable file
View File

@ -1,11 +1,11 @@
@font-face {
font-family: 'fontello';
src: url('../font/fontello.eot?94672585');
src: url('../font/fontello.eot?94672585#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?94672585') format('woff2'),
url('../font/fontello.woff?94672585') format('woff'),
url('../font/fontello.ttf?94672585') format('truetype'),
url('../font/fontello.svg?94672585#fontello') format('svg');
src: url('../font/fontello.eot?40679575');
src: url('../font/fontello.eot?40679575#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?40679575') format('woff2'),
url('../font/fontello.woff?40679575') format('woff'),
url('../font/fontello.ttf?40679575') format('truetype'),
url('../font/fontello.svg?40679575#fontello') format('svg');
font-weight: normal;
font-style: normal;
}
@ -15,7 +15,7 @@
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'fontello';
src: url('../font/fontello.svg?94672585#fontello') format('svg');
src: url('../font/fontello.svg?40679575#fontello') format('svg');
}
}
*/
@ -79,6 +79,7 @@
.icon-plus:before { content: '\e815'; } /* '' */
.icon-adjust:before { content: '\e816'; } /* '' */
.icon-edit:before { content: '\e817'; } /* '' */
.icon-pencil:before { content: '\e818'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */

17
static/font/demo.html Normal file → Executable file
View File

@ -229,11 +229,11 @@ body {
}
@font-face {
font-family: 'fontello';
src: url('./font/fontello.eot?28736547');
src: url('./font/fontello.eot?28736547#iefix') format('embedded-opentype'),
url('./font/fontello.woff?28736547') format('woff'),
url('./font/fontello.ttf?28736547') format('truetype'),
url('./font/fontello.svg?28736547#fontello') format('svg');
src: url('./font/fontello.eot?50378338');
src: url('./font/fontello.eot?50378338#iefix') format('embedded-opentype'),
url('./font/fontello.woff?50378338') format('woff'),
url('./font/fontello.ttf?50378338') format('truetype'),
url('./font/fontello.svg?50378338#fontello') format('svg');
font-weight: normal;
font-style: normal;
}
@ -334,24 +334,25 @@ body {
<div class="the-icons span3" title="Code: 0xe817"><i class="demo-icon icon-edit">&#xe817;</i> <span class="i-name">icon-edit</span><span class="i-code">0xe817</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xe818"><i class="demo-icon icon-pencil">&#xe818;</i> <span class="i-name">icon-pencil</span><span class="i-code">0xe818</span></div>
<div class="the-icons span3" title="Code: 0xe832"><i class="demo-icon icon-spin3 animate-spin">&#xe832;</i> <span class="i-name">icon-spin3</span><span class="i-code">0xe832</span></div>
<div class="the-icons span3" title="Code: 0xe834"><i class="demo-icon icon-spin4 animate-spin">&#xe834;</i> <span class="i-name">icon-spin4</span><span class="i-code">0xe834</span></div>
<div class="the-icons span3" title="Code: 0xf08e"><i class="demo-icon icon-link-ext">&#xf08e;</i> <span class="i-name">icon-link-ext</span><span class="i-code">0xf08e</span></div>
<div class="the-icons span3" title="Code: 0xf08f"><i class="demo-icon icon-link-ext-alt">&#xf08f;</i> <span class="i-name">icon-link-ext-alt</span><span class="i-code">0xf08f</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf08f"><i class="demo-icon icon-link-ext-alt">&#xf08f;</i> <span class="i-name">icon-link-ext-alt</span><span class="i-code">0xf08f</span></div>
<div class="the-icons span3" title="Code: 0xf0c9"><i class="demo-icon icon-menu">&#xf0c9;</i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span></div>
<div class="the-icons span3" title="Code: 0xf0e0"><i class="demo-icon icon-mail-alt">&#xf0e0;</i> <span class="i-name">icon-mail-alt</span><span class="i-code">0xf0e0</span></div>
<div class="the-icons span3" title="Code: 0xf0e5"><i class="demo-icon icon-comment-empty">&#xf0e5;</i> <span class="i-name">icon-comment-empty</span><span class="i-code">0xf0e5</span></div>
<div class="the-icons span3" title="Code: 0xf0fe"><i class="demo-icon icon-plus-squared">&#xf0fe;</i> <span class="i-name">icon-plus-squared</span><span class="i-code">0xf0fe</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf0fe"><i class="demo-icon icon-plus-squared">&#xf0fe;</i> <span class="i-name">icon-plus-squared</span><span class="i-code">0xf0fe</span></div>
<div class="the-icons span3" title="Code: 0xf112"><i class="demo-icon icon-reply">&#xf112;</i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div>
<div class="the-icons span3" title="Code: 0xf13e"><i class="demo-icon icon-lock-open-alt">&#xf13e;</i> <span class="i-name">icon-lock-open-alt</span><span class="i-code">0xf13e</span></div>
<div class="the-icons span3" title="Code: 0xf144"><i class="demo-icon icon-play-circled">&#xf144;</i> <span class="i-name">icon-play-circled</span><span class="i-code">0xf144</span></div>
<div class="the-icons span3" title="Code: 0xf164"><i class="demo-icon icon-thumbs-up-alt">&#xf164;</i> <span class="i-name">icon-thumbs-up-alt</span><span class="i-code">0xf164</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf164"><i class="demo-icon icon-thumbs-up-alt">&#xf164;</i> <span class="i-name">icon-thumbs-up-alt</span><span class="i-code">0xf164</span></div>
<div class="the-icons span3" title="Code: 0xf1e5"><i class="demo-icon icon-binoculars">&#xf1e5;</i> <span class="i-name">icon-binoculars</span><span class="i-code">0xf1e5</span></div>
<div class="the-icons span3" title="Code: 0xf234"><i class="demo-icon icon-user-plus">&#xf234;</i> <span class="i-name">icon-user-plus</span><span class="i-code">0xf234</span></div>
</div>

BIN
static/font/font/fontello.eot Normal file → Executable file

Binary file not shown.

2
static/font/font/fontello.svg Normal file → Executable file
View File

@ -54,6 +54,8 @@
<glyph glyph-name="edit" unicode="&#xe817;" d="M496 196l64 65-85 85-64-65v-31h53v-54h32z m245 402q-9 9-18 0l-196-196q-9-9 0-18t18 0l196 196q9 9 0 18z m45-331v-106q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h464q35 0 65-14 9-4 10-13 2-10-5-16l-27-28q-8-8-18-4-13 3-25 3h-464q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v70q0 7 5 12l36 36q8 8 20 4t11-16z m-54 411l161-160-375-375h-161v160z m248-73l-51-52-161 161 51 52q16 15 38 15t38-15l85-85q16-16 16-38t-16-38z" horiz-adv-x="1000" />
<glyph glyph-name="pencil" unicode="&#xe818;" d="M203 0l50 51-131 131-51-51v-60h72v-71h60z m291 518q0 12-12 12-5 0-9-4l-303-302q-4-4-4-10 0-12 13-12 5 0 9 4l303 302q3 4 3 10z m-30 107l232-232-464-465h-232v233z m381-54q0-29-20-50l-93-93-232 233 93 92q20 21 50 21 29 0 51-21l131-131q20-22 20-51z" horiz-adv-x="857.1" />
<glyph glyph-name="spin3" unicode="&#xe832;" d="M494 857c-266 0-483-210-494-472-1-19 13-20 13-20l84 0c16 0 19 10 19 18 10 199 176 358 378 358 107 0 205-45 273-118l-58-57c-11-12-11-27 5-31l247-50c21-5 46 11 37 44l-58 227c-2 9-16 22-29 13l-65-60c-89 91-214 148-352 148z m409-508c-16 0-19-10-19-18-10-199-176-358-377-358-108 0-205 45-274 118l59 57c10 12 10 27-5 31l-248 50c-21 5-46-11-37-44l58-227c2-9 16-22 30-13l64 60c89-91 214-148 353-148 265 0 482 210 493 473 1 18-13 19-13 19l-84 0z" horiz-adv-x="1000" />
<glyph glyph-name="spin4" unicode="&#xe834;" d="M498 857c-114 0-228-39-320-116l0 0c173 140 428 130 588-31 134-134 164-332 89-495-10-29-5-50 12-68 21-20 61-23 84 0 3 3 12 15 15 24 71 180 33 393-112 539-99 98-228 147-356 147z m-409-274c-14 0-29-5-39-16-3-3-13-15-15-24-71-180-34-393 112-539 185-185 479-195 676-31l0 0c-173-140-428-130-589 31-134 134-163 333-89 495 11 29 6 50-12 68-11 11-27 17-44 16z" horiz-adv-x="1001" />

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
static/font/font/fontello.ttf Normal file → Executable file

Binary file not shown.

BIN
static/font/font/fontello.woff Normal file → Executable file

Binary file not shown.

BIN
static/font/font/fontello.woff2 Normal file → Executable file

Binary file not shown.

View File

@ -12,9 +12,13 @@ const mutations = {
setError: () => {}
}
const actions = {
fetchUser: () => {},
fetchUserByScreenName: () => {}
}
const testGetters = {
userByName: state => getters.userByName(state.users),
userById: state => getters.userById(state.users)
findUser: state => getters.findUser(state.users)
}
const localUser = {
@ -31,6 +35,7 @@ const extUser = {
const externalProfileStore = new Vuex.Store({
mutations,
actions,
getters: testGetters,
state: {
api: {
@ -89,7 +94,7 @@ const externalProfileStore = new Vuex.Store({
currentUser: {
credentials: ''
},
usersObject: [extUser],
usersObject: { 100: extUser },
users: [extUser]
}
}
@ -97,6 +102,7 @@ const externalProfileStore = new Vuex.Store({
const localProfileStore = new Vuex.Store({
mutations,
actions,
getters: testGetters,
state: {
api: {
@ -155,7 +161,7 @@ const localProfileStore = new Vuex.Store({
currentUser: {
credentials: ''
},
usersObject: [localUser],
usersObject: { 100: localUser, 'testuser': localUser },
users: [localUser]
}
}

View File

@ -14,238 +14,258 @@ const makeMockStatus = ({id, text, type = 'status'}) => {
}
}
describe('Statuses.prepareStatus', () => {
it('sets deleted flag to false', () => {
const aStatus = makeMockStatus({id: '1', text: 'Hello oniichan'})
expect(prepareStatus(aStatus).deleted).to.eq(false)
})
})
describe('The Statuses module', () => {
it('adds the status to allStatuses and to the given timeline', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
describe('Statuses module', () => {
describe('prepareStatus', () => {
it('sets deleted flag to false', () => {
const aStatus = makeMockStatus({id: '1', text: 'Hello oniichan'})
expect(prepareStatus(aStatus).deleted).to.eq(false)
})
})
it('counts the status as new if it has not been seen on this timeline', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
describe('addNewStatuses', () => {
it('adds the status to allStatuses and to the given timeline', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], timeline: 'friends' })
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
})
expect(state.allStatuses).to.eql([status])
expect(state.timelines.friends.statuses).to.eql([status])
expect(state.timelines.friends.visibleStatuses).to.eql([])
expect(state.timelines.friends.newStatusCount).to.equal(1)
it('counts the status as new if it has not been seen on this timeline', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], timeline: 'friends' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
expect(state.allStatuses).to.eql([status])
expect(state.timelines.friends.statuses).to.eql([status])
expect(state.timelines.friends.visibleStatuses).to.eql([])
expect(state.timelines.friends.newStatusCount).to.equal(1)
})
it('add the statuses to allStatuses if no timeline is given', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status] })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('adds the status to allStatuses and to the given timeline, directly visible', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([status])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('removes statuses by tag on deletion', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const otherStatus = makeMockStatus({id: '3'})
status.uri = 'xxx'
const deletion = makeMockStatus({id: '2', type: 'deletion'})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx'
mutations.addNewStatuses(state, { statuses: [status, otherStatus], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [deletion], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([otherStatus])
expect(state.timelines.public.statuses).to.eql([otherStatus])
expect(state.timelines.public.visibleStatuses).to.eql([otherStatus])
expect(state.timelines.public.maxId).to.eql('3')
})
it('does not update the maxId when the noIdUpdate flag is set', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const secondStatus = makeMockStatus({id: '2'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.maxId).to.eql('1')
mutations.addNewStatuses(state, { statuses: [secondStatus], showImmediately: true, timeline: 'public', noIdUpdate: true })
expect(state.timelines.public.statuses).to.eql([secondStatus, status])
expect(state.timelines.public.visibleStatuses).to.eql([secondStatus, status])
expect(state.timelines.public.maxId).to.eql('1')
})
it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
const state = defaultState()
const nonVisibleStatus = makeMockStatus({id: '1'})
const status = makeMockStatus({id: '3'})
const statusTwo = makeMockStatus({id: '2'})
const statusThree = makeMockStatus({id: '4'})
mutations.addNewStatuses(state, { statuses: [nonVisibleStatus], showImmediately: false, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [statusTwo], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.minVisibleId).to.equal('2')
mutations.addNewStatuses(state, { statuses: [statusThree], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.statuses).to.eql([statusThree, status, statusTwo, nonVisibleStatus])
expect(state.timelines.public.visibleStatuses).to.eql([statusThree, status, statusTwo])
})
it('splits retweets from their status and links them', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
retweet.retweeted_status = status
// It adds both statuses, but only the retweet to visible.
mutations.addNewStatuses(state, { statuses: [retweet], timeline: 'public', showImmediately: true })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[1].id).to.equal('2')
// It refers to the modified status.
mutations.addNewStatuses(state, { statuses: [modStatus], timeline: 'public' })
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[0].text).to.equal(modStatus.text)
expect(state.allStatuses[1].id).to.equal('2')
expect(retweet.retweeted_status.text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [modStatus], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id, coming from a retweet', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
retweet.retweeted_status = modStatus
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [retweet], showImmediately: false, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
// Don't add the retweet itself if the tweet is visible
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('handles favorite actions', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const favorite = {
id: '2',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user: { id: '99' }
}
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// Adding it again does nothing
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
const user = {
id: '1'
}
const ownFavorite = {
id: '3',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user
}
mutations.addNewStatuses(state, { statuses: [ownFavorite], showImmediately: true, timeline: 'public', user })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true)
})
})
it('add the statuses to allStatuses if no timeline is given', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
describe('showNewStatuses', () => {
it('resets the minId to the min of the visible statuses when adding new to visible statuses', () => {
const state = defaultState()
const status = makeMockStatus({ id: '10' })
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
const newStatus = makeMockStatus({ id: '20' })
mutations.addNewStatuses(state, { statuses: [newStatus], showImmediately: false, timeline: 'public' })
state.timelines.public.minId = '5'
mutations.showNewStatuses(state, { timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status] })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(0)
expect(state.timelines.public.visibleStatuses.length).to.eql(2)
expect(state.timelines.public.minVisibleId).to.eql('10')
expect(state.timelines.public.minId).to.eql('10')
})
})
it('adds the status to allStatuses and to the given timeline, directly visible', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
describe('clearTimeline', () => {
it('keeps userId when clearing user timeline', () => {
const state = defaultState()
state.timelines.user.userId = 123
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.clearTimeline(state, { timeline: 'user' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([status])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('removes statuses by tag on deletion', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const otherStatus = makeMockStatus({id: '3'})
status.uri = 'xxx'
const deletion = makeMockStatus({id: '2', type: 'deletion'})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx'
mutations.addNewStatuses(state, { statuses: [status, otherStatus], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [deletion], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([otherStatus])
expect(state.timelines.public.statuses).to.eql([otherStatus])
expect(state.timelines.public.visibleStatuses).to.eql([otherStatus])
expect(state.timelines.public.maxId).to.eql('3')
})
it('does not update the maxId when the noIdUpdate flag is set', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const secondStatus = makeMockStatus({id: '2'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.maxId).to.eql('1')
mutations.addNewStatuses(state, { statuses: [secondStatus], showImmediately: true, timeline: 'public', noIdUpdate: true })
expect(state.timelines.public.statuses).to.eql([secondStatus, status])
expect(state.timelines.public.visibleStatuses).to.eql([secondStatus, status])
expect(state.timelines.public.maxId).to.eql('1')
})
it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
const state = defaultState()
const nonVisibleStatus = makeMockStatus({id: '1'})
const status = makeMockStatus({id: '3'})
const statusTwo = makeMockStatus({id: '2'})
const statusThree = makeMockStatus({id: '4'})
mutations.addNewStatuses(state, { statuses: [nonVisibleStatus], showImmediately: false, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [statusTwo], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.minVisibleId).to.equal('2')
mutations.addNewStatuses(state, { statuses: [statusThree], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.statuses).to.eql([statusThree, status, statusTwo, nonVisibleStatus])
expect(state.timelines.public.visibleStatuses).to.eql([statusThree, status, statusTwo])
})
it('splits retweets from their status and links them', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
retweet.retweeted_status = status
// It adds both statuses, but only the retweet to visible.
mutations.addNewStatuses(state, { statuses: [retweet], timeline: 'public', showImmediately: true })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[1].id).to.equal('2')
// It refers to the modified status.
mutations.addNewStatuses(state, { statuses: [modStatus], timeline: 'public' })
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[0].text).to.equal(modStatus.text)
expect(state.allStatuses[1].id).to.equal('2')
expect(retweet.retweeted_status.text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [modStatus], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id, coming from a retweet', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
retweet.retweeted_status = modStatus
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [retweet], showImmediately: false, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
// Don't add the retweet itself if the tweet is visible
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('handles favorite actions', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const favorite = {
id: '2',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user: { id: '99' }
}
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// Adding it again does nothing
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
const user = {
id: '1'
}
const ownFavorite = {
id: '3',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user
}
mutations.addNewStatuses(state, { statuses: [ownFavorite], showImmediately: true, timeline: 'public', user })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true)
})
it('keeps userId when clearing user timeline', () => {
const state = defaultState()
state.timelines.user.userId = 123
mutations.clearTimeline(state, { timeline: 'user' })
expect(state.timelines.user.userId).to.eql(123)
expect(state.timelines.user.userId).to.eql(123)
})
})
describe('notifications', () => {

View File

@ -34,40 +34,31 @@ describe('The users module', () => {
})
})
describe('getUserByName', () => {
describe('findUser', () => {
it('returns user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
users: [
{ screen_name: 'Guy', id: '1' }
]
usersObject: {
1: user,
guy: user
}
}
const name = 'Guy'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.userByName(state)(name)).to.eql(expected)
expect(getters.findUser(state)(name)).to.eql(expected)
})
it('returns user with matching screen_name with different case', () => {
const state = {
users: [
{ screen_name: 'guy', id: '1' }
]
}
const name = 'Guy'
const expected = { screen_name: 'guy', id: '1' }
expect(getters.userByName(state)(name)).to.eql(expected)
})
})
describe('getUserById', () => {
it('returns user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
users: [
{ screen_name: 'Guy', id: '1' }
]
usersObject: {
1: user,
guy: user
}
}
const id = '1'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.userById(state)(id)).to.eql(expected)
expect(getters.findUser(state)(id)).to.eql(expected)
})
})
})