Feature/polls attempt 2

This commit is contained in:
lain 2019-06-18 20:28:31 +00:00 committed by Shpuld Shpludson
parent 69eff65130
commit 0eed2ccca8
56 changed files with 1364 additions and 1458 deletions

View File

@ -35,7 +35,6 @@
"vue-popperjs": "^2.0.3",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.3.4",
"vue-timeago": "^3.1.2",
"vuelidate": "^0.7.4",
"vuex": "^3.0.1",
"whatwg-fetch": "^2.0.3"

View File

@ -184,7 +184,43 @@ input, textarea, .select {
flex: 1;
}
&[type=radio],
&[type=radio] {
display: none;
&:checked + label::before {
box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset;
box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset;
background-color: var(--link, $fallback--link);
}
&:disabled {
&,
& + label,
& + label::before {
opacity: .5;
}
}
+ label::before {
display: inline-block;
content: '';
transition: box-shadow 200ms;
width: 1.1em;
height: 1.1em;
border-radius: 100%; // Radio buttons should always be circle
box-shadow: 0px 0px 2px black inset;
box-shadow: var(--inputShadow);
margin-right: .5em;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
vertical-align: top;
text-align: center;
line-height: 1.1em;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
box-sizing: border-box;
}
}
&[type=checkbox] {
display: none;
&:checked + label::before {
@ -230,6 +266,15 @@ option {
background-color: var(--bg, $fallback--bg);
}
.hide-number-spinner {
-moz-appearance: textfield;
&[type=number]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button {
opacity: 0;
display: none;
}
}
i[class*=icon-] {
color: $fallback--icon;
color: var(--icon, $fallback--icon)

View File

@ -215,11 +215,12 @@ const getNodeInfo = async ({ store }) => {
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })

View File

@ -13,7 +13,7 @@
<style>
.media-upload {
font-size: 26px;
flex: 1;
min-width: 50px;
}
.icon-upload {

View File

@ -1,6 +1,7 @@
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -13,7 +14,10 @@ const Notification = {
},
props: [ 'notification' ],
components: {
Status, UserAvatar, UserCard
Status,
UserAvatar,
UserCard,
Timeago
},
methods: {
toggleUserExpanded () {

View File

@ -30,12 +30,12 @@
</div>
<div class="timeago" v-if="notification.type === 'follow'">
<span class="faint">
<timeago :since="notification.created_at" :auto-update="240"></timeago>
<Timeago :time="notification.created_at" :auto-update="240"></Timeago>
</span>
</div>
<div class="timeago" v-else>
<router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link">
<timeago :since="notification.created_at" :auto-update="240"></timeago>
<Timeago :time="notification.created_at" :auto-update="240"></Timeago>
</router-link>
</div>
</span>

107
src/components/poll/poll.js Normal file
View File

@ -0,0 +1,107 @@
import Timeago from '../timeago/timeago.vue'
import { forEach, map } from 'lodash'
export default {
name: 'Poll',
props: ['poll', 'statusId'],
components: { Timeago },
data () {
return {
loading: false,
choices: [],
refreshInterval: null
}
},
created () {
this.refreshInterval = setTimeout(this.refreshPoll, 30 * 1000)
// Initialize choices to booleans and set its length to match options
this.choices = this.poll.options.map(_ => false)
},
destroyed () {
clearTimeout(this.refreshInterval)
},
computed: {
expired () {
return Date.now() > Date.parse(this.poll.expires_at)
},
loggedIn () {
return this.$store.state.users.currentUser
},
showResults () {
return this.poll.voted || this.expired || !this.loggedIn
},
totalVotesCount () {
return this.poll.votes_count
},
expiresAt () {
return Date.parse(this.poll.expires_at).toLocaleString()
},
containerClass () {
return {
loading: this.loading
}
},
choiceIndices () {
// Convert array of booleans into an array of indices of the
// items that were 'true', so [true, false, false, true] becomes
// [0, 3].
return this.choices
.map((entry, index) => entry && index)
.filter(value => typeof value === 'number')
},
isDisabled () {
const noChoice = this.choiceIndices.length === 0
return this.loading || noChoice
}
},
methods: {
refreshPoll () {
if (this.expired) return
this.fetchPoll()
this.refreshInterval = setTimeout(this.refreshPoll, 30 * 1000)
},
percentageForOption (count) {
return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100)
},
resultTitle (option) {
return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
},
fetchPoll () {
this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id })
},
activateOption (index) {
// forgive me father: doing checking the radio/checkboxes
// in code because of customized input elements need either
// a) an extra element for the actual graphic, or b) use a
// pseudo element for the label. We use b) which mandates
// using "for" and "id" matching which isn't nice when the
// same poll appears multiple times on the site (notifs and
// timeline for example). With code we can make sure it just
// works without altering the pseudo element implementation.
const allElements = this.$el.querySelectorAll('input')
const clickedElement = this.$el.querySelector(`input[value="${index}"]`)
if (this.poll.multiple) {
// Checkboxes, toggle only the clicked one
clickedElement.checked = !clickedElement.checked
} else {
// Radio button, uncheck everything and check the clicked one
forEach(allElements, element => { element.checked = false })
clickedElement.checked = true
}
this.choices = map(allElements, e => e.checked)
},
optionId (index) {
return `poll${this.poll.id}-${index}`
},
vote () {
if (this.choiceIndices.length === 0) return
this.loading = true
this.$store.dispatch(
'votePoll',
{ id: this.statusId, pollId: this.poll.id, choices: this.choiceIndices }
).then(poll => {
this.loading = false
})
}
}
}

View File

@ -0,0 +1,117 @@
<template>
<div class="poll" v-bind:class="containerClass">
<div
class="poll-option"
v-for="(option, index) in poll.options"
:key="index"
>
<div v-if="showResults" :title="resultTitle(option)" class="option-result">
<div class="option-result-label">
<span class="result-percentage">
{{percentageForOption(option.votes_count)}}%
</span>
<span>{{option.title}}</span>
</div>
<div
class="result-fill"
:style="{ 'width': `${percentageForOption(option.votes_count)}%` }"
>
</div>
</div>
<div v-else @click="activateOption(index)">
<input
v-if="poll.multiple"
type="checkbox"
:disabled="loading"
:value="index"
>
<input
v-else
type="radio"
:disabled="loading"
:value="index"
>
<label>
{{option.title}}
</label>
</div>
</div>
<div class="footer faint">
<button
v-if="!showResults"
class="btn btn-default poll-vote-button"
type="button"
@click="vote"
:disabled="isDisabled"
>
{{$t('polls.vote')}}
</button>
<div class="total">
{{totalVotesCount}} {{ $t("polls.votes") }}&nbsp;·&nbsp;
</div>
<i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
<Timeago :time="this.poll.expires_at" :auto-update="60" :now-threshold="0" />
</i18n>
</div>
</div>
</template>
<script src="./poll.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.poll {
.votes {
display: flex;
flex-direction: column;
margin: 0 0 0.5em;
}
.poll-option {
margin: 0.5em 0;
height: 1.5em;
}
.option-result {
height: 100%;
display: flex;
flex-direction: row;
position: relative;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.option-result-label {
display: flex;
align-items: center;
padding: 0.1em 0.25em;
z-index: 1;
}
.result-percentage {
width: 3.5em;
}
.result-fill {
height: 100%;
position: absolute;
background-color: $fallback--lightBg;
background-color: var(--linkBg, $fallback--lightBg);
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
top: 0;
left: 0;
transition: width 0.5s;
}
input {
width: 3.5em;
}
.footer {
display: flex;
align-items: center;
}
&.loading * {
cursor: progress;
}
.poll-vote-button {
padding: 0 0.5em;
margin-right: 0.5em;
}
}
</style>

View File

@ -0,0 +1,121 @@
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import { uniq } from 'lodash'
export default {
name: 'PollForm',
props: ['visible'],
data: () => ({
pollType: 'single',
options: ['', ''],
expiryAmount: 10,
expiryUnit: 'minutes'
}),
computed: {
pollLimits () {
return this.$store.state.instance.pollLimits
},
maxOptions () {
return this.pollLimits.max_options
},
maxLength () {
return this.pollLimits.max_option_chars
},
expiryUnits () {
const allUnits = ['minutes', 'hours', 'days']
const expiry = this.convertExpiryFromUnit
return allUnits.filter(
unit => this.pollLimits.max_expiration >= expiry(unit, 1)
)
},
minExpirationInCurrentUnit () {
return Math.ceil(
this.convertExpiryToUnit(
this.expiryUnit,
this.pollLimits.min_expiration
)
)
},
maxExpirationInCurrentUnit () {
return Math.floor(
this.convertExpiryToUnit(
this.expiryUnit,
this.pollLimits.max_expiration
)
)
}
},
methods: {
clear () {
this.pollType = 'single'
this.options = ['', '']
this.expiryAmount = 10
this.expiryUnit = 'minutes'
},
nextOption (index) {
const element = this.$el.querySelector(`#poll-${index + 1}`)
if (element) {
element.focus()
} else {
// Try adding an option and try focusing on it
const addedOption = this.addOption()
if (addedOption) {
this.$nextTick(function () {
this.nextOption(index)
})
}
}
},
addOption () {
if (this.options.length < this.maxOptions) {
this.options.push('')
return true
}
return false
},
deleteOption (index, event) {
if (this.options.length > 2) {
this.options.splice(index, 1)
}
},
convertExpiryToUnit (unit, amount) {
// Note: we want seconds and not milliseconds
switch (unit) {
case 'minutes': return (1000 * amount) / DateUtils.MINUTE
case 'hours': return (1000 * amount) / DateUtils.HOUR
case 'days': return (1000 * amount) / DateUtils.DAY
}
},
convertExpiryFromUnit (unit, amount) {
// Note: we want seconds and not milliseconds
switch (unit) {
case 'minutes': return 0.001 * amount * DateUtils.MINUTE
case 'hours': return 0.001 * amount * DateUtils.HOUR
case 'days': return 0.001 * amount * DateUtils.DAY
}
},
expiryAmountChange () {
this.expiryAmount =
Math.max(this.minExpirationInCurrentUnit, this.expiryAmount)
this.expiryAmount =
Math.min(this.maxExpirationInCurrentUnit, this.expiryAmount)
this.updatePollToParent()
},
updatePollToParent () {
const expiresIn = this.convertExpiryFromUnit(
this.expiryUnit,
this.expiryAmount
)
const options = uniq(this.options.filter(option => option !== ''))
if (options.length < 2) {
this.$emit('update-poll', { error: this.$t('polls.not_enough_options') })
return
}
this.$emit('update-poll', {
options,
multiple: this.pollType === 'multiple',
expiresIn
})
}
}
}

View File

@ -0,0 +1,133 @@
<template>
<div class="poll-form" v-if="visible">
<div class="poll-option" v-for="(option, index) in options" :key="index">
<div class="input-container">
<input
class="poll-option-input"
type="text"
:placeholder="$t('polls.option')"
:maxlength="maxLength"
:id="`poll-${index}`"
v-model="options[index]"
@change="updatePollToParent"
@keydown.enter.stop.prevent="nextOption(index)"
>
</div>
<div class="icon-container" v-if="options.length > 2">
<i class="icon-cancel" @click="deleteOption(index)"></i>
</div>
</div>
<a
v-if="options.length < maxOptions"
class="add-option faint"
@click="addOption"
>
<i class="icon-plus" />
{{ $t("polls.add_option") }}
</a>
<div class="poll-type-expiry">
<div class="poll-type" :title="$t('polls.type')">
<label for="poll-type-selector" class="select">
<select class="select" v-model="pollType" @change="updatePollToParent">
<option value="single">{{$t('polls.single_choice')}}</option>
<option value="multiple">{{$t('polls.multiple_choices')}}</option>
</select>
<i class="icon-down-open"/>
</label>
</div>
<div class="poll-expiry" :title="$t('polls.expiry')">
<input
type="number"
class="expiry-amount hide-number-spinner"
:min="minExpirationInCurrentUnit"
:max="maxExpirationInCurrentUnit"
v-model="expiryAmount"
@change="expiryAmountChange"
>
<label class="expiry-unit select">
<select
v-model="expiryUnit"
@change="expiryAmountChange"
>
<option v-for="unit in expiryUnits" :value="unit">
{{ $t(`time.${unit}_short`, ['']) }}
</option>
</select>
<i class="icon-down-open"/>
</label>
</div>
</div>
</div>
</template>
<script src="./poll_form.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.poll-form {
display: flex;
flex-direction: column;
padding: 0 0.5em 0.5em;
.add-option {
align-self: flex-start;
padding-top: 0.25em;
cursor: pointer;
}
.poll-option {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25em;
}
.input-container {
width: 100%;
input {
// Hack: dodge the floating X icon
padding-right: 2.5em;
width: 100%;
}
}
.icon-container {
// Hack: Move the icon over the input box
width: 2em;
margin-left: -2em;
z-index: 1;
}
.poll-type-expiry {
margin-top: 0.5em;
display: flex;
width: 100%;
}
.poll-type {
margin-right: 0.75em;
flex: 1 1 60%;
.select {
border: none;
box-shadow: none;
background-color: transparent;
}
}
.poll-expiry {
display: flex;
.expiry-amount {
width: 3em;
text-align: right;
}
.expiry-unit {
border: none;
box-shadow: none;
background-color: transparent;
}
}
}
</style>

View File

@ -2,6 +2,7 @@ import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import PollForm from '../poll/poll_form.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { reject, map, uniqBy } from 'lodash'
import suggestor from '../emoji-input/suggestor.js'
@ -31,8 +32,9 @@ const PostStatusForm = {
],
components: {
MediaUpload,
ScopeSelector,
EmojiInput
EmojiInput,
PollForm,
ScopeSelector
},
mounted () {
this.resize(this.$refs.textarea)
@ -75,10 +77,12 @@ const PostStatusForm = {
status: statusText,
nsfw: false,
files: [],
poll: {},
visibility: scope,
contentType
},
caret: 0
caret: 0,
pollFormVisible: false
}
},
computed: {
@ -153,8 +157,17 @@ const PostStatusForm = {
safeDMEnabled () {
return this.$store.state.instance.safeDM
},
pollsAvailable () {
return this.$store.state.instance.pollsAvailable &&
this.$store.state.instance.pollLimits.max_options >= 2
},
hideScopeNotice () {
return this.$store.state.config.hideScopeNotice
},
pollContentError () {
return this.pollFormVisible &&
this.newStatus.poll &&
this.newStatus.poll.error
}
},
methods: {
@ -171,6 +184,12 @@ const PostStatusForm = {
}
}
const poll = this.pollFormVisible ? this.newStatus.poll : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
statusPoster.postStatus({
status: newStatus.status,
@ -180,7 +199,8 @@ const PostStatusForm = {
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType
contentType: newStatus.contentType,
poll
}).then((data) => {
if (!data.error) {
this.newStatus = {
@ -188,9 +208,12 @@ const PostStatusForm = {
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType
contentType: newStatus.contentType,
poll: {}
}
this.pollFormVisible = false
this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = 'auto'
@ -261,6 +284,17 @@ const PostStatusForm = {
changeVis (visibility) {
this.newStatus.visibility = visibility
},
togglePollForm () {
this.pollFormVisible = !this.pollFormVisible
},
setPoll (poll) {
this.newStatus.poll = poll
},
clearPollForm () {
if (this.$refs.pollForm) {
this.$refs.pollForm.clear()
}
},
dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
}

View File

@ -1,6 +1,6 @@
<template>
<div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)">
<form @submit.prevent="postStatus(newStatus)" autocomplete="off">
<div class="form-group" >
<i18n
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
@ -91,37 +91,52 @@
:onScopeChange="changeVis"/>
</div>
</div>
<div class='form-bottom'>
<poll-form
ref="pollForm"
v-if="pollsAvailable"
:visible="pollFormVisible"
@update-poll="setPoll"
/>
<div class='form-bottom'>
<div class='form-bottom-left'>
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>
<button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button>
<button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button>
<button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button>
</div>
<div class='alert error' v-if="error">
Error: {{ error }}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div>
<div class="attachments">
<div class="media-upload-wrapper" v-for="file in newStatus.files">
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i>
<div class="media-upload-container attachment">
<img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.url" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.url" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a>
</div>
<div v-if="pollsAvailable" class="poll-icon">
<i
:title="$t('polls.add_poll')"
@click="togglePollForm"
class="icon-chart-bar btn btn-default"
:class="pollFormVisible && 'selected'"
/>
</div>
</div>
<div class="upload_settings" v-if="newStatus.files.length > 0">
<input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw">
<label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label>
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>
<button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button>
<button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button>
<button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button>
</div>
<div class='alert error' v-if="error">
Error: {{ error }}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div>
<div class="attachments">
<div class="media-upload-wrapper" v-for="file in newStatus.files">
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i>
<div class="media-upload-container attachment">
<img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.url" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.url" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a>
</div>
</div>
</form>
</div>
</div>
<div class="upload_settings" v-if="newStatus.files.length > 0">
<input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw">
<label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label>
</div>
</form>
</div>
</template>
<script src="./post_status_form.js"></script>
@ -172,6 +187,11 @@
}
}
.form-bottom-left {
display: flex;
flex: 1;
}
.text-format {
.only-format {
color: $fallback--faint;
@ -179,6 +199,20 @@
}
}
.poll-icon {
font-size: 26px;
flex: 1;
.selected {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
.icon-chart-bar {
cursor: pointer;
}
.error {
text-align: center;
@ -240,7 +274,6 @@
}
}
.btn {
cursor: pointer;
}

View File

@ -302,4 +302,4 @@
</template>
<script src="./settings.js">
</script>
</script>

View File

@ -1,6 +1,7 @@
import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import Poll from '../poll/poll.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue'
@ -8,6 +9,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
@ -216,8 +218,8 @@ const Status = {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined'
? this.$store.state.instance.subjectLineBehavior
: this.$store.state.config.subjectLineBehavior
? this.$store.state.instance.subjectLineBehavior
: this.$store.state.config.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if (behavior !== 'noop' && startsWithRe || behavior === 'masto') {
return decodedSummary
@ -285,11 +287,13 @@ const Status = {
RetweetButton,
ExtraButtons,
PostStatusForm,
Poll,
UserCard,
UserAvatar,
Gallery,
LinkPreview,
AvatarList
AvatarList,
Timeago
},
methods: {
visibilityIcon (visibility) {
@ -377,7 +381,7 @@ const Status = {
this.preview = find(statuses, { 'id': targetId })
// or if we have to fetch it
if (!this.preview) {
this.$store.state.api.backendInteractor.fetchStatus({id}).then((status) => {
this.$store.state.api.backendInteractor.fetchStatus({ id }).then((status) => {
this.preview = status
})
}

View File

@ -52,7 +52,7 @@
<span class="heading-right">
<router-link class="timeago faint-link" :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago>
<Timeago :time="status.created_at" :auto-update="60"></Timeago>
</router-link>
<div class="button-icon visibility-icon" v-if="status.visibility">
<i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i>
@ -123,6 +123,10 @@
<a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">{{$t("general.show_less")}}</a>
</div>
<div v-if="status.poll && status.poll.options">
<poll :poll="status.poll" :status-id="status.id" />
</div>
<div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body">
<attachment
class="non-gallery"

View File

@ -0,0 +1,48 @@
<template>
<time :datetime="time" :title="localeDateString">
{{ $t(relativeTime.key, [relativeTime.num]) }}
</time>
</template>
<script>
import * as DateUtils from 'src/services/date_utils/date_utils.js'
export default {
name: 'Timeago',
props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'],
data () {
return {
relativeTime: { key: 'time.now', num: 0 },
interval: null
}
},
created () {
this.refreshRelativeTimeObject()
},
destroyed () {
clearTimeout(this.interval)
},
computed: {
localeDateString () {
return typeof this.time === 'string'
? new Date(Date.parse(this.time)).toLocaleString()
: this.time.toLocaleString()
}
},
methods: {
refreshRelativeTimeObject () {
const nowThreshold = typeof this.nowThreshold === 'number' ? this.nowThreshold : 1
this.relativeTime = this.longFormat
? DateUtils.relativeTime(this.time, nowThreshold)
: DateUtils.relativeTimeShort(this.time, nowThreshold)
if (this.autoUpdate) {
this.interval = setTimeout(
this.refreshRelativeTimeObject,
1000 * this.autoUpdate
)
}
}
}
}
</script>

View File

@ -168,6 +168,40 @@
"true": "sí"
}
},
"time": {
"day": "{0} dia",
"days": "{0} dies",
"day_short": "{0} dia",
"days_short": "{0} dies",
"hour": "{0} hour",
"hours": "{0} hours",
"hour_short": "{0}h",
"hours_short": "{0}h",
"in_future": "in {0}",
"in_past": "fa {0}",
"minute": "{0} minute",
"minutes": "{0} minutes",
"minute_short": "{0}min",
"minutes_short": "{0}min",
"month": "{0} mes",
"months": "{0} mesos",
"month_short": "{0} mes",
"months_short": "{0} mesos",
"now": "ara mateix",
"now_short": "ara mateix",
"second": "{0} second",
"seconds": "{0} seconds",
"second_short": "{0}s",
"seconds_short": "{0}s",
"week": "{0} setm.",
"weeks": "{0} setm.",
"week_short": "{0} setm.",
"weeks_short": "{0} setm.",
"year": "{0} any",
"years": "{0} anys",
"year_short": "{0} any",
"years_short": "{0} anys"
},
"timeline": {
"collapse": "Replega",
"conversation": "Conversa",

View File

@ -350,6 +350,40 @@
}
}
},
"time": {
"day": "{0} day",
"days": "{0} days",
"day_short": "{0}d",
"days_short": "{0}d",
"hour": "{0} hour",
"hours": "{0} hours",
"hour_short": "{0}h",
"hours_short": "{0}h",
"in_future": "in {0}",
"in_past": "{0} ago",
"minute": "{0} minute",
"minutes": "{0} minutes",
"minute_short": "{0}min",
"minutes_short": "{0}min",
"month": "{0} měs",
"months": "{0} měs",
"month_short": "{0} měs",
"months_short": "{0} měs",
"now": "teď",
"now_short": "teď",
"second": "{0} second",
"seconds": "{0} seconds",
"second_short": "{0}s",
"seconds_short": "{0}s",
"week": "{0} týd",
"weeks": "{0} týd",
"week_short": "{0} týd",
"weeks_short": "{0} týd",
"year": "{0} r",
"years": "{0} l",
"year_short": "{0}r",
"years_short": "{0}l"
},
"timeline": {
"collapse": "Zabalit",
"conversation": "Konverzace",

View File

@ -91,6 +91,20 @@
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications"
},
"polls": {
"add_poll": "Add Poll",
"add_option": "Add Option",
"option": "Option",
"votes": "votes",
"vote": "Vote",
"type": "Poll type",
"single_choice": "Single choice",
"multiple_choices": "Multiple choices",
"expiry": "Poll age",
"expires_in": "Poll ends in {0}",
"expired": "Poll ended {0} ago",
"not_enough_options": "Too few unique options in poll"
},
"interactions": {
"favs_repeats": "Repeats and Favorites",
"follows": "New follows",
@ -435,6 +449,40 @@
"frontend_version": "Frontend Version"
}
},
"time": {
"day": "{0} day",
"days": "{0} days",
"day_short": "{0}d",
"days_short": "{0}d",
"hour": "{0} hour",
"hours": "{0} hours",
"hour_short": "{0}h",
"hours_short": "{0}h",
"in_future": "in {0}",
"in_past": "{0} ago",
"minute": "{0} minute",
"minutes": "{0} minutes",
"minute_short": "{0}min",
"minutes_short": "{0}min",
"month": "{0} month",
"months": "{0} months",
"month_short": "{0}mo",
"months_short": "{0}mo",
"now": "just now",
"now_short": "now",
"second": "{0} second",
"seconds": "{0} seconds",
"second_short": "{0}s",
"seconds_short": "{0}s",
"week": "{0} week",
"weeks": "{0} weeks",
"week_short": "{0}w",
"weeks_short": "{0}w",
"year": "{0} year",
"years": "{0} years",
"year_short": "{0}y",
"years_short": "{0}y"
},
"timeline": {
"collapse": "Collapse",
"conversation": "Conversation",

View File

@ -36,6 +36,7 @@
"chat": "Paikallinen Chat",
"friend_requests": "Seurauspyynnöt",
"mentions": "Maininnat",
"interactions": "Interaktiot",
"dms": "Yksityisviestit",
"public_tl": "Julkinen Aikajana",
"timeline": "Aikajana",
@ -54,6 +55,25 @@
"repeated_you": "toisti viestisi",
"no_more_notifications": "Ei enempää ilmoituksia"
},
"polls": {
"add_poll": "Lisää äänestys",
"add_option": "Lisää vaihtoehto",
"option": "Vaihtoehto",
"votes": "ääntä",
"vote": "Äänestä",
"type": "Äänestyksen tyyppi",
"single_choice": "Yksi valinta",
"multiple_choices": "Monivalinta",
"expiry": "Äänestyksen kesto",
"expires_in": "Päättyy {0} päästä",
"expired": "Päättyi {0} sitten",
"not_enough_option": "Liian vähän uniikkeja vaihtoehtoja äänestyksessä"
},
"interactions": {
"favs_repeats": "Toistot ja tykkäykset",
"follows": "Uudet seuraukset",
"load_older": "Lataa vanhempia interaktioita"
},
"post_status": {
"new_status": "Uusi viesti",
"account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi",
@ -210,6 +230,40 @@
"true": "päällä"
}
},
"time": {
"day": "{0} päivä",
"days": "{0} päivää",
"day_short": "{0}pv",
"days_short": "{0}pv",
"hour": "{0} tunti",
"hours": "{0} tuntia",
"hour_short": "{0}t",
"hours_short": "{0}t",
"in_future": "{0} tulevaisuudessa",
"in_past": "{0} sitten",
"minute": "{0} minuutti",
"minutes": "{0} minuuttia",
"minute_short": "{0}min",
"minutes_short": "{0}min",
"month": "{0} kuukausi",
"months": "{0} kuukautta",
"month_short": "{0}kk",
"months_short": "{0}kk",
"now": "nyt",
"now_short": "juuri nyt",
"second": "{0} sekunti",
"seconds": "{0} sekuntia",
"second_short": "{0}s",
"seconds_short": "{0}s",
"week": "{0} viikko",
"weeks": "{0} viikkoa",
"week_short": "{0}vk",
"weeks_short": "{0}vk",
"year": "{0} vuosi",
"years": "{0} vuotta",
"year_short": "{0}v",
"years_short": "{0}v"
},
"timeline": {
"collapse": "Sulje",
"conversation": "Keskustelu",
@ -264,9 +318,9 @@
},
"upload":{
"error": {
"base": "Lataus epäonnistui.",
"file_too_big": "Tiedosto liian suuri [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Yritä uudestaan myöhemmin"
"base": "Lataus epäonnistui.",
"file_too_big": "Tiedosto liian suuri [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Yritä uudestaan myöhemmin"
},
"file_size_units": {
"B": "tavua",

View File

@ -170,6 +170,40 @@
"true": "tá"
}
},
"time": {
"day": "{0} lá",
"days": "{0} lá",
"day_short": "{0}l",
"days_short": "{0}l",
"hour": "{0} uair",
"hours": "{0} uair",
"hour_short": "{0}u",
"hours_short": "{0}u",
"in_future": "in {0}",
"in_past": "{0} ago",
"minute": "{0} nóimeád",
"minutes": "{0} nóimeád",
"minute_short": "{0}n",
"minutes_short": "{0}n",
"month": "{0} mí",
"months": "{0} mí",
"month_short": "{0}m",
"months_short": "{0}m",
"now": "Anois",
"now_short": "Anois",
"second": "{0} s",
"seconds": "{0} s",
"second_short": "{0}s",
"seconds_short": "{0}s",
"week": "{0} seachtain",
"weeks": "{0} seachtaine",
"week_short": "{0}se",
"weeks_short": "{0}se",
"year": "{0} bliainta",
"years": "{0} bliainta",
"year_short": "{0}b",
"years_short": "{0}b"
},
"timeline": {
"collapse": "Folaigh",
"conversation": "Cómhra",

View File

@ -402,6 +402,40 @@
"frontend_version": "フロントエンドのバージョン"
}
},
"time": {
"day": "{0}日",
"days": "{0}日",
"day_short": "{0}日",
"days_short": "{0}日",
"hour": "{0}時間",
"hours": "{0}時間",
"hour_short": "{0}時間",
"hours_short": "{0}時間",
"in_future": "{0}で",
"in_past": "{0}前",
"minute": "{0}分",
"minutes": "{0}分",
"minute_short": "{0}分",
"minutes_short": "{0}分",
"month": "{0}ヶ月前",
"months": "{0}ヶ月前",
"month_short": "{0}ヶ月前",
"months_short": "{0}ヶ月前",
"now": "たった今",
"now_short": "たった今",
"second": "{0}秒",
"seconds": "{0}秒",
"second_short": "{0}秒",
"seconds_short": "{0}秒",
"week": "{0}週間",
"weeks": "{0}週間",
"week_short": "{0}週間",
"weeks_short": "{0}週間",
"year": "{0}年",
"years": "{0}年",
"year_short": "{0}年",
"years_short": "{0}年"
},
"timeline": {
"collapse": "たたむ",
"conversation": "スレッド",

View File

@ -402,6 +402,40 @@
"frontend_version": "フロントエンドのバージョン"
}
},
"time": {
"day": "{0}日",
"days": "{0}日",
"day_short": "{0}日",
"days_short": "{0}日",
"hour": "{0}時間",
"hours": "{0}時間",
"hour_short": "{0}時間",
"hours_short": "{0}時間",
"in_future": "{0}で",
"in_past": "{0}前",
"minute": "{0}分",
"minutes": "{0}分",
"minute_short": "{0}分",
"minutes_short": "{0}分",
"month": "{0}ヶ月前",
"months": "{0}ヶ月前",
"month_short": "{0}ヶ月前",
"months_short": "{0}ヶ月前",
"now": "たった今",
"now_short": "たった今",
"second": "{0}秒",
"seconds": "{0}秒",
"second_short": "{0}秒",
"seconds_short": "{0}秒",
"week": "{0}週間",
"weeks": "{0}週間",
"week_short": "{0}週間",
"weeks_short": "{0}週間",
"year": "{0}年",
"years": "{0}年",
"year_short": "{0}年",
"years_short": "{0}年"
},
"timeline": {
"collapse": "たたむ",
"conversation": "スレッド",

View File

@ -381,6 +381,40 @@
"frontend_version": "Version Frontend"
}
},
"time": {
"day": "{0} jorn",
"days": "{0} jorns",
"day_short": "{0} jorn",
"days_short": "{0} jorns",
"hour": "{0} hour",
"hours": "{0} hours",
"hour_short": "{0}h",
"hours_short": "{0}h",
"in_future": "in {0}",
"in_past": "fa {0}",
"minute": "{0} minute",
"minutes": "{0} minutes",
"minute_short": "{0}min",
"minutes_short": "{0}min",
"month": "{0} mes",
"months": "{0} meses",
"month_short": "{0} mes",
"months_short": "{0} meses",
"now": "ara meteis",
"now_short": "ara meteis",
"second": "{0} second",
"seconds": "{0} seconds",
"second_short": "{0}s",
"seconds_short": "{0}s",
"week": "{0} setm.",
"weeks": "{0} setm.",
"week_short": "{0} setm.",
"weeks_short": "{0} setm.",
"year": "{0} an",
"years": "{0} ans",
"year_short": "{0} an",
"years_short": "{0} ans"
},
"timeline": {
"collapse": "Tampar",
"conversation": "Conversacion",

View File

@ -15,7 +15,6 @@ import mediaViewerModule from './modules/media_viewer.js'
import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js'
import VueTimeago from 'vue-timeago'
import VueI18n from 'vue-i18n'
import createPersistedState from './lib/persisted_state.js'
@ -33,14 +32,6 @@ const currentLocale = (window.navigator.language || 'en').split('-')[0]
Vue.use(Vuex)
Vue.use(VueRouter)
Vue.use(VueTimeago, {
locale: currentLocale === 'cs' ? 'cs' : currentLocale === 'ja' ? 'ja' : 'en',
locales: {
'cs': require('../static/timeago-cs.json'),
'en': require('../static/timeago-en.json'),
'ja': require('../static/timeago-ja.json')
}
})
Vue.use(VueI18n)
Vue.use(VueChatScroll)
Vue.use(VueClickOutside)

View File

@ -59,7 +59,7 @@ const api = {
// Set up websocket connection
if (!store.state.chatDisabled) {
const token = store.state.wsToken
const socket = new Socket('/socket', {params: {token}})
const socket = new Socket('/socket', { params: { token } })
socket.connect()
store.dispatch('initializeChat', socket)
}

View File

@ -52,7 +52,15 @@ const defaultState = {
// Version Information
backendVersion: '',
frontendVersion: ''
frontendVersion: '',
pollsAvailable: false,
pollLimits: {
max_options: 4,
max_option_chars: 255,
min_expiration: 60,
max_expiration: 60 * 60 * 24
}
}
const instance = {

View File

@ -494,6 +494,10 @@ export const mutations = {
const newStatus = state.allStatusesObject[id]
newStatus.favoritedBy = favoritedByUsers.filter(_ => _)
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
},
updateStatusWithPoll (state, { id, poll }) {
const status = state.allStatusesObject[id]
status.poll = poll
}
}
@ -578,6 +582,18 @@ const statuses = {
]).then(([favoritedByUsers, rebloggedByUsers]) =>
commit('addFavsAndRepeats', { id, favoritedByUsers, rebloggedByUsers })
)
},
votePoll ({ rootState, commit }, { id, pollId, choices }) {
return rootState.api.backendInteractor.vote(pollId, choices).then(poll => {
commit('updateStatusWithPoll', { id, poll })
return poll
})
},
refreshPoll ({ rootState, commit }, { id, pollId }) {
return rootState.api.backendInteractor.fetchPoll(pollId).then(poll => {
commit('updateStatusWithPoll', { id, poll })
return poll
})
}
},
mutations

View File

@ -1,3 +1,8 @@
import { each, map, concat, last } from 'lodash'
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
import 'whatwg-fetch'
import { StatusCodeError } from '../errors/errors'
/* eslint-env browser */
const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json'
const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json'
@ -52,6 +57,8 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
const MASTODON_POLL_URL = id => `/api/v1/polls/${id}`
const MASTODON_STATUS_FAVORITEDBY_URL = id => `/api/v1/statuses/${id}/favourited_by`
const MASTODON_STATUS_REBLOGGEDBY_URL = id => `/api/v1/statuses/${id}/reblogged_by`
const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials'
@ -59,11 +66,6 @@ const MASTODON_REPORT_USER_URL = '/api/v1/reports'
const MASTODON_PIN_OWN_STATUS = id => `/api/v1/statuses/${id}/pin`
const MASTODON_UNPIN_OWN_STATUS = id => `/api/v1/statuses/${id}/unpin`
import { each, map, concat, last } from 'lodash'
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
import 'whatwg-fetch'
import { StatusCodeError } from '../errors/errors'
const oldfetch = window.fetch
let fetch = (url, options) => {
@ -104,7 +106,7 @@ const promisedRequest = ({ method, url, payload, credentials, headers = {} }) =>
})
}
const updateNotificationSettings = ({credentials, settings}) => {
const updateNotificationSettings = ({ credentials, settings }) => {
const form = new FormData()
each(settings, (value, key) => {
@ -115,20 +117,18 @@ const updateNotificationSettings = ({credentials, settings}) => {
headers: authHeaders(credentials),
method: 'PUT',
body: form
})
.then((data) => data.json())
}).then((data) => data.json())
}
const updateAvatar = ({credentials, avatar}) => {
const updateAvatar = ({ credentials, avatar }) => {
const form = new FormData()
form.append('avatar', avatar)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
})
.then((data) => data.json())
.then((data) => parseUser(data))
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const updateBg = ({ credentials, background }) => {
@ -143,26 +143,24 @@ const updateBg = ({ credentials, background }) => {
.then((data) => parseUser(data))
}
const updateBanner = ({credentials, banner}) => {
const updateBanner = ({ credentials, banner }) => {
const form = new FormData()
form.append('header', banner)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
})
.then((data) => data.json())
.then((data) => parseUser(data))
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const updateProfile = ({credentials, params}) => {
const updateProfile = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
method: 'PATCH',
payload: params,
credentials
})
.then((data) => parseUser(data))
}).then((data) => parseUser(data))
}
// Params needed:
@ -212,7 +210,7 @@ const authHeaders = (accessToken) => {
}
}
const externalProfile = ({profileUrl, credentials}) => {
const externalProfile = ({ profileUrl, credentials }) => {
let url = `${EXTERNAL_PROFILE_URL}?profileurl=${profileUrl}`
return fetch(url, {
headers: authHeaders(credentials),
@ -220,7 +218,7 @@ const externalProfile = ({profileUrl, credentials}) => {
}).then((data) => data.json())
}
const followUser = ({id, credentials}) => {
const followUser = ({ id, credentials }) => {
let url = MASTODON_FOLLOW_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
@ -228,7 +226,7 @@ const followUser = ({id, credentials}) => {
}).then((data) => data.json())
}
const unfollowUser = ({id, credentials}) => {
const unfollowUser = ({ id, credentials }) => {
let url = MASTODON_UNFOLLOW_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
@ -246,21 +244,21 @@ const unpinOwnStatus = ({ id, credentials }) => {
.then((data) => parseStatus(data))
}
const blockUser = ({id, credentials}) => {
const blockUser = ({ id, credentials }) => {
return fetch(MASTODON_BLOCK_USER_URL(id), {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const unblockUser = ({id, credentials}) => {
const unblockUser = ({ id, credentials }) => {
return fetch(MASTODON_UNBLOCK_USER_URL(id), {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const approveUser = ({id, credentials}) => {
const approveUser = ({ id, credentials }) => {
let url = `${APPROVE_USER_URL}?user_id=${id}`
return fetch(url, {
headers: authHeaders(credentials),
@ -268,7 +266,7 @@ const approveUser = ({id, credentials}) => {
}).then((data) => data.json())
}
const denyUser = ({id, credentials}) => {
const denyUser = ({ id, credentials }) => {
let url = `${DENY_USER_URL}?user_id=${id}`
return fetch(url, {
headers: authHeaders(credentials),
@ -276,13 +274,13 @@ const denyUser = ({id, credentials}) => {
}).then((data) => data.json())
}
const fetchUser = ({id, credentials}) => {
const fetchUser = ({ id, credentials }) => {
let url = `${MASTODON_USER_URL}/${id}`
return promisedRequest({ url, credentials })
.then((data) => parseUser(data))
}
const fetchUserRelationship = ({id, credentials}) => {
const fetchUserRelationship = ({ id, credentials }) => {
let url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) })
.then((response) => {
@ -296,7 +294,7 @@ const fetchUserRelationship = ({id, credentials}) => {
})
}
const fetchFriends = ({id, maxId, sinceId, limit = 20, credentials}) => {
const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => {
let url = MASTODON_FOLLOWING_URL(id)
const args = [
maxId && `max_id=${maxId}`,
@ -310,7 +308,7 @@ const fetchFriends = ({id, maxId, sinceId, limit = 20, credentials}) => {
.then((data) => data.map(parseUser))
}
const exportFriends = ({id, credentials}) => {
const exportFriends = ({ id, credentials }) => {
return new Promise(async (resolve, reject) => {
try {
let friends = []
@ -330,7 +328,7 @@ const exportFriends = ({id, credentials}) => {
})
}
const fetchFollowers = ({id, maxId, sinceId, limit = 20, credentials}) => {
const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
let url = MASTODON_FOLLOWERS_URL(id)
const args = [
maxId && `max_id=${maxId}`,
@ -344,13 +342,13 @@ const fetchFollowers = ({id, maxId, sinceId, limit = 20, credentials}) => {
.then((data) => data.map(parseUser))
}
const fetchFollowRequests = ({credentials}) => {
const fetchFollowRequests = ({ credentials }) => {
const url = FOLLOW_REQUESTS_URL
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
}
const fetchConversation = ({id, credentials}) => {
const fetchConversation = ({ id, credentials }) => {
let urlContext = MASTODON_STATUS_CONTEXT_URL(id)
return fetch(urlContext, { headers: authHeaders(credentials) })
.then((data) => {
@ -360,13 +358,13 @@ const fetchConversation = ({id, credentials}) => {
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json())
.then(({ancestors, descendants}) => ({
.then(({ ancestors, descendants }) => ({
ancestors: ancestors.map(parseStatus),
descendants: descendants.map(parseStatus)
}))
}
const fetchStatus = ({id, credentials}) => {
const fetchStatus = ({ id, credentials }) => {
let url = MASTODON_STATUS_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
@ -379,7 +377,7 @@ const fetchStatus = ({id, credentials}) => {
.then((data) => parseStatus(data))
}
const tagUser = ({tag, credentials, ...options}) => {
const tagUser = ({ tag, credentials, ...options }) => {
const screenName = options.screen_name
const form = {
nicknames: [screenName],
@ -396,7 +394,7 @@ const tagUser = ({tag, credentials, ...options}) => {
})
}
const untagUser = ({tag, credentials, ...options}) => {
const untagUser = ({ tag, credentials, ...options }) => {
const screenName = options.screen_name
const body = {
nicknames: [screenName],
@ -413,7 +411,7 @@ const untagUser = ({tag, credentials, ...options}) => {
})
}
const addRight = ({right, credentials, ...user}) => {
const addRight = ({ right, credentials, ...user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
@ -423,7 +421,7 @@ const addRight = ({right, credentials, ...user}) => {
})
}
const deleteRight = ({right, credentials, ...user}) => {
const deleteRight = ({ right, credentials, ...user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
@ -433,7 +431,7 @@ const deleteRight = ({right, credentials, ...user}) => {
})
}
const setActivationStatus = ({status, credentials, ...user}) => {
const setActivationStatus = ({ status, credentials, ...user }) => {
const screenName = user.screen_name
const body = {
status: status
@ -449,7 +447,7 @@ const setActivationStatus = ({status, credentials, ...user}) => {
})
}
const deleteUser = ({credentials, ...user}) => {
const deleteUser = ({ credentials, ...user }) => {
const screenName = user.screen_name
const headers = authHeaders(credentials)
@ -459,7 +457,15 @@ const deleteUser = ({credentials, ...user}) => {
})
}
const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false, tag = false, withMuted = false}) => {
const fetchTimeline = ({
timeline,
credentials,
since = false,
until = false,
userId = false,
tag = false,
withMuted = false
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
friends: MASTODON_USER_HOME_TIMELINE_URL,
@ -558,8 +564,19 @@ const unretweet = ({ id, credentials }) => {
.then((data) => parseStatus(data))
}
const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds = [], inReplyToStatusId, contentType}) => {
const postStatus = ({
credentials,
status,
spoilerText,
visibility,
sensitive,
poll,
mediaIds = [],
inReplyToStatusId,
contentType
}) => {
const form = new FormData()
const pollOptions = poll.options || []
form.append('status', status)
form.append('source', 'Pleroma FE')
@ -570,6 +587,19 @@ const postStatus = ({credentials, status, spoilerText, visibility, sensitive, me
mediaIds.forEach(val => {
form.append('media_ids[]', val)
})
if (pollOptions.some(option => option !== '')) {
const normalizedPoll = {
expires_in: poll.expiresIn,
multiple: poll.multiple
}
Object.keys(normalizedPoll).forEach(key => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach(option => {
form.append('poll[options][]', option)
})
}
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
@ -598,7 +628,7 @@ const deleteStatus = ({ id, credentials }) => {
})
}
const uploadMedia = ({formData, credentials}) => {
const uploadMedia = ({ formData, credentials }) => {
return fetch(MASTODON_MEDIA_UPLOAD_URL, {
body: formData,
method: 'POST',
@ -608,7 +638,7 @@ const uploadMedia = ({formData, credentials}) => {
.then((data) => parseAttachment(data))
}
const importBlocks = ({file, credentials}) => {
const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(BLOCKS_IMPORT_URL, {
@ -619,7 +649,7 @@ const importBlocks = ({file, credentials}) => {
.then((response) => response.ok)
}
const importFollows = ({file, credentials}) => {
const importFollows = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(FOLLOW_IMPORT_URL, {
@ -630,7 +660,7 @@ const importFollows = ({file, credentials}) => {
.then((response) => response.ok)
}
const deleteAccount = ({credentials, password}) => {
const deleteAccount = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
@ -643,7 +673,7 @@ const deleteAccount = ({credentials, password}) => {
.then((response) => response.json())
}
const changePassword = ({credentials, password, newPassword, newPasswordConfirmation}) => {
const changePassword = ({ credentials, password, newPassword, newPasswordConfirmation }) => {
const form = new FormData()
form.append('password', password)
@ -658,14 +688,14 @@ const changePassword = ({credentials, password, newPassword, newPasswordConfirma
.then((response) => response.json())
}
const settingsMFA = ({credentials}) => {
const settingsMFA = ({ credentials }) => {
return fetch(MFA_SETTINGS_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const mfaDisableOTP = ({credentials, password}) => {
const mfaDisableOTP = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
@ -678,7 +708,7 @@ const mfaDisableOTP = ({credentials, password}) => {
.then((response) => response.json())
}
const mfaConfirmOTP = ({credentials, password, token}) => {
const mfaConfirmOTP = ({ credentials, password, token }) => {
const form = new FormData()
form.append('password', password)
@ -690,38 +720,38 @@ const mfaConfirmOTP = ({credentials, password, token}) => {
method: 'POST'
}).then((data) => data.json())
}
const mfaSetupOTP = ({credentials}) => {
const mfaSetupOTP = ({ credentials }) => {
return fetch(MFA_SETUP_OTP_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const generateMfaBackupCodes = ({credentials}) => {
const generateMfaBackupCodes = ({ credentials }) => {
return fetch(MFA_BACKUP_CODES_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const fetchMutes = ({credentials}) => {
const fetchMutes = ({ credentials }) => {
return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials })
.then((users) => users.map(parseUser))
}
const muteUser = ({id, credentials}) => {
const muteUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST' })
}
const unmuteUser = ({id, credentials}) => {
const unmuteUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' })
}
const fetchBlocks = ({credentials}) => {
const fetchBlocks = ({ credentials }) => {
return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials })
.then((users) => users.map(parseUser))
}
const fetchOAuthTokens = ({credentials}) => {
const fetchOAuthTokens = ({ credentials }) => {
const url = '/api/oauth_tokens.json'
return fetch(url, {
@ -734,7 +764,7 @@ const fetchOAuthTokens = ({credentials}) => {
})
}
const revokeOAuthToken = ({id, credentials}) => {
const revokeOAuthToken = ({ id, credentials }) => {
const url = `/api/oauth_tokens/${id}`
return fetch(url, {
@ -743,13 +773,13 @@ const revokeOAuthToken = ({id, credentials}) => {
})
}
const suggestions = ({credentials}) => {
const suggestions = ({ credentials }) => {
return fetch(SUGGESTIONS_URL, {
headers: authHeaders(credentials)
}).then((data) => data.json())
}
const markNotificationsAsSeen = ({id, credentials}) => {
const markNotificationsAsSeen = ({ id, credentials }) => {
const body = new FormData()
body.append('latest_id', id)
@ -761,15 +791,39 @@ const markNotificationsAsSeen = ({id, credentials}) => {
}).then((data) => data.json())
}
const fetchFavoritedByUsers = ({id}) => {
const vote = ({ pollId, choices, credentials }) => {
const form = new FormData()
form.append('choices', choices)
return promisedRequest({
url: MASTODON_VOTE_URL(encodeURIComponent(pollId)),
method: 'POST',
credentials,
payload: {
choices: choices
}
})
}
const fetchPoll = ({ pollId, credentials }) => {
return promisedRequest(
{
url: MASTODON_POLL_URL(encodeURIComponent(pollId)),
method: 'GET',
credentials
}
)
}
const fetchFavoritedByUsers = ({ id }) => {
return promisedRequest({ url: MASTODON_STATUS_FAVORITEDBY_URL(id) }).then((users) => users.map(parseUser))
}
const fetchRebloggedByUsers = ({id}) => {
const fetchRebloggedByUsers = ({ id }) => {
return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser))
}
const reportUser = ({credentials, userId, statusIds, comment, forward}) => {
const reportUser = ({ credentials, userId, statusIds, comment, forward }) => {
return promisedRequest({
url: MASTODON_REPORT_USER_URL,
method: 'POST',
@ -840,6 +894,8 @@ const apiService = {
denyUser,
suggestions,
markNotificationsAsSeen,
vote,
fetchPoll,
fetchFavoritedByUsers,
fetchRebloggedByUsers,
reportUser,

View File

@ -2,57 +2,57 @@ import apiService from '../api/api.service.js'
import timelineFetcherService from '../timeline_fetcher/timeline_fetcher.service.js'
import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
const backendInteractorService = (credentials) => {
const fetchStatus = ({id}) => {
return apiService.fetchStatus({id, credentials})
const backendInteractorService = credentials => {
const fetchStatus = ({ id }) => {
return apiService.fetchStatus({ id, credentials })
}
const fetchConversation = ({id}) => {
return apiService.fetchConversation({id, credentials})
const fetchConversation = ({ id }) => {
return apiService.fetchConversation({ id, credentials })
}
const fetchFriends = ({id, maxId, sinceId, limit}) => {
return apiService.fetchFriends({id, maxId, sinceId, limit, credentials})
const fetchFriends = ({ id, maxId, sinceId, limit }) => {
return apiService.fetchFriends({ id, maxId, sinceId, limit, credentials })
}
const exportFriends = ({id}) => {
return apiService.exportFriends({id, credentials})
const exportFriends = ({ id }) => {
return apiService.exportFriends({ id, credentials })
}
const fetchFollowers = ({id, maxId, sinceId, limit}) => {
return apiService.fetchFollowers({id, maxId, sinceId, limit, credentials})
const fetchFollowers = ({ id, maxId, sinceId, limit }) => {
return apiService.fetchFollowers({ id, maxId, sinceId, limit, credentials })
}
const fetchUser = ({id}) => {
return apiService.fetchUser({id, credentials})
const fetchUser = ({ id }) => {
return apiService.fetchUser({ id, credentials })
}
const fetchUserRelationship = ({id}) => {
return apiService.fetchUserRelationship({id, credentials})
const fetchUserRelationship = ({ id }) => {
return apiService.fetchUserRelationship({ id, credentials })
}
const followUser = (id) => {
return apiService.followUser({credentials, id})
return apiService.followUser({ credentials, id })
}
const unfollowUser = (id) => {
return apiService.unfollowUser({credentials, id})
return apiService.unfollowUser({ credentials, id })
}
const blockUser = (id) => {
return apiService.blockUser({credentials, id})
return apiService.blockUser({ credentials, id })
}
const unblockUser = (id) => {
return apiService.unblockUser({credentials, id})
return apiService.unblockUser({ credentials, id })
}
const approveUser = (id) => {
return apiService.approveUser({credentials, id})
return apiService.approveUser({ credentials, id })
}
const denyUser = (id) => {
return apiService.denyUser({credentials, id})
return apiService.denyUser({ credentials, id })
}
const startFetchingTimeline = ({ timeline, store, userId = false, tag }) => {
@ -63,73 +63,83 @@ const backendInteractorService = (credentials) => {
return notificationsFetcher.startFetching({ store, credentials })
}
const tagUser = ({screen_name}, tag) => {
return apiService.tagUser({screen_name, tag, credentials})
const tagUser = ({ screen_name }, tag) => {
return apiService.tagUser({ screen_name, tag, credentials })
}
const untagUser = ({screen_name}, tag) => {
return apiService.untagUser({screen_name, tag, credentials})
const untagUser = ({ screen_name }, tag) => {
return apiService.untagUser({ screen_name, tag, credentials })
}
const addRight = ({screen_name}, right) => {
return apiService.addRight({screen_name, right, credentials})
const addRight = ({ screen_name }, right) => {
return apiService.addRight({ screen_name, right, credentials })
}
const deleteRight = ({screen_name}, right) => {
return apiService.deleteRight({screen_name, right, credentials})
const deleteRight = ({ screen_name }, right) => {
return apiService.deleteRight({ screen_name, right, credentials })
}
const setActivationStatus = ({screen_name}, status) => {
return apiService.setActivationStatus({screen_name, status, credentials})
const setActivationStatus = ({ screen_name }, status) => {
return apiService.setActivationStatus({ screen_name, status, credentials })
}
const deleteUser = ({screen_name}) => {
return apiService.deleteUser({screen_name, credentials})
const deleteUser = ({ screen_name }) => {
return apiService.deleteUser({ screen_name, credentials })
}
const updateNotificationSettings = ({settings}) => {
return apiService.updateNotificationSettings({credentials, settings})
const vote = (pollId, choices) => {
return apiService.vote({ credentials, pollId, choices })
}
const fetchMutes = () => apiService.fetchMutes({credentials})
const muteUser = (id) => apiService.muteUser({credentials, id})
const unmuteUser = (id) => apiService.unmuteUser({credentials, id})
const fetchBlocks = () => apiService.fetchBlocks({credentials})
const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials})
const fetchOAuthTokens = () => apiService.fetchOAuthTokens({credentials})
const revokeOAuthToken = (id) => apiService.revokeOAuthToken({id, credentials})
const fetchPinnedStatuses = (id) => apiService.fetchPinnedStatuses({credentials, id})
const pinOwnStatus = (id) => apiService.pinOwnStatus({credentials, id})
const unpinOwnStatus = (id) => apiService.unpinOwnStatus({credentials, id})
const fetchPoll = (pollId) => {
return apiService.fetchPoll({ credentials, pollId })
}
const updateNotificationSettings = ({ settings }) => {
return apiService.updateNotificationSettings({ credentials, settings })
}
const fetchMutes = () => apiService.fetchMutes({ credentials })
const muteUser = (id) => apiService.muteUser({ credentials, id })
const unmuteUser = (id) => apiService.unmuteUser({ credentials, id })
const fetchBlocks = () => apiService.fetchBlocks({ credentials })
const fetchFollowRequests = () => apiService.fetchFollowRequests({ credentials })
const fetchOAuthTokens = () => apiService.fetchOAuthTokens({ credentials })
const revokeOAuthToken = (id) => apiService.revokeOAuthToken({ id, credentials })
const fetchPinnedStatuses = (id) => apiService.fetchPinnedStatuses({ credentials, id })
const pinOwnStatus = (id) => apiService.pinOwnStatus({ credentials, id })
const unpinOwnStatus = (id) => apiService.unpinOwnStatus({ credentials, id })
const getCaptcha = () => apiService.getCaptcha()
const register = (params) => apiService.register({ credentials, params })
const updateAvatar = ({avatar}) => apiService.updateAvatar({credentials, avatar})
const updateAvatar = ({ avatar }) => apiService.updateAvatar({ credentials, avatar })
const updateBg = ({ background }) => apiService.updateBg({ credentials, background })
const updateBanner = ({banner}) => apiService.updateBanner({credentials, banner})
const updateProfile = ({params}) => apiService.updateProfile({credentials, params})
const updateBanner = ({ banner }) => apiService.updateBanner({ credentials, banner })
const updateProfile = ({ params }) => apiService.updateProfile({ credentials, params })
const externalProfile = (profileUrl) => apiService.externalProfile({profileUrl, credentials})
const importBlocks = (file) => apiService.importBlocks({file, credentials})
const importFollows = (file) => apiService.importFollows({file, credentials})
const externalProfile = (profileUrl) => apiService.externalProfile({ profileUrl, credentials })
const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password})
const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation})
const importBlocks = (file) => apiService.importBlocks({ file, credentials })
const importFollows = (file) => apiService.importFollows({ file, credentials })
const fetchSettingsMFA = () => apiService.settingsMFA({credentials})
const generateMfaBackupCodes = () => apiService.generateMfaBackupCodes({credentials})
const mfaSetupOTP = () => apiService.mfaSetupOTP({credentials})
const mfaConfirmOTP = ({password, token}) => apiService.mfaConfirmOTP({credentials, password, token})
const mfaDisableOTP = ({password}) => apiService.mfaDisableOTP({credentials, password})
const deleteAccount = ({ password }) => apiService.deleteAccount({ credentials, password })
const changePassword = ({ password, newPassword, newPasswordConfirmation }) =>
apiService.changePassword({ credentials, password, newPassword, newPasswordConfirmation })
const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id})
const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id})
const reportUser = (params) => apiService.reportUser({credentials, ...params})
const fetchSettingsMFA = () => apiService.settingsMFA({ credentials })
const generateMfaBackupCodes = () => apiService.generateMfaBackupCodes({ credentials })
const mfaSetupOTP = () => apiService.mfaSetupOTP({ credentials })
const mfaConfirmOTP = ({ password, token }) => apiService.mfaConfirmOTP({ credentials, password, token })
const mfaDisableOTP = ({ password }) => apiService.mfaDisableOTP({ credentials, password })
const favorite = (id) => apiService.favorite({id, credentials})
const unfavorite = (id) => apiService.unfavorite({id, credentials})
const retweet = (id) => apiService.retweet({id, credentials})
const unretweet = (id) => apiService.unretweet({id, credentials})
const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({ id })
const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({ id })
const reportUser = (params) => apiService.reportUser({ credentials, ...params })
const favorite = (id) => apiService.favorite({ id, credentials })
const unfavorite = (id) => apiService.unfavorite({ id, credentials })
const retweet = (id) => apiService.retweet({ id, credentials })
const unretweet = (id) => apiService.unretweet({ id, credentials })
const backendInteractorServiceInstance = {
fetchStatus,
@ -180,6 +190,8 @@ const backendInteractorService = (credentials) => {
fetchFollowRequests,
approveUser,
denyUser,
vote,
fetchPoll,
fetchFavoritedByUsers,
fetchRebloggedByUsers,
reportUser,

View File

@ -0,0 +1,45 @@
export const SECOND = 1000
export const MINUTE = 60 * SECOND
export const HOUR = 60 * MINUTE
export const DAY = 24 * HOUR
export const WEEK = 7 * DAY
export const MONTH = 30 * DAY
export const YEAR = 365.25 * DAY
export const relativeTime = (date, nowThreshold = 1) => {
if (typeof date === 'string') date = Date.parse(date)
const round = Date.now() > date ? Math.floor : Math.ceil
const d = Math.abs(Date.now() - date)
let r = { num: round(d / YEAR), key: 'time.years' }
if (d < nowThreshold * SECOND) {
r.num = 0
r.key = 'time.now'
} else if (d < MINUTE) {
r.num = round(d / SECOND)
r.key = 'time.seconds'
} else if (d < HOUR) {
r.num = round(d / MINUTE)
r.key = 'time.minutes'
} else if (d < DAY) {
r.num = round(d / HOUR)
r.key = 'time.hours'
} else if (d < WEEK) {
r.num = round(d / DAY)
r.key = 'time.days'
} else if (d < MONTH) {
r.num = round(d / WEEK)
r.key = 'time.weeks'
} else if (d < YEAR) {
r.num = round(d / MONTH)
r.key = 'time.months'
}
// Remove plural form when singular
if (r.num === 1) r.key = r.key.slice(0, -1)
return r
}
export const relativeTimeShort = (date, nowThreshold = 1) => {
const r = relativeTime(date, nowThreshold)
r.key += '_short'
return r
}

View File

@ -234,6 +234,7 @@ export const parseStatus = (data) => {
output.summary_html = addEmojis(data.spoiler_text, data.emojis)
output.external_url = data.url
output.poll = data.poll
output.pinned = data.pinned
} else {
output.favorited = data.favorited

View File

@ -1,10 +1,19 @@
import { map } from 'lodash'
import apiService from '../api/api.service.js'
const postStatus = ({ store, status, spoilerText, visibility, sensitive, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
const mediaIds = map(media, 'id')
return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType})
return apiService.postStatus({
credentials: store.state.users.currentUser.credentials,
status,
spoilerText,
visibility,
sensitive,
mediaIds,
inReplyToStatusId,
contentType,
poll})
.then((data) => {
if (!data.error) {
store.dispatch('addNewStatuses', {

View File

@ -202,6 +202,7 @@ const generateColors = (input) => {
colors.topBarLink = col.topBarLink || getTextColor(colors.topBar, colors.fgLink)
colors.faintLink = col.faintLink || Object.assign({}, col.link)
colors.linkBg = alphaBlend(colors.link, 0.4, colors.bg)
colors.icon = mixrgb(colors.bg, colors.text)

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

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

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

@ -240,6 +240,12 @@
"code": 59416,
"src": "fontawesome"
},
{
"uid": "266d5d9adf15a61800477a5acf9a4462",
"css": "chart-bar",
"code": 59419,
"src": "fontawesome"
},
{
"uid": "671f29fa10dda08074a4c6a341bb4f39",
"css": "bell-alt",
@ -273,4 +279,4 @@
]
}
]
}
}

View File

@ -26,6 +26,7 @@
.icon-pencil:before { content: '\e818'; } /* '' */
.icon-pin:before { content: '\e819'; } /* '' */
.icon-wrench:before { content: '\e81a'; } /* '' */
.icon-chart-bar:before { content: '\e81b'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */

File diff suppressed because one or more lines are too long

View File

@ -26,6 +26,7 @@
.icon-pencil { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe818;&nbsp;'); }
.icon-pin { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe819;&nbsp;'); }
.icon-wrench { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe81a;&nbsp;'); }
.icon-chart-bar { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe81b;&nbsp;'); }
.icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe832;&nbsp;'); }
.icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe834;&nbsp;'); }
.icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }

View File

@ -37,6 +37,7 @@
.icon-pencil { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe818;&nbsp;'); }
.icon-pin { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe819;&nbsp;'); }
.icon-wrench { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe81a;&nbsp;'); }
.icon-chart-bar { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe81b;&nbsp;'); }
.icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe832;&nbsp;'); }
.icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe834;&nbsp;'); }
.icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }

View File

@ -1,11 +1,11 @@
@font-face {
font-family: 'fontello';
src: url('../font/fontello.eot?16609299');
src: url('../font/fontello.eot?16609299#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?16609299') format('woff2'),
url('../font/fontello.woff?16609299') format('woff'),
url('../font/fontello.ttf?16609299') format('truetype'),
url('../font/fontello.svg?16609299#fontello') format('svg');
src: url('../font/fontello.eot?3304725');
src: url('../font/fontello.eot?3304725#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?3304725') format('woff2'),
url('../font/fontello.woff?3304725') format('woff'),
url('../font/fontello.ttf?3304725') format('truetype'),
url('../font/fontello.svg?3304725#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?16609299#fontello') format('svg');
src: url('../font/fontello.svg?3304725#fontello') format('svg');
}
}
*/
@ -82,6 +82,7 @@
.icon-pencil:before { content: '\e818'; } /* '' */
.icon-pin:before { content: '\e819'; } /* '' */
.icon-wrench:before { content: '\e81a'; } /* '' */
.icon-chart-bar:before { content: '\e81b'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */

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

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

Binary file not shown.

View File

@ -60,6 +60,8 @@
<glyph glyph-name="wrench" unicode="&#xe81a;" d="M214 36q0 14-10 25t-25 10-25-10-11-25 11-25 25-11 25 11 10 25z m360 234l-381-381q-21-20-50-20-29 0-51 20l-59 61q-21 20-21 50 0 29 21 51l380 380q22-55 64-97t97-64z m354 243q0-22-13-59-27-75-92-122t-144-46q-104 0-177 73t-73 177 73 176 177 74q32 0 67-10t60-26q9-6 9-15t-9-16l-163-94v-125l108-60q2 2 44 27t75 45 40 20q8 0 13-5t5-14z" horiz-adv-x="928.6" />
<glyph glyph-name="chart-bar" unicode="&#xe81b;" d="M357 357v-286h-143v286h143z m214 286v-572h-142v572h142z m572-643v-72h-1143v858h71v-786h1072z m-357 500v-429h-143v429h143z m214 214v-643h-143v643h143z" horiz-adv-x="1142.9" />
<glyph glyph-name="spin3" unicode="&#xe832;" d="M494 857c-266 0-483-210-494-472-1-19 13-20 13-20l84 0c16 0 19 10 19 18 10 199 176 358 378 358 107 0 205-45 273-118l-58-57c-11-12-11-27 5-31l247-50c21-5 46 11 37 44l-58 227c-2 9-16 22-29 13l-65-60c-89 91-214 148-352 148z m409-508c-16 0-19-10-19-18-10-199-176-358-377-358-108 0-205 45-274 118l59 57c10 12 10 27-5 31l-248 50c-21 5-46-11-37-44l58-227c2-9 16-22 30-13l64 60c89-91 214-148 353-148 265 0 482 210 493 473 1 18-13 19-13 19l-84 0z" horiz-adv-x="1000" />
<glyph glyph-name="spin4" unicode="&#xe834;" d="M498 857c-114 0-228-39-320-116l0 0c173 140 428 130 588-31 134-134 164-332 89-495-10-29-5-50 12-68 21-20 61-23 84 0 3 3 12 15 15 24 71 180 33 393-112 539-99 98-228 147-356 147z m-409-274c-14 0-29-5-39-16-3-3-13-15-15-24-71-180-34-393 112-539 185-185 479-195 676-31l0 0c-173-140-428-130-589 31-134 134-163 333-89 495 11 29 6 50-12 68-11 11-27 17-44 16z" horiz-adv-x="1001" />

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,10 +0,0 @@
[
"ara mateix",
["fa %s s", "fa %s s"],
["fa %s min", "fa %s min"],
["fa %s h", "fa %s h"],
["fa %s dia", "fa %s dies"],
["fa %s setm.", "fa %s setm."],
["fa %s mes", "fa %s mesos"],
["fa %s any", "fa %s anys"]
]

View File

@ -1,10 +0,0 @@
[
"teď",
["%s s", "%s s"],
["%s min", "%s min"],
["%s h", "%s h"],
["%s d", "%s d"],
["%s týd", "%s týd"],
["%s měs", "%s měs"],
["%s r", "%s l"]
]

View File

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

View File

@ -1,10 +0,0 @@
[
"Anois",
["%s s", "%s s"],
["%s n", "%s nóimeád"],
["%s u", "%s uair"],
["%s l", "%s lá"],
["%s se", "%s seachtaine"],
["%s m", "%s mí"],
["%s b", "%s bliainta"]
]

View File

@ -1,10 +0,0 @@
[
"たった今",
"%s 秒前",
"%s 分前",
"%s 時間前",
"%s 日前",
"%s 週間前",
"%s ヶ月前",
"%s 年前"
]

View File

@ -1,10 +0,0 @@
[
"ara meteis",
["fa %s s", "fa %s s"],
["fa %s min", "fa %s min"],
["fa %s h", "fa %s h"],
["fa %s jorn", "fa %s jorns"],
["fa %s setm.", "fa %s setm."],
["fa %s mes", "fa %s meses"],
["fa %s an", "fa %s ans"]
]

View File

@ -0,0 +1,40 @@
import * as DateUtils from 'src/services/date_utils/date_utils.js'
describe('DateUtils', () => {
describe('relativeTime', () => {
it('returns now with low enough amount of seconds', () => {
const futureTime = Date.now() + 20 * DateUtils.SECOND
const pastTime = Date.now() - 20 * DateUtils.SECOND
expect(DateUtils.relativeTime(futureTime, 30)).to.eql({ num: 0, key: 'time.now' })
expect(DateUtils.relativeTime(pastTime, 30)).to.eql({ num: 0, key: 'time.now' })
})
it('rounds down for past', () => {
const time = Date.now() - 1.8 * DateUtils.HOUR
expect(DateUtils.relativeTime(time)).to.eql({ num: 1, key: 'time.hour' })
})
it('rounds up for future', () => {
const time = Date.now() + 1.8 * DateUtils.HOUR
expect(DateUtils.relativeTime(time)).to.eql({ num: 2, key: 'time.hours' })
})
it('uses plural when necessary', () => {
const time = Date.now() - 3.8 * DateUtils.WEEK
expect(DateUtils.relativeTime(time)).to.eql({ num: 3, key: 'time.weeks' })
})
it('works with date string', () => {
const time = Date.now() - 4 * DateUtils.MONTH
const dateString = new Date(time).toISOString()
expect(DateUtils.relativeTime(dateString)).to.eql({ num: 4, key: 'time.months' })
})
})
describe('relativeTimeShort', () => {
it('returns the short version of the same relative time', () => {
const time = Date.now() + 2 * DateUtils.YEAR
expect(DateUtils.relativeTimeShort(time)).to.eql({ num: 2, key: 'time.years_short' })
})
})
})

1183
yarn.lock

File diff suppressed because it is too large Load Diff