Merge branch 'develop' into feature/hash-routed

This commit is contained in:
Roger Braun 2017-08-16 15:52:28 +02:00
commit 680cc493b8
50 changed files with 959 additions and 130 deletions

View File

@ -8,6 +8,7 @@
"dev": "node build/dev-server.js",
"build": "node build/build.js",
"unit": "karma start test/unit/karma.conf.js --single-run",
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
@ -22,11 +23,12 @@
"object-path": "^0.11.3",
"sanitize-html": "^1.13.0",
"sass-loader": "^4.0.2",
"vue": "^2.1.0",
"vue-router": "^2.2.0",
"vue-template-compiler": "^2.1.10",
"vue": "^2.3.4",
"vue-router": "^2.5.3",
"vue-template-compiler": "^2.3.4",
"vue-timeago": "^3.1.2",
"vuex": "^2.1.0"
"vuex": "^2.3.1",
"whatwg-fetch": "^2.0.3"
},
"devDependencies": {
"autoprefixer": "^6.4.0",

View File

@ -29,6 +29,9 @@ export default {
},
scrollToTop () {
window.scrollTo(0, 0)
},
logout () {
this.$store.dispatch('logout')
}
}
}

View File

@ -33,9 +33,14 @@ button{
border: none;
border-radius: 5px;
cursor: pointer;
border-top: 1px solid rgba(255, 255, 255, 0.2);
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: 0px 0px 2px black;
font-size: 14px;
font-family: sans-serif;
&:hover {
opacity: 0.8;
box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3);
}
}

View File

@ -8,13 +8,14 @@
<div class='item right'>
<user-finder></user-finder>
<router-link :to="{ name: 'settings'}"><i class="icon-cog"></i></router-link>
<a href="#" v-if="currentUser" @click.prevent="logout"><i class="icon-logout" title="Logout" ></i></a>
</div>
</div>
</nav>
<div class="container" id="content">
<div class="panel-switcher">
<button @click="activatePanel('sidebar')">Sidebar</button>
<button @click="activatePanel('timeline')">Timeline</button>
<button @click="activatePanel('sidebar')" class="base01-background base04">Sidebar</button>
<button @click="activatePanel('timeline')" class="base01-background base04">Timeline</button>
</div>
<div class="sidebar-flexer" :class="{ 'mobile-hidden': mobileActivePanel != 'sidebar'}">
<div class="sidebar" :class="{ 'mobile-hidden': mobileActivePanel != 'sidebar' }">

View File

@ -11,7 +11,7 @@
<img class="base03-border" referrerpolicy="no-referrer" :src="attachment.large_thumb_url || attachment.url"/>
</a>
<video v-if="type === 'video' && !hidden" :src="attachment.url" controls></video>
<video v-if="type === 'video' && !hidden" :src="attachment.url" controls loop></video>
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio>

View File

@ -1,4 +1,4 @@
import { filter, sortBy } from 'lodash'
import { reduce, find, filter, sortBy } from 'lodash'
import { statusType } from '../../modules/statuses.js'
import Status from '../status/status.vue'
@ -10,7 +10,12 @@ const sortAndFilterConversation = (conversation) => {
const conversation = {
data () {
return {
highlight: null
highlight: null,
preview: {
x: 0,
y: 0,
status: null
}
}
},
props: [
@ -28,6 +33,21 @@ const conversation = {
const statuses = this.$store.state.statuses.allStatuses
const conversation = filter(statuses, { statusnet_conversation_id: conversationId })
return sortAndFilterConversation(conversation)
},
replies () {
let i = 1
return reduce(this.conversation, (result, {id, in_reply_to_status_id}) => {
const irid = Number(in_reply_to_status_id)
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id: id
})
}
i++
return result
}, {})
}
},
components: {
@ -54,18 +74,8 @@ const conversation = {
}
},
getReplies (id) {
let res = []
id = Number(id)
let i
for (i = 0; i < this.conversation.length; i++) {
if (Number(this.conversation[i].in_reply_to_status_id) === id) {
res.push({
name: `#${i}`,
id: this.conversation[i].id
})
}
}
return res
return this.replies[id] || []
},
focused (id) {
if (this.statusoid.retweeted_status) {
@ -76,6 +86,15 @@ const conversation = {
},
setHighlight (id) {
this.highlight = Number(id)
},
setPreview (id, x, y) {
if (id) {
this.preview.x = x
this.preview.y = y
this.preview.status = find(this.conversation, { id: id })
} else {
this.preview.status = null
}
}
}
}

View File

@ -8,7 +8,17 @@
</div>
<div class="panel-body">
<div class="timeline">
<status v-for="status in conversation" @goto="setHighlight" :key="status.id" :statusoid="status" :expandable='false' :focused="focused(status.id)" :inConversation='true' :highlight="highlight" :replies="getReplies(status.id)"></status>
<status v-for="status in conversation" @goto="setHighlight" :key="status.id" @preview="setPreview" :statusoid="status" :expandable='false' :focused="focused(status.id)" :inConversation='true' :highlight="highlight" :replies="getReplies(status.id)"></status>
</div>
</div>
<div class="status-preview base00-background base03-border" :style="{ left: preview.x + 'px', top: preview.y + 'px'}" v-if="preview.status">
<img class="avatar" :src="preview.status.user.profile_image_url_original">
<div class="text">
<h4>
{{ preview.status.user.name }}
<small><a>{{ preview.status.user.screen_name}}</a></small>
</h4>
<div @click.prevent="linkClicked" class="status-content" v-html="preview.status.statusnet_html"></div>
</div>
</div>
</div>
@ -21,4 +31,30 @@
border-bottom-style: solid;
border-bottom-width: 1px;
}
.status-preview {
position: absolute;
max-width: 35em;
padding: 0.5em;
display: flex;
border-color: inherit;
border-style: solid;
border-width: 1px;
border-radius: 4px;
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
}
.text {
h4 {
margin-bottom: 0.4em;
small {
font-weight: lighter;
}
}
padding: 0 0.5em 0.5em 0.5em;
}
}
</style>

View File

