From 6e74ef10b35526ed8668e645ff2a049981162c75 Mon Sep 17 00:00:00 2001 From: shu Date: Tue, 28 May 2019 11:51:06 +0200 Subject: [PATCH] Mastodon: Polls and better marks --- src/components/FromNowMixin.ts | 9 +++- src/components/ServiceEmiter.ts | 2 +- src/helpers/lists/Lists.ts | 14 ++++++ src/services/mastodon/Client.vue | 41 ++++++++++------- src/services/mastodon/Notification.vue | 11 +++-- src/services/mastodon/Status.vue | 58 ++++++++++++++++-------- src/services/mastodon/Types.ts | 12 +++-- src/services/nextcloud/NextcloudNews.vue | 2 +- 8 files changed, 105 insertions(+), 44 deletions(-) diff --git a/src/components/FromNowMixin.ts b/src/components/FromNowMixin.ts index 9e24393..892d18c 100644 --- a/src/components/FromNowMixin.ts +++ b/src/components/FromNowMixin.ts @@ -19,7 +19,11 @@ export default class FromNowMixin extends Vue { } fromNow(date: Date | number | string) { - const milliseconds = Math.floor(FromNowMixin.toNumber(TimeModule.now) - FromNowMixin.toNumber(date)) + const now = FromNowMixin.toNumber(TimeModule.now) + const target = FromNowMixin.toNumber(date) + + const prefix = target > now ? 'in ' : '' + const milliseconds = Math.floor(Math.abs(target - now)) let cur = 0 let divider = 1 @@ -28,10 +32,11 @@ export default class FromNowMixin extends Vue { divider *= time[1] const next = Math.floor(milliseconds / divider) if(next <= 0) { - return `${cur} ${name}${cur > 1 ? 's' : ''}` + return `${prefix}${cur} ${name}${cur > 1 ? 's' : ''}` } name = time[0] cur = next } + return `${prefix}a long time` } } \ No newline at end of file diff --git a/src/components/ServiceEmiter.ts b/src/components/ServiceEmiter.ts index ed78a0e..3b3606e 100644 --- a/src/components/ServiceEmiter.ts +++ b/src/components/ServiceEmiter.ts @@ -33,7 +33,7 @@ export default class ServiceEmiter extends Vue { this.emit(Events.SaveServiceEvent, service) } - catchEmit(req: AxiosPromise) { + catchEmit(req: AxiosPromise) { return req.catch(err => { this.emitError(err) throw err diff --git a/src/helpers/lists/Lists.ts b/src/helpers/lists/Lists.ts index 4625c85..17e6728 100644 --- a/src/helpers/lists/Lists.ts +++ b/src/helpers/lists/Lists.ts @@ -1,3 +1,5 @@ +import Vue from 'vue' + export default abstract class Lists { static last(list: T[]) { @@ -13,6 +15,18 @@ export default abstract class Lists { return list.length } + static setAt(list: T[], id: number, val: T) { + return Vue.set(list, id, val) + } + + static setFirst(list: T[], where: (val: T) => boolean, val: T) { + return this.setAt(list, this.getIndex(list, where), val) + } + + static setFirstBy(list: T[], mapper: (val: T) => U, key: U, val: T) { + return this.setFirst(list, e => mapper(e) === key, val) + } + static removeAt(list: T[], id: number) { list.splice(id, 1) } diff --git a/src/services/mastodon/Client.vue b/src/services/mastodon/Client.vue index 5403be1..9c75f41 100644 --- a/src/services/mastodon/Client.vue +++ b/src/services/mastodon/Client.vue @@ -4,7 +4,7 @@ .header(v-if="hasNotifications") Accueil success-loadable.list(:loadable="statues") template(v-for="status in statues.get()") - status(v-if="showStatus(status)" :key="status.id" :status="status" :showMedia="options.showMedia" @mark="onStatusMark") + status(v-if="showStatus(status)" :key="status.id" :status="status" :showMedia="options.showMedia" @mark="onStatusMark" @vote="onPollVote") .status(v-show="statues.loadingMore") .service-loader .notifications(v-if="hasNotifications") @@ -13,9 +13,9 @@ span.date(@click.stop.prevent="onNotificationsClear") ❌ .list notification(v-for="notification in notifications.get()" :key="notification.id" :notification="notification" - :showMedia="options.showMedia" @dismiss="onNotificationDismiss" @mark="onStatusMark") + :showMedia="options.showMedia" @dismiss="onNotificationDismiss" @mark="onStatusMark" @vote="onPollVote") .compose-toggle(@click="showCompose = !showCompose") 🖉 - .emoji-list(v-show="showCompose && showEmojis") + .emoji-list(v-if="options.showMedia" v-show="showCompose && showEmojis") img.emoji(v-for="emoji in emojis.get()" @click="addEmoji(emoji.shortcode)" :src="emoji.static_url" :alt="emoji.shortcode" :title="emoji.shortcode") .compose(v-show="showCompose") textarea.content(v-model="compose.status" placeholder="message") @@ -50,7 +50,7 @@ import AxiosLodableMore from '@/helpers/loadable/AxiosLoadableMore' import { AUTH, getHeaders, getRest } from './Mastodon.vue' import Notification from './Notification.vue' import Status from './Status.vue' -import { Emoji, MarkMessage, Notification as INotification, Options, Status as IStatus, StatusPost, TimelineType } from './Types' +import { Emoji, MarkStatus, Notification as INotification, Options, Poll, PollVote, Status as IStatus, StatusPost, TimelineType } from './Types' const STREAMS = { home: 'user', @@ -92,8 +92,8 @@ export default class Client extends Mixins>(ServiceClient created() { this.$watch('options.timeline', this.init, { immediate: true }) - this.notifications.load(this.get('/notifications')) - this.emojis.load(this.get('/custom_emojis'), res => Lists.sort(res.data, e => e.shortcode, Lists.stringCompare)) + this.notifications.load(this.get('/notifications')) + this.emojis.load(this.get('/custom_emojis'), res => Lists.sort(res.data, e => e.shortcode, Lists.stringCompare)) } init() { @@ -101,12 +101,12 @@ export default class Client extends Mixins>(ServiceClient this.setupStream() } - get(path: string, options = {}) { - return this.catchEmit(this.rest.get(path, { params: { limit: this.options.buffer, ...options } })) + get(path: string, options = {}) { + return this.catchEmit(this.rest.get(path, { params: { limit: this.options.buffer, ...options } })) } - post(path: string, options = {}) { - return this.catchEmit(this.rest.post(path, options)) + post(path: string, options = {}) { + return this.catchEmit(this.rest.post(path, options)) } addEmoji(code: string) { @@ -134,7 +134,7 @@ export default class Client extends Mixins>(ServiceClient if (this.options.timeline === 'local') { options.local = true } - return this.get(`/timelines/${this.options.timeline === 'home' ? 'home' : 'public'}`, options) + return this.get(`/timelines/${this.options.timeline === 'home' ? 'home' : 'public'}`, options) } onScroll(event: any) { @@ -149,9 +149,18 @@ export default class Client extends Mixins>(ServiceClient return (!status.in_reply_to_id || this.options.reply) && (!status.reblog || this.options.reblog) } - onStatusMark(action: MarkMessage) { - this.post(`/statuses/${action.id}/${action.type}`) - .then(() => action.callback()) + onStatusMark(action: MarkStatus) { + this.post(`/statuses/${action.id}/${action.type}`) + .then(res => this.statues.with( + sts => Lists.setFirstBy(sts, st => st.id, action.id, res.data) + )) + } + + onPollVote(action: PollVote) { + this.post(`/polls/${action.poll}/votes`, { choices: action.choices }) + .then(res => this.statues.with( + sts => sts.find(st => st.id == action.id)!.poll = res.data + )) } onNotificationDismiss(id: number) { @@ -170,7 +179,7 @@ export default class Client extends Mixins>(ServiceClient if(this.stream) { this.stream.close() } - this.get('/instance').then(res => { + this.get<{ version: string }>('/instance').then(res => { const oldAuth = res.data.version < '2.8.4' ? `access_token=${this.auth.get(AUTH.TOKEN)}&` : '' this.stream = new WebSocket( `wss://${this.auth.get(AUTH.SERVER)}/api/v1/streaming?${oldAuth}stream=${STREAMS[this.options.timeline]}`, @@ -291,7 +300,7 @@ export default class Client extends Mixins>(ServiceClient background-color: #00000044 color: white padding: .5em - .card + .card, .poll @include tile padding: .2em display: block diff --git a/src/services/mastodon/Notification.vue b/src/services/mastodon/Notification.vue index ee73459..f7bac4f 100644 --- a/src/services/mastodon/Notification.vue +++ b/src/services/mastodon/Notification.vue @@ -11,7 +11,7 @@ .content template(v-if="notification.type == 'follow'") Vous suit status.reblog(v-else-if="notification.status" :status="notification.status" - :showMedia="showMedia" :withAccount="notification.type != 'mention'" @mark="passMark") + :showMedia="showMedia" :withAccount="notification.type != 'mention'" @mark="passMark" @vote="passVote") a.date(@click.stop.prevent="makeDismiss" style="margin-top: -1em") ❌ @@ -23,7 +23,7 @@ import FromNowMixin from '@/components/FromNowMixin' import ShowMediaMixin from '@/components/ShowMediaMixin' import Account from './Account.vue' import Status from './Status.vue' -import { MarkMessage, Notification as INotification } from './Types' +import { MarkStatus, Notification as INotification, PollVote } from './Types' @Component({ components: { Account, Status } }) export default class Notification extends Mixins(ShowMediaMixin, FromNowMixin) { @@ -37,7 +37,12 @@ export default class Notification extends Mixins(ShowMediaMixin, FromNowMixin) { } @Emit('mark') - passMark(action: MarkMessage) { + passMark(action: MarkStatus) { + return action + } + + @Emit('vote') + passVote(action: PollVote) { return action } diff --git a/src/services/mastodon/Status.vue b/src/services/mastodon/Status.vue index affe2db..da07022 100644 --- a/src/services/mastodon/Status.vue +++ b/src/services/mastodon/Status.vue @@ -12,13 +12,23 @@ {{ status.spoiler_text || 'Spoiler' }} {{ status.sensitive ? '→' : '↓' }} div(v-if="!status.spoiler_text || !status.sensitive") .text(v-html="parseEmojis(status.content, status.emojis, showMedia)") + .poll(v-if="status.poll") + .date {{ fromNow(status.poll.expires_at) }} + form.options(@submit.prevent="makeVote(status, $event.target.elements)") + .option(v-for="option in status.poll.options") + input(v-if="!status.poll.expired && !status.poll.voted" :type="status.poll.multiple ? 'checkbox' : 'radio'" :id="status.poll.id + option.title" :value="option.title" :name="status.poll.id") + label(:for="status.poll.id + option.title") + | {{ option.title }} + span.note {{ option.votes_count }} + button(v-if="status.poll.voted") voted + button(v-else-if="status.poll.expired") expired + input(v-else type="submit" value="vote") a.media(v-for="media in status.media_attachments" :href="media.url" target="_blank") template(v-if="showMedia") img(v-if="media.type == 'image' || media.type == 'gifv'" :src="media.preview_url" :alt="media.description" :title="media.description") template(v-else) Wrong type .gif(v-if="media.type == 'gifv'") GIF template(v-else) Hidden media {{ media.description }} - .poll(v-if="status.poll") {{ renderPoll(status.poll) }} a.card(v-if="status.card" :href="status.card.url" target="_blank") a.provider(v-if="status.card.provider_name" :src="status.card.provider_url" target="_blank") {{ status.card.provider_name }} .title {{ status.card.title }} @@ -26,7 +36,7 @@ template(v-if="status.card.image") img(v-if="showMedia" :src="status.card.image") a(v-else-if="status.card.type == 'photo'" :src="status.card.image" target="_blank") Hidden media - status.reblog(v-else :status="status.reblog" :showMedia="showMedia") + status.reblog(v-else :status="status.reblog" :showMedia="showMedia" @mark="passMark" @vote="passVote") .meta(v-if="!status.reblog") a.replies(@click.stop.prevent="makeReply(status)") @@ -54,7 +64,7 @@ import FromNowMixin from '@/components/FromNowMixin' import ShowMediaMixin from '@/components/ShowMediaMixin' import Account from './Account.vue' import { ParseEmojisMixin } from './ParseEmojisMixin' -import { Card, MarkMessage, Poll, Status as IStatus } from './Types' +import { Card, MarkStatus, MarkStatusType, Poll, PollVote, Status as IStatus } from './Types' @Component({ components: { Account } }) export default class Status extends Mixins(ParseEmojisMixin, ShowMediaMixin, FromNowMixin) { @@ -73,31 +83,43 @@ export default class Status extends Mixins(ParseEmojisMixin, ShowMediaMixin, Fro throw status.id // TODO: } - renderPoll(poll: Poll) { - throw poll // TODO: + @Emit('mark') + emitMark(id: number, action: 'reblog' | 'favourite', undo = false): MarkStatus { + return { + id, type: (undo ? 'un' : '') + action as MarkStatusType + } + } + + @Emit('vote') + emitVote(id: number, poll: string, choices: string[]): PollVote { + return { + id, poll, choices + } } @Emit('mark') - emitMark(status: IStatus, action: 'reblog' | 'favourite', callback: CallableFunction, undo = false): MarkMessage { - return { - id: status.id, - type: (undo ? 'un' : '') + action, - callback + passMark(action: MarkStatus) { + return action + } + + @Emit('vote') + passVote(action: PollVote) { + return action + } + + makeVote(status: IStatus, elements: HTMLInputElement[]) { + const choices = Object.values(elements).filter(e => e.checked).map(e => e.value) + if(choices.length > 0) { + this.emitVote(status.id, status.poll!.id, choices) } } makeReblog(status: IStatus) { - this.emitMark(status, 'reblog', () => { - status.reblogs_count += (status.reblogged ? -1 : 1) - status.reblogged = !status.reblogged - }, status.reblogged) + this.emitMark(status.id, 'reblog', status.reblogged) } makeFav(status: IStatus) { - this.emitMark(status, 'favourite', () => { - status.favourites_count += (status.favourited ? -1 : 1) - status.favourited = !status.favourited - }, status.favourited) + this.emitMark(status.id, 'favourite', status.favourited) } } diff --git a/src/services/mastodon/Types.ts b/src/services/mastodon/Types.ts index 9e5f9ab..6a4caae 100644 --- a/src/services/mastodon/Types.ts +++ b/src/services/mastodon/Types.ts @@ -81,6 +81,12 @@ export interface Poll { voted?: boolean } +export interface PollVote { + id: number + poll: string + choices: string[] +} + export interface Media { description: string url: string @@ -96,8 +102,8 @@ export interface Notification { status?: Status } -export interface MarkMessage { +export type MarkStatusType = 'reblog' | 'unreblog' | 'favourite' | 'unfavourite' +export interface MarkStatus { id: number, - type: string, - callback: CallableFunction + type: MarkStatusType } \ No newline at end of file diff --git a/src/services/nextcloud/NextcloudNews.vue b/src/services/nextcloud/NextcloudNews.vue index 2951a46..cb72d3e 100644 --- a/src/services/nextcloud/NextcloudNews.vue +++ b/src/services/nextcloud/NextcloudNews.vue @@ -94,7 +94,7 @@ export default class NextcloudNews extends Mixins( - this.catchEmit(this.rest.get('/items', { params: { batchSize: this.params.buffer, type: 3, getRead: false } })), + this.catchEmit(this.rest.get<{ items: News[] }>('/items', { params: { batchSize: this.params.buffer, type: 3, getRead: false } })), res => res.data.items.map(n => { n.open = false return n