aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.scss50
-rw-r--r--src/boot/after_store.js3
-rw-r--r--src/components/emoji-input/emoji-input.js21
-rw-r--r--src/components/emoji-input/emoji-input.vue2
-rw-r--r--src/components/gallery/gallery.vue4
-rw-r--r--src/components/login_form/login_form.js3
-rw-r--r--src/components/media_upload/media_upload.vue2
-rw-r--r--src/components/notification/notification.js6
-rw-r--r--src/components/notification/notification.vue4
-rw-r--r--src/components/oauth_callback/oauth_callback.js3
-rw-r--r--src/components/poll/poll.js112
-rw-r--r--src/components/poll/poll.vue121
-rw-r--r--src/components/poll/poll_form.js121
-rw-r--r--src/components/poll/poll_form.vue133
-rw-r--r--src/components/post_status_form/post_status_form.js51
-rw-r--r--src/components/post_status_form/post_status_form.vue119
-rw-r--r--src/components/settings/settings.vue2
-rw-r--r--src/components/status/status.js12
-rw-r--r--src/components/status/status.vue6
-rw-r--r--src/components/timeago/timeago.vue48
-rw-r--r--src/components/user_profile/user_profile.vue2
-rw-r--r--src/i18n/ca.json34
-rw-r--r--src/i18n/cs.json34
-rw-r--r--src/i18n/en.json48
-rw-r--r--src/i18n/fi.json60
-rw-r--r--src/i18n/fr.json752
-rw-r--r--src/i18n/ga.json34
-rw-r--r--src/i18n/ja.json34
-rw-r--r--src/i18n/ja_pedantic.json34
-rw-r--r--src/i18n/oc.json34
-rw-r--r--src/lib/persisted_state.js3
-rw-r--r--src/main.js13
-rw-r--r--src/modules/api.js2
-rw-r--r--src/modules/chat.js2
-rw-r--r--src/modules/instance.js10
-rw-r--r--src/modules/oauth.js8
-rw-r--r--src/modules/polls.js70
-rw-r--r--src/modules/statuses.js9
-rw-r--r--src/modules/users.js2
-rw-r--r--src/services/api/api.service.js184
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js142
-rw-r--r--src/services/date_utils/date_utils.js45
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js1
-rw-r--r--src/services/new_api/user_search.js3
-rw-r--r--src/services/status_poster/status_poster.service.js13
-rw-r--r--src/services/style_setter/style_setter.js1
46 files changed, 1978 insertions, 419 deletions
diff --git a/src/App.scss b/src/App.scss
index 1591f1da..e4c764bf 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -131,6 +131,7 @@ input, textarea, .select {
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-size: 14px;
+ margin: 0;
padding: 8px .5em;
box-sizing: border-box;
display: inline-block;
@@ -184,7 +185,44 @@ 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 {
+ flex-shrink: 0;
+ 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 {
@@ -199,6 +237,7 @@ input, textarea, .select {
}
}
+ label::before {
+ flex-shrink: 0;
display: inline-block;
content: '✔';
transition: color 200ms;
@@ -230,6 +269,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)
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index cbe445ef..3fcbc246 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -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 })
diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js
index fde99320..b09dc628 100644
--- a/src/components/emoji-input/emoji-input.js
+++ b/src/components/emoji-input/emoji-input.js
@@ -59,7 +59,8 @@ const EmojiInput = {
input: undefined,
highlighted: 0,
caret: 0,
- focused: false
+ focused: false,
+ blurTimeout: null
}
},
computed: {
@@ -124,12 +125,12 @@ const EmojiInput = {
this.$emit('input', newValue)
this.caret = 0
},
- replaceText (e) {
+ replaceText (e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) { return }
- if (len > 0) {
- const suggestion = this.suggestions[this.highlighted]
- const replacement = suggestion.replacement
+ if (len > 0 || suggestion) {
+ const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
+ const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.highlighted = 0
@@ -175,13 +176,21 @@ const EmojiInput = {
onBlur (e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
- setTimeout(() => {
+ this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
this.resize()
}, 200)
},
+ onClick (e, suggestion) {
+ this.replaceText(e, suggestion)
+ },
onFocus (e) {
+ if (this.blurTimeout) {
+ clearTimeout(this.blurTimeout)
+ this.blurTimeout = null
+ }
+
this.focused = true
this.setCaret(e)
this.resize()
diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue
index 8437a495..b1f7afa5 100644
--- a/src/components/emoji-input/emoji-input.vue
+++ b/src/components/emoji-input/emoji-input.vue
@@ -6,7 +6,7 @@
<div
v-for="(suggestion, index) in suggestions"
:key="index"
- @click.stop.prevent="replaceText"
+ @click.stop.prevent="onClick($event, suggestion)"
class="autocomplete-item"
:class="{ highlighted: suggestion.highlighted }"
>
diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue
index ea525c95..f0433f10 100644
--- a/src/components/gallery/gallery.vue
+++ b/src/components/gallery/gallery.vue
@@ -28,7 +28,9 @@
flex-grow: 1;
margin-top: 0.5em;
- .attachments, .attachment {
+ // FIXME: specificity problem with this and .attachments.attachment
+ // we shouldn't have the need for .image here
+ .attachment.image {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index 93214646..4a5b1965 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -26,9 +26,10 @@ const LoginForm = {
this.isTokenAuth ? this.submitToken() : this.submitPassword()
},
submitToken () {
- const { clientId } = this.oauth
+ const { clientId, clientSecret } = this.oauth
const data = {
clientId,
+ clientSecret,
instance: this.instance.server,
commit: this.$store.commit
}
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index fcdc3471..eb785735 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -13,7 +13,7 @@
<style>
.media-upload {
font-size: 26px;
- flex: 1;
+ min-width: 50px;
}
.icon-upload {
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index e59e7497..896c6d52 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -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 () {
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 3427b9c5..5ad365ad 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -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>
diff --git a/src/components/oauth_callback/oauth_callback.js b/src/components/oauth_callback/oauth_callback.js
index 2c6ca235..a3c7b7f9 100644
--- a/src/components/oauth_callback/oauth_callback.js
+++ b/src/components/oauth_callback/oauth_callback.js
@@ -4,10 +4,11 @@ const oac = {
props: ['code'],
mounted () {
if (this.code) {
- const { clientId } = this.$store.state.oauth
+ const { clientId, clientSecret } = this.$store.state.oauth
oauth.getToken({
clientId,
+ clientSecret,
instance: this.$store.state.instance.server,
code: this.code
}).then((result) => {
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
new file mode 100644
index 00000000..98db5582
--- /dev/null
+++ b/src/components/poll/poll.js
@@ -0,0 +1,112 @@
+import Timeago from '../timeago/timeago.vue'
+import { forEach, map } from 'lodash'
+
+export default {
+ name: 'Poll',
+ props: ['basePoll'],
+ components: { Timeago },
+ data () {
+ return {
+ loading: false,
+ choices: []
+ }
+ },
+ created () {
+ if (!this.$store.state.polls.pollsObject[this.pollId]) {
+ this.$store.dispatch('mergeOrAddPoll', this.basePoll)
+ }
+ this.$store.dispatch('trackPoll', this.pollId)
+ },
+ destroyed () {
+ this.$store.dispatch('untrackPoll', this.pollId)
+ },
+ computed: {
+ pollId () {
+ return this.basePoll.id
+ },
+ poll () {
+ const storePoll = this.$store.state.polls.pollsObject[this.pollId]
+ return storePoll || {}
+ },
+ options () {
+ return (this.poll && this.poll.options) || []
+ },
+ expiresAt () {
+ return (this.poll && this.poll.expires_at) || 0
+ },
+ expired () {
+ return (this.poll && this.poll.expired) || false
+ },
+ loggedIn () {
+ return this.$store.state.users.currentUser
+ },
+ showResults () {
+ return this.poll.voted || this.expired || !this.loggedIn
+ },
+ totalVotesCount () {
+ return this.poll.votes_count
+ },
+ 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: {
+ 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
+ })
+ }
+ }
+}
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
new file mode 100644
index 00000000..bb67101a
--- /dev/null
+++ b/src/components/poll/poll.vue
@@ -0,0 +1,121 @@
+<template>
+ <div class="poll" v-bind:class="containerClass">
+ <div
+ class="poll-option"
+ v-for="(option, index) in 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 class="option-vote">
+ <div>{{option.title}}</div>
+ </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.expiresAt" :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.75em 0.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;
+ flex-shrink: 0;
+ }
+ .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;
+ }
+ .option-vote {
+ display: flex;
+ align-items: center;
+ }
+ 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>
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
new file mode 100644
index 00000000..c0c1ccf7
--- /dev/null
+++ b/src/components/poll/poll_form.js
@@ -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
+ })
+ }
+ }
+}
diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue
new file mode 100644
index 00000000..0bc6006d
--- /dev/null
+++ b/src/components/poll/poll_form.vue
@@ -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>
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index a7874acd..ef6b0fce 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -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'
@@ -246,8 +269,11 @@ const PostStatusForm = {
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
- const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
- Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
+ const topPaddingStr = window.getComputedStyle(target)['padding-top']
+ const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
+ // Remove "px" at the end of the values
+ const vertPadding = Number(topPaddingStr.substr(0, topPaddingStr.length - 2)) +
+ Number(bottomPaddingStr.substr(0, bottomPaddingStr.length - 2))
// Auto is needed to make textbox shrink when removing lines
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
@@ -261,6 +287,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 })
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 9929aab0..67cdc721 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -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'"
@@ -48,7 +48,7 @@
<EmojiInput
:suggest="emojiUserSuggestor"
v-model="newStatus.status"
- class="form-control"
+ class="form-control main-input"
>
<textarea
ref="textarea"
@@ -65,6 +65,13 @@
class="form-post-body"
>
</textarea>
+ <p
+ v-if="hasStatusLengthLimit"
+ class="character-counter faint"
+ :class="{ error: isOverLengthLimit }"
+ >
+ {{ charactersLeft }}
+ </p>
</EmojiInput>
<div class="visibility-tray">
<div class="text-format" v-if="postFormats.length > 1">
@@ -91,37 +98,50 @@
: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>
+
+ <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 +192,11 @@
}
}
+ .form-bottom-left {
+ display: flex;
+ flex: 1;
+ }
+
.text-format {
.only-format {
color: $fallback--faint;
@@ -179,6 +204,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 +279,6 @@
}
}
-
.btn {
cursor: pointer;
}
@@ -271,10 +309,12 @@
}
.form-post-body {
- line-height:16px;
+ height: 16px; // Only affects the empty-height
+ line-height: 16px;
resize: none;
overflow: hidden;
transition: min-height 200ms 100ms;
+ padding-bottom: 1.75em;
min-height: 1px;
box-sizing: content-box;
}
@@ -283,6 +323,23 @@
min-height: 48px;
}
+ .main-input {
+ position: relative;
+ }
+
+ .character-counter {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ padding: 0;
+ margin: 0 0.5em;
+
+ &.error {
+ color: $fallback--cRed;
+ color: var(--cRed, $fallback--cRed);
+ }
+ }
+
.btn {
cursor: pointer;
}
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index 7067c508..528c13cc 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -302,4 +302,4 @@
</template>
<script src="./settings.js">
-</script>
+</script> \ No newline at end of file
diff --git a/src/components/status/status.js b/src/components/status/status.js
index ea4c2b9d..d2452935 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -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
})
}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index e1dd81ac..440e1957 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -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 :base-poll="status.poll" />
+ </div>
+
<div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body">
<attachment
class="non-gallery"
diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue
new file mode 100644
index 00000000..3f185a2d
--- /dev/null
+++ b/src/components/timeago/timeago.vue
@@ -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> \ No newline at end of file
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 48b774ea..84a159da 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -3,7 +3,7 @@
<div v-if="user" class="user-profile panel panel-default">
<UserCard :user="user" :switcher="true" :selected="timeline.viewing" rounded="top"/>
<tab-switcher :renderOnlyFocused="true" ref="tabSwitcher">
- <div :label="$t('user_card.statuses')" :disabled="!user.statuses_count">
+ <div :label="$t('user_card.statuses')">
<div class="timeline">
<template v-for="statusId in user.pinnedStatuseIds">
<Conversation
diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index 8fa3a88b..42d7745c 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -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",
diff --git a/src/i18n/cs.json b/src/i18n/cs.json
index 5f2f2b71..42e75567 100644
--- a/src/i18n/cs.json
+++ b/src/i18n/cs.json
@@ -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",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a29f394b..dd34a95d 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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",
diff --git a/src/i18n/fi.json b/src/i18n/fi.json
index 62cbecb8..f4179495 100644
--- a/src/i18n/fi.json
+++ b/src/i18n/fi.json
@@ -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",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 8f9f243e..5f0053d5 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -1,209 +1,549 @@
{
- "chat": {
- "title": "Chat"
- },
- "features_panel": {
- "chat": "Chat",
- "gopher": "Gopher",
- "media_proxy": "Proxy média",
- "scope_options": "Options de visibilité",
- "text_limit": "Limite du texte",
- "title": "Caractéristiques",
- "who_to_follow": "Qui s'abonner"
- },
- "finder": {
- "error_fetching_user": "Erreur lors de la recherche de l'utilisateur",
- "find_user": "Chercher un utilisateur"
- },
- "general": {
- "apply": "Appliquer",
- "submit": "Envoyer"
- },
- "login": {
- "login": "Connexion",
- "description": "Connexion avec OAuth",
- "logout": "Déconnexion",
- "password": "Mot de passe",
- "placeholder": "p.e. lain",
- "register": "S'inscrire",
- "username": "Identifiant"
- },
- "nav": {
- "chat": "Chat local",
- "friend_requests": "Demandes d'ami",
- "dms": "Messages adressés",
- "mentions": "Notifications",
- "public_tl": "Statuts locaux",
- "timeline": "Journal",
- "twkn": "Le réseau connu"
- },
- "notifications": {
- "broken_favorite": "Chargement d'un message inconnu ...",
- "favorited_you": "a aimé votre statut",
- "followed_you": "a commencé à vous suivre",
- "load_older": "Charger les notifications précédentes",
- "notifications": "Notifications",
- "read": "Lu !",
- "repeated_you": "a partagé votre statut"
- },
- "post_status": {
- "account_not_locked_warning": "Votre compte n'est pas {0}. N'importe qui peut vous suivre pour voir vos billets en Abonné·e·s uniquement.",
- "account_not_locked_warning_link": "verrouillé",
- "attachments_sensitive": "Marquer le média comme sensible",
- "content_type": {
- "text/plain": "Texte brut"
- },
- "content_warning": "Sujet (optionnel)",
- "default": "Écrivez ici votre prochain statut.",
- "direct_warning": "Ce message sera visible à toutes les personnes mentionnées.",
- "posting": "Envoi en cours",
- "scope": {
- "direct": "Direct - N'envoyer qu'aux personnes mentionnées",
- "private": "Abonné·e·s uniquement - Seul·e·s vos abonné·e·s verront vos billets",
- "public": "Publique - Afficher dans les fils publics",
- "unlisted": "Non-Listé - Ne pas afficher dans les fils publics"
- }
- },
- "registration": {
- "bio": "Biographie",
- "email": "Adresse email",
- "fullname": "Pseudonyme",
- "password_confirm": "Confirmation du mot de passe",
- "registration": "Inscription",
- "token": "Jeton d'invitation"
- },
- "settings": {
- "attachmentRadius": "Pièces jointes",
- "attachments": "Pièces jointes",
- "autoload": "Charger la suite automatiquement une fois le bas de la page atteint",
- "avatar": "Avatar",
- "avatarAltRadius": "Avatars (Notifications)",
- "avatarRadius": "Avatars",
- "background": "Arrière-plan",
- "bio": "Biographie",
- "btnRadius": "Boutons",
- "cBlue": "Bleu (Répondre, suivre)",
- "cGreen": "Vert (Partager)",
- "cOrange": "Orange (Aimer)",
- "cRed": "Rouge (Annuler)",
- "change_password": "Changez votre mot de passe",
- "change_password_error": "Il y a eu un problème pour changer votre mot de passe.",
- "changed_password": "Mot de passe modifié avec succès !",
- "collapse_subject": "Réduire les messages avec des sujets",
- "confirm_new_password": "Confirmation du nouveau mot de passe",
- "current_avatar": "Avatar actuel",
- "current_password": "Mot de passe actuel",
- "current_profile_banner": "Bannière de profil actuelle",
- "data_import_export_tab": "Import / Export des Données",
- "default_vis": "Portée de visibilité par défaut",
- "delete_account": "Supprimer le compte",
- "delete_account_description": "Supprimer définitivement votre compte et tous vos statuts.",
- "delete_account_error": "Il y a eu un problème lors de la tentative de suppression de votre compte. Si le problème persiste, contactez l'administrateur de cette instance.",
- "delete_account_instructions": "Indiquez votre mot de passe ci-dessous pour confirmer la suppression de votre compte.",
- "export_theme": "Enregistrer le thème",
- "filtering": "Filtre",
- "filtering_explanation": "Tous les statuts contenant ces mots seront masqués. Un mot par ligne",
- "follow_export": "Exporter les abonnements",
- "follow_export_button": "Exporter les abonnements en csv",
- "follow_export_processing": "Exportation en cours…",
- "follow_import": "Importer des abonnements",
- "follow_import_error": "Erreur lors de l'importation des abonnements",
- "follows_imported": "Abonnements importés ! Le traitement peut prendre un moment.",
- "foreground": "Premier plan",
- "general": "Général",
- "hide_attachments_in_convo": "Masquer les pièces jointes dans les conversations",
- "hide_attachments_in_tl": "Masquer les pièces jointes dans le journal",
- "hide_post_stats": "Masquer les statistiques de publication (le nombre de favoris)",
- "hide_user_stats": "Masquer les statistiques de profil (le nombre d'amis)",
- "import_followers_from_a_csv_file": "Importer des abonnements depuis un fichier csv",
- "import_theme": "Charger le thème",
- "inputRadius": "Champs de texte",
- "instance_default": "(default: {value})",
- "instance_default_simple" : "(default)",
- "interfaceLanguage": "Langue de l'interface",
- "invalid_theme_imported": "Le fichier sélectionné n'est pas un thème Pleroma pris en charge. Aucun changement n'a été apporté à votre thème.",
- "limited_availability": "Non disponible dans votre navigateur",
- "links": "Liens",
- "lock_account_description": "Limitez votre compte aux abonnés acceptés uniquement",
- "loop_video": "Vidéos en boucle",
- "loop_video_silent_only": "Boucle uniquement les vidéos sans le son (les «gifs» de Mastodon)",
- "name": "Nom",
- "name_bio": "Nom & Bio",
- "new_password": "Nouveau mot de passe",
- "no_rich_text_description": "Ne formatez pas le texte",
- "notification_visibility": "Types de notifications à afficher",
- "notification_visibility_follows": "Abonnements",
- "notification_visibility_likes": "J’aime",
- "notification_visibility_mentions": "Mentionnés",
- "notification_visibility_repeats": "Partages",
- "nsfw_clickthrough": "Masquer les images marquées comme contenu adulte ou sensible",
- "oauth_tokens": "Jetons OAuth",
- "token": "Jeton",
- "refresh_token": "Refresh Token",
- "valid_until": "Valable jusque",
- "revoke_token": "Révoquer",
- "panelRadius": "Fenêtres",
- "pause_on_unfocused": "Suspendre le streaming lorsque l'onglet n'est pas centré",
- "presets": "Thèmes prédéfinis",
- "profile_background": "Image de fond",
- "profile_banner": "Bannière de profil",
- "profile_tab": "Profil",
- "radii_help": "Vous pouvez ici choisir le niveau d'arrondi des angles de l'interface (en pixels)",
- "replies_in_timeline": "Réponses au journal",
- "reply_link_preview": "Afficher un aperçu lors du survol de liens vers une réponse",
- "reply_visibility_all": "Montrer toutes les réponses",
- "reply_visibility_following": "Afficher uniquement les réponses adressées à moi ou aux utilisateurs que je suis",
- "reply_visibility_self": "Afficher uniquement les réponses adressées à moi",
- "saving_err": "Erreur lors de l'enregistrement des paramètres",
- "saving_ok": "Paramètres enregistrés",
- "security_tab": "Sécurité",
- "set_new_avatar": "Changer d'avatar",
- "set_new_profile_background": "Changer d'image de fond",
- "set_new_profile_banner": "Changer de bannière",
- "settings": "Paramètres",
- "stop_gifs": "N'animer les GIFS que lors du survol du curseur de la souris",
- "streaming": "Charger automatiquement les nouveaux statuts lorsque vous êtes au haut de la page",
- "text": "Texte",
- "theme": "Thème",
- "theme_help": "Spécifiez des codes couleur hexadécimaux (#rrvvbb) pour personnaliser les couleurs du thème.",
- "tooltipRadius": "Info-bulles/alertes",
- "user_settings": "Paramètres utilisateur",
- "values": {
- "false": "non",
- "true": "oui"
+ "chat": {
+ "title": "Chat"
+ },
+ "exporter": {
+ "export": "Exporter",
+ "processing": "En cours de traitement, vous pourrez bientôt télécharger votre fichier"
+ },
+ "features_panel": {
+ "chat": "Chat",
+ "gopher": "Gopher",
+ "media_proxy": "Proxy média",
+ "scope_options": "Options de visibilité",
+ "text_limit": "Limite de texte",
+ "title": "Caractéristiques",
+ "who_to_follow": "Personnes à suivre"
+ },
+ "finder": {
+ "error_fetching_user": "Erreur lors de la recherche de l'utilisateur·ice",
+ "find_user": "Chercher un-e utilisateur·ice"
+ },
+ "general": {
+ "apply": "Appliquer",
+ "submit": "Envoyer",
+ "more": "Plus",
+ "generic_error": "Une erreur s'est produite",
+ "optional": "optionnel",
+ "show_more": "Montrer plus",
+ "show_less": "Montrer moins",
+ "cancel": "Annuler",
+ "disable": "Désactiver",
+ "enable": "Activer",
+ "confirm": "Confirmer",
+ "verify": "Vérifier"
+ },
+ "image_cropper": {
+ "crop_picture": "Rogner l'image",
+ "save": "Sauvegarder",
+ "save_without_cropping": "Sauvegarder sans rogner",
+ "cancel": "Annuler"
+ },
+ "importer": {
+ "submit": "Soumettre",
+ "success": "Importé avec succès.",
+ "error": "Une erreur est survenue pendant l'import de ce fichier."
+ },
+ "login": {
+ "login": "Connexion",
+ "description": "Connexion avec OAuth",
+ "logout": "Déconnexion",
+ "password": "Mot de passe",
+ "placeholder": "p.e. lain",
+ "register": "S'inscrire",
+ "username": "Identifiant",
+ "hint": "Connectez-vous pour rejoindre la discussion",
+ "authentication_code": "Code d'authentification",
+ "enter_recovery_code": "Entrez un code de récupération",
+ "enter_two_factor_code": "Entrez un code à double authentification",
+ "recovery_code": "Code de récupération",
+ "heading": {
+ "totp": "Authentification à double authentification",
+ "recovery": "Récuperation de la double authentification"
+ }
+ },
+ "media_modal": {
+ "previous": "Précédent",
+ "next": "Suivant"
+ },
+ "nav": {
+ "about": "À propos",
+ "back": "Retour",
+ "chat": "Chat local",
+ "friend_requests": "Demandes de suivi",
+ "mentions": "Notifications",
+ "interactions": "Interactions",
+ "dms": "Messages directs",
+ "public_tl": "Fil d'actualité public",
+ "timeline": "Fil d'actualité",
+ "twkn": "Ensemble du réseau connu",
+ "user_search": "Recherche d'utilisateur·ice",
+ "who_to_follow": "Qui suivre",
+ "preferences": "Préférences"
+ },
+ "notifications": {
+ "broken_favorite": "Chargement d'un message inconnu…",
+ "favorited_you": "a aimé votre statut",
+ "followed_you": "a commencé à vous suivre",
+ "load_older": "Charger les notifications précédentes",
+ "notifications": "Notifications",
+ "read": "Lu !",
+ "repeated_you": "a partagé votre statut",
+ "no_more_notifications": "Aucune notification supplémentaire"
+ },
+ "interactions": {
+ "favs_repeats": "Partages et favoris",
+ "follows": "Nouveaux⋅elles abonné⋅e⋅s ?",
+ "load_older": "Chargez d'anciennes interactions"
+ },
+ "post_status": {
+ "new_status": "Poster un nouveau statut",
+ "account_not_locked_warning": "Votre compte n'est pas {0}. N'importe qui peut vous suivre pour voir vos billets en Abonné·e·s uniquement.",
+ "account_not_locked_warning_link": "verrouillé",
+ "attachments_sensitive": "Marquer le média comme sensible",
+ "content_type": {
+ "text/plain": "Texte brut",
+ "text/html": "HTML",
+ "text/markdown": "Markdown",
+ "text/bbcode": "BBCode"
+ },
+ "content_warning": "Sujet (optionnel)",
+ "default": "Écrivez ici votre prochain statut.",
+ "direct_warning_to_all": "Ce message sera visible pour toutes les personnes mentionnées.",
+ "direct_warning_to_first_only": "Ce message sera visible uniquement pour personnes mentionnées au début du message.",
+ "posting": "Envoi en cours",
+ "scope_notice": {
+ "public": "Ce statut sera visible par tout le monde",
+ "private": "Ce statut sera visible par seulement vos abonné⋅e⋅s",
+ "unlisted": "Ce statut ne sera pas visible dans le Fil d'actualité public et l'Ensemble du réseau connu"
+ },
+ "scope": {
+ "direct": "Direct - N'envoyer qu'aux personnes mentionnées",
+ "private": "Abonné·e·s uniquement - Seul·e·s vos abonné·e·s verront vos billets",
+ "public": "Publique - Afficher dans les fils publics",
+ "unlisted": "Non-Listé - Ne pas afficher dans les fils publics"
+ }
+ },
+ "registration": {
+ "bio": "Biographie",
+ "email": "Adresse mail",
+ "fullname": "Pseudonyme",
+ "password_confirm": "Confirmation du mot de passe",
+ "registration": "Inscription",
+ "token": "Jeton d'invitation",
+ "captcha": "CAPTCHA",
+ "new_captcha": "Cliquez sur l'image pour avoir un nouveau captcha",
+ "username_placeholder": "p.e. lain",
+ "fullname_placeholder": "p.e. Lain Iwakura",
+ "bio_placeholder": "p.e.\nSalut, je suis Lain\nJe suis une héroïne d'animé qui vit dans une banlieue japonaise. Vous me connaissez peut-être du Wired.",
+ "validations": {
+ "username_required": "ne peut pas être laissé vide",
+ "fullname_required": "ne peut pas être laissé vide",
+ "email_required": "ne peut pas être laissé vide",
+ "password_required": "ne peut pas être laissé vide",
+ "password_confirmation_required": "ne peut pas être laissé vide",
+ "password_confirmation_match": "doit être identique au mot de passe"
+ }
+ },
+ "selectable_list": {
+ "select_all": "Tout selectionner"
+ },
+ "settings": {
+ "app_name": "Nom de l'application",
+ "security": "Sécurité",
+ "enter_current_password_to_confirm": "Entrez votre mot de passe actuel pour confirmer votre identité",
+ "mfa": {
+ "otp": "OTP",
+ "setup_otp": "Configurer OTP",
+ "wait_pre_setup_otp": "préconfiguration OTP",
+ "confirm_and_enable": "Confirmer & activer OTP",
+ "title": "Double authentification",
+ "generate_new_recovery_codes": "Générer de nouveaux codes de récupération",
+ "warning_of_generate_new_codes": "Quand vous générez de nouveauc codes de récupération, vos anciens codes ne fonctionnerons plus.",
+ "recovery_codes": "Codes de récupération.",
+ "waiting_a_recovery_codes": "Récéption des codes de récupération…",
+ "recovery_codes_warning": "Écrivez les codes ou sauvez les quelquepart sécurisé - sinon vous ne les verrez plus jamais. Si vous perdez l'accès à votre application de double authentification et codes de récupération vous serez vérouillé en dehors de votre compte.",
+ "authentication_methods": "Methodes d'authentification",
+ "scan": {
+ "title": "Scanner",
+ "desc": "En utilisant votre application de double authentification, scannez ce QR code ou entrez la clé textuelle :",
+ "secret_code": "Clé"
+ },
+ "verify": {
+ "desc": "Pour activer la double authentification, entrez le code depuis votre application:"
+ }
+ },
+ "attachmentRadius": "Pièces jointes",
+ "attachments": "Pièces jointes",
+ "autoload": "Charger la suite automatiquement une fois le bas de la page atteint",
+ "avatar": "Avatar",
+ "avatarAltRadius": "Avatars (Notifications)",
+ "avatarRadius": "Avatars",
+ "background": "Arrière-plan",
+ "bio": "Biographie",
+ "block_export": "Export des comptes bloqués",
+ "block_export_button": "Export des comptes bloqués vers un fichier csv",
+ "block_import": "Import des comptes bloqués",
+ "block_import_error": "Erreur lors de l'import des comptes bloqués",
+ "blocks_imported": "Blocks importés! Le traitement va prendre un moment.",
+ "blocks_tab": "Bloqué·e·s",
+ "btnRadius": "Boutons",
+ "cBlue": "Bleu (répondre, suivre)",
+ "cGreen": "Vert (partager)",
+ "cOrange": "Orange (aimer)",
+ "cRed": "Rouge (annuler)",
+ "change_password": "Changez votre mot de passe",
+ "change_password_error": "Il y a eu un problème pour changer votre mot de passe.",
+ "changed_password": "Mot de passe modifié avec succès !",
+ "collapse_subject": "Réduire les messages avec des sujets",
+ "composing": "Composition",
+ "confirm_new_password": "Confirmation du nouveau mot de passe",
+ "current_avatar": "Avatar actuel",
+ "current_password": "Mot de passe actuel",
+ "current_profile_banner": "Bannière de profil actuelle",
+ "data_import_export_tab": "Import / Export des Données",
+ "default_vis": "Visibilité par défaut",
+ "delete_account": "Supprimer le compte",
+ "delete_account_description": "Supprimer définitivement votre compte et tous vos statuts.",
+ "delete_account_error": "Il y a eu un problème lors de la tentative de suppression de votre compte. Si le problème persiste, contactez l'administrateur⋅ice de cette instance.",
+ "delete_account_instructions": "Indiquez votre mot de passe ci-dessous pour confirmer la suppression de votre compte.",
+ "avatar_size_instruction": "La taille minimale recommandée pour l'image de l'avatar est de 150x150 pixels.",
+ "export_theme": "Enregistrer le thème",
+ "filtering": "Filtre",
+ "filtering_explanation": "Tous les statuts contenant ces mots seront masqués. Un mot par ligne",
+ "follow_export": "Exporter les abonnements",
+ "follow_export_button": "Exporter les abonnements en csv",
+ "follow_import": "Importer des abonnements",
+ "follow_import_error": "Erreur lors de l'importation des abonnements",
+ "follows_imported": "Abonnements importés ! Le traitement peut prendre un moment.",
+ "foreground": "Premier plan",
+ "general": "Général",
+ "hide_attachments_in_convo": "Masquer les pièces jointes dans les conversations",
+ "hide_attachments_in_tl": "Masquer les pièces jointes dans le journal",
+ "hide_muted_posts": "Masquer les statuts des utilisateurs masqués",
+ "max_thumbnails": "Nombre maximum de miniatures par statuts",
+ "hide_isp": "Masquer le panneau spécifique a l'instance",
+ "preload_images": "Précharger les images",
+ "use_one_click_nsfw": "Ouvrir les pièces-jointes NSFW avec un seul clic",
+ "hide_post_stats": "Masquer les statistiques de publication (le nombre de favoris)",
+ "hide_user_stats": "Masquer les statistiques de profil (le nombre d'amis)",
+ "hide_filtered_statuses": "Masquer les statuts filtrés",
+ "import_blocks_from_a_csv_file": "Importer les blocages depuis un fichier csv",
+ "import_followers_from_a_csv_file": "Importer des abonnements depuis un fichier csv",
+ "import_theme": "Charger le thème",
+ "inputRadius": "Champs de texte",
+ "checkboxRadius": "Cases à cocher",
+ "instance_default": "(default: {value})",
+ "instance_default_simple": "(default)",
+ "interface": "Interface",
+ "interfaceLanguage": "Langue de l'interface",
+ "invalid_theme_imported": "Le fichier sélectionné n'est pas un thème Pleroma pris en charge. Aucun changement n'a été apporté à votre thème.",
+ "limited_availability": "Non disponible dans votre navigateur",
+ "links": "Liens",
+ "lock_account_description": "Limitez votre compte aux abonnés acceptés uniquement",
+ "loop_video": "Vidéos en boucle",
+ "loop_video_silent_only": "Boucle uniquement les vidéos sans le son (les « gifs » de Mastodon)",
+ "mutes_tab": "Comptes silenciés",
+ "play_videos_in_modal": "Jouer les vidéos directement dans le visionneur de médias",
+ "use_contain_fit": "Ne pas rogner les miniatures des pièces-jointes",
+ "name": "Nom",
+ "name_bio": "Nom & Bio",
+ "new_password": "Nouveau mot de passe",
+ "notification_visibility": "Types de notifications à afficher",
+ "notification_visibility_follows": "Abonnements",
+ "notification_visibility_likes": "J'aime",
+ "notification_visibility_mentions": "Mentionnés",
+ "notification_visibility_repeats": "Partages",
+ "no_rich_text_description": "Ne formatez pas le texte",
+ "no_blocks": "Aucun bloqués",
+ "no_mutes": "Aucun masqués",
+ "hide_follows_description": "Ne pas afficher à qui je suis abonné",
+ "hide_followers_description": "Ne pas afficher qui est abonné à moi",
+ "show_admin_badge": "Afficher le badge d'Administrateur⋅ice sur mon profil",
+ "show_moderator_badge": "Afficher le badge de Modérateur⋅ice sur mon profil",
+ "nsfw_clickthrough": "Masquer les images marquées comme contenu adulte ou sensible",
+ "oauth_tokens": "Jetons OAuth",
+ "token": "Jeton",
+ "refresh_token": "Refresh Token",
+ "valid_until": "Valable jusque",
+ "revoke_token": "Révoquer",
+ "panelRadius": "Fenêtres",
+ "pause_on_unfocused": "Suspendre le streaming lorsque l'onglet n'est pas actif",
+ "presets": "Thèmes prédéfinis",
+ "profile_background": "Image de fond",
+ "profile_banner": "Bannière de profil",
+ "profile_tab": "Profil",
+ "radii_help": "Vous pouvez ici choisir le niveau d'arrondi des angles de l'interface (en pixels)",
+ "replies_in_timeline": "Réponses au journal",
+ "reply_link_preview": "Afficher un aperçu lors du survol de liens vers une réponse",
+ "reply_visibility_all": "Montrer toutes les réponses",
+ "reply_visibility_following": "Afficher uniquement les réponses adressées à moi ou aux personnes que je suis",
+ "reply_visibility_self": "Afficher uniquement les réponses adressées à moi",
+ "autohide_floating_post_button": "Automatiquement cacher le bouton de Nouveau Statut (sur mobile)",
+ "saving_err": "Erreur lors de l'enregistrement des paramètres",
+ "saving_ok": "Paramètres enregistrés",
+ "search_user_to_block": "Rechercher qui vous voulez bloquer",
+ "search_user_to_mute": "Rechercher qui vous voulez masquer",
+ "security_tab": "Sécurité",
+ "scope_copy": "Garder la même visibilité en répondant (les DMs restent toujours des DMs)",
+ "minimal_scopes_mode": "Rétrécir les options de séléction de la portée",
+ "set_new_avatar": "Changer d'avatar",
+ "set_new_profile_background": "Changer d'image de fond",
+ "set_new_profile_banner": "Changer de bannière",
+ "settings": "Paramètres",
+ "subject_input_always_show": "Toujours copier le champ de sujet",
+ "subject_line_behavior": "Copier le sujet en répondant",
+ "subject_line_email": "Comme les mails: « re: sujet »",
+ "subject_line_mastodon": "Comme mastodon: copier tel quel",
+ "subject_line_noop": "Ne pas copier",
+ "post_status_content_type": "Type de contenu du statuts",
+ "stop_gifs": "N'animer les GIFS que lors du survol du curseur de la souris",
+ "streaming": "Charger automatiquement les nouveaux statuts lorsque vous êtes au haut de la page",
+ "text": "Texte",
+ "theme": "Thème",
+ "theme_help": "Spécifiez des codes couleur hexadécimaux (#rrvvbb) pour personnaliser les couleurs du thème.",
+ "theme_help_v2_1": "Vous pouvez aussi surcharger certaines couleurs de composants et transparence via la case à cocher, utilisez le bouton « Vider tout » pour effacer toutes les surcharges.",
+ "theme_help_v2_2": "Les icônes sous certaines des entrées ont un indicateur de contraste du fond/texte, survolez les pour plus d'informations détailles. Veuillez garder a l'esprit que lors de l'utilisation de transparence l'indicateur de contraste indique le pire des cas.",
+ "tooltipRadius": "Info-bulles/alertes",
+ "upload_a_photo": "Envoyer une photo",
+ "user_settings": "Paramètres utilisateur",
+ "values": {
+ "false": "non",
+ "true": "oui"
+ },
+ "notifications": "Notifications",
+ "notification_setting": "Reçevoir les notifications de:",
+ "notification_setting_follows": "Utilisateurs que vous suivez",
+ "notification_setting_non_follows": "Utilisateurs que vous ne suivez pas",
+ "notification_setting_followers": "Utilisateurs qui vous suivent",
+ "notification_setting_non_followers": "Utilisateurs qui ne vous suivent pas",
+ "notification_mutes": "Pour stopper la récéption de notifications d'un utilisateur particulier, utilisez un masquage.",
+ "notification_blocks": "Bloquer un utilisateur stoppe toute notification et se désabonne de lui.",
+ "enable_web_push_notifications": "Activer les notifications de push web",
+ "style": {
+ "switcher": {
+ "keep_color": "Garder les couleurs",
+ "keep_shadows": "Garder les ombres",
+ "keep_opacity": "Garder la transparence",
+ "keep_roundness": "Garder la rondeur",
+ "keep_fonts": "Garder les polices",
+ "save_load_hint": "L'option « Garder » préserve les options activés en cours lors de la séléction ou chargement des thèmes, il sauve aussi les dites options lors de l'export d'un thème. Quand toutes les cases sont décochés, exporter un thème sauvera tout.",
+ "reset": "Remise à zéro",
+ "clear_all": "Tout vider",
+ "clear_opacity": "Vider la transparence"
+ },
+ "common": {
+ "color": "Couleur",
+ "opacity": "Transparence",
+ "contrast": {
+ "hint": "Le ratio de contraste est {ratio}, il {level} {context}",
+ "level": {
+ "aa": "répond aux directives de niveau AA (minimum)",
+ "aaa": "répond aux directives de niveau AAA (recommandé)",
+ "bad": "ne réponds à aucune directive d'accessibilité"
+ },
+ "context": {
+ "18pt": "pour texte large (19pt+)",
+ "text": "pour texte"
+ }
+ }
+ },
+ "common_colors": {
+ "_tab_label": "Commun",
+ "main": "Couleurs communes",
+ "foreground_hint": "Voir l'onglet « Avancé » pour plus de contrôle détaillé",
+ "rgbo": "Icônes, accents, badges"
+ },
+ "advanced_colors": {
+ "_tab_label": "Avancé",
+ "alert": "Fond d'alerte",
+ "alert_error": "Erreur",
+ "badge": "Fond de badge",
+ "badge_notification": "Notification",
+ "panel_header": "Entête de panneau",
+ "top_bar": "Barre du haut",
+ "borders": "Bordures",
+ "buttons": "Boutons",
+ "inputs": "Champs de saisie",
+ "faint_text": "Texte en fondu"
+ },
+ "radii": {
+ "_tab_label": "Rondeur"
+ },
+ "shadows": {
+ "_tab_label": "Ombres et éclairage",
+ "component": "Composant",
+ "override": "Surcharger",
+ "shadow_id": "Ombre #{value}",
+ "blur": "Flou",
+ "spread": "Dispersion",
+ "inset": "Interne",
+ "hint": "Pour les ombres, vous pouvez aussi utiliser --variable comme valeur de couleur en CSS3. Veuillez noter que spécifier la transparence ne fonctionnera pas dans ce cas.",
+ "filter_hint": {
+ "always_drop_shadow": "Attention, cette ombre utilise toujours {0} quand le navigateur le supporte.",
+ "drop_shadow_syntax": "{0} ne supporte pas le paramètre {1} et mot-clé {2}.",
+ "avatar_inset": "Veuillez noter que combiner a la fois les ombres internes et non-internes sur les avatars peut fournir des résultats innatendus avec la transparence des avatars.",
+ "spread_zero": "Les ombres avec une dispersion > 0 apparaitrons comme si ils étaient à zéro",
+ "inset_classic": "L'ombre interne utilisera toujours {0}"
+ },
+ "components": {
+ "panel": "Panneau",
+ "panelHeader": "En-tête de panneau",
+ "topBar": "Barre du haut",
+ "avatar": "Avatar utilisateur⋅ice (dans la vue de profil)",
+ "avatarStatus": "Avatar utilisateur⋅ice (dans la vue de statuts)",
+ "popup": "Popups et infobulles",
+ "button": "Bouton",
+ "buttonHover": "Bouton (survol)",
+ "buttonPressed": "Bouton (cliqué)",
+ "buttonPressedHover": "Bouton (cliqué+survol)",
+ "input": "Champ de saisie"
+ }
+ },
+ "fonts": {
+ "_tab_label": "Polices",
+ "help": "Sélectionnez la police à utiliser pour les éléments de l'UI. Pour « personnalisé » vous avez à entrer le nom exact de la police comme il apparaît dans le système.",
+ "components": {
+ "interface": "Interface",
+ "input": "Champs de saisie",
+ "post": "Post text",
+ "postCode": "Texte à taille fixe dans un article (texte enrichi)"
+ },
+ "family": "Nom de la police",
+ "size": "Taille (en px)",
+ "weight": "Poid (gras)",
+ "custom": "Personnalisé"
+ },
+ "preview": {
+ "header": "Prévisualisation",
+ "content": "Contenu",
+ "error": "Exemple d'erreur",
+ "button": "Bouton",
+ "text": "Un certain nombre de {0} et {1}",
+ "mono": "contenu",
+ "input": "Je viens juste d’atterrir à L.A.",
+ "faint_link": "manuel utile",
+ "fine_print": "Lisez notre {0} pour n'apprendre rien d'utile !",
+ "header_faint": "Tout va bien",
+ "checkbox": "J'ai survolé les conditions d'utilisation",
+ "link": "un petit lien sympa"
+ }
+ },
+ "version": {
+ "title": "Version",
+ "backend_version": "Version du Backend",
+ "frontend_version": "Version du Frontend"
+ }
+ },
+ "timeline": {
+ "collapse": "Fermer",
+ "conversation": "Conversation",
+ "error_fetching": "Erreur en cherchant les mises à jour",
+ "load_older": "Afficher plus",
+ "no_retweet_hint": "Le message est marqué en abonnés-seulement ou direct et ne peut pas être partagé",
+ "repeated": "a partagé",
+ "show_new": "Afficher plus",
+ "up_to_date": "À jour",
+ "no_more_statuses": "Pas plus de statuts",
+ "no_statuses": "Aucun statuts"
+ },
+ "status": {
+ "favorites": "Favoris",
+ "repeats": "Partages",
+ "delete": "Supprimer statuts",
+ "pin": "Agraffer sur le profil",
+ "unpin": "Dégraffer du profil",
+ "pinned": "Agraffé",
+ "delete_confirm": "Voulez-vous vraiment supprimer ce statuts ?",
+ "reply_to": "Réponse à",
+ "replies_list": "Réponses:"
+ },
+ "user_card": {
+ "approve": "Accepter",
+ "block": "Bloquer",
+ "blocked": "Bloqué !",
+ "deny": "Rejeter",
+ "favorites": "Favoris",
+ "follow": "Suivre",
+ "follow_sent": "Demande envoyée !",
+ "follow_progress": "Demande en cours…",
+ "follow_again": "Renvoyer la demande ?",
+ "follow_unfollow": "Désabonner",
+ "followees": "Suivis",
+ "followers": "Vous suivent",
+ "following": "Suivi !",
+ "follows_you": "Vous suit !",
+ "its_you": "C'est vous !",
+ "media": "Media",
+ "mute": "Masquer",
+ "muted": "Masqué",
+ "per_day": "par jour",
+ "remote_follow": "Suivre d'une autre instance",
+ "report": "Signalement",
+ "statuses": "Statuts",
+ "unblock": "Débloquer",
+ "unblock_progress": "Déblocage…",
+ "block_progress": "Blocage…",
+ "unmute": "Démasquer",
+ "unmute_progress": "Démasquage…",
+ "mute_progress": "Masquage…",
+ "admin_menu": {
+ "moderation": "Moderation",
+ "grant_admin": "Promouvoir Administrateur⋅ice",
+ "revoke_admin": "Dégrader Administrateur⋅ice",
+ "grant_moderator": "Promouvoir Modérateur⋅ice",
+ "revoke_moderator": "Dégrader Modérateur⋅ice",
+ "activate_account": "Activer le compte",
+ "deactivate_account": "Désactiver le compte",
+ "delete_account": "Supprimer le compte",
+ "force_nsfw": "Marquer tous les statuts comme NSFW",
+ "strip_media": "Supprimer les medias des statuts",
+ "force_unlisted": "Forcer les statuts à être délistés",
+ "sandbox": "Forcer les statuts à être visibles seuleument pour les abonné⋅e⋅s",
+ "disable_remote_subscription": "Interdir de s'abonner a l'utilisateur depuis l'instance distante",
+ "disable_any_subscription": "Interdir de s'abonner à l'utilisateur tout court",
+ "quarantine": "Interdir les statuts de l'utilisateur à fédérer",
+ "delete_user": "Supprimer l'utilisateur",
+ "delete_user_confirmation": "Êtes-vous absolument-sûr⋅e ? Cette action ne peut être annulée."
+ }
+ },
+ "user_profile": {
+ "timeline_title": "Journal de l'utilisateur⋅ice",
+ "profile_does_not_exist": "Désolé, ce profil n'existe pas.",
+ "profile_loading_error": "Désolé, il y a eu une erreur au chargement du profil."
+ },
+ "user_reporting": {
+ "title": "Signaler {0}",
+ "add_comment_description": "Ce signalement sera envoyé aux modérateur⋅ice⋅s de votre instance. Vous pouvez fournir une explication de pourquoi vous signalez ce compte ci-dessous :",
+ "additional_comments": "Commentaires additionnels",
+ "forward_description": "Le compte vient d'un autre serveur. Envoyer une copie du signalement à celui-ci aussi ?",
+ "forward_to": "Transmettre à {0}",
+ "submit": "Envoyer",
+ "generic_error": "Une erreur est survenue lors du traitement de votre requête."
+ },
+ "who_to_follow": {
+ "more": "Plus",
+ "who_to_follow": "À qui s'abonner"
+ },
+ "tool_tip": {
+ "media_upload": "Envoyer un media",
+ "repeat": "Répéter",
+ "reply": "Répondre",
+ "favorite": "Favoriser",
+ "user_settings": "Paramètres utilisateur"
+ },
+ "upload": {
+ "error": {
+ "base": "L'envoi a échoué.",
+ "file_too_big": "Fichier trop gros [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+ "default": "Réessayez plus tard"
+ },
+ "file_size_units": {
+ "B": "O",
+ "KiB": "KiO",
+ "MiB": "MiO",
+ "GiB": "GiO",
+ "TiB": "TiO"
+ }
}
- },
- "timeline": {
- "collapse": "Fermer",
- "conversation": "Conversation",
- "error_fetching": "Erreur en cherchant les mises à jour",
- "load_older": "Afficher plus",
- "no_retweet_hint": "Le message est marqué en abonnés-seulement ou direct et ne peut pas être répété",
- "repeated": "a partagé",
- "show_new": "Afficher plus",
- "up_to_date": "À jour"
- },
- "user_card": {
- "approve": "Accepter",
- "block": "Bloquer",
- "blocked": "Bloqué !",
- "deny": "Rejeter",
- "follow": "Suivre",
- "followees": "Suivis",
- "followers": "Vous suivent",
- "following": "Suivi !",
- "follows_you": "Vous suit !",
- "mute": "Masquer",
- "muted": "Masqué",
- "per_day": "par jour",
- "remote_follow": "Suivre d'une autre instance",
- "statuses": "Statuts"
- },
- "user_profile": {
- "timeline_title": "Journal de l'utilisateur"
- },
- "who_to_follow": {
- "more": "Plus",
- "who_to_follow": "Qui s'abonner"
- }
}
diff --git a/src/i18n/ga.json b/src/i18n/ga.json
index 31250876..7a10ba40 100644
--- a/src/i18n/ga.json
+++ b/src/i18n/ga.json
@@ -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",
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index 87ab9dfd..559bb4c9 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -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": "スレッド",
diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json
index 9036baf5..345e1f1c 100644
--- a/src/i18n/ja_pedantic.json
+++ b/src/i18n/ja_pedantic.json
@@ -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": "スレッド",
diff --git a/src/i18n/oc.json b/src/i18n/oc.json
index 5f8d153f..ec7f5740 100644
--- a/src/i18n/oc.json
+++ b/src/i18n/oc.json
@@ -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",
diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js
index 7ab89c12..cad7ea25 100644
--- a/src/lib/persisted_state.js
+++ b/src/lib/persisted_state.js
@@ -19,7 +19,8 @@ const saveImmedeatelyActions = [
'setHighlight',
'setOption',
'setClientData',
- 'setToken'
+ 'setToken',
+ 'clearToken'
]
const defaultStorage = (() => {
diff --git a/src/main.js b/src/main.js
index 5758c7bd..3287fa2b 100644
--- a/src/main.js
+++ b/src/main.js
@@ -14,8 +14,8 @@ import authFlowModule from './modules/auth_flow.js'
import mediaViewerModule from './modules/media_viewer.js'
import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js'
+import pollsModule from './modules/polls.js'
-import VueTimeago from 'vue-timeago'
import VueI18n from 'vue-i18n'
import createPersistedState from './lib/persisted_state.js'
@@ -33,14 +33,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)
@@ -81,7 +73,8 @@ const persistedStateOptions = {
authFlow: authFlowModule,
mediaViewer: mediaViewerModule,
oauthTokens: oauthTokensModule,
- reports: reportsModule
+ reports: reportsModule,
+ polls: pollsModule
},
plugins: [persistedState, pushNotifications],
strict: false // Socket modifies itself, let's ignore this for now.
diff --git a/src/modules/api.js b/src/modules/api.js
index 7ed3edac..d51b31f3 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -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)
}
diff --git a/src/modules/chat.js b/src/modules/chat.js
index 2804e577..4d8d6699 100644
--- a/src/modules/chat.js
+++ b/src/modules/chat.js
@@ -21,7 +21,7 @@ const chat = {
},
actions: {
disconnectFromChat (store) {
- store.state.socket.disconnect()
+ store.state.socket && store.state.socket.disconnect()
},
initializeChat (store, socket) {
const channel = socket.channel('chat:public')
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 59beb23c..22addb9b 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -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 = {
diff --git a/src/modules/oauth.js b/src/modules/oauth.js
index 11cb10fe..a2a83450 100644
--- a/src/modules/oauth.js
+++ b/src/modules/oauth.js
@@ -1,3 +1,5 @@
+import { delete as del } from 'vue'
+
const oauth = {
state: {
clientId: false,
@@ -22,6 +24,12 @@ const oauth = {
},
setToken (state, token) {
state.userToken = token
+ },
+ clearToken (state) {
+ state.userToken = false
+ // state.token is userToken with older name, coming from persistent state
+ // let's clear it as well, since it is being used as a fallback of state.userToken
+ del(state, 'token')
}
},
getters: {
diff --git a/src/modules/polls.js b/src/modules/polls.js
new file mode 100644
index 00000000..e6158b63
--- /dev/null
+++ b/src/modules/polls.js
@@ -0,0 +1,70 @@
+import { merge } from 'lodash'
+import { set } from 'vue'
+
+const polls = {
+ state: {
+ // Contains key = id, value = number of trackers for this poll
+ trackedPolls: {},
+ pollsObject: {}
+ },
+ mutations: {
+ mergeOrAddPoll (state, poll) {
+ const existingPoll = state.pollsObject[poll.id]
+ // Make expired-state change trigger re-renders properly
+ poll.expired = Date.now() > Date.parse(poll.expires_at)
+ if (existingPoll) {
+ set(state.pollsObject, poll.id, merge(existingPoll, poll))
+ } else {
+ set(state.pollsObject, poll.id, poll)
+ }
+ },
+ trackPoll (state, pollId) {
+ const currentValue = state.trackedPolls[pollId]
+ if (currentValue) {
+ set(state.trackedPolls, pollId, currentValue + 1)
+ } else {
+ set(state.trackedPolls, pollId, 1)
+ }
+ },
+ untrackPoll (state, pollId) {
+ const currentValue = state.trackedPolls[pollId]
+ if (currentValue) {
+ set(state.trackedPolls, pollId, currentValue - 1)
+ } else {
+ set(state.trackedPolls, pollId, 0)
+ }
+ }
+ },
+ actions: {
+ mergeOrAddPoll ({ commit }, poll) {
+ commit('mergeOrAddPoll', poll)
+ },
+ updateTrackedPoll ({ rootState, dispatch, commit }, pollId) {
+ rootState.api.backendInteractor.fetchPoll(pollId).then(poll => {
+ setTimeout(() => {
+ if (rootState.polls.trackedPolls[pollId]) {
+ dispatch('updateTrackedPoll', pollId)
+ }
+ }, 30 * 1000)
+ commit('mergeOrAddPoll', poll)
+ })
+ },
+ trackPoll ({ rootState, commit, dispatch }, pollId) {
+ if (!rootState.polls.trackedPolls[pollId]) {
+ setTimeout(() => dispatch('updateTrackedPoll', pollId), 30 * 1000)
+ }
+ commit('trackPoll', pollId)
+ },
+ untrackPoll ({ commit }, pollId) {
+ commit('untrackPoll', pollId)
+ },
+ votePoll ({ rootState, commit }, { id, pollId, choices }) {
+ return rootState.api.backendInteractor.vote(pollId, choices).then(poll => {
+ commit('mergeOrAddPoll', poll)
+ return poll
+ })
+ }
+ }
+}
+
+export default polls
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index e6ee5447..9b11a13e 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -146,7 +146,8 @@ const removeStatusFromGlobalStorage = (state, status) => {
}
}
-const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId }) => {
+const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {},
+ noIdUpdate = false, userId }) => {
// Sanity check
if (!isArray(statuses)) {
return false
@@ -494,6 +495,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
}
}
@@ -539,7 +544,7 @@ const statuses = {
},
fetchPinnedStatuses ({ rootState, dispatch }, userId) {
rootState.api.backendInteractor.fetchPinnedStatuses(userId)
- .then(statuses => dispatch('addNewStatuses', { statuses, timeline: 'user', userId, showImmediately: true }))
+ .then(statuses => dispatch('addNewStatuses', { statuses, timeline: 'user', userId, showImmediately: true, noIdUpdate: true }))
},
pinStatus ({ rootState, commit }, statusId) {
return rootState.api.backendInteractor.pinOwnStatus(statusId)
diff --git a/src/modules/users.js b/src/modules/users.js
index 22340271..1e0b16f5 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -399,7 +399,7 @@ const users = {
logout (store) {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromChat')
- store.commit('setToken', false)
+ store.commit('clearToken')
store.dispatch('stopFetching', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.dispatch('stopFetching', 'notifications')
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index ab1fc0b2..d5fc418d 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -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,
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index 8614a0f2..e095d3d2 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -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 externalProfile = (profileUrl) => apiService.externalProfile({profileUrl, credentials})
- const importBlocks = (file) => apiService.importBlocks({file, credentials})
- const importFollows = (file) => apiService.importFollows({file, credentials})
+ const importBlocks = (file) => apiService.importBlocks({ file, credentials })
+ const importFollows = (file) => apiService.importFollows({ file, credentials })
- const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password})
- const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation})
+ const deleteAccount = ({ password }) => apiService.deleteAccount({ credentials, password })
+ const changePassword = ({ password, newPassword, newPasswordConfirmation }) =>
+ apiService.changePassword({ credentials, password, newPassword, newPasswordConfirmation })
- 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 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 fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id})
- const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id})
- const reportUser = (params) => apiService.reportUser({credentials, ...params})
+ 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 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,
diff --git a/src/services/date_utils/date_utils.js b/src/services/date_utils/date_utils.js
new file mode 100644
index 00000000..32e13bca
--- /dev/null
+++ b/src/services/date_utils/date_utils.js
@@ -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
+}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index cdce1538..9af71e4f 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -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
diff --git a/src/services/new_api/user_search.js b/src/services/new_api/user_search.js
index 869afa9c..c5a87cce 100644
--- a/src/services/new_api/user_search.js
+++ b/src/services/new_api/user_search.js
@@ -6,7 +6,8 @@ const search = ({query, store}) => {
store,
url: '/api/v1/accounts/search',
params: {
- q: query
+ q: query,
+ resolve: true
}
})
.then((data) => data.json())
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index e70b0f26..1efecf90 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -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', {
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index d0b6ccbf..d73106b8 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -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)