@ -4,7 +4,8 @@ const LoginForm = {
authError: false
}),
computed: {
loggingIn () { return this.$store.state.users.loggingIn }
loggingIn () { return this.$store.state.users.loggingIn },
registrationOpen () { return this.$store.state.config.registrationOpen }
},
methods: {
submit () {

View File

@ -15,7 +15,10 @@
<input :disabled="loggingIn" v-model='user.password' class='form-control' id='password' type='password'>
</div>
<div class='form-group'>
<button :disabled="loggingIn" type='submit' class='btn btn-default base05 base01-background'>Submit</button>
<div class='login-bottom'>
<div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>Register</router-link></div>
<button :disabled="loggingIn" type='submit' class='btn btn-default base05 base01-background'>Log in</button>
</div>
</div>
<div v-if="authError" class='form-group'>
<div class='error base05'>{{authError}}</div>
@ -39,8 +42,8 @@
}
.btn {
margin-top: 1.0em;
min-height: 28px;
width: 10em;
}
.error {
@ -50,6 +53,18 @@
min-height: 28px;
line-height: 28px;
}
.register {
flex: 1 1;
}
.login-bottom {
margin-top: 1.0em;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
</style>

View File

@ -49,6 +49,10 @@
color: $green;
}
.icon-user-plus.lit {
color: $blue;
}
.icon-reply.lit {
color: $blue;
}

View File

@ -4,7 +4,7 @@
<div class="panel-heading base01-background base04">
<span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span>
Notifications
<button @click.prevent="markAsSeen" class="base06 base02-background read-button">Read!</button>
<button @click.prevent="markAsSeen" class="base05 base01-background read-button">Read!</button>
</div>
<div class="panel-body base03-border">
<div v-for="notification in visibleNotifications" :key="notification" class="notification" :class='{"unseen": !notification.seen}'>
@ -36,6 +36,15 @@
</h1>
<status :compact="true" :statusoid="notification.status"></status>
</div>
<div v-if="notification.type === 'follow'">
<h1>
<span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<i class="fa icon-user-plus lit"></i>
</h1>
<div>
<router-link :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{ notification.action.user.screen_name }}</router-link> followed you
</div>
</div>
</div>
</div>
</div>

View File

@ -1,8 +1,8 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { reject, map, uniqBy } from 'lodash'
import Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions]
@ -42,15 +42,48 @@ const PostStatusForm = {
newStatus: {
status: statusText,
files: []
}
},
caret: 0
}
},
computed: {
candidates () {
if (this.textAtCaret.charAt(0) === '@') {
const matchedUsers = filter(this.users, (user) => (String(user.name + user.screen_name)).match(this.textAtCaret.slice(1)))
if (matchedUsers.length <= 0) {
return false
}
// eslint-disable-next-line camelcase
return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}) => ({
screen_name: screen_name,
name: name,
img: profile_image_url_original
}))
} else {
return false
}
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {}
return word
},
users () {
return this.$store.state.users.users
}
},
methods: {
replace (replacement) {
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
const el = this.$el.querySelector('textarea')
el.focus()
this.caret = 0
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
},
postStatus (newStatus) {
statusPoster.postStatus({
status: newStatus.status,

View File

@ -2,7 +2,18 @@
<div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)">
<div class="form-group base03-border" >
<textarea id="benis" v-model="newStatus.status" placeholder="Just landed in L.A." rows="1" class="form-control" @keyup.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize"></textarea>
<textarea @click="setCaret" @keyup="setCaret" v-model="newStatus.status" placeholder="Just landed in L.A." rows="1" class="form-control" @keydown.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize"></textarea>
</div>
<div style="position:relative;" v-if="candidates">
<div class="autocomplete-panel base05-background">
<div v-for="candidate in candidates" @click="replace('@' + candidate.screen_name + ' ')" class="autocomplete base01">
<img :src="candidate.img"></img>
<span>
@{{candidate.screen_name}}
<small class="base02">{{candidate.name}}</small>
</span>
</div>
</div>
</div>
<div class='form-bottom'>
<media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload>
@ -69,6 +80,18 @@
}
}
.btn {
cursor: pointer;
}
.btn[disabled] {
cursor: not-allowed;
}
.icon-cancel {
cursor: pointer;
}
form {
display: flex;
flex-direction: column;
@ -108,6 +131,34 @@
.icon-cancel {
cursor: pointer;
}
.autocomplete-panel {
margin: 0 0.5em 0 0.5em;
border-radius: 5px;
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
min-width: 75%;
}
.autocomplete {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
border-radius: 2px;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
font-style: italic;
}
}
}
</style>

View File

@ -0,0 +1,37 @@
const registration = {
data: () => ({
user: {},
error: false,
registering: false
}),
created () {
if (!this.$store.state.config.registrationOpen || !!this.$store.state.users.currentUser) {
this.$router.push('/main/all')
}
},
computed: {
termsofservice () { return this.$store.state.config.tos }
},
methods: {
submit () {
this.registering = true
this.user.nickname = this.user.username
this.$store.state.api.backendInteractor.register(this.user).then(
(response) => {
if (response.ok) {
this.$store.dispatch('loginUser', this.user)
this.$router.push('/main/all')
this.registering = false
} else {
this.registering = false
response.json().then((data) => {
this.error = data.error
})
}
}
)
}
}
}
export default registration

View File

