Compare commits

...

11 Commits

Author SHA1 Message Date
May B. 766c54423d Fix: load more 2019-06-11 12:03:25 +02:00
May B. 19fe6973b2 Herit 2019-06-11 11:52:44 +02:00
May B. cd19aa864f Mastodon: layout fix and poll notification 2019-06-04 16:02:42 +02:00
May B. 271dd81d9e Use bus and constants icons 2019-05-31 11:03:25 +02:00
May B. 95fa082b94 Mastodon: Context 2019-05-29 09:02:43 +02:00
May B. 6e74ef10b3 Mastodon: Polls and better marks 2019-05-28 11:51:06 +02:00
May B. cb5c6ccb22 OpenWeather: add description in chart 2019-05-28 09:58:17 +02:00
May B. 240ace2ced Mastodon: emojis 2019-05-28 09:35:50 +02:00
May B. 539b15437c Mastdon: handle timelines (public, local, home) 2019-05-27 16:24:42 +02:00
May B. 35167c283f Mastodon: compose toot 2019-05-27 15:44:49 +02:00
May B. a97ffb491b Mastodon: websocket token and card 2019-05-27 14:31:43 +02:00
25 changed files with 627 additions and 143 deletions

View File

@ -21,12 +21,6 @@
"@vue/cli-plugin-typescript": "^3.0.3",
"@vue/cli-service": "^3.0.3",
"babel-plugin-transform-decorators": "^6.24.1",
"pug": "^2.0.3",
"pug-plain-loader": "^1.0.0",
"sass-loader": "^7.0.1",
"tslint-config-prettier": "^1.15.0",
"tslint-plugin-prettier": "^1.3.0",
"typescript": "^3.0.0",
"core-js": "^2.6.5",
"css-loader": "^0.28.11",
"eslint": "^5.16.0",
@ -37,6 +31,12 @@
"less-loader": "^4.1.0",
"node-sass": "^4.11.0",
"optimize-css-assets-webpack-plugin": "^4.0.0",
"pug": "^2.0.3",
"pug-plain-loader": "^1.0.0",
"sass-loader": "^7.0.1",
"tslint-config-prettier": "^1.15.0",
"tslint-plugin-prettier": "^1.3.0",
"typescript": "^3.0.0",
"url-loader": "^1.0.1",
"vue-loader": "^15.4.2",
"vue-style-loader": "^4.1.2",

View File

@ -10,7 +10,7 @@
<noscript>
<strong>We're sorry but mixit doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<main id="app"></main>
<!-- built files will be auto injected -->
</body>
</html>

View File

@ -263,7 +263,7 @@ a, .osef
text-decoration: none
color: $noneColor
input, select, button
input, select, button, textarea
background-color: $backColor
color: $foreColor
border: 1px solid $halfColor
@ -340,6 +340,7 @@ input, select, button
float: right
width: 1.2em
.service-content
flex-grow: 1
overflow: hidden
.service-loader
display: inline-block

View File

