diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/media_upload/media_upload.vue | 2 | ||||
| -rw-r--r-- | src/components/notification/notification.js | 6 | ||||
| -rw-r--r-- | src/components/notification/notification.vue | 4 | ||||
| -rw-r--r-- | src/components/poll/poll.js | 107 | ||||
| -rw-r--r-- | src/components/poll/poll.vue | 117 | ||||
| -rw-r--r-- | src/components/poll/poll_form.js | 121 | ||||
| -rw-r--r-- | src/components/poll/poll_form.vue | 133 | ||||
| -rw-r--r-- | src/components/post_status_form/post_status_form.js | 44 | ||||
| -rw-r--r-- | src/components/post_status_form/post_status_form.vue | 91 | ||||
| -rw-r--r-- | src/components/settings/settings.vue | 2 | ||||
| -rw-r--r-- | src/components/status/status.js | 12 | ||||
| -rw-r--r-- | src/components/status/status.vue | 6 | ||||
| -rw-r--r-- | src/components/timeago/timeago.vue | 48 |
13 files changed, 649 insertions, 44 deletions
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/poll/poll.js b/src/components/poll/poll.js new file mode 100644 index 00000000..ecacbc35 --- /dev/null +++ b/src/components/poll/poll.js @@ -0,0 +1,107 @@ +import Timeago from '../timeago/timeago.vue' +import { forEach, map } from 'lodash' + +export default { + name: 'Poll', + props: ['poll', 'statusId'], + components: { Timeago }, + data () { + return { + loading: false, + choices: [], + refreshInterval: null + } + }, + created () { + this.refreshInterval = setTimeout(this.refreshPoll, 30 * 1000) + // Initialize choices to booleans and set its length to match options + this.choices = this.poll.options.map(_ => false) + }, + destroyed () { + clearTimeout(this.refreshInterval) + }, + computed: { + expired () { + return Date.now() > Date.parse(this.poll.expires_at) + }, + loggedIn () { + return this.$store.state.users.currentUser + }, + showResults () { + return this.poll.voted || this.expired || !this.loggedIn + }, + totalVotesCount () { + return this.poll.votes_count + }, + expiresAt () { + return Date.parse(this.poll.expires_at).toLocaleString() + }, + containerClass () { + return { + loading: this.loading + } + }, + choiceIndices () { + // Convert array of booleans into an array of indices of the + // items that were 'true', so [true, false, false, true] becomes + // [0, 3]. + return this.choices + .map((entry, index) => entry && index) + .filter(value => typeof value === 'number') + }, + isDisabled () { + const noChoice = this.choiceIndices.length === 0 + return this.loading || noChoice + } + }, + methods: { + refreshPoll () { + if (this.expired) return + this.fetchPoll() + this.refreshInterval = setTimeout(this.refreshPoll, 30 * 1000) + }, + percentageForOption (count) { + return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100) + }, + resultTitle (option) { + return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}` + }, + fetchPoll () { + this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id }) + }, + activateOption (index) { + // forgive me father: doing checking the radio/checkboxes + // in code because of customized input elements need either + // a) an extra element for the actual graphic, or b) use a + // pseudo element for the label. We use b) which mandates + // using "for" and "id" matching which isn't nice when the + // same poll appears multiple times on the site (notifs and + // timeline for example). With code we can make sure it just + // works without altering the pseudo element implementation. + const allElements = this.$el.querySelectorAll('input') + const clickedElement = this.$el.querySelector(`input[value="${index}"]`) + if (this.poll.multiple) { + // Checkboxes, toggle only the clicked one + clickedElement.checked = !clickedElement.checked + } else { + // Radio button, uncheck everything and check the clicked one + forEach(allElements, element => { element.checked = false }) + clickedElement.checked = true + } + this.choices = map(allElements, e => e.checked) + }, + optionId (index) { + return `poll${this.poll.id}-${index}` + }, + vote () { + if (this.choiceIndices.length === 0) return + this.loading = true + this.$store.dispatch( + 'votePoll', + { id: this.statusId, pollId: this.poll.id, choices: this.choiceIndices } + ).then(poll => { + this.loading = false + }) + } + } +}
\ No newline at end of file diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue new file mode 100644 index 00000000..28e9f4a8 --- /dev/null +++ b/src/components/poll/poll.vue @@ -0,0 +1,117 @@ +<template> + <div class="poll" v-bind:class="containerClass"> + <div + class="poll-option" + v-for="(option, index) in poll.options" + :key="index" + > + <div v-if="showResults" :title="resultTitle(option)" class="option-result"> + <div class="option-result-label"> + <span class="result-percentage"> + {{percentageForOption(option.votes_count)}}% + </span> + <span>{{option.title}}</span> + </div> + <div + class="result-fill" + :style="{ 'width': `${percentageForOption(option.votes_count)}%` }" + > + </div> + </div> + <div v-else @click="activateOption(index)"> + <input + v-if="poll.multiple" + type="checkbox" + :disabled="loading" + :value="index" + > + <input + v-else + type="radio" + :disabled="loading" + :value="index" + > + <label> + {{option.title}} + </label> + </div> + </div> + <div class="footer faint"> + <button + v-if="!showResults" + class="btn btn-default poll-vote-button" + type="button" + @click="vote" + :disabled="isDisabled" + > + {{$t('polls.vote')}} + </button> + <div class="total"> + {{totalVotesCount}} {{ $t("polls.votes") }} ยท + </div> + <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'"> + <Timeago :time="this.poll.expires_at" :auto-update="60" :now-threshold="0" /> + </i18n> + </div> + </div> +</template> + +<script src="./poll.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.poll { + .votes { + display: flex; + flex-direction: column; + margin: 0 0 0.5em; + } + .poll-option { + margin: 0.5em 0; + height: 1.5em; + } + .option-result { + height: 100%; + display: flex; + flex-direction: row; + position: relative; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + .option-result-label { + display: flex; + align-items: center; + padding: 0.1em 0.25em; + z-index: 1; + } + .result-percentage { + width: 3.5em; + } + .result-fill { + height: 100%; + position: absolute; + background-color: $fallback--lightBg; + background-color: var(--linkBg, $fallback--lightBg); + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + top: 0; + left: 0; + transition: width 0.5s; + } + input { + width: 3.5em; + } + .footer { + display: flex; + align-items: center; + } + &.loading * { + cursor: progress; + } + .poll-vote-button { + padding: 0 0.5em; + margin-right: 0.5em; + } +} +</style> 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..5dbb1c9d 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' @@ -261,6 +284,17 @@ const PostStatusForm = { changeVis (visibility) { this.newStatus.visibility = visibility }, + togglePollForm () { + this.pollFormVisible = !this.pollFormVisible + }, + setPoll (poll) { + this.newStatus.poll = poll + }, + clearPollForm () { + if (this.$refs.pollForm) { + this.$refs.pollForm.clear() + } + }, dismissScopeNotice () { this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 9929aab0..fbeaf39b 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'" @@ -91,37 +91,52 @@ :onScopeChange="changeVis"/> </div> </div> - <div class='form-bottom'> + <poll-form + ref="pollForm" + v-if="pollsAvailable" + :visible="pollFormVisible" + @update-poll="setPoll" + /> + <div class='form-bottom'> + <div class='form-bottom-left'> <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> - - <p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p> - <p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> - - <button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button> - <button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button> - <button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button> - </div> - <div class='alert error' v-if="error"> - Error: {{ error }} - <i class="button-icon icon-cancel" @click="clearError"></i> - </div> - <div class="attachments"> - <div class="media-upload-wrapper" v-for="file in newStatus.files"> - <i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i> - <div class="media-upload-container attachment"> - <img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img> - <video v-if="type(file) === 'video'" :src="file.url" controls></video> - <audio v-if="type(file) === 'audio'" :src="file.url" controls></audio> - <a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a> - </div> + <div v-if="pollsAvailable" class="poll-icon"> + <i + :title="$t('polls.add_poll')" + @click="togglePollForm" + class="icon-chart-bar btn btn-default" + :class="pollFormVisible && 'selected'" + /> </div> </div> - <div class="upload_settings" v-if="newStatus.files.length > 0"> - <input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw"> - <label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label> + <p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p> + <p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> + + <button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button> + <button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button> + <button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button> + </div> + <div class='alert error' v-if="error"> + Error: {{ error }} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + <div class="attachments"> + <div class="media-upload-wrapper" v-for="file in newStatus.files"> + <i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i> + <div class="media-upload-container attachment"> + <img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img> + <video v-if="type(file) === 'video'" :src="file.url" controls></video> + <audio v-if="type(file) === 'audio'" :src="file.url" controls></audio> + <a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a> + </div> </div> - </form> - </div> + </div> + <div class="upload_settings" v-if="newStatus.files.length > 0"> + <input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw"> + <label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label> + </div> + </form> +</div> </template> <script src="./post_status_form.js"></script> @@ -172,6 +187,11 @@ } } + .form-bottom-left { + display: flex; + flex: 1; + } + .text-format { .only-format { color: $fallback--faint; @@ -179,6 +199,20 @@ } } + .poll-icon { + font-size: 26px; + flex: 1; + + .selected { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + } + + .icon-chart-bar { + cursor: pointer; + } + .error { text-align: center; @@ -240,7 +274,6 @@ } } - .btn { cursor: pointer; } 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..58402f7e 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 :poll="status.poll" :status-id="status.id" /> + </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 |