@ -0,0 +1,134 @@
<template>
<div class="settings panel panel-default base00-background">
<div class="panel-heading base01-background base04">
Registration
</div>
<div class="panel-body">
<form v-on:submit.prevent='submit(user)' class='registration-form'>
<div class='container'>
<div class='text-fields'>
<div class='form-group'>
<label for='username'>Username</label>
<input :disabled="registering" v-model='user.username' class='form-control' id='username' placeholder='e.g. lain'>
</div>
<div class='form-group'>
<label for='fullname'>Fullname</label>
<input :disabled="registering" v-model='user.fullname' class='form-control' id='fullname' placeholder='e.g. Lain Iwakura'>
</div>
<div class='form-group'>
<label for='email'>Email</label>
<input :disabled="registering" v-model='user.email' class='form-control' id='email' type="email">
</div>
<div class='form-group'>
<label for='bio'>Bio</label>
<input :disabled="registering" v-model='user.bio' class='form-control' id='bio'>
</div>
<div class='form-group'>
<label for='password'>Password</label>
<input :disabled="registering" v-model='user.password' class='form-control' id='password' type='password'>
</div>
<div class='form-group'>
<label for='password_confirmation'>Password confirmation</label>
<input :disabled="registering" v-model='user.confirm' class='form-control' id='password_confirmation' type='password'>
</div>
<!--
<div class='form-group'>
<label for='captcha'>Captcha</label>
<img src='/qvittersimplesecurity/captcha.jpg' alt='captcha' class='captcha'>
<input :disabled="registering" v-model='user.captcha' placeholder='Enter captcha' type='test' class='form-control' id='captcha'>
</div>
-->
<div class='form-group'>
<button :disabled="registering" type='submit' class='btn btn-default base05 base01-background'>Submit</button>
</div>
</div>
<div class='terms-of-service' v-html="termsofservice">
</div>
</div>
<div v-if="error" class='form-group'>
<div class='error base05'>{{error}}</div>
</div>
</form>
</div>
</div>
</template>
<script src="./registration.js"></script>
<style lang="scss">
.registration-form {
display: flex;
flex-direction: column;
margin: 0.6em;
.container {
display: flex;
flex-direction: row;
//margin-bottom: 1em;
}
.terms-of-service {
flex: 0 1 50%;
margin: 0.8em;
}
.text-fields {
margin-top: 0.6em;
flex: 1 0;
display: flex;
flex-direction: column;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.3em 0.0em 0.3em;
line-height:24px;
}
form textarea {
border: solid;
border-width: 1px;
border-color: silver;
border-radius: 5px;
line-height:16px;
padding: 5px;
resize: vertical;
}
input {
border-width: 1px;
border-style: solid;
border-color: silver;
border-radius: 5px;
padding: 0.1em 0.2em 0.2em 0.2em;
}
.captcha {
max-width: 350px;
margin-bottom: 0.4em;
}
.btn {
//align-self: flex-start;
//width: 10em;
margin-top: 0.6em;
height: 28px;
}
.error {
border-radius: 5px;
text-align: center;
margin: 0.5em 0.6em 0;
background-color: rgba(255, 48, 16, 0.65);
min-height: 28px;
line-height: 28px;
}
}
@media all and (max-width: 959px) {
.registration-form .container {
flex-direction: column-reverse;
}
}
</style>

View File

@ -7,13 +7,58 @@ const settings = {
hideAttachmentsLocal: this.$store.state.config.hideAttachments,
hideAttachmentsInConvLocal: this.$store.state.config.hideAttachmentsInConv,
hideNsfwLocal: this.$store.state.config.hideNsfw,
muteWordsString: this.$store.state.config.muteWords.join('\n'),
autoLoadLocal: this.$store.state.config.autoLoad,
muteWordsString: this.$store.state.config.muteWords.join('\n')
hoverPreviewLocal: this.$store.state.config.hoverPreview,
previewfile: null
}
},
components: {
StyleSwitcher
},
computed: {
user () {
return this.$store.state.users.currentUser
}
},
methods: {
uploadAvatar ({target}) {
const file = target.files[0]
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({target}) => {
const img = target.result
this.previewfile = img
}
reader.readAsDataURL(file)
},
submitAvatar () {
if (!this.previewfile) { return }
const img = this.previewfile
// eslint-disable-next-line no-undef
let imginfo = new Image()
let cropX, cropY, cropW, cropH
imginfo.src = this.previewfile
if (imginfo.height > imginfo.width) {
cropX = 0
cropW = imginfo.width
cropY = Math.floor((imginfo.height - imginfo.width) / 2)
cropH = imginfo.width
} else {
cropY = 0
cropH = imginfo.height
cropX = Math.floor((imginfo.width - imginfo.height) / 2)
cropW = imginfo.height
}
this.$store.state.api.backendInteractor.updateAvatar({params: {img, cropX, cropY, cropW, cropH}}).then((user) => {
if (!user.error) {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
}
})
}
},
watch: {
hideAttachmentsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideAttachments', value })
@ -27,6 +72,9 @@ const settings = {
autoLoadLocal (value) {
this.$store.dispatch('setOption', { name: 'autoLoad', value })
},
hoverPreviewLocal (value) {
this.$store.dispatch('setOption', { name: 'hoverPreview', value })
},
muteWordsString (value) {
value = filter(value.split('\n'), (word) => trim(word).length > 0)
this.$store.dispatch('setOption', { name: 'muteWords', value })

View File

@ -8,6 +8,18 @@
<h2>Theme</h2>
<style-switcher></style-switcher>
</div>
<div class="setting-item" v-if="user">
<h2>Avatar</h2>
<p>Your current avatar:</p>
<img :src="user.profile_image_url_original" class="old-avatar"></img>
<p>Set new avatar:</p>
<img class="new-avatar" v-bind:src="previewfile" v-if="previewfile">
</img>
<div>
<input name="avatar-upload" id="avatar-upload" type="file" @change="uploadAvatar" ></input>
</div>
<button class="btn btn-default base05 base01-background" v-if="previewfile" @click="submitAvatar">Submit</button>
</div>
<div class="setting-item">
<h2>Filtering</h2>
<p>All notices containing these words will be muted, one per line</p>
@ -32,6 +44,10 @@
<input type="checkbox" id="autoLoad" v-model="autoLoadLocal">
<label for="autoLoad">Enable automatic loading when scrolled to the bottom</label>
</li>
<li>
<input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal">
<label for="hoverPreview">Enable reply-link preview on mouse hover</label>
</li>
</ul>
</div>
</div>
@ -48,6 +64,24 @@
width: 100%;
height: 100px;
}
.old-avatar {
width: 128px;
border-radius: 5px;
}
.new-avatar {
object-fit: cover;
width: 128px;
height: 128px;
border-radius: 5px;
}
.btn {
margin-top: 1em;
min-height: 28px;
width: 10em;
}
}
.setting-list {
list-style-type: none;

View File

@ -100,6 +100,15 @@ const Status = {
},
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
replyEnter (id, event) {
if (this.$store.state.config.hoverPreview) {
let rect = event.target.getBoundingClientRect()
this.$emit('preview', Number(id), rect.left + 20, rect.top + 20 + window.pageYOffset)
}
},
replyLeave () {
this.$emit('preview', 0, 0, 0)
}
},
watch: {

View File

@ -19,7 +19,7 @@
<div class="media status container muted">
<small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small>
<small class="muteWords">{{muteWordHits.join(', ')}}</small>
<a href="#" class="unmute" @click.prevent="toggleMute"><i class="icon-eye-off"></i></a>
<a href="#" class="unmute" @click.prevent="toggleMute"><i class="fa icon-eye-off"></i></a>
</div>
</template>
<template v-if="!muted">
@ -56,7 +56,7 @@
</small>
<template v-if="isReply && !expandable">
<small>
<a href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" ><i class="icon-reply"></i></a>
<a href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"><i class="icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i></a>
</small>
</template>
-
@ -70,7 +70,7 @@
<h4 class="replies" v-if="inConversation">
<small v-if="replies.length">Replies:</small>
<small v-for="reply in replies">
<a href="#" @click.prevent="gotoOriginal(reply.id)">{{reply.name}}&nbsp;</a>
<a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}&nbsp;</a>
</small>
</h4>
</div>
@ -178,10 +178,6 @@
margin-right: -0.3em;
}
.greentext {
color: green;
}
a {
display: inline-block;
word-break: break-all;
@ -222,6 +218,10 @@
}
}
.greentext {
color: green;
}
.status-conversation {
border-left-style: solid;
}
@ -278,7 +278,7 @@
}
.muted {
padding: 0.1em 0.7em 0.1em 0.8em;
padding: 0.1em 0.4em 0.1em 0.8em;
button {
margin-left: auto;
}

View File

@ -6,7 +6,8 @@ const Timeline = {
props: [
'timeline',
'timelineName',
'title'
'title',
'userId'
],
computed: {
timelineError () { return this.$store.state.statuses.error }
@ -26,7 +27,8 @@ const Timeline = {
store,
credentials,
timeline: this.timelineName,
showImmediately
showImmediately,
userId: this.userId
})
},
methods: {
@ -42,7 +44,8 @@ const Timeline = {
credentials,
timeline: this.timelineName,
older: true,
showImmediately: true
showImmediately: true,
userId: this.userId
}).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false }))
},
scrollLoad (e) {

View File

@ -4,15 +4,15 @@
<div class="title">
{{title}}
</div>
<button @click.prevent="showNewStatuses" class="base06 base02-background loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError">
<button @click.prevent="showNewStatuses" class="base05 base01-background loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError">
Show new ({{timeline.newStatusCount}})
</button>
<button @click.prevent class="base06 error no-press loadmore-button" v-if="timelineError">
<div @click.prevent class="base06 error loadmore-text" v-if="timelineError">
Error fetching updates
</button>
<button @click.prevent class="base04 base01-background no-press loadmore-button" v-if="!timeline.newStatusCount > 0 && !timelineError">
</div>
<div @click.prevent class="base04 base01-background loadmore-text" v-if="!timeline.newStatusCount > 0 && !timelineError">
Up-to-date
</button>
</div>
</div>
<div class="panel-body">
<div class="timeline">
@ -43,18 +43,26 @@
.loadmore-button {
position: absolute;
right: 0.6em;
font-size: 14px;
min-width: 6em;
height: 1.8em;
line-height: 100%;
}
.loadmore-text {
position: absolute;
right: 0.6em;
font-size: 14px;
min-width: 6em;
border-radius: 5px;
font-family: sans-serif;
text-align: center;
padding: 0 0.5em 0 0.5em;
opacity: 0.8;
}
.error {
background-color: rgba(255, 48, 16, 0.65);
}
.no-press {
opacity: 0.8;
cursor: default;
}
}

