update branch and fix merge conflicts

This commit is contained in:
Shpuld Shpuldson 2017-06-15 23:25:19 +03:00
commit e0e8965c08
52 changed files with 876 additions and 242 deletions

View File

@ -2,7 +2,7 @@
> A Qvitter-style frontend for certain GS servers.
![screenshot](http://i.imgur.com/3q30Zxt.jpg)
![screenshot](https://my.mixtape.moe/kjzioz.PNG)
# FOR ADMINS

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

View File

@ -23,11 +23,11 @@
"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"
},
"devDependencies": {
"autoprefixer": "^6.4.0",

View File

@ -1,13 +1,15 @@
import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
import Notifications from './components/notifications/notifications.vue'
import UserFinder from './components/user_finder/user_finder.vue'
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications
Notifications,
UserFinder
},
data: () => ({
mobileActivePanel: 'timeline'

View File

@ -52,6 +52,8 @@ button{
.item {
flex: 1;
line-height: 21px;
height: 21px;
}
.gaps > .item {
@ -134,11 +136,6 @@ main-router {
background-color: rgba(0,0,0,0.1);
}
.media-body {
flex: 1;
padding-left: 0.5em;
}
.container > * {
min-width: 0px;
}
@ -147,60 +144,6 @@ main-router {
color: grey;
}
.status-actions {
width: 50%;
display: flex;
div, favorite-button {
flex: 1;
}
}
status-text-container {
display: block;
}
.status-el {
line-height: 18px;
.notify {
.avatar {
border-width: 3px;
border-style: solid;
}
}
.media-left {
img {
margin-top: 0.2em;
float: right;
margin-right: 0.3em;
border-radius: 5px;
}
}
.retweet-info {
padding: 0.7em 0 0 0.6em;
.media-left {
display: flex;
i {
align-self: center;
text-align: right;
flex: 1;
padding-right: 0.3em;
}
}
}
.media-heading {
small {
font-weight: lighter;
}
margin-bottom: 0.3em;
}
}
nav {
z-index: 1000;
}
@ -213,13 +156,20 @@ nav {
}
.main {
flex: 1;
flex-basis: 65%;
flex-basis: 60%;
flex-grow: 1;
flex-shrink: 1;
}
.sidebar {
flex: 1;
flex-basis: 35%;
flex: 0;
flex-basis: 35%;
}
.sidebar-flexer {
flex: 1;
flex-basis: 345px;
width: 365px;
}
.mobile-shown {
@ -238,6 +188,30 @@ nav {
}
}
@media all and (min-width: 960px) {
.sidebar {
overflow: hidden;
max-height: 100vh;
width: 350px;
position: fixed;
margin-top: -10px;
.sidebar-container {
height: 96vh;
width: 362px;
padding-top: 10px;
padding-right: 20px;
overflow-x: hidden;
overflow-y: scroll;
}
}
.sidebar-flexer {
max-height: 96vh;
flex-shrink: 0;
flex-grow: 0;
}
}
@media all and (max-width: 959px) {
.mobile-hidden {
display: none;

View File

@ -6,6 +6,7 @@
<router-link :to="{ name: 'root'}">{{sitename}}</router-link>
</div>
<div class='item right'>
<user-finder></user-finder>
<router-link :to="{ name: 'settings'}"><i class="icon-cog"></i></router-link>
</div>
</div>
@ -15,10 +16,14 @@
<button @click="activatePanel('sidebar')">Sidebar</button>
<button @click="activatePanel('timeline')">Timeline</button>
</div>
<div class="sidebar" :class="{ 'mobile-hidden': mobileActivePanel != 'sidebar' }">
<user-panel></user-panel>
<nav-panel></nav-panel>
<notifications v-if="currentUser"></notifications>
<div class="sidebar-flexer" :class="{ 'mobile-hidden': mobileActivePanel != 'sidebar'}">
<div class="sidebar" :class="{ 'mobile-hidden': mobileActivePanel != 'sidebar' }">
<div class="sidebar-container">
<user-panel></user-panel>
<nav-panel></nav-panel>
<notifications v-if="currentUser"></notifications>
</div>
</div>
</div>
<div class="main" :class="{ 'mobile-hidden': mobileActivePanel != 'timeline' }">
<transition name="fade">

View File

@ -26,7 +26,7 @@ const Attachment = {
autoHeight () {
if (this.type === 'image' && this.nsfw) {
return {
'min-height': '311px'
'min-height': '109px'
}
}
}

View File

@ -33,10 +33,10 @@
.attachments {
display: flex;
flex-wrap: wrap;
margin-right: -0.8em;
margin-right: -0.7em;
.attachment {
flex: 1 0 30%;
margin: 0.5em 0.8em 0.6em 0.0em;
margin: 0.5em 0.7em 0.6em 0.0em;
align-self: flex-start;
&.html {
@ -116,8 +116,10 @@
border-style: solid;
border-width: 1px;
border-radius: 5px;
object-fit: contain;
width: 100%;
height: 100%; /* If this isn't here, chrome will stretch the images */
max-height: 500px;
}
}
}

View File

@ -1,4 +1,4 @@
import { filter, sortBy } from 'lodash'
import { find, filter, sortBy } from 'lodash'
import { statusType } from '../../modules/statuses.js'
import Status from '../status/status.vue'
@ -8,6 +8,16 @@ const sortAndFilterConversation = (conversation) => {
}
const conversation = {
data () {
return {
highlight: null,
preview: {
x: 0,
y: 0,
status: null
}
}
},
props: [
'statusoid',
'collapsable'
@ -22,7 +32,6 @@ const conversation = {
const conversationId = this.status.statusnet_conversation_id
const statuses = this.$store.state.statuses.allStatuses
const conversation = filter(statuses, { statusnet_conversation_id: conversationId })
return sortAndFilterConversation(conversation)
}
},
@ -41,6 +50,7 @@ const conversation = {
const conversationId = this.status.statusnet_conversation_id
this.$store.state.api.backendInteractor.fetchConversation({id: conversationId})
.then((statuses) => this.$store.dispatch('addNewStatuses', { statuses }))
.then(() => this.setHighlight(this.statusoid.id))
} else {
const id = this.$route.params.id
this.$store.state.api.backendInteractor.fetchStatus({id})
@ -48,12 +58,38 @@ const conversation = {
.then(() => this.fetchConversation())
}
},
focused: function (id) {
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
},
focused (id) {
if (this.statusoid.retweeted_status) {
return (id === this.statusoid.retweeted_status.id)
} else {
return (id === this.statusoid.id)
}
},
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" :key="status.id" :statusoid="status" :expandable='false' :focused="focused(status.id)" :inConversation='true'></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

@ -1,3 +1,5 @@
import Status from '../status/status.vue'
import { sortBy, take, filter } from 'lodash'
const Notifications = {
@ -23,6 +25,9 @@ const Notifications = {
return this.unseenNotifications.length
}
},
components: {
Status
},
watch: {
unseenCount (count) {
if (count > 0) {

View File

@ -1,6 +1,8 @@
@import '../../_variables.scss';
.notifications {
// a bit of a hack to allow scrolling below notifications
padding-bottom: 15em;
.panel-heading {
// force the text to stay centered, while keeping
@ -43,19 +45,23 @@
word-wrap: break-word;
line-height:18px;
.icon-retweet {
.icon-retweet.lit {
color: $green;
}
.icon-reply {
.icon-reply.lit {
color: $blue;
}
h1 {
word-break: break-all;
margin: 0 0 0.3em;
padding: 0;
font-size: 1em;
line-height:20px;
small {
font-weight: lighter;
}
}
padding: 0.3em 0.8em 0.5em;

View File

@ -7,23 +7,34 @@
<button @click.prevent="markAsSeen" class="base06 base02-background read-button">Read!</button>
</div>
<div class="panel-body base03-border">
<div v-for="notification in visibleNotifications" class="notification" :class='{"unseen": !notification.seen}'>
<div v-for="notification in visibleNotifications" :key="notification" class="notification" :class='{"unseen": !notification.seen}'>
<a :href="notification.action.user.statusnet_profile_url">
<img class='avatar' :src="notification.action.user.profile_image_url_original">
</a>
<div class='text'>
<timeago :since="notification.action.created_at" :auto-update="240"></timeago>
<div class='text' style="width: 100%;">
<div v-if="notification.type === 'favorite'">
<h1>{{ notification.action.user.name }}<br><i class="fa icon-star"></i> favorited your <router-link :to="{ name: 'conversation', params: { id: notification.status.id } }">status</h1>
<p>{{ notification.status.text }}</p>
<h1>
<span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<i class="fa icon-star"></i>
<small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
</h1>
<div v-html="notification.status.statusnet_html"></div>
</div>
<div v-if="notification.type === 'repeat'">
<h1>{{ notification.action.user.name }}<br><i class="fa icon-retweet"></i> repeated your <router-link :to="{ name: 'conversation', params: { id: notification.status.id } }">status</h1>
<p>{{ notification.status.text }}</p>
<h1>
<span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<i class="fa icon-retweet lit"></i>
<small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
</h1>
<div v-html="notification.status.statusnet_html"></div>
</div>
<div v-if="notification.type === 'mention'">
<h1>{{ notification.action.user.name }}<br><i class="fa icon-reply"></i> <router-link :to="{ name: 'conversation', params: { id: notification.status.id } }">mentioned</router-link> you</h1>
<p>{{ notification.status.text }}</p>
<h1>
<span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<i class="fa icon-reply lit"></i>
<small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
</h1>
<status :compact="true" :statusoid="notification.status"></status>
</div>
</div>
</div>

View File

@ -5,6 +5,7 @@ import Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions]
@ -87,6 +88,8 @@ const PostStatusForm = {
files: []
}
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = '16px'
},
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
@ -113,6 +116,13 @@ const PostStatusForm = {
},
fileDrag (e) {
e.dataTransfer.dropEffect = 'copy'
},
resize (e) {
e.target.style.height = 'auto'
e.target.style.height = `${e.target.scrollHeight - 10}px`
if (e.target.value === '') {
e.target.style.height = '16px'
}
}
}
}

View File

@ -1,17 +1,8 @@
<template>
<div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)">
<div class="form-group" >
<textarea @click="setCaret" @keyup="setCaret" v-model="newStatus.status" placeholder="Just landed in L.A." rows="3" class="form-control" @keyup.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag"></textarea>
</div>
<div class="attachments">
<div class="attachment" v-for="file in newStatus.files">
<i class="fa icon-cancel" @click="removeMediaFile(file)"></i>
<img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.image" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
</div>
<div class="form-group base03-border" >
<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>
<h1>Word</h1>
@ -24,6 +15,15 @@
<media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload>
<button :disabled="submitDisabled" type="submit" class="btn btn-default base05 base01-background">Submit</button>
</div>
<div class="attachments">
<div class="attachment" v-for="file in newStatus.files">
<i class="fa icon-cancel" @click="removeMediaFile(file)"></i>
<img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.image" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
</div>
</div>
</form>
</div>
</template>
@ -51,14 +51,20 @@
.form-bottom {
display: flex;
padding: 0.5em;
height: 32px;
button {
flex: 2;
width: 10em;
}
}
.attachments {
padding: 0.5em;
padding: 0 0.5em;
.attachment {
position: relative;
margin: 0.5em 0.8em 0.2em 0;
}
i {
position: absolute;
@ -86,11 +92,16 @@
form textarea {
border: solid;
border-width: 1px;
border-color: silver;
border-color: inherit;
border-radius: 5px;
line-height:16px;
padding: 5px;
resize: vertical;
resize: none;
overflow: hidden;
}
form textarea:focus {
min-height: 48px;
}
.btn {

View File

@ -1,11 +1,15 @@
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import { filter, trim } from 'lodash'
const settings = {
data () {
return {
hideAttachmentsLocal: this.$store.state.config.hideAttachments,
hideAttachmentsInConvLocal: this.$store.state.config.hideAttachmentsInConv,
hideNsfwLocal: this.$store.state.config.hideNsfw
hideNsfwLocal: this.$store.state.config.hideNsfw,
autoLoadLocal: this.$store.state.config.autoLoad,
hoverPreviewLocal: this.$store.state.config.hoverPreview,
muteWordsString: this.$store.state.config.muteWords.join('\n')
}
},
components: {
@ -20,6 +24,16 @@ const settings = {
},
hideNsfwLocal (value) {
this.$store.dispatch('setOption', { name: 'hideNsfw', value })
},
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,11 @@
<h2>Theme</h2>
<style-switcher></style-switcher>
</div>
<div class="setting-item">
<h2>Filtering</h2>
<p>All notices containing these words will be muted, one per line</p>
<textarea id="muteWords" v-model="muteWordsString"></textarea>
</div>
<div class="setting-item">
<h2>Attachments</h2>
<ul class="setting-list">
@ -23,6 +28,14 @@
<input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal">
<label for="hideNsfw">Enable clickthrough NSFW attachment hiding</label>
</li>
<li>
<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>
@ -32,9 +45,13 @@
<script src="./settings.js">
</script>
<style>
<style lang="scss">
.setting-item {
margin: 1em 1em 1.4em;
textarea {
width: 100%;
height: 100px;
}
}
.setting-list {
list-style-type: none;

View File

@ -4,13 +4,17 @@ import RetweetButton from '../retweet_button/retweet_button.vue'
import DeleteButton from '../delete_button/delete_button.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
import { filter } from 'lodash'
const Status = {
props: [
'statusoid',
'expandable',
'inConversation',
'focused'
'focused',
'highlight',
'compact',
'replies'
],
data: () => ({
replying: false,
@ -19,6 +23,9 @@ const Status = {
userExpanded: false
}),
computed: {
muteWords () {
return this.$store.state.config.muteWords
},
hideAttachments () {
return (this.$store.state.config.hideAttachments && !this.inConversation) ||
(this.$store.state.config.hideAttachmentsInConv && this.inConversation)
@ -35,12 +42,30 @@ const Status = {
loggedIn () {
return !!this.$store.state.users.currentUser
},
muted () { return !this.unmuted && this.status.user.muted },
muteWordHits () {
const statusText = this.status.text.toLowerCase()
const hits = filter(this.muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase())
})
return hits
},
muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) },
isReply () { return !!this.status.in_reply_to_status_id },
borderColor () {
return {
borderBottomColor: this.$store.state.config.colors['base02']
}
},
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
}
},
components: {
@ -63,6 +88,10 @@ const Status = {
toggleReplying () {
this.replying = !this.replying
},
gotoOriginal (id) {
// only handled by conversation, not status_or_conversation
this.$emit('goto', id)
},
toggleExpanded () {
this.$emit('toggleExpanded')
},
@ -71,6 +100,28 @@ 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: {
'highlight': function (id) {
id = Number(id)
if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
window.scrollBy(0, rect.top - 200)
} else if (rect.bottom > window.innerHeight - 50) {
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
}
}
}

View File

@ -1,9 +1,25 @@
<template>
<div class="status-el base00-background base03-border" v-if="!status.deleted" v-bind:class="[{ 'base01-background': focused }, { 'status-conversation': inConversation }]" >
<div class="status-el base00-background" v-if="compact">
<div @click.prevent="linkClicked" class="status-content" v-html="status.statusnet_html"></div>
<div v-if="loggedIn">
<div class='status-actions'>
<div>
<a href="#" v-on:click.prevent="toggleReplying">
<i class="fa icon-reply" :class="{'icon-reply-active': replying}"></i>
</a>
</div>
<retweet-button :status=status></retweet-button>
<favorite-button :status=status></favorite-button>
</div>
</div>
<post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" v-on:posted="toggleReplying" v-if="replying"/>
</div>
<div class="status-el base00-background base03-border" v-else-if="!status.deleted" v-bind:class="[{ 'base01-background': isFocused }, { 'status-conversation': inConversation }]" >
<template v-if="muted">
<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>
<a href="#" class="unmute" @click.prevent="toggleMute"><i class="icon-eye-off"></i></a>
<small class="muteWords">{{muteWordHits.join(', ')}}</small>
<a href="#" class="unmute" @click.prevent="toggleMute"><i class="fa icon-eye-off"></i></a>
</div>
</template>
<template v-if="!muted">
@ -12,13 +28,14 @@
<i class='fa icon-retweet retweeted'></i>
</div>
<div class="media-body">
Retweeted by {{retweeter}}
Repeated by <a :href="statusoid.user.statusnet_profile_url" style="font-weight: bold;" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a>
</div>
</div>
<div class="media status container">
<div class="media-left">
<a :href="status.user.statusnet_profile_url">
<img @click.prevent="toggleUserExpanded" class='avatar' :src="status.user.profile_image_url_original">
<img @click.prevent="toggleUserExpanded" :class="{retweeted: retweet}" class='avatar' :src="status.user.profile_image_url_original">
<img v-if="retweet" class='avatar-retweeter' :src="statusoid.user.profile_image_url_original"></img>
</a>
</div>
<div class="media-body">
@ -26,40 +43,45 @@
<user-card-content :user="status.user"></user-card-content>
</div>
<div class="user-content">
<h4 class="media-heading">
{{status.user.name}}
<small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small>
<small v-if="status.in_reply_to_screen_name"> &gt;
<router-link :to="{ name: 'user-profile', params: { id: status.in_reply_to_user_id } }">
{{status.in_reply_to_screen_name}}
</router-link>
</small>
<template v-if="isReply">
<small>
<router-link :to="{ name: 'conversation', params: { id: status.in_reply_to_status_id } }">
<i class="icon-reply"></i>
</router-link>
<div class="media-heading">
<div class="name-and-links">
<h4 class="user-name">{{status.user.name}}</h4>
<div class="links">
<h4>
<small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small>
<small v-if="status.in_reply_to_screen_name"> &gt;
<router-link :to="{ name: 'user-profile', params: { id: status.in_reply_to_user_id } }">
{{status.in_reply_to_screen_name}}
</router-link>
</small>
</template>
-
<small>
<router-link :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago>
</router-link>
</small>
<template v-if="expandable">
-
<small>
<a href="#" @click.prevent="toggleExpanded" ><i class="icon-plus-squared"></i></a>
</small>
<small v-if="status.user.muted">
<a href="#" @click.prevent="toggleMute" ><i class="icon-eye-off"></i></a>
</small>
</template>
<small v-if="!status.is_local" class="source_url">
<a :href="status.external_url" target="_blank" ><i class="icon-binoculars"></i></a>
</small>
</h4>
<template v-if="isReply && !expandable">
<small>
<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>
-
<small>
<router-link :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago>
</router-link>
</small>
</h4>
</div>
<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)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}&nbsp;</a>
</small>
</h4>
</div>
<div class="heading-icons">
<a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="fa icon-eye-off"></i></a>
<a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url"><i class="fa icon-binoculars"></i></a>
<template v-if="expandable">
<a href="#" @click.prevent="toggleExpanded" class="expand"><i class="fa icon-plus-squared"></i></a>
</template>
</div>
</div>
<div @click.prevent="linkClicked" class="status-content" v-html="status.statusnet_html"></div>
@ -95,24 +117,65 @@
<style lang="scss">
@import '../../_variables.scss';
status-text-container {
display: block;
}
.status-el {
hyphens: auto;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px;
line-height: 18px;
.notify {
.avatar {
border-width: 3px;
border-style: solid;
}
}
.media-body {
flex: 1;
padding-left: 0.5em;
}
.user-content {
min-height: 52px;
padding-top: 1px;
}
.source_url {
float: right;
.media-heading {
display: flex;
min-height: 1.4em;
margin-bottom: 0.3em;
small {
font-weight: lighter;
}
h4 {
margin-right: 0.4em;
}
.name-and-links {
flex: 1 0;
display: flex;
flex-wrap: wrap;
}
.replies {
flex-basis: 100%;
}
}
.greentext {
color: green;
.source_url {
}
.expand {
margin-right: -0.3em;
}
a {
@ -129,6 +192,34 @@
margin-top: 0.2em;
margin-bottom: 0.5em;
}
.media-left {
img {
margin-top: 0.2em;
float: right;
margin-right: 0.3em;
border-radius: 5px;
}
}
.retweet-info {
padding: 0.7em 0 0 0.6em;
.media-left {
display: flex;
i {
align-self: center;
text-align: right;
flex: 1;
padding-right: 0.3em;
}
}
}
}
.greentext {
color: green;
}
.status-conversation {
@ -136,7 +227,14 @@
}
.status-actions {
padding-top: 5px;
padding-top: 0.15em;
width: 100%;
display: flex;
div, favorite-button {
max-width: 6em;
flex: 1;
}
}
.icon-reply:hover {
@ -148,7 +246,23 @@
}
.status .avatar {
width: 48px;
width: 48px;
height: 48px;
&.retweeted {
width: 40px;
height: 40px;
margin-right: 8px;
margin-bottom: 8px;
}
}
.status img.avatar-retweeter {
width: 24px;
height: 24px;
position: absolute;
margin-left: 24px;
margin-top: 24px;
}
.status.compact .avatar {
@ -156,14 +270,22 @@
}
.status {
padding: 0.65em 0.7em 0.8em 0.8em;
padding: 0.4em 0.7em 0.45em 0.7em;
border-bottom: 1px solid;
border-bottom-color: inherit;
border-left: 4px rgba(255, 48, 16, 0.65);
border-left-style: inherit;
}
.muted button {
margin-left: auto;
.muted {
padding: 0.1em 0.4em 0.1em 0.8em;
button {
margin-left: auto;
}
.muteWords {
margin-left: 10px;
}
}
a.unmute {
@ -188,4 +310,35 @@
flex: 1;
}
@media all and (max-width: 960px) {
.status-el {
.name-and-links {
margin-left: -0.25em;
}
}
.status {
max-width: 100%;
}
.status .avatar {
width: 40px;
height: 40px;
&.retweeted {
width: 34px;
height: 34px;
margin-right: 8px;
margin-bottom: 8px;
}
}
.status img.avatar-retweeter {
width: 22px;
height: 22px;
position: absolute;
margin-left: 18px;
margin-top: 18px;
}
}
</style>

View File

@ -6,7 +6,8 @@ const Timeline = {
props: [
'timeline',
'timelineName',
'title'
'title',
'userId'
],
computed: {
timelineError () { return this.$store.state.statuses.error }
@ -20,11 +21,14 @@ const Timeline = {
const credentials = store.state.users.currentUser.credentials
const showImmediately = this.timeline.visibleStatuses.length === 0
window.onscroll = this.scrollLoad
timelineFetcher.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
showImmediately
showImmediately,
userId: this.userId
})
},
methods: {
@ -40,8 +44,15 @@ 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) {
let height = Math.max(document.body.offsetHeight, document.body.scrollHeight)
if (this.timeline.loading === false && this.$store.state.config.autoLoad && (window.innerHeight + window.pageYOffset) >= (height - 750)) {
this.fetchOlderStatuses()
}
}
}
}

View File

@ -61,10 +61,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 () {
@ -79,9 +82,8 @@
return this.$store.state.users.currentUser
},
dailyAvg () {
return Math.round(
this.user.statuses_count / ((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000))
)
const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000))
return Math.round(this.user.statuses_count / days)
}
},
methods: {
@ -117,7 +119,6 @@
}
.profile-panel-body {
padding-top: 0em;
top: -0em;
padding-top: 4em;
}

View File

@ -0,0 +1,22 @@
const UserFinder = {
data: () => ({
username: undefined,
hidden: true
}),
methods: {
findUser (username) {
this.$store.state.api.backendInteractor.externalProfile(username)
.then((user) => {
if (!user.error) {
this.$store.commit('addNewUsers', [user])
this.$router.push({name: 'user-profile', params: {id: user.id}})
}
})
},
toggleHidden () {
this.hidden = !this.hidden
}
}
}
export default UserFinder

View File

@ -0,0 +1,23 @@
<template>
<a href="#" v-if="hidden"><i class="icon-user-plus user-finder-icon" @click.prevent="toggleHidden"/></a>
<span v-else>
<input class="user-finder-input base03-border" @keyup.enter="findUser(username)" v-model="username" placeholder="Find user" id="user-finder-input" type="text"/>
<i class="icon-cancel user-finder-icon" @click="toggleHidden"/>
</span>
</template>
<script src="./user_finder.js"></script>
<style lang="scss">
.user-finder-icon {
margin-right: 0.25em;
}
.user-finder-input {
border-width: 1px;
border-style: solid;
border-color: inherit;
border-radius: 5px;
padding: 0.1em 0.2em 0.2em 0.2em;
}
</style>

View File

@ -1,16 +1,30 @@
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
}
}
},
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

@ -3,6 +3,8 @@ import objectPath from 'object-path'
import localforage from 'localforage'
import { throttle, each } from 'lodash'
let loaded = false
const defaultReducer = (state, paths) => (
paths.length === 0 ? state : paths.reduce((substate, path) => {
objectPath.set(substate, path, objectPath.get(state, path))
@ -15,7 +17,11 @@ const defaultStorage = (() => {
})()
const defaultSetState = (key, state, storage) => {
return storage.setItem(key, state)
if (!loaded) {
console.log('waiting for old state to be loaded...')
} else {
return storage.setItem(key, state)
}
}
export default function createPersistedState ({
@ -32,17 +38,23 @@ export default function createPersistedState ({
} = {}) {
return store => {
getState(key, storage).then((savedState) => {
if (typeof savedState === 'object') {
// build user cache
const usersState = savedState.users || {}
usersState.usersObject = {}
const users = usersState.users || []
each(users, (user) => { usersState.usersObject[user.id] = user })
savedState.users = usersState
try {
if (typeof savedState === 'object') {
// build user cache
const usersState = savedState.users || {}
usersState.usersObject = {}
const users = usersState.users || []
each(users, (user) => { usersState.usersObject[user.id] = user })
savedState.users = usersState
store.replaceState(
merge({}, store.state, savedState)
)
store.replaceState(
merge({}, store.state, savedState)
)
}
loaded = true
} catch (e) {
console.log("Couldn't load state")
loaded = true
}
})

View File

@ -24,7 +24,7 @@ Vue.use(VueRouter)
Vue.use(VueTimeago, {
locale: 'en-US',
locales: {
'en-US': require('vue-timeago/locales/en-US.json')
'en-US': require('../static/timeago.json')
}
})
@ -33,6 +33,9 @@ const persistedStateOptions = {
'config.hideAttachments',
'config.hideAttachmentsInConv',
'config.hideNsfw',
'config.autoLoad',
'config.hoverPreview',
'config.muteWords',
'statuses.notifications',
'users.users'
]

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

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

View File

@ -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: {},
@ -242,6 +253,14 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
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 +295,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

@ -17,10 +17,14 @@ 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 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'
const oldfetch = window.fetch
import { map } from 'lodash'
let fetch = (url, options) => {
const baseUrl = ''
const fullUrl = baseUrl + url
@ -35,6 +39,13 @@ const authHeaders = (user) => {
}
}
const externalProfile = (profileUrl) => {
let url = `${EXTERNAL_PROFILE_URL}?profileurl=${profileUrl}`
return fetch(url, {
method: 'GET'
}).then((data) => data.json())
}
const followUser = ({id, credentials}) => {
let url = `${FOLLOWING_URL}?user_id=${id}`
return fetch(url, {
@ -90,24 +101,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())
}
@ -198,7 +219,8 @@ const apiService = {
uploadMedia,
fetchAllFollowing,
setUserMute,
fetchMutes
fetchMutes,
externalProfile
}
export default apiService

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 externalProfile = (profileUrl) => apiService.externalProfile(profileUrl)
const backendInteractorServiceInstance = {
fetchStatus,
fetchConversation,
@ -46,7 +48,8 @@ const backendInteractorService = (credentials) => {
verifyCredentials: apiService.verifyCredentials,
startFetching,
setUserMute,
fetchMutes
fetchMutes,
externalProfile
}
return backendInteractorServiceInstance

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

@ -1,6 +1,6 @@
{
"name": "Pleroma FE",
"theme": "base16-ashes.css",
"theme": "base16-pleroma-dark.css",
"background": "/static/bg.jpg",
"logo": "/static/logo.png"
}

View File

@ -0,0 +1,33 @@
.base00-background { background-color: #161c20; }
.base01-background { background-color: #282e32; }
.base02-background { background-color: #343a3f; }
.base03-background { background-color: #4e5256; }
.base04-background { background-color: #ababab; }
.base05-background { background-color: #b9b9b9; }
.base06-background { background-color: #d0d0d0; }
.base07-background { background-color: #e7e7e7; }
.base08-background { background-color: #baaa9c; }
.base09-background { background-color: #999999; }
.base0A-background { background-color: #a0a0a0; }
.base0B-background { background-color: #8e8e8e; }
.base0C-background { background-color: #868686; }
.base0D-background { background-color: #686868; }
.base0E-background { background-color: #747474; }
.base0F-background { background-color: #5e5e5e; }
.base00 { color: #161c20; }
.base01 { color: #282e32; }
.base02 { color: #36393e; }
.base03 { color: #4e5256; }
.base04 { color: #ababab; }
.base05 { color: #b9b9b9; }
.base06 { color: #d0d0d0; }
.base07 { color: #e7e7e7; }
.base08 { color: #baaa9c; }
.base09 { color: #999999; }
.base0A { color: #a0a0a0; }
.base0B { color: #8e8e8e; }
.base0C { color: #868686; }
.base0D { color: #686868; }
.base0E { color: #747474; }
.base0F { color: #5e5e5e; }

View File

@ -0,0 +1,33 @@
.base00-background { background-color: #f2f4f6; }
.base01-background { background-color: #dde2e6; }
.base02-background { background-color: #c0c6cb; }
.base03-background { background-color: #a4a4a4; }
.base04-background { background-color: #545454; }
.base05-background { background-color: #304055; }
.base06-background { background-color: #040404; }
.base07-background { background-color: #000000; }
.base08-background { background-color: #e92f2f; }
.base09-background { background-color: #e09448; }
.base0A-background { background-color: #dddd13; }
.base0B-background { background-color: #0ed839; }
.base0C-background { background-color: #23edda; }
.base0D-background { background-color: #3b48e3; }
.base0E-background { background-color: #f996e2; }
.base0F-background { background-color: #69542d; }
.base00 { color: #f2f4f6; }
.base01 { color: #dde2e6; }
.base02 { color: #c0c6cb; }
.base03 { color: #a4a4a4; }
.base04 { color: #545454; }
.base05 { color: #304055; }
.base06 { color: #040404; }
.base07 { color: #000000; }
.base08 { color: #e46f0f; }
.base09 { color: #e09448; }
.base0A { color: #dddd13; }
.base0B { color: #0ed839; }
.base0C { color: #23edda; }
.base0D { color: #3b48e3; }
.base0E { color: #f996e2; }
.base0F { color: #69542d; }

View File

@ -1,4 +1,6 @@
[
"base16-pleroma-dark.css",
"base16-pleroma-light.css",
"base16-3024.css",
"base16-apathy.css",
"base16-ashes.css",

View File

@ -77,6 +77,18 @@
"css": "cog",
"code": 59399,
"src": "fontawesome"
},
{
"uid": "1bafeeb1808a5fe24484c7890096901a",
"css": "user-plus",
"code": 62004,
"src": "fontawesome"
},
{
"uid": "559647a6f430b3aeadbecd67194451dd",
"css": "menu",
"code": 61641,
"src": "fontawesome"
}
]
}

View File

@ -9,5 +9,7 @@
.icon-cog:before { content: '\e807'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-menu:before { content: '\f0c9'; } /* '' */
.icon-reply:before { content: '\f112'; } /* '' */
.icon-binoculars:before { content: '\f1e5'; } /* '' */
.icon-binoculars:before { content: '\f1e5'; } /* '' */
.icon-user-plus:before { content: '\f234'; } /* '' */

File diff suppressed because one or more lines are too long

View File

@ -9,5 +9,7 @@
.icon-cog { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&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;'); }
.icon-reply { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf112;&nbsp;'); }
.icon-binoculars { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf1e5;&nbsp;'); }
.icon-binoculars { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf1e5;&nbsp;'); }
.icon-user-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf234;&nbsp;'); }

View File

@ -20,5 +20,7 @@
.icon-cog { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&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;'); }
.icon-reply { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf112;&nbsp;'); }
.icon-binoculars { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf1e5;&nbsp;'); }
.icon-binoculars { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf1e5;&nbsp;'); }
.icon-user-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf234;&nbsp;'); }

View File

@ -1,11 +1,11 @@
@font-face {
font-family: 'fontello';
src: url('../font/fontello.eot?90538621');
src: url('../font/fontello.eot?90538621#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?90538621') format('woff2'),
url('../font/fontello.woff?90538621') format('woff'),
url('../font/fontello.ttf?90538621') format('truetype'),
url('../font/fontello.svg?90538621#fontello') format('svg');
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');
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?90538621#fontello') format('svg');
src: url('../font/fontello.svg?79576261#fontello') format('svg');
}
}
*/
@ -65,5 +65,7 @@
.icon-cog:before { content: '\e807'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-menu:before { content: '\f0c9'; } /* '' */
.icon-reply:before { content: '\f112'; } /* '' */
.icon-binoculars:before { content: '\f1e5'; } /* '' */
.icon-binoculars:before { content: '\f1e5'; } /* '' */
.icon-user-plus:before { content: '\f234'; } /* '' */

View File

@ -229,11 +229,11 @@ body {
}
@font-face {
font-family: 'fontello';
src: url('./font/fontello.eot?15442171');
src: url('./font/fontello.eot?15442171#iefix') format('embedded-opentype'),
url('./font/fontello.woff?15442171') format('woff'),
url('./font/fontello.ttf?15442171') format('truetype'),
url('./font/fontello.svg?15442171#fontello') format('svg');
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');
font-weight: normal;
font-style: normal;
}
@ -315,8 +315,12 @@ body {
<div class="row">
<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: 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>
</div>
<div class="container footer">Generated by <a href="http://fontello.com">fontello.com</a></div>

Binary file not shown.

View File

@ -26,9 +26,13 @@
<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" />
<glyph glyph-name="menu" unicode="&#xf0c9;" d="M857 100v-71q0-15-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 25t25 11h785q15 0 26-11t10-25z m0 286v-72q0-14-10-25t-26-10h-785q-15 0-25 10t-11 25v72q0 14 11 25t25 10h785q15 0 26-10t10-25z m0 285v-71q0-14-10-25t-26-11h-785q-15 0-25 11t-11 25v71q0 15 11 26t25 10h785q15 0 26-10t10-26z" horiz-adv-x="857.1" />
<glyph glyph-name="reply" unicode="&#xf112;" d="M1000 225q0-93-71-252-1-4-6-13t-7-17-7-12q-7-10-16-10-8 0-13 6t-5 14q0 5 1 15t2 13q3 38 3 69 0 56-10 101t-27 77-45 56-59 39-74 24-86 12-98 3h-125v-143q0-14-10-25t-26-11-25 11l-285 286q-11 10-11 25t11 25l285 286q11 10 25 10t26-10 10-25v-143h125q398 0 488-225 30-75 30-186z" horiz-adv-x="1000" />
<glyph glyph-name="binoculars" unicode="&#xf1e5;" d="M393 671v-428q0-15-11-25t-25-11v-321q0-15-10-25t-26-11h-285q-15 0-25 11t-11 25v285l139 488q4 12 17 12h237z m178 0v-392h-142v392h142z m429-500v-285q0-15-11-25t-25-11h-285q-15 0-25 11t-11 25v321q-15 0-25 11t-11 25v428h237q13 0 17-12z m-589 661v-125h-197v125q0 8 5 13t13 5h161q8 0 13-5t5-13z m375 0v-125h-197v125q0 8 5 13t13 5h161q8 0 13-5t5-13z" horiz-adv-x="1000" />
<glyph glyph-name="user-plus" unicode="&#xf234;" d="M393 350q-89 0-152 63t-62 151 62 152 152 63 151-63 63-152-63-151-151-63z m536-71h196q7 0 13-6t5-12v-107q0-8-5-13t-13-5h-196v-197q0-7-6-12t-12-6h-107q-8 0-13 6t-5 12v197h-197q-7 0-12 5t-6 13v107q0 7 6 12t12 6h197v196q0 7 5 13t13 5h107q7 0 12-5t6-13v-196z m-411-125q0-29 21-51t50-21h143v-133q-38-28-95-28h-488q-67 0-108 39t-41 106q0 30 2 58t8 61 15 60 24 55 34 45 48 30 62 11q11 0 22-10 44-34 86-51t92-17 92 17 86 51q11 10 22 10 73 0 121-54h-125q-29 0-50-21t-21-50v-107z" horiz-adv-x="1142.9" />
</font>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

10
static/timeago.json Normal file
View File

@ -0,0 +1,10 @@
[
"now",
["%ss", "%ss"],
["%smin", "%smin"],
["%sh", "%sh"],
["%sd", "%sd"],
["%sw", "%sw"],
["%sm", "%sm"],
["%sy", "%sy"]
]

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

@ -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"