@ -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`
}
}

View File

@ -0,0 +1,14 @@
import Vue from 'vue'
import { Component, Prop } from 'vue-property-decorator'
export interface IEmit {
$emit: (name: string, arg: any) => void
}
@Component
export default class LocalBusMixin<O, B extends O & IEmit> extends Vue {
@Prop(Object)
readonly bus!: B
}

View File

@ -33,7 +33,7 @@ export default class ServiceEmiter extends Vue {
this.emit(Events.SaveServiceEvent, service)
}
catchEmit(req: AxiosPromise) {
catchEmit<T>(req: AxiosPromise<T>) {
return req.catch(err => {
this.emitError(err)
throw err

View File

@ -0,0 +1,29 @@
<template lang="pug">
extends model
block input
input(:id="id" ref="input" type="checkbox" :checked="value" @change.stop="handleChange")
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator'
import BaseSetting from './BaseSetting'
@Component
export default class SettingHeritBoolean extends BaseSetting {
@Prop(Boolean)
readonly value!: boolean | undefined
mounted () {
const input: any = this.$refs.input
input.indeterminate = this.value === undefined
}
handleChange() {
this.sendChange(this.value === false ? undefined : !this.value)
}
}
</script>

View File

@ -0,0 +1,24 @@
<template lang="pug">
extends model
block input
select(:id="id" :value="value" @change="sendChange($event.target.value)")
option(v-for="option in options" :value="option") {{ option }}
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator'
import BaseSetting from './BaseSetting'
@Component
export default class SettingSelect extends BaseSetting {
@Prop(String)
readonly value!: string
@Prop(Array)
readonly options!: string[]
}
</script>

View File

@ -2,12 +2,14 @@ import { Component, Prop, Watch } from 'vue-property-decorator'
import { Auth } from '@/types/App'
import SettingBoolean from '../input/SettingBoolean.vue'
import SettingHeritBoolean from '../input/SettingHeritBoolean.vue'
import SettingInt from '../input/SettingInt.vue'
import SettingSelect from '../input/SettingSelect.vue'
import SettingString from '../input/SettingString.vue'
import ServiceEmiter from '../ServiceEmiter'
import ServiceHeader from '../ServiceHeader.vue'
@Component({ components: { ServiceHeader, SettingString, SettingInt, SettingBoolean } })
@Component({ components: { ServiceHeader, SettingString, SettingInt, SettingBoolean, SettingHeritBoolean, SettingSelect } })
export default class BaseService extends ServiceEmiter {
@Prop({

View File

@ -1,3 +1,5 @@
import Vue from 'vue'
export default abstract class Lists {
static last<T>(list: T[]) {
@ -13,6 +15,18 @@ export default abstract class Lists {
return list.length
}
static setAt<T>(list: T[], id: number, val: T) {
return Vue.set(list, id, val)
}
static setFirst<T>(list: T[], where: (val: T) => boolean, val: T) {
return this.setAt(list, this.getIndex(list, where), val)
}
static setFirstBy<T, U>(list: T[], mapper: (val: T) => U, key: U, val: T) {
return this.setFirst(list, e => mapper(e) === key, val)
}
static removeAt<T>(list: T[], id: number) {
list.splice(id, 1)
}
@ -43,4 +57,12 @@ export default abstract class Lists {
}
}
static sort<T, U>(list: T[], mapper: (val: T) => U, comparer: (a: U, b: U) => number) {
return list.sort((a, b) => comparer(mapper(a), mapper(b)))
}
static stringCompare(a: string, b: string) {
return ('' + a).localeCompare(b)
}
}

View File

@ -9,6 +9,10 @@ export default class ErrorLoadable<T, E> extends Loadable<T> {
this.error = undefined
}
get hasError() {
return this.error !== undefined
}
get isSuccess() {
return this.loaded && this.error === undefined
}

View File

@ -9,6 +9,10 @@ export default class Loadable<T> {
this.reset()
}
get isLoaded() {
return this.loaded
}
reset() {
this.loaded = false
this.data = undefined

View File

@ -8,16 +8,20 @@ a.account(target="_blank" :href="account.url")
<script lang="ts">
import { Component, Mixins, Prop } from 'vue-property-decorator'
import ShowMediaMixin from '@/components/ShowMediaMixin'
import BusMixin from './BusMixin'
import { ParseEmojisMixin } from './ParseEmojisMixin'
import { Account as IAccount } from './Types'
@Component
export default class Account extends Mixins(ParseEmojisMixin, ShowMediaMixin) {
export default class Account extends Mixins(ParseEmojisMixin, BusMixin) {
@Prop(Object)
readonly account!: IAccount
get showMedia() {
return this.bus.showMedia
}
avatarStyle(avatar: string) {
return {
'background-image': `url(${avatar})`

View File

@ -0,0 +1,13 @@
import { Component } from 'vue-property-decorator'
import LocalBusMixin from '@/components/LocalBusMixin'
import { BusOptions } from './Types'
@Component
export default class BusMixin extends LocalBusMixin<BusOptions, any> { }
export const LocalEvents = {
Mark: 'mark',
Vote: 'vote',
Context: 'context'
}

View File

@ -1,41 +1,106 @@
<template lang="pug">
.client(@scroll.passive="onScroll")
.statues
.client
.statues(v-show="!hasContext" @scroll.passive="onScroll")
.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" :bus="bus")
.status(v-show="statues.loadingMore")
.service-loader
.context(v-if="hasContext")
.header(@click="closeContext")
| Context
span.date(@click.stop.prevent="closeContext") {{ icons.close }}
.list
.ancestors
template(v-if="targetContext.isSuccess")
status(v-for="status in targetContext.get().ancestors" :key="status.id" :status="status" :bus="bus")
.service-loader(v-else)
status.selected(:status="targetStatus" :bus="bus")
.descendants
template(v-if="targetContext.isSuccess")
status(v-for="status in targetContext.get().descendants" :key="status.id" :status="status" :bus="bus")
.service-loader(v-else)
.notifications(v-if="hasNotifications")
.header
| Notifications
span.date(@click.stop.prevent="onNotificationsClear")
span.date(@click.stop.prevent="onNotificationsClear") {{ icons.close }}
.list
notification(v-for="notification in notifications.get()" :key="notification.id" :notification="notification"
:showMedia="options.showMedia" @dismiss="onNotificationDismiss" @mark="onStatusMark")
@dismiss="onNotificationDismiss" :bus="bus")
.compose-toggle(@click="showCompose = !showCompose") {{ icons.compose }}
.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")
.options
.emojis
button(v-if="options.showMedia" @click="showEmojis = !showEmojis")
select(v-else @change="addEmoji($event.target.value)")
option(v-for="emoji in emojis.get()" :value="emoji.shortcode") {{ emoji.shortcode }}
.sens
label.note(for="sensitive") Sensitive:&nbsp;
input(id="sensitive" v-model="compose.sensitive" type="checkbox")
.cw
input(v-show="compose.sensitive" v-model="compose.spoiler_text" placeholder="content warning")
.visibility
select(v-model="compose.visibility")
option(v-for="(icon, value) in visibilities" :value="value") {{ icon }}
span.note {{ compose.visibility }}
button(@click="sendStatus") Toot
</template>
<script lang="ts">
import axios, { AxiosResponse } from 'axios'
import { Component, Mixins } from 'vue-property-decorator'
import { Component, Mixins, Prop, Vue, Watch } from 'vue-property-decorator'
import LocalBusMixin from '@/components/LocalBusMixin'
import ServiceClient from '@/components/ServiceClient'
import Lists from '@/helpers/lists/Lists'
import AxiosLodable from '@/helpers/loadable/AxiosLoadable'
import AxiosLodableMore from '@/helpers/loadable/AxiosLoadableMore'
import { AUTH, getRest } from './Mastodon.vue'
import AxiosLoadable from '@/helpers/loadable/AxiosLoadable'
import AxiosLoadableMore from '@/helpers/loadable/AxiosLoadableMore'
import { LocalEvents } from './BusMixin'
import { Icons, Visibility } from './Icons'
import { AUTH, getHeaders, getRest } from './Mastodon.vue'
import Notification from './Notification.vue'
import Status from './Status.vue'
import { MarkMessage, Notification as INotification, Options, Status as IStatus } from './Types'
import { BusOptions, Context, Emoji, MarkStatus, Notification as INotification, Options, Poll, PollVote, Status as IStatus, StatusPost, TimelineType } from './Types'
const STREAMS = {
home: 'user',
local: 'public:local',
public: 'public'
}
@Component({ components: { Status, Notification } })
export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient) {
rest = getRest(this.auth, this.options.timeout)
statues = new AxiosLodableMore<IStatus[], object>()
notifications = new AxiosLodable<INotification[], object>()
statues = new AxiosLoadableMore<IStatus[], object>()
notifications = new AxiosLoadable<INotification[], object>()
emojis = new AxiosLoadable<Emoji[], object>()
stream?: WebSocket = undefined
targetStatus: IStatus | null = null
targetContext = new AxiosLoadable<Context, object>()
showCompose = false
compose: StatusPost = {
status: '',
visibility: 'unlisted',
sensitive: false,
spoiler_text: ''
}
showEmojis = false // MAYBE: show tabs with unicode emoticons
bus = new Vue({ data: {
showMedia: this.options.showMedia,
showCounts: this.options.showCounts
} })
get hasNotifications() {
if(!this.notifications.isSuccess) {
@ -50,22 +115,77 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
}
}
get hasContext() {
return this.targetStatus && !this.targetContext.hasError
}
get visibilities() {
return Visibility
}
get icons() {
return Icons
}
created() {
new Map<string, (arg: any) => void>([
[ LocalEvents.Mark, this.onStatusMark ],
[ LocalEvents.Vote, this.onPollVote ],
[ LocalEvents.Context, this.onHandleContext ]
]).forEach((handler, name) => this.bus.$on(name, handler))
this.notifications.load(this.get<INotification[]>('/notifications'))
this.emojis.load(this.get<Emoji[]>('/custom_emojis'), res => Lists.sort(res.data, e => e.shortcode, Lists.stringCompare))
}
@Watch('options', { deep: true })
change(o: any) {
Object.keys(this.bus.$data).forEach(key => this.bus.$data[key] = o[key])
}
@Watch('options.timeline', { immediate: true })
init() {
this.statues.load(this.getTimeline())
this.notifications.load(this.get('/notifications'))
this.setupStream()
}
get(path: string, options = {}) {
return this.catchEmit(this.rest.get(path, { params: { limit: this.options.buffer, ...options } }))
get<T>(path: string, options = {}) {
return this.catchEmit(this.rest.get<T>(path, { params: { limit: this.options.buffer, ...options } }))
}
post(path: string, options = {}) {
return this.catchEmit(this.rest.post(path, options))
post<T>(path: string, options = {}) {
return this.catchEmit(this.rest.post<T>(path, options))
}
getTimeline(options = {}) {
return this.get('/timelines/home', options)
addEmoji(code: string) {
this.compose.status += `:${code}:`
}
sendStatus() {
if(this.compose.status) {
const post: StatusPost = {
status: this.compose.status,
visibility: this.compose.visibility
}
if(this.compose.sensitive) {
post.sensitive = true
if(this.compose.spoiler_text) {
post.spoiler_text = this.compose.spoiler_text
}
}
if(this.targetStatus) {
post.in_reply_to_id = this.targetStatus.id
}
this.post('/statuses', post)
this.compose.status = ''
}
}
getTimeline(options: any = {}) {
if (this.options.timeline === 'local') {
options.local = true
}
return this.get<IStatus[]>(`/timelines/${this.options.timeline === 'home' ? 'home' : 'public'}`, options)
}
onScroll(event: any) {
@ -80,9 +200,35 @@ export default class Client extends Mixins<ServiceClient<Options>>(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<IStatus>(`/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<Poll>(`/polls/${action.poll}/votes`, { choices: action.choices })
.then(res => this.statues.with(
sts => sts.find(st => st.id === action.id)!.poll = res.data
))
}
onHandleContext(status: IStatus) {
if(this.targetStatus && this.targetStatus.id === status.id) {
this.closeContext()
return
}
this.statues.with(sts => {
this.targetStatus = status
this.targetContext.load(this.get(`/statuses/${status.id}/context`), undefined, true)
})
}
closeContext() {
this.targetStatus = null
this.targetContext.reset()
}
onNotificationDismiss(id: number) {
@ -98,36 +244,43 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
}
setupStream() {
const ws = new WebSocket(
`wss://${this.auth.get(AUTH.SERVER)}/api/v1/streaming?access_token=${this.auth.get(AUTH.TOKEN)}&stream=user`
)
ws.onmessage = event => {
const data = JSON.parse(event.data)
const payload = JSON.parse(data.payload)
switch (data.event) {
case 'update':
this.statues.with(s => s.unshift(payload))
break
case 'notification':
this.notifications.with(n => n.unshift(payload))
break
case 'delete':
this.statues.with(st => Lists.removeFirstBy(st, s => s.id, payload.id))
break
}
if(this.stream) {
this.stream.close()
}
ws.onerror = ev => this.emitError(ev.type)
ws.onclose = () => {
this.emitError(
'Mastodon stream disconnected !' +
(this.options.reconnect ? ' Reconnecting...' : '')
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]}`,
this.auth.get(AUTH.TOKEN)
)
if (this.options.reconnect) {
setTimeout(() => this.setupStream(), this.options.timeout)
this.stream.onmessage = event => {
const data = JSON.parse(event.data)
const payload = JSON.parse(data.payload)
switch (data.event) {
case 'update':
this.statues.with(s => s.unshift(payload))
break
case 'notification':
this.notifications.with(n => n.unshift(payload))
break
case 'delete':
this.statues.with(st => Lists.removeFirstBy(st, s => s.id, payload.id))
break
}
}
}
this.stream.onerror = ev => this.emitError(ev.type)
this.stream.onclose = () => {
this.emitError(
'Mastodon stream disconnected !' +
(this.options.reconnect ? ' Reconnecting...' : '')
)
if (this.options.reconnect) {
setTimeout(() => this.setupStream(), this.options.timeout)
}
}
})
}
}
</script>
@ -139,16 +292,51 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
.mastodon
.client
display: flex
flex-direction: column
height: 100%
overflow-y: auto
.header
overflow: hidden
position: relative
.header, .emoji-list
@include main-tile
.header
margin-bottom: 0
.list
@include group-tile
.statues
flex: 1
.notifications
max-width: 33%
flex-grow: 1
.statues, .notifications, .context, .emoji-list
flex-grow: 1
display: flex
flex-direction: column
overflow-y: scroll
height: 100%
.ancestors, .descendants
.status
font-size: .9em
padding: $borderRadius
@include tile
.compose-toggle
position: absolute
bottom: .5em
right: 1.5em
background-color: $backColor
border: 1px solid $darkColor
border-radius: 100%
height: 2em
width: 2em
text-align: center
line-height: 2em
.emoji-list
img
width: 2em
height: 2em
.compose
@include main-tile
display: flex
min-height: 5em
textarea
flex: 1
.options
margin-right: 1em
.account
.name
@ -191,9 +379,17 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
background-color: #00000044
color: white
padding: .5em
.card, .poll
@include tile
padding: .2em
display: block
.provider
float: right
.meta
margin-left: 1em + $avatarSize
font-size: .8em
.fil
float: right
a
margin: 0 .5em

View File

@ -0,0 +1,26 @@
export const Visibility = {
public: '◍',
unlisted: '👁',
private: '⚿',
direct: '✉'
}
export const Notification = {
mention: '✉',
reblog: '⟳',
favourite: '⚝',
follow: '👁',
poll: '✓'
}
export const Icons = {
compose: '🖉',
close: '❌',
}
export const Context = {
full: '⮃',
up: '⮥',
down: '⮦',
no: '⭿'
}

View File

@ -5,13 +5,15 @@
| {{ serviceName }}:
loadable-inline(:loadable="account")
template(#success)
span(v-html="parseEmojis(account.data.display_name, account.data.emojis) + '@' + server", params.showMedia)
span(v-html="parseEmojis(account.data.display_name, account.data.emojis, params.showMedia) + '@' + server")
template(#settings)
setting-boolean(:id="'reconnect'" :title="'Reconnect'" :value="params.reconnect" @change="saveOptionCouple")
setting-boolean(:id="'reblog'" :title="'Show reblogs'" :value="params.reblog" @change="saveOptionCouple")
setting-boolean(:id="'reply'" :title="'Show replies'" :value="params.reply" @change="saveOptionCouple")
setting-int(:id="'buffer'" :title="'Buffer size'" :value="params.buffer" @change="saveOptionCouple")
setting-boolean(:id="'showMedia'" :title="'Show medias'" :value="params.showMedia" @change="saveOptionCouple")
setting-boolean(:id="'showCounts'" :title="'Show counts'" :value="params.showCounts" @change="saveOptionCouple")
setting-select(:id="'timeline'" :title="'Timeline'" :value="params.timeline" @change="saveOptionCouple" :options="['home', 'local', 'public']")
loadable-block.service-content(:loadable="account")
template(#success)
client(:auth="auth" :options="params" :emit="emit")
@ -38,10 +40,13 @@ import { ParseEmojisMixin } from './ParseEmojisMixin'
import { Account, Options } from './Types'
export const AUTH = { SERVER: 'server', TOKEN: 'token' }
export function getHeaders(auth: Auth) {
return { headers: { Authorization: 'Bearer ' + auth.get(AUTH.TOKEN) } }
}
export function getRest(auth: Auth, timeout: number) {
return axios.create({
baseURL: `https://${auth.get(AUTH.SERVER)}/api/v1/`, timeout,
headers: { Authorization: 'Bearer ' + auth.get(AUTH.TOKEN) },
...getHeaders(auth)
})
}
@ -54,7 +59,7 @@ export default class Mastodon extends Mixins<AccountService<Account, object>>(Ac
get params(): Options {
return { timeout: 5000, reconnect: false, buffer: 20, reblog: true, reply: false,
showMedia: true, ...this.options }
showMedia: true, showCounts: true, timeline: 'home', ...this.options }
}
get isSetup() {

View File

@ -1,45 +1,48 @@
<template lang="pug">
.notification
account(:account="notification.account" :showMedia="showMedia")
span.colored.text-icon.letter(v-if="notification.type == 'mention'")
span.colored.text-icon.letter(v-if="notification.type == 'reblog'")
span.colored.text-icon.letter(v-if="notification.type == 'favourite'")
account(:account="notification.account" :bus="bus")
a.date
| {{ fromNow(notification.created_at) }}
span(@click.stop.prevent="makeDismiss") {{ closeIcon }}
span.date {{ fromNow(notification.created_at) }}
span.colored.text-icon.letter {{ notificationTypeIcon }}
.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")
:withAccount="notification.type != 'mention'" :bus="bus")
a.date(@click.stop.prevent="makeDismiss" style="margin-top: -1em")
</template>
<script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator'
import FromNowMixin from '@/components/FromNowMixin'
import ShowMediaMixin from '@/components/ShowMediaMixin'
import Account from './Account.vue'
import BusMixin from './BusMixin'
import { Icons, Notification as NotificationIcons } from './Icons'
import Status from './Status.vue'
import { MarkMessage, Notification as INotification } from './Types'
import { MarkStatus, Notification as INotification, PollVote, Status as IStatus } from './Types'
@Component({ components: { Account, Status } })
export default class Notification extends Mixins(ShowMediaMixin, FromNowMixin) {
export default class Notification extends Mixins(FromNowMixin, BusMixin) {
@Prop(Object)
readonly notification!: INotification
get notificationTypeIcon() {
return NotificationIcons[this.notification.type] || '?'
}
get closeIcon() {
return Icons.close
}
@Emit('dismiss')
makeDismiss() {
return this.notification.id
}
@Emit('mark')
passMark(action: MarkMessage) {
return action
}
}
</script>

View File

@ -6,10 +6,12 @@ import { Emoji } from './Types'
export class ParseEmojisMixin extends Vue {
parseEmojis(text: string, emojis: Emoji[], show = true) {
for (const emoji of emojis) {
text = text.split(`:${emoji.shortcode}:`).join(
show ? `<img draggable="false" class="icon" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.static_url}">` : emoji.shortcode
)
if (show) {
for (const emoji of emojis) {
text = text.split(`:${emoji.shortcode}:`).join(
`<img draggable="false" class="icon" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.static_url}">`
)
}
}
return text
}

View File

@ -1,8 +1,8 @@
<template lang="pug">
.status
account(v-if="withAccount" :account="status.account" :showMedia="showMedia")
account(v-if="withAccount" :account="status.account" :bus="bus")
span.text-icon.letter(v-if="status.reblog")
span.text-icon.letter(v-if="status.reblog") {{ reblogIcon }}
a.date(target="_blank" :href="status.uri") {{ fromNow(status.created_at) }}
@ -12,39 +12,56 @@
{{ status.spoiler_text || 'Spoiler' }} {{ status.sensitive ? '&rarr;' : '&darr;' }}
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($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")
template(v-if="bus.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
status.reblog(v-else :status="status.reblog" :showMedia="showMedia")
template(v-else) Hidden media {{ media.description }}
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 }}
.descr {{ status.card.description }}
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" :bus="bus")
.meta(v-if="!status.reblog")
a.replies(@click.stop.prevent="makeReply(status)")
span.text-icon
| {{ status.replies_count }}
a.reblogs(:class="{ colored: status.reblogged }" @click.stop.prevent="makeReblog(status)")
span.text-icon
| {{ status.reblogs_count }}
a.favourites(:class="{ colored: status.favourited }" @click.stop.prevent="makeFav(status)")
span.text-icon
| {{ status.favourites_count }}
a.fil(v-if="status.in_reply_to_id" @click.stop.prevent="showReply(status.in_reply_to_id)")
| Voir le fil
status-meta(v-for="meta in metas" :key="meta.name" :meta="meta" :bus="bus")
a {{ statusVisibilityIcon }}
a.fil(@click.stop.prevent="showContext")
| {{ contextIcon }}
</template>
<script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator'
import FromNowMixin from '@/components/FromNowMixin'
import ShowMediaMixin from '@/components/ShowMediaMixin'
import Account from './Account.vue'
import BusMixin, { LocalEvents } from './BusMixin'
import { Context, Notification, Visibility } from './Icons'
import { ParseEmojisMixin } from './ParseEmojisMixin'
import { MarkMessage, Status as IStatus } from './Types'
import StatusMeta from './StatusMeta.vue'
import { Card, MarkStatus, MarkStatusType, Poll, PollVote, Status as IStatus } from './Types'
@Component({ components: { Account } })
export default class Status extends Mixins(ParseEmojisMixin, ShowMediaMixin, FromNowMixin) {
@Component({ components: { Account, StatusMeta } })
export default class Status extends Mixins(ParseEmojisMixin, FromNowMixin, BusMixin) {
@Prop(Object)
readonly status!: IStatus
@ -52,35 +69,61 @@ export default class Status extends Mixins(ParseEmojisMixin, ShowMediaMixin, Fro
@Prop({ type: Boolean, default: true })
readonly withAccount!: boolean
showReply(statusId: number) {
throw statusId // TODO:
get statusVisibilityIcon() {
return Visibility[this.status.visibility] || '?'
}
makeReply(status: IStatus) {
throw status.id // TODO:
get reblogIcon() {
return Notification.reblog
}
@Emit('mark')
emitMark(status: IStatus, action: 'reblog' | 'favourite', callback: CallableFunction, undo = false): MarkMessage {
return {
id: status.id,
type: (undo ? 'un' : '') + action,
callback
get contextIcon() {
if(this.status.in_reply_to_id) {
return Context[this.status.replies_count ? 'full' : 'up']
} else {
return Context[this.status.replies_count ? 'down' : 'no']
}
}
makeReblog(status: IStatus) {
this.emitMark(status, 'reblog', () => {
status.reblogs_count += (status.reblogged ? -1 : 1)
status.reblogged = !status.reblogged
}, status.reblogged)
get showMedia() {
return this.bus.$data.showMedia
}
makeFav(status: IStatus) {
this.emitMark(status, 'favourite', () => {
status.favourites_count += (status.favourited ? -1 : 1)
status.favourited = !status.favourited
}, status.favourited)
get metas() {
return [
{ name: 'reply', click: this.makeReply, active: false, icon: Notification.mention, count: this.status.replies_count },
{ name: 'reblog', click: this.makeReblog, active: this.status.reblogged, icon: Notification.reblog, count: this.status.reblogs_count },
{ name: 'fav', click: this.makeFav, active: this.status.favourited, icon: Notification.favourite, count: this.status.favourites_count }
]
}
makeVote(elements: HTMLInputElement[]) {
const choices = Object.values(elements).filter(e => e.checked).map(e => e.value)
if(choices.length > 0) {
this.bus.$emit(LocalEvents.Vote, { id: this.status.id, poll: this.status.poll!.id, choices })
}
}
makeMark(action: string, undo: boolean) {
this.bus.$emit(LocalEvents.Mark, {
id: this.status.id, type: (undo ? 'un' : '') + action as MarkStatusType
})
}
makeReblog() {
this.makeMark('reblog', this.status.reblogged)
}
makeFav() {
this.makeMark('favourite', this.status.favourited)
}
showContext() {
this.bus.$emit(LocalEvents.Context, this.status)
}
makeReply() {
throw this // TODO:
}
}

View File

@ -0,0 +1,23 @@
<template lang="pug">
a(:class="{ colored: meta.active }" @click.stop.prevent="meta.click")
span.text-icon {{ meta.icon }}
template(v-if="showCounts") {{ meta.count }}
</template>
<script lang="ts">
import { Component, Mixins, Prop } from 'vue-property-decorator'
import BusMixin from './BusMixin'
@Component
export default class StatusMeta extends Mixins(BusMixin) {
@Prop(Object)
readonly meta!: object
get showCounts() {
return this.bus.$data.showCounts
}
}
</script>

View File

@ -9,13 +9,31 @@ export interface Account {
emojis: Emoji[]
}
export interface Options {
export type TimelineType = 'home' | 'local' | 'public'
export interface BusOptions {
showMedia: boolean
showCounts: boolean
}
export interface Options extends BusOptions {
timeout: number
reconnect: boolean
buffer: number
reblog: boolean
reply: boolean
showMedia: boolean
timeline: TimelineType
}
export type VisibilityType = 'public' | 'unlisted' | 'private' | 'direct'
export interface StatusPost {
status: string
in_reply_to_id?: number
media_ids?: number[] // TODO:
sensitive?: boolean
spoiler_text?: string
visibility: VisibilityType
}
export interface Status {
@ -35,6 +53,42 @@ export interface Status {
in_reply_to_id?: number
reblog?: Status
spoiler_text?: string
card?: Card
poll?: Poll
visibility: VisibilityType
}
export type CardType = 'link' | 'photo' | 'video' | 'rich'
export interface Card {
url: string
title: string
description: string
image?: string
type: CardType
author_name?: string
author_url?: string
provider_name?: string
provider_url?: string
}
export interface PollOption {
title: string
votes_count?: number
}
export interface Poll {
id: string
expires_at?: string
expired: boolean
multiple: boolean
votes_count: number
options: PollOption[]
voted?: boolean
}
export interface PollVote {
id: number
poll: string
choices: string[]
}
export interface Media {
@ -44,16 +98,22 @@ export interface Media {
type: string
}
export interface Context {
ancestors: Status[]
descendants: Status[]
}
export type NotificationType = 'follow' | 'mention' | 'reblog' | 'favourite' | 'poll'
export interface Notification {
id: number
account: Account
type: string
type: NotificationType
created_at: string
status?: Status
}
export interface MarkMessage {
export type MarkStatusType = 'reblog' | 'unreblog' | 'favourite' | 'unfavourite'
export interface MarkStatus {
id: number,
type: string,
callback: CallableFunction
type: MarkStatusType
}

View File

@ -94,7 +94,7 @@ export default class NextcloudNews extends Mixins<ConnectedService<object, objec
loadData() {
this.news.load<{ items: News[] }>(
this.catchEmit(this.rest.get<News[]>('/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

View File

@ -53,10 +53,11 @@ export default {
},
tooltips: {
intersect: false,
mode: 'index',
callbacks: {
title(tooltipItem: any, myData: any) {
const item = myData.datasets[tooltipItem[0].datasetIndex].data[tooltipItem[0].index]
return moment(item.x || item.t).format('HH[h]')
title(tooltipItems: any, myData: any) {
const item = myData.datasets[tooltipItems[0].datasetIndex].data[tooltipItems[0].index]
return moment(item.x || item.t).format('HH[h]') + (item.d ? ` - ${item.d}` : '')
},
label(tooltipItem: any, myData: any) {
let label = myData.datasets[tooltipItem.datasetIndex].label || ''

View File

@ -44,6 +44,9 @@ interface Forecast {
rain?: {
'3h': number
}
weather: Array<{
description: string
}>,
}
const AUTH = { TOKEN: 'token' }
@ -83,9 +86,9 @@ export default class OpenWeatherMap extends ConnectedService<object, object> {
borderWidth: 1,
fill: false,
data: fs.map(line => ({
x: line.dt * 1000, y: line.main.temp
x: line.dt * 1000, y: line.main.temp, d: line.weather[0].description
}))
},{
}, {
type: 'bar',
label: 'Percipitation',
yAxisID: 'y-axis-rain',