View File

@ -5,8 +5,10 @@
<div class='container'>
<img :src="user.profile_image_url">
<span class="glyphicon glyphicon-user"></span>
<div class='user-name'>{{user.name}}</div>
<div class='user-screen-name'>@{{user.screen_name}}</div>
<div class="name-and-screen-name">
<div class='user-name'>{{user.name}}</div>
<div class='user-screen-name'>@{{user.screen_name}}</div>
</div>
</div>
<div v-if="isOtherUser" class="user-interactions">
<div v-if="user.follows_you && loggedIn" class="following base06">
@ -61,10 +63,13 @@
props: [ 'user' ],
computed: {
headingStyle () {
let rgb = this.$store.state.config.colors['base00'].match(/\d+/g)
return {
backgroundColor: `rgb(${Math.floor(rgb[0] * 0.53)}, ${Math.floor(rgb[1] * 0.56)}, ${Math.floor(rgb[2] * 0.59)})`,
backgroundImage: `url(${this.user.cover_photo})`
let color = this.$store.state.config.colors['base00']
if (color) {
let rgb = this.$store.state.config.colors['base00'].match(/\d+/g)
return {
backgroundColor: `rgb(${Math.floor(rgb[0] * 0.53)}, ${Math.floor(rgb[1] * 0.56)}, ${Math.floor(rgb[2] * 0.59)})`,
backgroundImage: `url(${this.user.cover_photo})`
}
}
},
bodyStyle () {
@ -118,6 +123,8 @@
.profile-panel-body {
top: -0em;
padding-top: 4em;
word-wrap: break-word;
}
.user-info {
@ -132,33 +139,37 @@
align-content: flex-start;
justify-content: center;
max-height: 60px;
overflow: hidden;
}
img {
border: 2px solid;
border-radius: 5px;
flex: 1 0 100%;
max-width: 48px;
max-height: 48px;
border: 2px solid;
border-radius: 5px;
flex: 1 0 100%;
width: 48px;
height: 48px;
object-fit: cover;
}
text-shadow: 0px 1px 1.5px rgba(0, 0, 0, 1.0);
.user-name{
margin-top: 0.0em;
.name-and-screen-name {
display: block;
margin-top: 0.0em;
margin-left: 0.6em;
flex: 0 0 auto;
align-self: flex-start;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-name{
}
.user-screen-name {
margin-top: 0.0em;
margin-left: 0.6em;
font-weight: lighter;
font-size: 15px;
padding-right: 0.1em;
flex: 0 0 auto;
align-self: flex-start;
}
.user-interactions {

View File

@ -1,16 +1,36 @@
import UserCardContent from '../user_card_content/user_card_content.vue'
import { find } from 'lodash'
import Timeline from '../timeline/timeline.vue'
const UserProfile = {
created () {
this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.dispatch('startFetching', ['user', this.userId])
},
destroyed () {
this.$store.dispatch('stopFetching', 'user')
},
computed: {
timeline () { return this.$store.state.statuses.timelines.user },
userId () {
return this.$route.params.id
},
user () {
const id = this.$route.params.id
const user = find(this.$store.state.users.users, {id})
return user
if (this.timeline.statuses[0]) {
return this.timeline.statuses[0].user
} else {
return false
}
}
},
watch: {
userId () {
this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.dispatch('startFetching', ['user', this.userId])
}
},
components: {
UserCardContent
UserCardContent,
Timeline
}
}

View File

@ -1,6 +1,9 @@
<template>
<div class="user-profile panel panel-default base00-background">
<user-card-content :user="user"></user-card-content>
<div>
<div v-if="user" class="user-profile panel panel-default base00-background">
<user-card-content :user="user"></user-card-content>
</div>
<Timeline :title="'User Timeline'" v-bind:timeline="timeline" v-bind:timeline-name="'user'" :user-id="userId"/>
</div>
</template>

View File

@ -9,6 +9,7 @@ import ConversationPage from './components/conversation-page/conversation-page.v
import Mentions from './components/mentions/mentions.vue'
import UserProfile from './components/user_profile/user_profile.vue'
import Settings from './components/settings/settings.vue'
import Registration from './components/registration/registration.vue'
import statusesModule from './modules/statuses.js'
import usersModule from './modules/users.js'
@ -34,6 +35,7 @@ const persistedStateOptions = {
'config.hideAttachmentsInConv',
'config.hideNsfw',
'config.autoLoad',
'config.hoverPreview',
'config.muteWords',
'statuses.notifications',
'users.users'
@ -59,7 +61,8 @@ const routes = [
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'user-profile', path: '/users/:id', component: UserProfile },
{ name: 'mentions', path: '/:username/mentions', component: Mentions },
{ name: 'settings', path: '/settings', component: Settings }
{ name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration }
]
const router = new VueRouter({
@ -82,9 +85,16 @@ new Vue({
window.fetch('/static/config.json')
.then((res) => res.json())
.then(({name, theme, background, logo}) => {
.then(({name, theme, background, logo, registrationOpen}) => {
store.dispatch('setOption', { name: 'name', value: name })
store.dispatch('setOption', { name: 'theme', value: theme })
store.dispatch('setOption', { name: 'background', value: background })
store.dispatch('setOption', { name: 'logo', value: logo })
store.dispatch('setOption', { name: 'registrationOpen', value: registrationOpen })
})
window.fetch('/static/terms-of-service.html')
.then((res) => res.text())
.then((html) => {
store.dispatch('setOption', { name: 'tos', value: html })
})

View File

@ -1,4 +1,5 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import {isArray} from 'lodash'
const api = {
state: {
@ -18,9 +19,17 @@ const api = {
},
actions: {
startFetching (store, timeline) {
let userId = false
// This is for user timelines
if (isArray(timeline)) {
userId = timeline[1]
timeline = timeline[0]
}
// Don't start fetching if we already are.
if (!store.state.fetchers[timeline]) {
const fetcher = store.state.backendInteractor.startFetching({timeline, store})
const fetcher = store.state.backendInteractor.startFetching({timeline, store, userId})
store.commit('addFetcher', {timeline, fetcher})
}
},

View File

@ -8,6 +8,7 @@ const defaultState = {
hideAttachmentsInConv: false,
hideNsfw: true,
autoLoad: true,
hoverPreview: true,
muteWords: []
}

View File

@ -1,4 +1,4 @@
import { remove, slice, sortBy, toInteger, each, find, flatten, maxBy, last, merge, max, isArray } from 'lodash'
import { includes, remove, slice, sortBy, toInteger, each, find, flatten, maxBy, last, merge, max, isArray } from 'lodash'
import apiService from '../services/api/api.service.js'
// import parse from '../services/status_parser/status_parser.js'
@ -32,6 +32,17 @@ export const defaultState = {
minVisibleId: 0,
loading: false
},
user: {
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minVisibleId: 0,
loading: false
},
publicAndExternal: {
statuses: [],
statusesObject: {},
@ -57,11 +68,15 @@ 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) {
const nsfwRegex = /#nsfw/i
status.nsfw = !!status.text.match(nsfwRegex)
status.nsfw = isNsfw(status)
}
// Set deleted flag
@ -90,6 +105,10 @@ export const statusType = (status) => {
return 'deletion'
}
if (status.text.match(/started following/)) {
return 'follow'
}
return 'unknown'
}
@ -238,10 +257,21 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
favoriteStatus(favorite)
}
},
'follow': (status) => {
addNotification({ type: 'follow', status: status, action: status })
},
'deletion': (deletion) => {
const uri = deletion.uri
updateMaxId(deletion)
// Remove possible notification
const status = find(allStatuses, {uri})
if (!status) {
return
}
remove(state.notifications, ({action: {id}}) => id === status.id)
remove(allStatuses, { uri })
if (timeline) {
remove(timelineObject.statuses, { uri })
@ -276,6 +306,21 @@ export const mutations = {
oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status })
},
clearTimeline (state, { timeline }) {
const emptyTimeline = {
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minVisibleId: 0,
loading: false
}
state.timelines[timeline] = emptyTimeline
},
setFavorited (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.favorited = value

View File

@ -24,7 +24,10 @@ export const mutations = {
set(user, 'muted', muted)
},
setCurrentUser (state, user) {
state.currentUser = user
state.currentUser = merge(state.currentUser || {}, user)
},
clearCurrentUser (state) {
state.currentUser = false
},
beginLogin (state) {
state.loggingIn = true
@ -66,6 +69,11 @@ const users = {
store.commit('setUserForStatus', status)
})
},
logout (store) {
store.commit('clearCurrentUser')
store.dispatch('stopFetching', 'friends')
store.commit('setBackendInteractor', backendInteractorService())
},
loginUser (store, userCredentials) {
return new Promise((resolve, reject) => {
const commit = store.commit

View File

@ -17,9 +17,15 @@ const FRIENDS_URL = '/api/statuses/friends.json'
const FOLLOWING_URL = '/api/friendships/create.json'
const UNFOLLOWING_URL = '/api/friendships/destroy.json'
const QVITTER_USER_PREF_URL = '/api/qvitter/set_profile_pref.json'
const REGISTRATION_URL = '/api/account/register.json'
const AVATAR_UPDATE_URL = '/api/qvitter/update_avatar.json'
const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json'
const QVITTER_USER_TIMELINE_URL = '/api/qvitter/statuses/user_timeline.json'
// const USER_URL = '/api/users/show.json'
import { each, map } from 'lodash'
import 'whatwg-fetch'
const oldfetch = window.fetch
let fetch = (url, options) => {
@ -28,6 +34,55 @@ let fetch = (url, options) => {
return oldfetch(fullUrl, options)
}
// Params
// cropH
// cropW
// cropX
// cropY
// img (base 64 encodend data url)
const updateAvatar = ({credentials, params}) => {
let url = AVATAR_UPDATE_URL
const form = new FormData()
each(params, (value, key) => {
if (value) {
form.append(key, value)
}
})
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST',
body: form
}).then((data) => data.json())
}
// Params needed:
// nickname
// email
// fullname
// password
// password_confirm
//
// Optional
// bio
// homepage
// location
const register = (params) => {
const form = new FormData()
each(params, (value, key) => {
if (value) {
form.append(key, value)
}
})
return fetch(REGISTRATION_URL, {
method: 'POST',
body: form
})
}
const authHeaders = (user) => {
if (user && user.username && user.password) {
return { 'Authorization': `Basic ${btoa(`${user.username}:${user.password}`)}` }
@ -98,24 +153,34 @@ const setUserMute = ({id, credentials, muted = true}) => {
})
}
const fetchTimeline = ({timeline, credentials, since = false, until = false}) => {
const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false}) => {
const timelineUrls = {
public: PUBLIC_TIMELINE_URL,
friends: FRIENDS_TIMELINE_URL,
mentions: MENTIONS_URL,
'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL
'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL,
user: QVITTER_USER_TIMELINE_URL
}
let url = timelineUrls[timeline]
let params = []
if (since) {
url += `?since_id=${since}`
params.push(['since_id', since])
}
if (until) {
url += `?max_id=${until}`
params.push(['max_id', until])
}
if (userId) {
params.push(['user_id', userId])
}
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}`
return fetch(url, { headers: authHeaders(credentials) }).then((data) => data.json())
}
@ -207,6 +272,8 @@ const apiService = {
fetchAllFollowing,
setUserMute,
fetchMutes,
register,
updateAvatar,
externalProfile
}

View File

@ -26,8 +26,8 @@ const backendInteractorService = (credentials) => {
return apiService.unfollowUser({credentials, id})
}
const startFetching = ({timeline, store}) => {
return timelineFetcherService.startFetching({timeline, store, credentials})
const startFetching = ({timeline, store, userId = false}) => {
return timelineFetcherService.startFetching({timeline, store, credentials, userId})
}
const setUserMute = ({id, muted = true}) => {
@ -36,6 +36,8 @@ const backendInteractorService = (credentials) => {
const fetchMutes = () => apiService.fetchMutes({credentials})
const register = (params) => apiService.register(params)
const updateAvatar = ({params}) => apiService.updateAvatar({credentials, params})
const externalProfile = (profileUrl) => apiService.externalProfile(profileUrl)
const backendInteractorServiceInstance = {
@ -49,6 +51,8 @@ const backendInteractorService = (credentials) => {
startFetching,
setUserMute,
fetchMutes,
register,
updateAvatar,
externalProfile
}

View File

@ -0,0 +1,70 @@
import { reduce, find } from 'lodash'
export const replaceWord = (str, toReplace, replacement) => {
return str.slice(0, toReplace.start) + replacement + str.slice(toReplace.end)
}
export const wordAtPosition = (str, pos) => {
const words = splitIntoWords(str)
const wordsWithPosition = addPositionToWords(words)
return find(wordsWithPosition, ({start, end}) => start <= pos && end > pos)
}
export const addPositionToWords = (words) => {
return reduce(words, (result, word) => {
const data = {
word,
start: 0,
end: word.length
}
if (result.length > 0) {
const previous = result.pop()
data.start += previous.end
data.end += previous.end
result.push(previous)
}
result.push(data)
return result
}, [])
}
export const splitIntoWords = (str) => {
// Split at word boundaries
const regex = /\b/
const triggers = /[@#]+$/
let split = str.split(regex)
// Add trailing @ and # to the following word.
const words = reduce(split, (result, word) => {
if (result.length > 0) {
let previous = result.pop()
const matches = previous.match(triggers)
if (matches) {
previous = previous.replace(triggers, '')
word = matches[0] + word
}
result.push(previous)
}
result.push(word)
return result
}, [])
return words
}
const completion = {
wordAtPosition,
addPositionToWords,
splitIntoWords,
replaceWord
}
export default completion

View File

@ -14,7 +14,7 @@ const update = ({store, statuses, timeline, showImmediately}) => {
})
}
const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false}) => {
const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false}) => {
const args = { timeline, credentials }
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
@ -25,14 +25,16 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false
args['since'] = timelineData.maxId
}
args['userId'] = userId
return apiService.fetchTimeline(args)
.then((statuses) => update({store, statuses, timeline, showImmediately}),
() => store.dispatch('setError', { value: true }))
}
const startFetching = ({ timeline = 'friends', credentials, store }) => {
fetchAndUpdate({timeline, credentials, store, showImmediately: true})
const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store })
const startFetching = ({timeline = 'friends', credentials, store, userId = false}) => {
fetchAndUpdate({timeline, credentials, store, showImmediately: true, userId})
const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store, userId })
return setInterval(boundFetchAndUpdate, 10000)
}
const timelineFetcher = {

View File

@ -2,5 +2,6 @@
"name": "Pleroma FE",
"theme": "base16-pleroma-dark.css",
"background": "/static/bg.jpg",
"logo": "/static/logo.png"
"logo": "/static/logo.png",
"registrationOpen": false
}

View File

@ -89,6 +89,12 @@
"css": "menu",
"code": 61641,
"src": "fontawesome"
},
{
"uid": "0d20938846444af8deb1920dc85a29fb",
"css": "logout",
"code": 59400,
"src": "fontawesome"
}
]
}

View File

@ -7,6 +7,7 @@
.icon-eye-off:before { content: '\e805'; } /* '' */
.icon-plus-squared:before { content: '\e806'; } /* '' */
.icon-cog:before { content: '\e807'; } /* '' */
.icon-logout:before { content: '\e808'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-menu:before { content: '\f0c9'; } /* '' */

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,7 @@
.icon-eye-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe805;&nbsp;'); }
.icon-plus-squared { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe806;&nbsp;'); }
.icon-cog { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&nbsp;'); }
.icon-logout { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe808;&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-menu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0c9;&nbsp;'); }

View File

@ -18,6 +18,7 @@
.icon-eye-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe805;&nbsp;'); }
.icon-plus-squared { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe806;&nbsp;'); }
.icon-cog { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&nbsp;'); }
.icon-logout { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe808;&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-menu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0c9;&nbsp;'); }

View File

@ -1,11 +1,11 @@
@font-face {
font-family: 'fontello';
src: url('../font/fontello.eot?79576261');
src: url('../font/fontello.eot?79576261#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?79576261') format('woff2'),
url('../font/fontello.woff?79576261') format('woff'),
url('../font/fontello.ttf?79576261') format('truetype'),
url('../font/fontello.svg?79576261#fontello') format('svg');
src: url('../font/fontello.eot?64848116');
src: url('../font/fontello.eot?64848116#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?64848116') format('woff2'),
url('../font/fontello.woff?64848116') format('woff'),
url('../font/fontello.ttf?64848116') format('truetype'),
url('../font/fontello.svg?64848116#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?79576261#fontello') format('svg');
src: url('../font/fontello.svg?64848116#fontello') format('svg');
}
}
*/
@ -63,6 +63,7 @@
.icon-eye-off:before { content: '\e805'; } /* '' */
.icon-plus-squared:before { content: '\e806'; } /* '' */
.icon-cog:before { content: '\e807'; } /* '' */
.icon-logout:before { content: '\e808'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-menu:before { content: '\f0c9'; } /* '' */

View File

@ -229,11 +229,11 @@ body {
}
@font-face {
font-family: 'fontello';
src: url('./font/fontello.eot?13861244');
src: url('./font/fontello.eot?13861244#iefix') format('embedded-opentype'),
url('./font/fontello.woff?13861244') format('woff'),
url('./font/fontello.ttf?13861244') format('truetype'),
url('./font/fontello.svg?13861244#fontello') format('svg');
src: url('./font/fontello.eot?1253892');
src: url('./font/fontello.eot?1253892#iefix') format('embedded-opentype'),
url('./font/fontello.woff?1253892') format('woff'),
url('./font/fontello.ttf?1253892') format('truetype'),
url('./font/fontello.svg?1253892#fontello') format('svg');
font-weight: normal;
font-style: normal;
}
@ -313,12 +313,13 @@ body {
<div title="Code: 0xe807" class="the-icons span3"><i class="demo-icon icon-cog">&#xe807;</i> <span class="i-name">icon-cog</span><span class="i-code">0xe807</span></div>
</div>
<div class="row">
<div title="Code: 0xe808" class="the-icons span3"><i class="demo-icon icon-logout">&#xe808;</i> <span class="i-name">icon-logout</span><span class="i-code">0xe808</span></div>
<div title="Code: 0xe832" class="the-icons span3"><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 title="Code: 0xe834" class="the-icons span3"><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 title="Code: 0xf0c9" class="the-icons span3"><i class="demo-icon icon-menu">&#xf0c9;</i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span></div>
<div title="Code: 0xf112" class="the-icons span3"><i class="demo-icon icon-reply">&#xf112;</i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div>
</div>
<div class="row">
<div title="Code: 0xf112" class="the-icons span3"><i class="demo-icon icon-reply">&#xf112;</i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div>
<div title="Code: 0xf1e5" class="the-icons span3"><i class="demo-icon icon-binoculars">&#xf1e5;</i> <span class="i-name">icon-binoculars</span><span class="i-code">0xf1e5</span></div>
<div title="Code: 0xf234" class="the-icons span3"><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>

Binary file not shown.

View File

@ -22,6 +22,8 @@
<glyph glyph-name="cog" unicode="&#xe807;" d="M571 350q0 59-41 101t-101 42-101-42-42-101 42-101 101-42 101 42 41 101z m286 61v-124q0-7-4-13t-11-7l-104-16q-10-30-21-51 19-27 59-77 6-6 6-13t-5-13q-15-21-55-61t-53-39q-7 0-14 5l-77 60q-25-13-51-21-9-76-16-104-4-16-20-16h-124q-8 0-14 5t-6 12l-16 103q-27 9-50 21l-79-60q-6-5-14-5-8 0-14 6-70 64-92 94-4 5-4 13 0 6 5 12 8 12 28 37t30 40q-15 28-23 55l-102 15q-7 1-11 7t-5 13v124q0 7 5 13t10 7l104 16q8 25 22 51-23 32-60 77-6 7-6 14 0 5 5 12 15 20 55 60t53 40q7 0 15-5l77-60q24 13 50 21 9 76 17 104 3 16 20 16h124q7 0 13-5t7-12l15-103q28-9 51-20l79 59q5 5 13 5 7 0 14-5 72-67 92-95 4-5 4-12 0-7-4-13-9-12-29-37t-30-40q15-28 23-54l102-16q7-1 12-7t4-13z" horiz-adv-x="857.1" />
<glyph glyph-name="logout" unicode="&#xe808;" d="M357 46q0-2 1-11t0-14-2-14-5-11-12-3h-178q-67 0-114 47t-47 114v392q0 67 47 114t114 47h178q8 0 13-5t5-13q0-2 1-11t0-15-2-13-5-11-12-3h-178q-37 0-63-26t-27-64v-392q0-37 27-63t63-27h174t6 0 7-2 4-3 4-5 1-8z m518 304q0-14-11-25l-303-304q-11-10-25-10t-25 10-11 25v161h-250q-14 0-25 11t-11 25v214q0 15 11 25t25 11h250v161q0 14 11 25t25 10 25-10l303-304q11-10 11-25z" horiz-adv-x="928.6" />
<glyph glyph-name="spin3" unicode="&#xe832;" d="M494 850c-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 850c-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: 6.5 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,7 @@
<h4>Terms of Service</h4>
<p>This is a placeholder ToS.</p>
<p>Edit <code>"/static/terms-of-service.html"</code> to make it fit the needs of your instance.</p>
<br>
<img src="/static/logo.png"/ style="display: block; margin: auto;">

View File

@ -125,18 +125,19 @@ describe('The Statuses module', () => {
it('removes statuses by tag on deletion', () => {
const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1})
const otherStatus = makeMockStatus({id: 3})
status.uri = 'xxx'
const deletion = makeMockStatus({id: 2, is_post_verb: false})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx'
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status, otherStatus], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [deletion], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([])
expect(state.timelines.public.statuses).to.eql([])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.maxId).to.eql(2)
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', () => {
@ -319,6 +320,36 @@ describe('The Statuses module', () => {
expect(state.notifications[0].type).to.eql('mention')
})
it('removes a notification when the notice gets removed', () => {
const user = { id: 1 }
const state = cloneDeep(defaultState)
const status = makeMockStatus({id: 1})
const otherStatus = makeMockStatus({id: 3})
const mentionedStatus = makeMockStatus({id: 2})
mentionedStatus.attentions = [user]
mentionedStatus.uri = 'xxx'
otherStatus.attentions = [user]
const deletion = makeMockStatus({id: 4, is_post_verb: false})
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], user })
expect(state.notifications.length).to.eql(1)
mutations.addNewStatuses(state, { statuses: [mentionedStatus], user })
expect(state.allStatuses.length).to.eql(3)
expect(state.notifications.length).to.eql(2)
expect(state.notifications[1].status).to.eql(mentionedStatus)
expect(state.notifications[1].action).to.eql(mentionedStatus)
expect(state.notifications[1].type).to.eql('mention')
mutations.addNewStatuses(state, { statuses: [deletion], user })
expect(state.allStatuses.length).to.eql(2)
expect(state.notifications.length).to.eql(1)
})
it('adds the message to mentions when you are mentioned', () => {
const user = { id: 1 }
const state = cloneDeep(defaultState)

View File

@ -0,0 +1,70 @@
import { replaceWord, addPositionToWords, wordAtPosition, splitIntoWords } from '../../../../../src/services/completion/completion.js'
describe('addPositiontoWords', () => {
it('adds the position to a word list', () => {
const words = ['hey', 'this', 'is', 'fun']
const expected = [
{
word: 'hey',
start: 0,
end: 3
},
{
word: 'this',
start: 3,
end: 7
},
{
word: 'is',
start: 7,
end: 9
},
{
word: 'fun',
start: 9,
end: 12
}
]
const res = addPositionToWords(words)
expect(res).to.eql(expected)
})
})
describe('splitIntoWords', () => {
it('splits at whitespace boundaries', () => {
const str = 'This is a #nice @test for you, @idiot.'
const expected = ['This', ' ', 'is', ' ', 'a', ' ', '#nice', ' ', '@test', ' ', 'for', ' ', 'you', ', ', '@idiot', '.']
const res = splitIntoWords(str)
expect(res).to.eql(expected)
})
})
describe('wordAtPosition', () => {
it('returns the word for a given string and postion, plus the start and end position of that word', () => {
const str = 'Hey this is fun'
const { word, start, end } = wordAtPosition(str, 4)
expect(word).to.eql('this')
expect(start).to.eql(4)
expect(end).to.eql(8)
})
})
describe('replaceWord', () => {
it('replaces a word (with start and end) with another word in a given string', () => {
const str = 'hey @take, how are you'
const wordsWithPosition = addPositionToWords(splitIntoWords(str))
const toReplace = wordsWithPosition[2]
expect(toReplace.word).to.eql('@take')
const expected = 'hey @takeshitakenji, how are you'
const res = replaceWord(str, toReplace, '@takeshitakenji')
expect(res).to.eql(expected)
})
})

View File

@ -5713,9 +5713,9 @@ vue-loader@^11.1.0:
vue-style-loader "^2.0.0"
vue-template-es2015-compiler "^1.2.2"
vue-router@^2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-2.2.1.tgz#b027f9fac2cf13462725e843d6dc631b6aa077f6"
vue-router@^2.5.3:
version "2.5.3"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-2.5.3.tgz#073783f564b6aece6c8a59c63e298dc2aabfb51b"
vue-style-loader@^2.0.0:
version "2.0.0"
@ -5724,9 +5724,9 @@ vue-style-loader@^2.0.0:
hash-sum "^1.0.2"
loader-utils "^0.2.7"
vue-template-compiler@^2.1.10:
version "2.1.10"
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.1.10.tgz#cb89643adc395e97435585522e43d0a9b1913257"
vue-template-compiler@^2.3.4:
version "2.3.4"
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.3.4.tgz#5a88ac2c5e4d5d6218e6aa80e7e221fb7e67894c"
dependencies:
de-indent "^1.0.2"
he "^1.1.0"
@ -5739,13 +5739,13 @@ vue-timeago@^3.1.2:
version "3.2.0"
resolved "https://registry.yarnpkg.com/vue-timeago/-/vue-timeago-3.2.0.tgz#73fd0635de6ea4ecfbbce035b2e44035d806fba1"
vue@^2.1.0:
version "2.1.10"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.1.10.tgz#c9235ca48c7925137be5807832ac4e3ac180427b"
vue@^2.3.4:
version "2.3.4"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.3.4.tgz#5ec3b87a191da8090bbef56b7cfabd4158038171"
vuex@^2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-2.1.2.tgz#15d2da62dd6ff59c071f0a91cd4f434eacf6ca6c"
vuex@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-2.3.1.tgz#cde8e997c1f9957719bc7dea154f9aa691d981a6"
watchpack@^0.2.1:
version "0.2.9"
@ -5816,6 +5816,10 @@ webpack@^1.13.2:
watchpack "^0.2.1"
webpack-core "~0.6.9"
whatwg-fetch@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"
whet.extend@~0.9.9:
version "0.9.9"
resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"