diff options
Diffstat (limited to 'src/client/app')
158 files changed, 3508 insertions, 1780 deletions
diff --git a/src/client/app/app.styl b/src/client/app/app.styl index 431b9daa65..3911f83a61 100644 --- a/src/client/app/app.styl +++ b/src/client/app/app.styl @@ -6,6 +6,10 @@ html &, * cursor progress !important +html + // iOSのため + overflow auto + body overflow-wrap break-word diff --git a/src/client/app/auth/views/index.vue b/src/client/app/auth/views/index.vue index 609e758994..ba7df911e5 100644 --- a/src/client/app/auth/views/index.vue +++ b/src/client/app/auth/views/index.vue @@ -80,7 +80,7 @@ export default Vue.extend({ accepted() { this.state = 'accepted'; if (this.session.app.callbackUrl) { - location.href = this.session.app.callbackUrl + '?token=' + this.session.token; + location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; } } } diff --git a/src/client/app/boot.js b/src/client/app/boot.js index 54397c98c6..25aa26dd19 100644 --- a/src/client/app/boot.js +++ b/src/client/app/boot.js @@ -18,6 +18,8 @@ return; } + const langs = LANGS; + //#region Load settings let settings = null; const vuex = localStorage.getItem('vuex'); @@ -40,10 +42,10 @@ //#region Detect the user language let lang = null; - if (LANGS.includes(navigator.language)) { + if (langs.includes(navigator.language)) { lang = navigator.language; } else { - lang = LANGS.find(x => x.split('-')[0] == navigator.language); + lang = langs.find(x => x.split('-')[0] == navigator.language); if (lang == null) { // Fallback @@ -52,7 +54,7 @@ } if (settings && settings.device.lang && - LANGS.includes(settings.device.lang)) { + langs.includes(settings.device.lang)) { lang = settings.device.lang; } //#endregion @@ -94,7 +96,7 @@ // Get salt query const salt = localStorage.getItem('salt') - ? '?salt=' + localStorage.getItem('salt') + ? `?salt=${localStorage.getItem('salt')}` : ''; // Load an app script @@ -140,7 +142,7 @@ // Random localStorage.setItem('salt', Math.random().toString()); - // Clear cache (serive worker) + // Clear cache (service worker) try { navigator.serviceWorker.controller.postMessage('clear'); diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts index 4445eefc39..91b165b45d 100644 --- a/src/client/app/common/scripts/check-for-update.ts +++ b/src/client/app/common/scripts/check-for-update.ts @@ -9,7 +9,7 @@ export default async function(mios: MiOS, force = false, silent = false) { localStorage.setItem('should-refresh', 'true'); localStorage.setItem('v', newer); - // Clear cache (serive worker) + // Clear cache (service worker) try { if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage('clear'); diff --git a/src/client/app/common/scripts/gcd.ts b/src/client/app/common/scripts/gcd.ts deleted file mode 100644 index 9a19f9da66..0000000000 --- a/src/client/app/common/scripts/gcd.ts +++ /dev/null @@ -1,2 +0,0 @@ -const gcd = (a, b) => !b ? a : gcd(b, a % b); -export default gcd; diff --git a/src/client/app/common/scripts/parse-search-query.ts b/src/client/app/common/scripts/parse-search-query.ts deleted file mode 100644 index 5f6ae3320a..0000000000 --- a/src/client/app/common/scripts/parse-search-query.ts +++ /dev/null @@ -1,53 +0,0 @@ -export default function(qs: string) { - const q = { - text: '' - }; - - qs.split(' ').forEach(x => { - if (/^([a-z_]+?):(.+?)$/.test(x)) { - const [key, value] = x.split(':'); - switch (key) { - case 'user': - q['includeUserUsernames'] = value.split(','); - break; - case 'exclude_user': - q['excludeUserUsernames'] = value.split(','); - break; - case 'follow': - q['following'] = value == 'null' ? null : value == 'true'; - break; - case 'reply': - q['reply'] = value == 'null' ? null : value == 'true'; - break; - case 'renote': - q['renote'] = value == 'null' ? null : value == 'true'; - break; - case 'media': - q['media'] = value == 'null' ? null : value == 'true'; - break; - case 'poll': - q['poll'] = value == 'null' ? null : value == 'true'; - break; - case 'until': - case 'since': - // YYYY-MM-DD - if (/^[0-9]+\-[0-9]+\-[0-9]+$/) { - const [yyyy, mm, dd] = value.split('-'); - q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime(); - } - break; - default: - q[key] = value; - break; - } - } else { - q.text += x + ' '; - } - }); - - if (q.text) { - q.text = q.text.trim(); - } - - return q; -} diff --git a/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts b/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts index e6b02fcfdb..adfa75ff3b 100644 --- a/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts +++ b/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts @@ -3,8 +3,10 @@ import MiOS from '../../../../../mios'; export class ReversiGameStream extends Stream { constructor(os: MiOS, me, game) { - super(os, 'games/reversi-game', { - i: me ? me.token : null, + super(os, 'games/reversi-game', me ? { + i: me.token, + game: game.id + } : { game: game.id }); } diff --git a/src/client/app/common/scripts/streaming/hashtag.ts b/src/client/app/common/scripts/streaming/hashtag.ts new file mode 100644 index 0000000000..276b8f8d3d --- /dev/null +++ b/src/client/app/common/scripts/streaming/hashtag.ts @@ -0,0 +1,13 @@ +import Stream from './stream'; +import MiOS from '../../../mios'; + +export class HashtagStream extends Stream { + constructor(os: MiOS, me, q) { + super(os, 'hashtag', me ? { + i: me.token, + q: JSON.stringify(q) + } : { + q: JSON.stringify(q) + }); + } +} diff --git a/src/client/app/common/scripts/streaming/local-timeline.ts b/src/client/app/common/scripts/streaming/local-timeline.ts index 2834262bdc..41c36aa14c 100644 --- a/src/client/app/common/scripts/streaming/local-timeline.ts +++ b/src/client/app/common/scripts/streaming/local-timeline.ts @@ -7,9 +7,9 @@ import MiOS from '../../../mios'; */ export class LocalTimelineStream extends Stream { constructor(os: MiOS, me) { - super(os, 'local-timeline', { + super(os, 'local-timeline', me ? { i: me.token - }); + } : {}); } } diff --git a/src/client/app/common/scripts/streaming/stream-manager.ts b/src/client/app/common/scripts/streaming/stream-manager.ts index 568b8b0372..8dd06f67d3 100644 --- a/src/client/app/common/scripts/streaming/stream-manager.ts +++ b/src/client/app/common/scripts/streaming/stream-manager.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'eventemitter3'; import * as uuid from 'uuid'; import Connection from './stream'; +import { erase } from '../../../../../prelude/array'; /** * ストリーム接続を管理するクラス @@ -89,7 +90,7 @@ export default abstract class StreamManager<T extends Connection> extends EventE * @param userId use で発行したユーザーID */ public dispose(userId) { - this.users = this.users.filter(id => id != userId); + this.users = erase(userId, this.users); this._connection.user = `Managed (${ this.users.length })`; diff --git a/src/client/app/common/scripts/streaming/stream.ts b/src/client/app/common/scripts/streaming/stream.ts index fefa8e5ced..4ab78f1190 100644 --- a/src/client/app/common/scripts/streaming/stream.ts +++ b/src/client/app/common/scripts/streaming/stream.ts @@ -44,11 +44,11 @@ export default class Connection extends EventEmitter { const query = params ? Object.keys(params) - .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&') : null; - this.socket = new ReconnectingWebsocket(`${wsUrl}/${endpoint}${query ? '?' + query : ''}`); + this.socket = new ReconnectingWebsocket(`${wsUrl}/${endpoint}${query ? `?${query}` : ''}`); this.socket.addEventListener('open', this.onOpen); this.socket.addEventListener('close', this.onClose); this.socket.addEventListener('message', this.onMessage); diff --git a/src/client/app/common/views/components/acct.vue b/src/client/app/common/views/components/acct.vue index 1ad222afdd..542fbb4296 100644 --- a/src/client/app/common/views/components/acct.vue +++ b/src/client/app/common/views/components/acct.vue @@ -1,19 +1,25 @@ <template> <span class="mk-acct"> <span class="name">@{{ user.username }}</span> - <span class="host" v-if="user.host">@{{ user.host }}</span> + <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span> </span> </template> <script lang="ts"> import Vue from 'vue'; +import { host } from '../../../config'; export default Vue.extend({ - props: ['user'] + props: ['user', 'detail'], + data() { + return { + host + }; + } }); </script> <style lang="stylus" scoped> .mk-acct - > .host + > .host.fade opacity 0.5 </style> diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue index b274eaa0a0..ea05afd6dc 100644 --- a/src/client/app/common/views/components/autocomplete.vue +++ b/src/client/app/common/views/components/autocomplete.vue @@ -125,7 +125,7 @@ export default Vue.extend({ } if (this.type == 'user') { - const cacheKey = 'autocomplete:user:' + this.q; + const cacheKey = `autocomplete:user:${this.q}`; const cache = sessionStorage.getItem(cacheKey); if (cache) { const users = JSON.parse(cache); @@ -148,7 +148,7 @@ export default Vue.extend({ this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]'); this.fetching = false; } else { - const cacheKey = 'autocomplete:hashtag:' + this.q; + const cacheKey = `autocomplete:hashtag:${this.q}`; const cache = sessionStorage.getItem(cacheKey); if (cache) { const hashtags = JSON.parse(cache); diff --git a/src/client/app/common/views/components/avatar.vue b/src/client/app/common/views/components/avatar.vue index c5ac74e537..a2b0fc6bd3 100644 --- a/src/client/app/common/views/components/avatar.vue +++ b/src/client/app/common/views/components/avatar.vue @@ -1,15 +1,15 @@ <template> - <span class="mk-avatar" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick"> - <span class="inner" :style="style"></span> + <span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick"> + <span class="inner" :style="icon"></span> </span> - <span class="mk-avatar" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick"> - <span class="inner" :style="style"></span> + <span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick"> + <span class="inner" :style="icon"></span> </span> - <router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id"> - <span class="inner" :style="style"></span> + <router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id"> + <span class="inner" :style="icon"></span> </router-link> - <router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview"> - <span class="inner" :style="style"></span> + <router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview"> + <span class="inner" :style="icon"></span> </router-link> </template> @@ -43,6 +43,11 @@ export default Vue.extend({ }, style(): any { return { + borderRadius: this.$store.state.settings.circleIcons ? '100%' : null + }; + }, + icon(): any { + return { backgroundColor: this.lightmode ? `rgb(${this.user.avatarColor.slice(0, 3).join(',')})` : this.user.avatarColor && this.user.avatarColor.length == 3 diff --git a/src/client/app/common/views/components/connect-failed.troubleshooter.vue b/src/client/app/common/views/components/connect-failed.troubleshooter.vue index 6c23cc7969..f64cae6b4b 100644 --- a/src/client/app/common/views/components/connect-failed.troubleshooter.vue +++ b/src/client/app/common/views/components/connect-failed.troubleshooter.vue @@ -57,7 +57,7 @@ export default Vue.extend({ } // Check internet connection - fetch('https://google.com?rand=' + Math.random(), { + fetch(`https://google.com?rand=${Math.random()}`, { mode: 'no-cors' }).then(() => { this.internet = true; diff --git a/src/client/app/common/views/components/cw-button.vue b/src/client/app/common/views/components/cw-button.vue new file mode 100644 index 0000000000..06087edc93 --- /dev/null +++ b/src/client/app/common/views/components/cw-button.vue @@ -0,0 +1,44 @@ +<template> +<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle">{{ value ? '%i18n:@hide%' : '%i18n:@show%' }}</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + type: Boolean, + required: true + } + }, + + methods: { + toggle() { + this.$emit('input', !this.value); + } + } +}); +</script> + +<style lang="stylus" scoped> +root(isDark) + display inline-block + padding 4px 8px + font-size 0.7em + color isDark ? #393f4f : #fff + background isDark ? #687390 : #b1b9c1 + border-radius 2px + cursor pointer + user-select none + + &:hover + background isDark ? #707b97 : #bbc4ce + +.nrvgflfuaxwgkxoynpnumyookecqrrvh[data-darkmode] + root(true) + +.nrvgflfuaxwgkxoynpnumyookecqrrvh:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/common/views/components/games/reversi/reversi.game.vue b/src/client/app/common/views/components/games/reversi/reversi.game.vue index b432a2308d..fea19d917e 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.game.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.game.vue @@ -50,15 +50,15 @@ </div> <div class="player" v-if="game.isEnded"> - <el-button-group> - <el-button type="primary" @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</el-button> - <el-button type="primary" @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</el-button> - </el-button-group> + <div> + <button @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</button> + <button @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</button> + </div> <span>{{ logPos }} / {{ logs.length }}</span> - <el-button-group> - <el-button type="primary" @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</el-button> - <el-button type="primary" @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</el-button> - </el-button-group> + <div> + <button @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</button> + <button @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</button> + </div> </div> <div class="info"> @@ -159,11 +159,9 @@ export default Vue.extend({ canPutEverywhere: this.game.settings.canPutEverywhere, loopedBoard: this.game.settings.loopedBoard }); - this.logs.forEach((log, i) => { - if (i < v) { - this.o.put(log.color, log.pos); - } - }); + for (const log of this.logs.slice(0, v)) { + this.o.put(log.color, log.pos); + } this.$forceUpdate(); } }, diff --git a/src/client/app/common/views/components/games/reversi/reversi.index.vue b/src/client/app/common/views/components/games/reversi/reversi.index.vue index fa88aeaaf4..d23902aae7 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.index.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.index.vue @@ -3,7 +3,6 @@ <h1>%i18n:@title%</h1> <p>%i18n:@sub-title%</p> <div class="play"> - <!--<el-button round>フリーマッチ(準備中)</el-button>--> <form-button primary round @click="match">%i18n:@invite%</form-button> <details> <summary>%i18n:@rule%</summary> diff --git a/src/client/app/common/views/components/games/reversi/reversi.room.vue b/src/client/app/common/views/components/games/reversi/reversi.room.vue index aed8718dd0..fef833d63e 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.room.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.room.vue @@ -59,11 +59,6 @@ </header> <div> - <el-alert v-for="message in messages" - :title="message.text" - :type="message.type" - :key="message.id"/> - <template v-for="item in form"> <mk-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm(item)">{{ item.desc || '' }}</mk-switch> @@ -93,7 +88,7 @@ </header> <div> - <el-input v-model="item.value" @change="onChangeForm(item)"/> + <input v-model="item.value" @change="onChangeForm(item)"/> </div> </div> </template> diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index 422a3da050..6f8152cea2 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -1,5 +1,8 @@ import Vue from 'vue'; +import cwButton from './cw-button.vue'; +import tagCloud from './tag-cloud.vue'; +import trends from './trends.vue'; import analogClock from './analog-clock.vue'; import menu from './menu.vue'; import noteHeader from './note-header.vue'; @@ -40,6 +43,9 @@ import uiSelect from './ui/select.vue'; import formButton from './ui/form/button.vue'; import formRadio from './ui/form/radio.vue'; +Vue.component('mk-cw-button', cwButton); +Vue.component('mk-tag-cloud', tagCloud); +Vue.component('mk-trends', trends); Vue.component('mk-analog-clock', analogClock); Vue.component('mk-menu', menu); Vue.component('mk-note-header', noteHeader); diff --git a/src/client/app/common/views/components/media-banner.vue b/src/client/app/common/views/components/media-banner.vue new file mode 100644 index 0000000000..211dbf0208 --- /dev/null +++ b/src/client/app/common/views/components/media-banner.vue @@ -0,0 +1,90 @@ +<template> +<div class="mk-media-banner"> + <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false"> + <span class="icon">%fa:exclamation-triangle%</span> + <b>%i18n:@sensitive%</b> + <span>%i18n:@click-to-show%</span> + </div> + <div class="audio" v-else-if="media.type.startsWith('audio')"> + <audio class="audio" + :src="media.url" + :title="media.name" + controls + ref="audio" + preload="metadata" /> + </div> + <a class="download" v-else + :href="media.url" + :title="media.name" + :download="media.name" + > + <span class="icon">%fa:download%</span> + <b>{{ media.name }}</b> + </a> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + media: { + type: Object, + required: true + } + }, + data() { + return { + hide: true + }; + } +}) +</script> + +<style lang="stylus" scoped> +root(isDark) + width 100% + border-radius 4px + margin-top 4px + overflow hidden + + > .download, + > .sensitive + display flex + align-items center + font-size 12px + padding 8px 12px + white-space nowrap + + > * + display block + + > b + overflow hidden + text-overflow ellipsis + + > *:not(:last-child) + margin-right .2em + + > .icon + font-size 1.6em + + > .download + background isDark ? #21242d : #f7f7f7 + + > .sensitive + background #111 + color #fff + + > .audio + .audio + display block + width 100% + +.mk-media-banner[data-darkmode] + root(true) + +.mk-media-banner:not([data-darkmode]) + root(false) +</style> diff --git a/src/client/app/common/views/components/media-list.vue b/src/client/app/common/views/components/media-list.vue index cdfc2c8d3c..d83d6f85cd 100644 --- a/src/client/app/common/views/components/media-list.vue +++ b/src/client/app/common/views/components/media-list.vue @@ -1,18 +1,27 @@ <template> <div class="mk-media-list"> - <div :data-count="mediaList.length" ref="grid"> - <template v-for="media in mediaList"> - <mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')" :inline-playable="mediaList.length === 1"/> - <mk-media-image :image="media" :key="media.id" v-else :raw="raw"/> - </template> + <template v-for="media in mediaList.filter(media => !previewable(media))"> + <x-banner :media="media" :key="media.id"/> + </template> + <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> + <div :data-count="mediaList.filter(media => previewable(media)).length" ref="grid"> + <template v-for="media in mediaList"> + <mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> + <mk-media-image :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> + </template> + </div> </div> </div> </template> <script lang="ts"> import Vue from 'vue'; +import XBanner from './media-banner.vue'; export default Vue.extend({ + components: { + XBanner + }, props: { mediaList: { required: true @@ -22,70 +31,80 @@ export default Vue.extend({ } }, mounted() { - // for Safari bug - this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` : '128px'; + //#region for Safari bug + if (this.$refs.grid) { + this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` : '128px'; + } + //#endregion + }, + methods: { + previewable(file) { + return file.type.startsWith('video') || file.type.startsWith('image'); + } } }); </script> <style lang="stylus" scoped> .mk-media-list - width 100% + > .gird-container + width 100% + margin-top 4px - &:before - content '' - display block - padding-top 56.25% // 16:9 + &:before + content '' + display block + padding-top 56.25% // 16:9 - > div - position absolute - top 0 - right 0 - bottom 0 - left 0 - display grid - grid-gap 4px + > div + position absolute + top 0 + right 0 + bottom 0 + left 0 + display grid + grid-gap 4px - > * - overflow hidden - border-radius 4px + > * + overflow hidden + border-radius 4px - &[data-count="1"] - grid-template-rows 1fr + &[data-count="1"] + grid-template-rows 1fr - &[data-count="2"] - grid-template-columns 1fr 1fr - grid-template-rows 1fr + &[data-count="2"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr - &[data-count="3"] - grid-template-columns 1fr 0.5fr - grid-template-rows 1fr 1fr + &[data-count="3"] + grid-template-columns 1fr 0.5fr + grid-template-rows 1fr 1fr - > *:nth-child(1) - grid-row 1 / 3 + > *:nth-child(1) + grid-row 1 / 3 - > *:nth-child(3) - grid-column 2 / 3 - grid-row 2 / 3 + > *:nth-child(3) + grid-column 2 / 3 + grid-row 2 / 3 - &[data-count="4"] - grid-template-columns 1fr 1fr - grid-template-rows 1fr 1fr + &[data-count="4"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr 1fr - > *:nth-child(1) - grid-column 1 / 2 - grid-row 1 / 2 + > *:nth-child(1) + grid-column 1 / 2 + grid-row 1 / 2 - > *:nth-child(2) - grid-column 2 / 3 - grid-row 1 / 2 + > *:nth-child(2) + grid-column 2 / 3 + grid-row 1 / 2 - > *:nth-child(3) - grid-column 1 / 2 - grid-row 2 / 3 + > *:nth-child(3) + grid-column 1 / 2 + grid-row 2 / 3 - > *:nth-child(4) - grid-column 2 / 3 - grid-row 2 / 3 + > *:nth-child(4) + grid-column 2 / 3 + grid-row 2 / 3 </style> diff --git a/src/client/app/common/views/components/menu.vue b/src/client/app/common/views/components/menu.vue index 9b16732b9a..fba7e235e0 100644 --- a/src/client/app/common/views/components/menu.vue +++ b/src/client/app/common/views/components/menu.vue @@ -1,5 +1,5 @@ <template> -<div class="mk-menu"> +<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv"> <div class="backdrop" ref="backdrop" @click="close"></div> <div class="popover" :class="{ hukidasi }" ref="popover"> <template v-for="item in items"> @@ -108,7 +108,7 @@ export default Vue.extend({ easing: 'easeInBack', complete: () => { this.$emit('closed'); - this.$destroy(); + this.destroyDom(); } }); } @@ -119,9 +119,10 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -$border-color = rgba(27, 31, 35, 0.15) +root(isDark) + $bg-color = isDark ? #2c303c : #fff + $border-color = rgba(27, 31, 35, 0.15) -.mk-menu position initial > .backdrop @@ -131,14 +132,14 @@ $border-color = rgba(27, 31, 35, 0.15) z-index 10000 width 100% height 100% - background rgba(#000, 0.1) + background rgba(#000, isDark ? 0.5 : 0.1) opacity 0 > .popover position absolute z-index 10001 padding 8px 0 - background #fff + background $bg-color border 1px solid $border-color border-radius 4px box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) @@ -172,12 +173,13 @@ $border-color = rgba(27, 31, 35, 0.15) border-top solid $balloon-size transparent border-left solid $balloon-size transparent border-right solid $balloon-size transparent - border-bottom solid $balloon-size #fff + border-bottom solid $balloon-size $bg-color > button display block padding 8px 16px width 100% + color isDark ? #d6dce2 : #111 &:hover color $theme-color-foreground @@ -191,6 +193,12 @@ $border-color = rgba(27, 31, 35, 0.15) > div margin 8px 0 height 1px - background #eee + background isDark ? #1c2023 : #eee + +.onchrpzrvnoruiaenfcqvccjfuupzzwv[data-darkmode] + root(true) + +.onchrpzrvnoruiaenfcqvccjfuupzzwv:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue index 30143b4f1d..1de41855df 100644 --- a/src/client/app/common/views/components/messaging-room.vue +++ b/src/client/app/common/views/components/messaging-room.vue @@ -3,7 +3,7 @@ @dragover.prevent.stop="onDragover" @drop.prevent.stop="onDrop" > - <div class="stream"> + <div class="body"> <p class="init" v-if="init">%fa:spinner .spin%%i18n:common.loading%</p> <p class="empty" v-if="!init && messages.length == 0">%fa:info-circle%%i18n:@empty%</p> <p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages">%fa:flag%%i18n:@no-history%</p> @@ -77,6 +77,12 @@ export default Vue.extend({ this.connection.on('message', this.onMessage); this.connection.on('read', this.onRead); + if (this.isNaked) { + window.addEventListener('scroll', this.onScroll, { passive: true }); + } else { + this.$el.addEventListener('scroll', this.onScroll, { passive: true }); + } + document.addEventListener('visibilitychange', this.onVisibilitychange); this.fetchMessages().then(() => { @@ -90,6 +96,12 @@ export default Vue.extend({ this.connection.off('read', this.onRead); this.connection.close(); + if (this.isNaked) { + window.removeEventListener('scroll', this.onScroll); + } else { + this.$el.removeEventListener('scroll', this.onScroll); + } + document.removeEventListener('visibilitychange', this.onVisibilitychange); }, @@ -226,6 +238,14 @@ export default Vue.extend({ }, 4000); }, + onScroll() { + const el = this.isNaked ? window.document.documentElement : this.$el; + const current = el.scrollTop + el.clientHeight; + if (current > el.scrollHeight - 1) { + this.showIndicator = false; + } + }, + onVisibilitychange() { if (document.hidden) return; this.messages.forEach(message => { @@ -251,7 +271,7 @@ root(isDark) height 100% background isDark ? #191b22 : #fff - > .stream + > .body width 100% max-width 600px margin 0 auto diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.ts b/src/client/app/common/views/components/misskey-flavored-markdown.ts index e97da4302c..224bd6f5de 100644 --- a/src/client/app/common/views/components/misskey-flavored-markdown.ts +++ b/src/client/app/common/views/components/misskey-flavored-markdown.ts @@ -1,4 +1,4 @@ -import Vue from 'vue'; +import Vue, { VNode } from 'vue'; import * as emojilib from 'emojilib'; import { length } from 'stringz'; import parse from '../../../../../mfm/parse'; @@ -6,10 +6,7 @@ import getAcct from '../../../../../misc/acct/render'; import { url } from '../../../config'; import MkUrl from './url.vue'; import MkGoogle from './google.vue'; - -const flatten = list => list.reduce( - (a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [] -); +import { concat } from '../../../../../prelude/array'; export default Vue.component('misskey-flavored-markdown', { props: { @@ -32,20 +29,20 @@ export default Vue.component('misskey-flavored-markdown', { }, render(createElement) { - let ast; + let ast: any[]; if (this.ast == null) { // Parse text to ast ast = parse(this.text); } else { - ast = this.ast; + ast = this.ast as any[]; } let bigCount = 0; let motionCount = 0; // Parse ast to DOM - const els = flatten(ast.map(token => { + const els = concat(ast.map((token): VNode[] => { switch (token.type) { case 'text': { const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); @@ -56,12 +53,12 @@ export default Vue.component('misskey-flavored-markdown', { x[x.length - 1].pop(); return x; } else { - return createElement('span', text.replace(/\n/g, ' ')); + return [createElement('span', text.replace(/\n/g, ' '))]; } } case 'bold': { - return createElement('b', token.bold); + return [createElement('b', token.bold)]; } case 'big': { @@ -95,23 +92,23 @@ export default Vue.component('misskey-flavored-markdown', { } case 'url': { - return createElement(MkUrl, { + return [createElement(MkUrl, { props: { url: token.content, target: '_blank' } - }); + })]; } case 'link': { - return createElement('a', { + return [createElement('a', { attrs: { class: 'link', href: token.url, target: '_blank', title: token.url } - }, token.title); + }, token.title)]; } case 'mention': { @@ -129,16 +126,16 @@ export default Vue.component('misskey-flavored-markdown', { } case 'hashtag': { - return createElement('a', { + return [createElement('a', { attrs: { href: `${url}/tags/${encodeURIComponent(token.hashtag)}`, target: '_blank' } - }, token.content); + }, token.content)]; } case 'code': { - return createElement('pre', { + return [createElement('pre', { class: 'code' }, [ createElement('code', { @@ -146,15 +143,15 @@ export default Vue.component('misskey-flavored-markdown', { innerHTML: token.html } }) - ]); + ])]; } case 'inline-code': { - return createElement('code', { + return [createElement('code', { domProps: { innerHTML: token.html } - }); + })]; } case 'quote': { @@ -164,58 +161,51 @@ export default Vue.component('misskey-flavored-markdown', { const x = text2.split('\n') .map(t => [createElement('span', t), createElement('br')]); x[x.length - 1].pop(); - return createElement('div', { + return [createElement('div', { attrs: { class: 'quote' } - }, x); + }, x)]; } else { - return createElement('span', { + return [createElement('span', { attrs: { class: 'quote' } - }, text2.replace(/\n/g, ' ')); + }, text2.replace(/\n/g, ' '))]; } } case 'title': { - return createElement('div', { + return [createElement('div', { attrs: { class: 'title' } - }, token.title); + }, token.title)]; } case 'emoji': { const emoji = emojilib.lib[token.emoji]; - return createElement('span', emoji ? emoji.char : token.content); + return [createElement('span', emoji ? emoji.char : token.content)]; } case 'search': { - return createElement(MkGoogle, { + return [createElement(MkGoogle, { props: { q: token.query } - }); + })]; } default: { console.log('unknown ast type:', token.type); - } - } - })); - const _els = []; - els.forEach((el, i) => { - if (el.tag == 'br') { - if (!['div', 'pre'].includes(els[i - 1].tag)) { - _els.push(el); + return []; } - } else { - _els.push(el); } - }); + })); + // el.tag === 'br' のとき i !== 0 が保証されるため、短絡評価により els[i - 1] は配列外参照しない + const _els = els.filter((el, i) => !(el.tag === 'br' && ['div', 'pre'].includes(els[i - 1].tag))); return createElement('span', _els); } }); diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/app/common/views/components/note-menu.vue index 27a49a6536..c9912fb1e2 100644 --- a/src/client/app/common/views/components/note-menu.vue +++ b/src/client/app/common/views/components/note-menu.vue @@ -6,17 +6,27 @@ <script lang="ts"> import Vue from 'vue'; +import { url } from '../../../config'; +import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; export default Vue.extend({ props: ['note', 'source', 'compact'], computed: { items() { - const items = []; - items.push({ + const items = [{ + icon: '%fa:info-circle%', + text: '%i18n:@detail%', + action: this.detail + }, { + icon: '%fa:link%', + text: '%i18n:@copy-link%', + action: this.copyLink + }, null, { icon: '%fa:star%', text: '%i18n:@favorite%', action: this.favorite - }); + }]; + if (this.note.userId == this.$store.state.i.id) { items.push({ icon: '%fa:thumbtack%', @@ -42,11 +52,19 @@ export default Vue.extend({ } }, methods: { + detail() { + this.$router.push(`/notes/${ this.note.id }`); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${ this.note.id }`); + }, + pin() { (this as any).api('i/pin', { noteId: this.note.id }).then(() => { - this.$destroy(); + this.destroyDom(); }); }, @@ -55,7 +73,7 @@ export default Vue.extend({ (this as any).api('notes/delete', { noteId: this.note.id }).then(() => { - this.$destroy(); + this.destroyDom(); }); }, @@ -63,13 +81,13 @@ export default Vue.extend({ (this as any).api('notes/favorites/create', { noteId: this.note.id }).then(() => { - this.$destroy(); + this.destroyDom(); }); }, closed() { this.$nextTick(() => { - this.$destroy(); + this.destroyDom(); }); } } diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue index 115c934c8b..30d9799fec 100644 --- a/src/client/app/common/views/components/poll-editor.vue +++ b/src/client/app/common/views/components/poll-editor.vue @@ -20,6 +20,7 @@ <script lang="ts"> import Vue from 'vue'; +import { erase } from '../../../../../prelude/array'; export default Vue.extend({ data() { return { @@ -53,7 +54,7 @@ export default Vue.extend({ get() { return { - choices: this.choices.filter(choice => choice != '') + choices: erase('', this.choices) } }, diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue index 660247edbc..4fe51d219b 100644 --- a/src/client/app/common/views/components/poll.vue +++ b/src/client/app/common/views/components/poll.vue @@ -21,6 +21,7 @@ <script lang="ts"> import Vue from 'vue'; +import { sum } from '../../../../../prelude/array'; export default Vue.extend({ props: ['note'], data() { @@ -33,7 +34,7 @@ export default Vue.extend({ return this.note.poll; }, total(): number { - return this.poll.choices.reduce((a, b) => a + b.votes, 0); + return sum(this.poll.choices.map(x => x.votes)); }, isVoted(): boolean { return this.poll.choices.some(c => c.isVoted); diff --git a/src/client/app/common/views/components/reaction-icon.vue b/src/client/app/common/views/components/reaction-icon.vue index 46886b8ab2..c668efac6b 100644 --- a/src/client/app/common/views/components/reaction-icon.vue +++ b/src/client/app/common/views/components/reaction-icon.vue @@ -1,17 +1,17 @@ <template> <span class="mk-reaction-icon"> - <img v-if="reaction == 'like'" src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%"> - <img v-if="reaction == 'love'" src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%"> - <img v-if="reaction == 'laugh'" src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%"> - <img v-if="reaction == 'hmm'" src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%"> - <img v-if="reaction == 'surprise'" src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%"> - <img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%"> - <img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%"> - <img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%"> - <img v-if="reaction == 'rip'" src="/assets/reactions/rip.png" alt="%i18n:common.reactions.rip%"> + <img v-if="reaction == 'like'" src="https://twemoji.maxcdn.com/2/svg/1f44d.svg" alt="%i18n:common.reactions.like%"> + <img v-if="reaction == 'love'" src="https://twemoji.maxcdn.com/2/svg/2764.svg" alt="%i18n:common.reactions.love%"> + <img v-if="reaction == 'laugh'" src="https://twemoji.maxcdn.com/2/svg/1f606.svg" alt="%i18n:common.reactions.laugh%"> + <img v-if="reaction == 'hmm'" src="https://twemoji.maxcdn.com/2/svg/1f914.svg" alt="%i18n:common.reactions.hmm%"> + <img v-if="reaction == 'surprise'" src="https://twemoji.maxcdn.com/2/svg/1f62e.svg" alt="%i18n:common.reactions.surprise%"> + <img v-if="reaction == 'congrats'" src="https://twemoji.maxcdn.com/2/svg/1f389.svg" alt="%i18n:common.reactions.congrats%"> + <img v-if="reaction == 'angry'" src="https://twemoji.maxcdn.com/2/svg/1f4a2.svg" alt="%i18n:common.reactions.angry%"> + <img v-if="reaction == 'confused'" src="https://twemoji.maxcdn.com/2/svg/1f625.svg" alt="%i18n:common.reactions.confused%"> + <img v-if="reaction == 'rip'" src="https://twemoji.maxcdn.com/2/svg/1f607.svg" alt="%i18n:common.reactions.rip%"> <template v-if="reaction == 'pudding'"> - <img v-if="$store.getters.isSignedIn && $store.state.settings.iLikeSushi" src="/assets/reactions/sushi.png" alt="%i18n:common.reactions.pudding%"> - <img v-else src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%"> + <img v-if="$store.getters.isSignedIn && $store.state.settings.iLikeSushi" src="https://twemoji.maxcdn.com/2/svg/1f363.svg" alt="%i18n:common.reactions.pudding%"> + <img v-else src="https://twemoji.maxcdn.com/2/svg/1f36e.svg" alt="%i18n:common.reactions.pudding%"> </template> </span> </template> diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue index a455afbf7d..a4828c987b 100644 --- a/src/client/app/common/views/components/reaction-picker.vue +++ b/src/client/app/common/views/components/reaction-picker.vue @@ -95,7 +95,7 @@ export default Vue.extend({ reaction: reaction }).then(() => { if (this.cb) this.cb(); - this.$destroy(); + this.destroyDom(); }); }, onMouseover(e) { @@ -120,7 +120,7 @@ export default Vue.extend({ scale: 0.5, duration: 200, easing: 'easeInBack', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index 5230ac371a..b1c6782e93 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -78,7 +78,7 @@ export default Vue.extend({ cursor wait !important > .avatar - margin 16px auto 0 auto + margin 0 auto 0 auto width 64px height 64px background #ddd diff --git a/src/client/app/common/views/components/tag-cloud.vue b/src/client/app/common/views/components/tag-cloud.vue new file mode 100644 index 0000000000..5f2cc5276a --- /dev/null +++ b/src/client/app/common/views/components/tag-cloud.vue @@ -0,0 +1,90 @@ +<template> +<div class="jtivnzhfwquxpsfidertopbmwmchmnmo"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <p class="empty" v-else-if="tags.length == 0">%fa:exclamation-circle%%i18n:@empty%</p> + <div v-else> + <vue-word-cloud + :words="tags.slice(0, 20).map(x => [x.name, x.count])" + :color="color" + :spacing="1"> + <template slot-scope="{word, text, weight}"> + <div style="cursor: pointer;" :title="weight"> + {{ text }} + </div> + </template> + </vue-word-cloud> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as VueWordCloud from 'vuewordcloud'; + +export default Vue.extend({ + components: { + [VueWordCloud.name]: VueWordCloud + }, + data() { + return { + tags: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 1000 * 60); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + fetch() { + (this as any).api('aggregation/hashtags').then(tags => { + this.tags = tags; + this.fetching = false; + }); + }, + color([, weight]) { + const peak = Math.max.apply(null, this.tags.map(x => x.count)); + const w = weight / peak; + + if (w > 0.9) { + return this.$store.state.device.darkmode ? '#ff4e69' : '#ff4e69'; + } else if (w > 0.5) { + return this.$store.state.device.darkmode ? '#3bc4c7' : '#3bc4c7'; + } else { + return this.$store.state.device.darkmode ? '#fff' : '#555'; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +root(isDark) + height 100% + width 100% + + > .fetching + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + > div + height 100% + width 100% + +.jtivnzhfwquxpsfidertopbmwmchmnmo[data-darkmode] + root(true) + +.jtivnzhfwquxpsfidertopbmwmchmnmo:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/common/views/widgets/hashtags.chart.vue b/src/client/app/common/views/components/trends.chart.vue index 723a3947f8..723a3947f8 100644 --- a/src/client/app/common/views/widgets/hashtags.chart.vue +++ b/src/client/app/common/views/components/trends.chart.vue diff --git a/src/client/app/common/views/components/trends.vue b/src/client/app/common/views/components/trends.vue new file mode 100644 index 0000000000..0042dbe853 --- /dev/null +++ b/src/client/app/common/views/components/trends.vue @@ -0,0 +1,103 @@ +<template> +<div class="csqvmxybqbycalfhkxvyfrgbrdalkaoc"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <p class="empty" v-else-if="stats.length == 0">%fa:exclamation-circle%%i18n:@empty%</p> + <!-- トランジションを有効にするとなぜかメモリリークする --> + <transition-group v-else tag="div" name="chart"> + <div v-for="stat in stats" :key="stat.tag"> + <div class="tag"> + <router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> + <p>{{ '%i18n:@count%'.replace('{}', stat.usersCount) }}</p> + </div> + <x-chart class="chart" :src="stat.chart"/> + </div> + </transition-group> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XChart from './trends.chart.vue'; + +export default Vue.extend({ + components: { + XChart + }, + data() { + return { + stats: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 1000 * 60); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + fetch() { + (this as any).api('hashtags/trend').then(stats => { + this.stats = stats; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +root(isDark) + > .fetching + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + > div + .chart-move + transition transform 1s ease + + > div + display flex + align-items center + padding 14px 16px + + &:not(:last-child) + border-bottom solid 1px isDark ? #393f4f : #eee + + > .tag + flex 1 + overflow hidden + font-size 14px + color isDark ? #9baec8 : #65727b + + > a + display block + width 100% + white-space nowrap + overflow hidden + text-overflow ellipsis + color inherit + + > p + margin 0 + font-size 75% + opacity 0.7 + + > .chart + height 30px + +.csqvmxybqbycalfhkxvyfrgbrdalkaoc[data-darkmode] + root(true) + +.csqvmxybqbycalfhkxvyfrgbrdalkaoc:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/common/views/components/ui/card.vue b/src/client/app/common/views/components/ui/card.vue index 05c51bca6b..aa16b557e1 100644 --- a/src/client/app/common/views/components/ui/card.vue +++ b/src/client/app/common/views/components/ui/card.vue @@ -24,19 +24,34 @@ export default Vue.extend({ root(isDark) margin 16px - padding 16px color isDark ? #fff : #000 background isDark ? #282C37 : #fff box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12) - @media (min-width 500px) - padding 32px - > header - font-weight normal - font-size 24px + padding 16px + font-weight bold + font-size 20px color isDark ? #fff : #444 + @media (min-width 500px) + padding 24px 32px + + > section + padding 20px 16px + border-top solid 1px isDark ? rgba(#000, 0.3) : rgba(#000, 0.1) + + @media (min-width 500px) + padding 32px + + &.fit-top + padding-top 0 + + > header + margin-bottom 16px + font-weight bold + color isDark ? #fff : #444 + .ui-card[data-darkmode] root(true) diff --git a/src/client/app/common/views/components/ui/radio.vue b/src/client/app/common/views/components/ui/radio.vue index 04a46c5a96..dcdda1cf0e 100644 --- a/src/client/app/common/views/components/ui/radio.vue +++ b/src/client/app/common/views/components/ui/radio.vue @@ -55,7 +55,7 @@ export default Vue.extend({ root(isDark) display inline-block - margin 32px 32px 32px 0 + margin 0 32px 0 0 cursor pointer transition all 0.3s diff --git a/src/client/app/common/views/components/ui/switch.vue b/src/client/app/common/views/components/ui/switch.vue index a9e00d73d2..e88b867801 100644 --- a/src/client/app/common/views/components/ui/switch.vue +++ b/src/client/app/common/views/components/ui/switch.vue @@ -64,6 +64,12 @@ root(isDark) cursor pointer transition all 0.3s + &:first-child + margin-top 0 + + &:last-child + margin-bottom 0 + > * user-select none @@ -89,6 +95,7 @@ root(isDark) > .button display inline-block + flex-shrink 0 margin 3px 0 0 0 width 34px height 14px diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue index 242d9ba5c6..f9b8415b5b 100644 --- a/src/client/app/common/views/components/url-preview.vue +++ b/src/client/app/common/views/components/url-preview.vue @@ -8,13 +8,13 @@ </blockquote> </div> <div v-else class="mk-url-preview"> - <a :href="url" target="_blank" :title="url" v-if="!fetching"> + <a :class="{ mini }" :href="url" target="_blank" :title="url" v-if="!fetching"> <div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div> <article> <header> <h1>{{ title }}</h1> </header> - <p>{{ description }}</p> + <p>{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> <footer> <img class="icon" v-if="icon" :src="icon"/> <p>{{ sitename }}</p> @@ -118,6 +118,12 @@ export default Vue.extend({ type: Boolean, required: false, default: false + }, + + mini: { + type: Boolean, + required: false, + default: false } }, @@ -164,7 +170,7 @@ export default Vue.extend({ return; } - fetch('/url?url=' + encodeURIComponent(this.url)).then(res => { + fetch(`/url?url=${encodeURIComponent(this.url)}`).then(res => { res.json().then(info => { if (info.url == null) return; this.title = info.title; @@ -293,6 +299,29 @@ root(isDark) width 12px height 12px + &.mini + font-size 10px + + > .thumbnail + position relative + width 100% + height 60px + + > article + left 0 + width 100% + padding 8px + + > header + margin-bottom 4px + + > footer + margin-top 4px + + > img + width 12px + height 12px + .mk-url-preview[data-darkmode] root(true) diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue index e6ffe4466d..04a1f30135 100644 --- a/src/client/app/common/views/components/url.vue +++ b/src/client/app/common/views/components/url.vue @@ -12,6 +12,7 @@ <script lang="ts"> import Vue from 'vue'; +import { toUnicode as decodePunycode } from 'punycode'; export default Vue.extend({ props: ['url', 'target'], data() { @@ -27,11 +28,11 @@ export default Vue.extend({ created() { const url = new URL(this.url); this.schema = url.protocol; - this.hostname = url.hostname; + this.hostname = decodePunycode(url.hostname); this.port = url.port; - this.pathname = url.pathname; - this.query = url.search; - this.hash = url.hash; + this.pathname = decodeURIComponent(url.pathname); + this.query = decodeURIComponent(url.search); + this.hash = decodeURIComponent(url.hash); } }); </script> diff --git a/src/client/app/common/views/components/visibility-chooser.vue b/src/client/app/common/views/components/visibility-chooser.vue index 4691604e57..1830b1832e 100644 --- a/src/client/app/common/views/components/visibility-chooser.vue +++ b/src/client/app/common/views/components/visibility-chooser.vue @@ -47,7 +47,7 @@ export default Vue.extend({ props: ['source', 'compact'], data() { return { - v: this.$store.state.device.visibility || 'public' + v: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility } }, mounted() { @@ -97,9 +97,11 @@ export default Vue.extend({ }, methods: { choose(visibility) { - this.$store.commit('device/setVisibility', visibility); + if (this.$store.state.settings.rememberNoteVisibility) { + this.$store.commit('device/setVisibility', visibility); + } this.$emit('chosen', visibility); - this.$destroy(); + this.destroyDom(); }, close() { (this.$refs.backdrop as any).style.pointerEvents = 'none'; @@ -117,7 +119,7 @@ export default Vue.extend({ scale: 0.5, duration: 200, easing: 'easeInBack', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue index 5a8b9df476..965ec78559 100644 --- a/src/client/app/common/views/components/welcome-timeline.vue +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -1,22 +1,24 @@ <template> <div class="mk-welcome-timeline"> - <div v-for="note in notes"> - <mk-avatar class="avatar" :user="note.user" target="_blank"/> - <div class="body"> - <header> - <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link> - <span class="username">@{{ note.user | acct }}</span> - <div class="info"> - <router-link class="created-at" :to="note | notePage"> - <mk-time :time="note.createdAt"/> - </router-link> + <transition-group name="ldzpakcixzickvggyixyrhqwjaefknon" tag="div"> + <div v-for="note in notes" :key="note.id"> + <mk-avatar class="avatar" :user="note.user" target="_blank"/> + <div class="body"> + <header> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link> + <span class="username">@{{ note.user | acct }}</span> + <div class="info"> + <router-link class="created-at" :to="note | notePage"> + <mk-time :time="note.createdAt"/> + </router-link> + </div> + </header> + <div class="text"> + <misskey-flavored-markdown v-if="note.text" :text="note.text"/> </div> - </header> - <div class="text"> - <misskey-flavored-markdown v-if="note.text" :text="note.text"/> </div> </div> - </div> + </transition-group> </div> </template> @@ -31,15 +33,30 @@ export default Vue.extend({ default: undefined } }, + data() { return { fetching: true, - notes: [] + notes: [], + connection: null, + connectionId: null }; }, + mounted() { this.fetch(); + + this.connection = (this as any).os.streams.localTimelineStream.getConnection(); + this.connectionId = (this as any).os.streams.localTimelineStream.use(); + + this.connection.on('note', this.onNote); + }, + + beforeDestroy() { + this.connection.off('note', this.onNote); + (this as any).os.streams.localTimelineStream.dispose(this.connectionId); }, + methods: { fetch(cb?) { this.fetching = true; @@ -48,77 +65,93 @@ export default Vue.extend({ local: true, reply: false, renote: false, - media: false, - poll: false, - bot: false + file: false, + poll: false }).then(notes => { this.notes = notes; this.fetching = false; }); - } + }, + + onNote(note) { + if (note.replyId != null) return; + if (note.renoteId != null) return; + if (note.poll != null) return; + + this.notes.unshift(note); + }, } }); </script> <style lang="stylus" scoped> +.ldzpakcixzickvggyixyrhqwjaefknon-enter +.ldzpakcixzickvggyixyrhqwjaefknon-leave-to + opacity 0 + transform translateY(-30px) + root(isDark) background isDark ? #282C37 : #fff > div - padding 16px - overflow-wrap break-word - font-size .9em - color isDark ? #fff : #4C4C4C - border-bottom 1px solid isDark ? rgba(#000, 0.1) : rgba(#000, 0.05) + > * + transition transform .3s ease, opacity .3s ease + + > div + padding 16px + overflow-wrap break-word + font-size .9em + color isDark ? #fff : #4C4C4C + border-bottom 1px solid isDark ? rgba(#000, 0.1) : rgba(#000, 0.05) - &:after - content "" - display block - clear both + &:after + content "" + display block + clear both - > .avatar - display block - float left - position -webkit-sticky - position sticky - top 16px - width 42px - height 42px - border-radius 6px + > .avatar + display block + float left + position -webkit-sticky + position sticky + top 16px + width 42px + height 42px + border-radius 6px - > .body - float right - width calc(100% - 42px) - padding-left 12px + > .body + float right + width calc(100% - 42px) + padding-left 12px - > header - display flex - align-items center - margin-bottom 4px - white-space nowrap + > header + display flex + align-items center + margin-bottom 4px + white-space nowrap - > .name - display block - margin 0 .5em 0 0 - padding 0 - overflow hidden - font-weight bold - text-overflow ellipsis - color isDark ? #fff : #627079 + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + font-weight bold + text-overflow ellipsis + color isDark ? #fff : #627079 - > .username - margin 0 .5em 0 0 - color isDark ? #606984 : #ccc + > .username + margin 0 .5em 0 0 + color isDark ? #606984 : #ccc - > .info - margin-left auto - font-size 0.9em + > .info + margin-left auto + font-size 0.9em - > .created-at - color isDark ? #606984 : #c0c0c0 + > .created-at + color isDark ? #606984 : #c0c0c0 - > .text - text-align left + > .text + text-align left .mk-welcome-timeline[data-darkmode] root(true) diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/app/common/views/directives/autocomplete.ts index b252cf5c1f..f7f8e9bf16 100644 --- a/src/client/app/common/views/directives/autocomplete.ts +++ b/src/client/app/common/views/directives/autocomplete.ts @@ -167,7 +167,7 @@ class Autocomplete { private close() { if (this.suggestion == null) return; - this.suggestion.$destroy(); + this.suggestion.destroyDom(); this.suggestion = null; this.textarea.focus(); @@ -191,7 +191,7 @@ class Autocomplete { const acct = renderAcct(value); // 挿入 - this.text = trimmedBefore + '@' + acct + ' ' + after; + this.text = `${trimmedBefore}@${acct} ${after}`; // キャレットを戻す this.vm.$nextTick(() => { @@ -207,7 +207,7 @@ class Autocomplete { const after = source.substr(caret); // 挿入 - this.text = trimmedBefore + '#' + value + ' ' + after; + this.text = `${trimmedBefore}#${value} ${after}`; // キャレットを戻す this.vm.$nextTick(() => { diff --git a/src/client/app/common/views/filters/note.ts b/src/client/app/common/views/filters/note.ts index a611dc8685..3c9c8b7485 100644 --- a/src/client/app/common/views/filters/note.ts +++ b/src/client/app/common/views/filters/note.ts @@ -1,5 +1,5 @@ import Vue from 'vue'; Vue.filter('notePage', note => { - return '/notes/' + note.id; + return `/notes/${note.id}`; }); diff --git a/src/client/app/common/views/filters/user.ts b/src/client/app/common/views/filters/user.ts index ca0910fc53..e5220229b7 100644 --- a/src/client/app/common/views/filters/user.ts +++ b/src/client/app/common/views/filters/user.ts @@ -11,5 +11,5 @@ Vue.filter('userName', user => { }); Vue.filter('userPage', (user, path?) => { - return '/@' + Vue.filter('acct')(user) + (path ? '/' + path : ''); + return `/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`; }); diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue index 13d855d20a..80a870a257 100644 --- a/src/client/app/common/views/pages/follow.vue +++ b/src/client/app/common/views/pages/follow.vue @@ -1,6 +1,6 @@ <template> <div class="syxhndwprovvuqhmyvveewmbqayniwkv" v-if="!fetching" :data-darkmode="$store.state.device.darkmode"> - <div class="signed-in-as" v-html="'%i18n:@signed-in-as%'.replace('{}', '<b>' + myName + '</b>')"></div> + <div class="signed-in-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${myName}`)"></div> <main> <div class="banner" :style="bannerStyle"></div> @@ -32,7 +32,6 @@ <script lang="ts"> import Vue from 'vue'; import parseAcct from '../../../../../misc/acct/parse'; -import getUserName from '../../../../../misc/get-user-name'; import Progress from '../../../common/scripts/loading'; export default Vue.extend({ @@ -83,7 +82,7 @@ export default Vue.extend({ userId: this.user.id }); } else { - if (this.user.isLocked && this.user.hasPendingFollowRequestFromYou) { + if (this.user.hasPendingFollowRequestFromYou) { this.user = await (this as any).api('following/requests/cancel', { userId: this.user.id }); diff --git a/src/client/app/common/views/widgets/analog-clock.vue b/src/client/app/common/views/widgets/analog-clock.vue index 0de30228b3..04223f0d21 100644 --- a/src/client/app/common/views/widgets/analog-clock.vue +++ b/src/client/app/common/views/widgets/analog-clock.vue @@ -1,8 +1,8 @@ <template> <div class="mkw-analog-clock"> - <mk-widget-container :naked="!(props.design % 2)" :show-header="false"> + <mk-widget-container :naked="props.style % 2 === 0" :show-header="false"> <div class="mkw-analog-clock--body"> - <mk-analog-clock :dark="$store.state.device.darkmode" :smooth="!(props.design && ~props.design)"/> + <mk-analog-clock :dark="$store.state.device.darkmode" :smooth="props.style < 2"/> </div> </mk-widget-container> </div> @@ -13,13 +13,12 @@ import define from '../../../common/define-widget'; export default define({ name: 'analog-clock', props: () => ({ - design: -1 + style: 0 }) }).extend({ methods: { func() { - if (++this.props.design > 2) - this.props.design = -1; + this.props.style = (this.props.style + 1) % 4; this.save(); } } diff --git a/src/client/app/common/views/widgets/broadcast.vue b/src/client/app/common/views/widgets/broadcast.vue index 69b2a54fe9..f2fa720f52 100644 --- a/src/client/app/common/views/widgets/broadcast.vue +++ b/src/client/app/common/views/widgets/broadcast.vue @@ -1,6 +1,6 @@ <template> -<div class="mkw-broadcast" - :data-found="broadcasts.length != 0" +<div class="anltbovirfeutcigvwgmgxipejaeozxi" + :data-found="announcements && announcements.length != 0" :data-melt="props.design == 1" :data-mobile="platform == 'mobile'" > @@ -14,18 +14,17 @@ </svg> </div> <p class="fetching" v-if="fetching">%i18n:@fetching%<mk-ellipsis/></p> - <h1 v-if="!fetching">{{ broadcasts.length == 0 ? '%i18n:@no-broadcasts%' : broadcasts[i].title }}</h1> + <h1 v-if="!fetching">{{ announcements.length == 0 ? '%i18n:@no-broadcasts%' : announcements[i].title }}</h1> <p v-if="!fetching"> - <span v-if="broadcasts.length != 0" v-html="broadcasts[i].text"></span> - <template v-if="broadcasts.length == 0">%i18n:@have-a-nice-day%</template> + <span v-if="announcements.length != 0" v-html="announcements[i].text"></span> + <template v-if="announcements.length == 0">%i18n:@have-a-nice-day%</template> </p> - <a v-if="broadcasts.length > 1" @click="next">%i18n:@next% >></a> + <a v-if="announcements.length > 1" @click="next">%i18n:@next% >></a> </div> </template> <script lang="ts"> import define from '../../../common/define-widget'; -import { lang } from '../../../config'; export default define({ name: 'broadcast', @@ -37,26 +36,18 @@ export default define({ return { i: 0, fetching: true, - broadcasts: [] + announcements: [] }; }, mounted() { (this as any).os.getMeta().then(meta => { - let broadcasts = []; - if (meta.broadcasts) { - meta.broadcasts.forEach(broadcast => { - if (broadcast[lang]) { - broadcasts.push(broadcast[lang]); - } - }); - } - this.broadcasts = broadcasts; + this.announcements = meta.broadcasts; this.fetching = false; }); }, methods: { next() { - if (this.i == this.broadcasts.length - 1) { + if (this.i == this.announcements.length - 1) { this.i = 0; } else { this.i++; @@ -75,7 +66,7 @@ export default define({ </script> <style lang="stylus" scoped> -.mkw-broadcast +root(isDark) padding 10px border solid 1px #4078c0 border-radius 6px @@ -135,22 +126,18 @@ export default define({ margin 0 font-size 0.95em font-weight normal - color #4078c0 + color isDark ? #539eff : #4078c0 > p display block z-index 1 margin 0 font-size 0.7em - color #555 + color isDark ? #fff : #555 &.fetching text-align center - a - color #555 - text-decoration underline - > a display block font-size 0.7em @@ -159,4 +146,10 @@ export default define({ > p color #fff +.anltbovirfeutcigvwgmgxipejaeozxi[data-darkmode] + root(true) + +.anltbovirfeutcigvwgmgxipejaeozxi:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/widgets/hashtags.vue b/src/client/app/common/views/widgets/hashtags.vue index 56520400b6..0cb6b2df10 100644 --- a/src/client/app/common/views/widgets/hashtags.vue +++ b/src/client/app/common/views/widgets/hashtags.vue @@ -4,20 +4,7 @@ <template slot="header">%fa:hashtag%%i18n:@title%</template> <div class="mkw-hashtags--body" :data-mobile="platform == 'mobile'"> - <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <p class="empty" v-else-if="stats.length == 0">%fa:exclamation-circle%%i18n:@empty%</p> - <!-- トランジションを有効にするとなぜかメモリリークする --> - <!-- <transition-group v-else tag="div" name="chart"> --> - <div> - <div v-for="stat in stats" :key="stat.tag"> - <div class="tag"> - <router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> - <p>{{ '%i18n:@count%'.replace('{}', stat.usersCount) }}</p> - </div> - <x-chart class="chart" :src="stat.chart"/> - </div> - </div> - <!-- </transition-group> --> + <mk-trends/> </div> </mk-widget-container> </div> @@ -25,7 +12,6 @@ <script lang="ts"> import define from '../../../common/define-widget'; -import XChart from './hashtags.chart.vue'; export default define({ name: 'hashtags', @@ -33,89 +19,11 @@ export default define({ compact: false }) }).extend({ - components: { - XChart - }, - data() { - return { - stats: [], - fetching: true, - clock: null - }; - }, - mounted() { - this.fetch(); - this.clock = setInterval(this.fetch, 1000 * 60); - }, - beforeDestroy() { - clearInterval(this.clock); - }, methods: { func() { this.props.compact = !this.props.compact; this.save(); - }, - fetch() { - (this as any).api('hashtags/trend').then(stats => { - this.stats = stats; - this.fetching = false; - }); } } }); </script> - -<style lang="stylus" scoped> -root(isDark) - .mkw-hashtags--body - > .fetching - > .empty - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - > div - .chart-move - transition transform 1s ease - - > div - display flex - align-items center - padding 14px 16px - - &:not(:last-child) - border-bottom solid 1px isDark ? #393f4f : #eee - - > .tag - flex 1 - overflow hidden - font-size 14px - color isDark ? #9baec8 : #65727b - - > a - display block - width 100% - white-space nowrap - overflow hidden - text-overflow ellipsis - color inherit - - > p - margin 0 - font-size 75% - opacity 0.7 - - > .chart - height 30px - -.mkw-hashtags[data-darkmode] - root(true) - -.mkw-hashtags:not([data-darkmode]) - root(false) - -</style> diff --git a/src/client/app/config.ts b/src/client/app/config.ts index 74b9ea21c8..a326c521db 100644 --- a/src/client/app/config.ts +++ b/src/client/app/config.ts @@ -4,6 +4,7 @@ declare const _THEME_COLOR_: string; declare const _COPYRIGHT_: string; declare const _VERSION_: string; declare const _CODENAME_: string; +declare const _ENV_: string; const address = new URL(location.href); @@ -18,3 +19,4 @@ export const themeColor = _THEME_COLOR_; export const copyright = _COPYRIGHT_; export const version = _VERSION_; export const codename = _CODENAME_; +export const env = _ENV_; diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts index e9d92d1eb1..f08e8a2b4e 100644 --- a/src/client/app/desktop/api/update-avatar.ts +++ b/src/client/app/desktop/api/update-avatar.ts @@ -16,7 +16,7 @@ export default (os: OS) => { text: '%i18n:common.got-it%' }] }); - reject(); + return reject('invalid-filetype'); } const w = os.new(CropWindow, { diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts index e8fa35149b..42c9d69349 100644 --- a/src/client/app/desktop/api/update-banner.ts +++ b/src/client/app/desktop/api/update-banner.ts @@ -16,7 +16,7 @@ export default (os: OS) => { text: '%i18n:common.got-it%' }] }); - reject(); + return reject('invalid-filetype'); } const w = os.new(CropWindow, { diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index f0e8a42662..e32682286c 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -6,7 +6,6 @@ import VueRouter from 'vue-router'; // Style import './style.styl'; -import '../../element.scss'; import init from '../init'; import fuckAdBlock from '../common/scripts/fuck-ad-block'; diff --git a/src/client/app/desktop/views/components/charts.vue b/src/client/app/desktop/views/components/charts.vue index c4e92e429f..e401095363 100644 --- a/src/client/app/desktop/views/components/charts.vue +++ b/src/client/app/desktop/views/components/charts.vue @@ -19,6 +19,11 @@ <option value="drive">%i18n:@charts.drive%</option> <option value="drive-total">%i18n:@charts.drive-total%</option> </optgroup> + <optgroup label="%i18n:@network%"> + <option value="network-requests">%i18n:@charts.network-requests%</option> + <option value="network-time">%i18n:@charts.network-time%</option> + <option value="network-usage">%i18n:@charts.network-usage%</option> + </optgroup> </select> <div> <span @click="span = 'day'" :class="{ active: span == 'day' }">%i18n:@per-day%</span> | <span @click="span = 'hour'" :class="{ active: span == 'hour' }">%i18n:@per-hour%</span> @@ -41,7 +46,10 @@ const colors = { localPlus: 'rgb(52, 178, 118)', remotePlus: 'rgb(158, 255, 209)', localMinus: 'rgb(255, 97, 74)', - remoteMinus: 'rgb(255, 149, 134)' + remoteMinus: 'rgb(255, 149, 134)', + + incoming: 'rgb(52, 178, 118)', + outgoing: 'rgb(255, 97, 74)', }; const rgba = (color: string): string => { @@ -75,6 +83,9 @@ export default Vue.extend({ case 'drive-total': return this.driveTotalChart(); case 'drive-files': return this.driveFilesChart(); case 'drive-files-total': return this.driveFilesTotalChart(); + case 'network-requests': return this.networkRequestsChart(); + case 'network-time': return this.networkTimeChart(); + case 'network-usage': return this.networkUsageChart(); } }, @@ -89,7 +100,7 @@ export default Vue.extend({ created() { (this as any).api('chart', { - limit: 32 + limit: 35 }).then(chart => { this.chart = chart; }); @@ -544,7 +555,95 @@ export default Vue.extend({ } } }]; - } + }, + + networkRequestsChart(): any { + const data = this.stats.slice().reverse().map(x => ({ + date: new Date(x.date), + requests: x.network.requests + })); + + return [{ + datasets: [{ + label: 'Requests', + fill: true, + backgroundColor: rgba(colors.localPlus), + borderColor: colors.localPlus, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: data.map(x => ({ t: x.date, y: x.requests })) + }] + }]; + }, + + networkTimeChart(): any { + const data = this.stats.slice().reverse().map(x => ({ + date: new Date(x.date), + time: x.network.requests != 0 ? (x.network.totalTime / x.network.requests) : 0, + })); + + return [{ + datasets: [{ + label: 'Avg time (ms)', + fill: true, + backgroundColor: rgba(colors.localPlus), + borderColor: colors.localPlus, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: data.map(x => ({ t: x.date, y: x.time })) + }] + }]; + }, + + networkUsageChart(): any { + const data = this.stats.slice().reverse().map(x => ({ + date: new Date(x.date), + incoming: x.network.incomingBytes, + outgoing: x.network.outgoingBytes + })); + + return [{ + datasets: [{ + label: 'Incoming', + fill: true, + backgroundColor: rgba(colors.incoming), + borderColor: colors.incoming, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: data.map(x => ({ t: x.date, y: x.incoming })) + }, { + label: 'Outgoing', + fill: true, + backgroundColor: rgba(colors.outgoing), + borderColor: colors.outgoing, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: data.map(x => ({ t: x.date, y: x.outgoing })) + }] + }, { + scales: { + yAxes: [{ + ticks: { + callback: value => { + return Vue.filter('bytes')(value, 1); + } + } + }] + }, + tooltips: { + callbacks: { + label: (tooltipItem, data) => { + const label = data.datasets[tooltipItem.datasetIndex].label || ''; + return `${label}: ${Vue.filter('bytes')(tooltipItem.yLabel, 1)}`; + } + } + } + }]; + }, } }); </script> @@ -582,6 +681,6 @@ export default Vue.extend({ > div > * display block - height 320px + height 350px </style> diff --git a/src/client/app/desktop/views/components/context-menu.vue b/src/client/app/desktop/views/components/context-menu.vue index afb6838eb6..49aeac143f 100644 --- a/src/client/app/desktop/views/components/context-menu.vue +++ b/src/client/app/desktop/views/components/context-menu.vue @@ -64,7 +64,7 @@ export default Vue.extend({ }); this.$emit('closed'); - this.$destroy(); + this.destroyDom(); } } }); diff --git a/src/client/app/desktop/views/components/dialog.vue b/src/client/app/desktop/views/components/dialog.vue index aff21c1754..bbb1e0030c 100644 --- a/src/client/app/desktop/views/components/dialog.vue +++ b/src/client/app/desktop/views/components/dialog.vue @@ -78,7 +78,7 @@ export default Vue.extend({ scale: 0.8, duration: 300, easing: [ 0.5, -0.5, 1, 0.5 ], - complete: () => this.$destroy() + complete: () => this.destroyDom() }); }, onBgClick() { diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/app/desktop/views/components/drive.folder.vue index 83880fef5c..e6b71f9426 100644 --- a/src/client/app/desktop/views/components/drive.folder.vue +++ b/src/client/app/desktop/views/components/drive.folder.vue @@ -163,7 +163,7 @@ export default Vue.extend({ }); break; default: - alert('%i18n:@unhandled-error% ' + err); + alert(`%i18n:@unhandled-error% ${err}`); } }); } diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue index d919e4a5ea..cb289027d4 100644 --- a/src/client/app/desktop/views/components/drive.vue +++ b/src/client/app/desktop/views/components/drive.vue @@ -323,7 +323,7 @@ export default Vue.extend({ }); break; default: - alert('%i18n:@unhandled-error% ' + err); + alert(`%i18n:@unhandled-error% ${err}`); } }); } @@ -404,7 +404,7 @@ export default Vue.extend({ folder: folder }); } else { - window.open(url + '/i/drive/folder/' + folder.id, + window.open(`${url}/i/drive/folder/${folder.id}`, 'drive_window', 'height=500, width=800'); } diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue index 62742a8f39..1db4b0cfa4 100644 --- a/src/client/app/desktop/views/components/follow-button.vue +++ b/src/client/app/desktop/views/components/follow-button.vue @@ -55,13 +55,15 @@ export default Vue.extend({ methods: { onFollow(user) { if (user.id == this.u.id) { - this.user.isFollowing = user.isFollowing; + this.u.isFollowing = user.isFollowing; + this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } }, onUnfollow(user) { if (user.id == this.u.id) { - this.user.isFollowing = user.isFollowing; + this.u.isFollowing = user.isFollowing; + this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } }, @@ -74,7 +76,7 @@ export default Vue.extend({ userId: this.u.id }); } else { - if (this.u.isLocked && this.u.hasPendingFollowRequestFromYou) { + if (this.u.hasPendingFollowRequestFromYou) { this.u = await (this as any).api('following/requests/cancel', { userId: this.u.id }); diff --git a/src/client/app/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue index 7dfd9e4359..4e8a212b00 100644 --- a/src/client/app/desktop/views/components/friends-maker.vue +++ b/src/client/app/desktop/views/components/friends-maker.vue @@ -14,7 +14,7 @@ <p class="empty" v-if="!fetching && users.length == 0">%i18n:@empty%</p> <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@fetching%<mk-ellipsis/></p> <a class="refresh" @click="refresh">%i18n:@refresh%</a> - <button class="close" @click="$destroy()" title="%i18n:@close%">%fa:times%</button> + <button class="close" @click="destroyDom()" title="%i18n:@close%">%fa:times%</button> </div> </template> diff --git a/src/client/app/desktop/views/components/media-image-dialog.vue b/src/client/app/desktop/views/components/media-image-dialog.vue index 026522d907..89a340d3ae 100644 --- a/src/client/app/desktop/views/components/media-image-dialog.vue +++ b/src/client/app/desktop/views/components/media-image-dialog.vue @@ -26,7 +26,7 @@ export default Vue.extend({ opacity: 0, duration: 100, easing: 'linear', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } diff --git a/src/client/app/desktop/views/components/media-image.vue b/src/client/app/desktop/views/components/media-image.vue index 8b68f260fa..f9ab188ca5 100644 --- a/src/client/app/desktop/views/components/media-image.vue +++ b/src/client/app/desktop/views/components/media-image.vue @@ -1,5 +1,5 @@ <template> -<div class="ldwbgwstjsdgcjruamauqdrffetqudry" v-if="image.isSensitive && hide" @click="hide = false"> +<div class="ldwbgwstjsdgcjruamauqdrffetqudry" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> <div> <b>%fa:exclamation-triangle% %i18n:@sensitive%</b> <span>%i18n:@click-to-show%</span> @@ -27,12 +27,13 @@ export default Vue.extend({ }, raw: { default: false - }, - hide: { - type: Boolean, - default: true } }, + data() { + return { + hide: true + }; + }, computed: { style(): any { return { @@ -48,7 +49,7 @@ export default Vue.extend({ const mouseY = e.clientY - rect.top; const xp = mouseX / this.$el.offsetWidth * 100; const yp = mouseY / this.$el.offsetHeight * 100; - this.$el.style.backgroundPosition = xp + '% ' + yp + '%'; + this.$el.style.backgroundPosition = `${xp}% ${yp}%`; this.$el.style.backgroundImage = `url("${this.image.url}")`; }, @@ -89,7 +90,7 @@ export default Vue.extend({ text-align center font-size 12px - > b + > * display block </style> diff --git a/src/client/app/desktop/views/components/media-video-dialog.vue b/src/client/app/desktop/views/components/media-video-dialog.vue index 959cefa42c..03c93c8939 100644 --- a/src/client/app/desktop/views/components/media-video-dialog.vue +++ b/src/client/app/desktop/views/components/media-video-dialog.vue @@ -28,7 +28,7 @@ export default Vue.extend({ opacity: 0, duration: 100, easing: 'linear', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue index 6c60f2da96..1ff762abc2 100644 --- a/src/client/app/desktop/views/components/media-video.vue +++ b/src/client/app/desktop/views/components/media-video.vue @@ -36,12 +36,13 @@ export default Vue.extend({ }, inlinePlayable: { default: false - }, - hide: { - type: Boolean, - default: true } }, + data() { + return { + hide: true + }; + }, computed: { imageStyle(): any { return { @@ -79,7 +80,6 @@ export default Vue.extend({ justify-content center align-items center font-size 3.5em - cursor zoom-in overflow hidden background-position center @@ -101,5 +101,4 @@ export default Vue.extend({ > b display block - </style> diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue index 1ba4a9a447..7307eeb7dc 100644 --- a/src/client/app/desktop/views/components/note-detail.vue +++ b/src/client/app/desktop/views/components/note-detail.vue @@ -37,20 +37,26 @@ </router-link> </header> <div class="body"> - <div class="text"> - <span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span> - <span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span> - <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> - </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media" :raw="true"/> - </div> - <mk-poll v-if="p.poll" :note="p"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> - <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> - <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> + <p v-if="p.cw != null" class="cw"> + <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="p.cw == null || showContent"> + <div class="text"> + <span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span> + <span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span> + <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> + </div> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files" :raw="true"/> + </div> + <mk-poll v-if="p.poll" :note="p"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> + <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="renote" v-if="p.renote"> + <mk-note-preview :note="p.renote"/> + </div> </div> </div> <footer> @@ -86,6 +92,7 @@ import MkRenoteFormWindow from './renote-form-window.vue'; import MkNoteMenu from '../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './notes.note.sub.vue'; +import { sum } from '../../../../../prelude/array'; export default Vue.extend({ components: { @@ -104,6 +111,7 @@ export default Vue.extend({ data() { return { + showContent: false, conversation: [], conversationFetching: false, replies: [] @@ -114,22 +122,24 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, + p(): any { return this.isRenote ? this.note.renote : this.note; }, + reactionsCount(): number { return this.p.reactionCounts - ? Object.keys(this.p.reactionCounts) - .map(key => this.p.reactionCounts[key]) - .reduce((a, b) => a + b) + ? sum(Object.values(this.p.reactionCounts)) : 0; }, + title(): string { return new Date(this.p.createdAt).toLocaleString(); }, + urls(): string[] { if (this.p.text) { const ast = parse(this.p.text); @@ -184,22 +194,26 @@ export default Vue.extend({ this.conversation = conversation.reverse(); }); }, + reply() { (this as any).os.new(MkPostFormWindow, { reply: this.p }); }, + renote() { (this as any).os.new(MkRenoteFormWindow, { note: this.p }); }, + react() { (this as any).os.new(MkReactionPicker, { source: this.$refs.reactButton, note: this.p }); }, + menu() { (this as any).os.new(MkNoteMenu, { source: this.$refs.menuButton, @@ -327,37 +341,49 @@ root(isDark) > .body padding 8px 0 - > .text + > .cw cursor default display block margin 0 padding 0 overflow-wrap break-word - font-size 1.5em color isDark ? #fff : #717171 - > .renote - margin 8px 0 + > .text + margin-right 8px + + > .content + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.5em + color isDark ? #fff : #717171 + + > .renote + margin 8px 0 - > .mk-note-preview - padding 16px - border dashed 1px #c0dac6 - border-radius 8px + > * + padding 16px + border dashed 1px #c0dac6 + border-radius 8px - > .location - margin 4px 0 - font-size 12px - color #ccc + > .location + margin 4px 0 + font-size 12px + color #ccc - > .map - width 100% - height 300px + > .map + width 100% + height 300px - &:empty - display none + &:empty + display none - > .mk-url-preview - margin-top 8px + > .mk-url-preview + margin-top 8px > footer font-size 1.2em diff --git a/src/client/app/desktop/views/components/note-preview.vue b/src/client/app/desktop/views/components/note-preview.vue index c723db98c0..6c84165356 100644 --- a/src/client/app/desktop/views/components/note-preview.vue +++ b/src/client/app/desktop/views/components/note-preview.vue @@ -1,10 +1,16 @@ <template> -<div class="mk-note-preview" :title="title"> +<div class="qiziqtywpuaucsgarwajitwaakggnisj" :title="title"> <mk-avatar class="avatar" :user="note.user" v-if="!mini"/> <div class="main"> <mk-note-header class="header" :note="note" :mini="true"/> <div class="body"> - <mk-sub-note-content class="text" :note="note"/> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <mk-sub-note-content class="text" :note="note"/> + </div> </div> </div> </div> @@ -25,6 +31,13 @@ export default Vue.extend({ default: false } }, + + data() { + return { + showContent: false + }; + }, + computed: { title(): string { return new Date(this.note.createdAt).toLocaleString(); @@ -52,16 +65,28 @@ root(isDark) > .body - > .text + > .cw cursor default + display block margin 0 padding 0 - color isDark ? #959ba7 : #717171 + overflow-wrap break-word + color isDark ? #fff : #717171 + + > .text + margin-right 8px + + > .content + > .text + cursor default + margin 0 + padding 0 + color isDark ? #959ba7 : #717171 -.mk-note-preview[data-darkmode] +.qiziqtywpuaucsgarwajitwaakggnisj[data-darkmode] root(true) -.mk-note-preview:not([data-darkmode]) +.qiziqtywpuaucsgarwajitwaakggnisj:not([data-darkmode]) root(false) </style> diff --git a/src/client/app/desktop/views/components/notes.note.sub.vue b/src/client/app/desktop/views/components/notes.note.sub.vue index fc851e83e9..8f01ddd43c 100644 --- a/src/client/app/desktop/views/components/notes.note.sub.vue +++ b/src/client/app/desktop/views/components/notes.note.sub.vue @@ -1,10 +1,16 @@ <template> -<div class="sub" :title="title"> +<div class="tkfdzaxtkdeianobciwadajxzbddorql" :title="title"> <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <mk-note-header class="header" :note="note"/> <div class="body"> - <mk-sub-note-content class="text" :note="note"/> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <mk-sub-note-content class="text" :note="note"/> + </div> </div> </div> </div> @@ -14,7 +20,19 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['note'], + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + }, + computed: { title(): string { return new Date(this.note.createdAt).toLocaleString(); @@ -48,20 +66,32 @@ root(isDark) > .body - > .text + > .cw cursor default + display block margin 0 padding 0 - color isDark ? #959ba7 : #717171 + overflow-wrap break-word + color isDark ? #fff : #717171 + + > .text + margin-right 8px + + > .content + > .text + cursor default + margin 0 + padding 0 + color isDark ? #959ba7 : #717171 - pre - max-height 120px - font-size 80% + pre + max-height 120px + font-size 80% -.sub[data-darkmode] +.tkfdzaxtkdeianobciwadajxzbddorql[data-darkmode] root(true) -.sub:not([data-darkmode]) +.tkfdzaxtkdeianobciwadajxzbddorql:not([data-darkmode]) root(false) </style> diff --git a/src/client/app/desktop/views/components/notes.note.vue b/src/client/app/desktop/views/components/notes.note.vue index 7592ae3905..46a866f9a7 100644 --- a/src/client/app/desktop/views/components/notes.note.vue +++ b/src/client/app/desktop/views/components/notes.note.vue @@ -18,7 +18,7 @@ <div class="body"> <p v-if="p.cw != null" class="cw"> <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> - <span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@hide%' : '%i18n:@see-more%' }}</span> + <mk-cw-button v-model="showContent"/> </p> <div class="content" v-show="p.cw == null || showContent"> <div class="text"> @@ -28,15 +28,13 @@ <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/> <a class="rp" v-if="p.renote">RP:</a> </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files"/> </div> <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a> <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> - </div> + <div class="renote" v-if="p.renote"><mk-note-preview :note="p.renote"/></div> <mk-url-preview v-for="url in urls" :url="url" :key="url"/> </div> </div> @@ -78,6 +76,7 @@ import MkRenoteFormWindow from './renote-form-window.vue'; import MkNoteMenu from '../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './notes.note.sub.vue'; +import { sum } from '../../../../../prelude/array'; function focus(el, fn) { const target = fn(el); @@ -95,7 +94,12 @@ export default Vue.extend({ XSub }, - props: ['note'], + props: { + note: { + type: Object, + required: true + } + }, data() { return { @@ -110,7 +114,7 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, @@ -120,9 +124,7 @@ export default Vue.extend({ reactionsCount(): number { return this.p.reactionCounts - ? Object.keys(this.p.reactionCounts) - .map(key => this.p.reactionCounts[key]) - .reduce((a, b) => a + b) + ? sum(Object.values(this.p.reactionCounts)) : 0; }, @@ -399,19 +401,6 @@ root(isDark) > .text margin-right 8px - > .toggle - display inline-block - padding 4px 8px - font-size 0.7em - color isDark ? #393f4f : #fff - background isDark ? #687390 : #b1b9c1 - border-radius 2px - cursor pointer - user-select none - - &:hover - background isDark ? #707b97 : #bbc4ce - > .content > .text @@ -470,7 +459,7 @@ root(isDark) > .renote margin 8px 0 - > .mk-note-preview + > * padding 16px border dashed 1px isDark ? #4e945e : #c0dac6 border-radius 8px diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue index a1c1207a7b..ec9aa285d0 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -10,8 +10,7 @@ </div> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!--<transition-group name="mk-notes" class="transition">--> - <div class="notes"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div"> <template v-for="(note, i) in _notes"> <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> @@ -19,8 +18,7 @@ <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span> </p> </template> - </div> - <!--</transition-group>--> + </component> <footer v-if="more"> <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> @@ -122,7 +120,7 @@ export default Vue.extend({ prepend(note, silent = false) { //#region 弾く const isMyNote = note.userId == this.$store.state.i.id; - const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; if (this.$store.state.settings.showMyRenotes === false) { if (isMyNote && isPureRenote) { diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue index bfe71903e4..2eb80dcd01 100644 --- a/src/client/app/desktop/views/components/notifications.vue +++ b/src/client/app/desktop/views/components/notifications.vue @@ -2,8 +2,7 @@ <div class="mk-notifications"> <div class="notifications" v-if="notifications.length != 0"> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!-- <transition-group name="mk-notifications" class="transition"> --> - <div> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition" tag="div"> <template v-for="(notification, i) in _notifications"> <div class="notification" :class="notification.type" :key="notification.id"> <mk-time :time="notification.createdAt"/> @@ -97,8 +96,7 @@ <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> </p> </template> - </div> - <!-- </transition-group> --> + </component> </div> <button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }} diff --git a/src/client/app/desktop/views/components/post-form-window.vue b/src/client/app/desktop/views/components/post-form-window.vue index 51a416e281..a88c96d1bf 100644 --- a/src/client/app/desktop/views/components/post-form-window.vue +++ b/src/client/app/desktop/views/components/post-form-window.vue @@ -4,7 +4,7 @@ <span class="icon" v-if="geo">%fa:map-marker-alt%</span> <span v-if="!reply">%i18n:@note%</span> <span v-if="reply">%i18n:@reply%</span> - <span class="count" v-if="media.length != 0">{{ '%i18n:@attaches%'.replace('{}', media.length) }}</span> + <span class="count" v-if="files.length != 0">{{ '%i18n:@attaches%'.replace('{}', files.length) }}</span> <span class="count" v-if="uploadings.length != 0">{{ '%i18n:@uploading-media%'.replace('{}', uploadings.length) }}<mk-ellipsis/></span> </span> @@ -14,7 +14,7 @@ :reply="reply" @posted="onPosted" @change-uploadings="onChangeUploadings" - @change-attached-media="onChangeMedia" + @change-attached-files="onChangeFiles" @geo-attached="onGeoAttached" @geo-dettached="onGeoDettached"/> </div> @@ -29,7 +29,7 @@ export default Vue.extend({ data() { return { uploadings: [], - media: [], + files: [], geo: null }; }, @@ -42,8 +42,8 @@ export default Vue.extend({ onChangeUploadings(files) { this.uploadings = files; }, - onChangeMedia(media) { - this.media = media; + onChangeFiles(files) { + this.files = files; }, onGeoAttached(geo) { this.geo = geo; diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue index bacaea65ee..8db85aeaca 100644 --- a/src/client/app/desktop/views/components/post-form.vue +++ b/src/client/app/desktop/views/components/post-form.vue @@ -20,7 +20,7 @@ @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" v-autocomplete="'text'" ></textarea> - <div class="medias" :class="{ with: poll }" v-show="files.length != 0"> + <div class="files" :class="{ with: poll }" v-show="files.length != 0"> <x-draggable :list="files" :options="{ animation: 150 }"> <div v-for="file in files" :key="file.id"> <div class="img" :style="{ backgroundImage: `url(${file.thumbnailUrl})` }" :title="file.name"></div> @@ -35,7 +35,7 @@ <button class="upload" title="%i18n:@attach-media-from-local%" @click="chooseFile">%fa:upload%</button> <button class="drive" title="%i18n:@attach-media-from-drive%" @click="chooseFileFromDrive">%fa:cloud%</button> <button class="kao" title="%i18n:@insert-a-kao%" @click="kao">%fa:R smile%</button> - <button class="poll" title="%i18n:@create-poll%" @click="poll = true">%fa:chart-pie%</button> + <button class="poll" title="%i18n:@create-poll%" @click="poll = !poll">%fa:chart-pie%</button> <button class="poll" title="%i18n:@hide-contents%" @click="useCw = !useCw">%fa:eye-slash%</button> <button class="geo" title="%i18n:@attach-location-information%" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> <button class="visibility" title="%i18n:@visibility%" @click="setVisibility" ref="visibilityButton"> @@ -45,11 +45,11 @@ <span v-if="visibility === 'specified'">%fa:envelope%</span> <span v-if="visibility === 'private'">%fa:lock%</span> </button> - <p class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</p> + <p class="text-count" :class="{ over: this.trimmedLength(text) > 1000 }">{{ 1000 - this.trimmedLength(text) }}</p> <button :class="{ posting }" class="submit" :disabled="!canPost" @click="post"> {{ posting ? '%i18n:@posting%' : submitText }}<mk-ellipsis v-if="posting"/> </button> - <input ref="file" type="file" accept="image/*" multiple="multiple" tabindex="-1" @change="onChangeFile"/> + <input ref="file" type="file" multiple="multiple" tabindex="-1" @change="onChangeFile"/> <div class="dropzone" v-if="draghover"></div> </div> </template> @@ -62,6 +62,9 @@ import getFace from '../../../common/scripts/get-face'; import MkVisibilityChooser from '../../../common/views/components/visibility-chooser.vue'; import parse from '../../../../../mfm/parse'; import { host } from '../../../config'; +import { erase, unique } from '../../../../../prelude/array'; +import { length } from 'stringz'; +import parseAcct from '../../../../../misc/acct/parse'; export default Vue.extend({ components: { @@ -99,7 +102,7 @@ export default Vue.extend({ useCw: false, cw: null, geo: null, - visibility: this.$store.state.device.visibility || 'public', + visibility: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility, visibleUsers: [], autocomplete: null, draghover: false, @@ -110,9 +113,9 @@ export default Vue.extend({ computed: { draftId(): string { return this.renote - ? 'renote:' + this.renote.id + ? `renote:${this.renote.id}` : this.reply - ? 'reply:' + this.reply.id + ? `reply:${this.reply.id}` : 'note'; }, @@ -145,7 +148,7 @@ export default Vue.extend({ canPost(): boolean { return !this.posting && (1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) && - (this.text.trim().length <= 1000); + (length(this.text.trim()) <= 1000); } }, @@ -188,7 +191,7 @@ export default Vue.extend({ (this.$refs.poll as any).set(draft.data.poll); }); } - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); } } @@ -197,6 +200,10 @@ export default Vue.extend({ }, methods: { + trimmedLength(text: string) { + return length(text.trim()); + }, + addTag(tag: string) { insertTextAtCursor(this.$refs.text, ` #${tag} `); }, @@ -225,12 +232,12 @@ export default Vue.extend({ attachMedia(driveFile) { this.files.push(driveFile); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, detachMedia(id) { this.files = this.files.filter(x => x.id != id); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, onChangeFile() { @@ -249,7 +256,7 @@ export default Vue.extend({ this.text = ''; this.files = []; this.poll = false; - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, onKeydown(e) { @@ -297,7 +304,7 @@ export default Vue.extend({ if (driveFile != null && driveFile != '') { const file = JSON.parse(driveFile); this.files.push(file); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); e.preventDefault(); } //#endregion @@ -313,7 +320,7 @@ export default Vue.extend({ this.geo = pos.coords; this.$emit('geo-attached', this.geo); }, err => { - alert('%i18n:@error%: ' + err.message); + alert(`%i18n:@error%: ${err.message}`); }, { enableHighAccuracy: true }); @@ -336,17 +343,16 @@ export default Vue.extend({ addVisibleUser() { (this as any).apis.input({ title: '%i18n:@enter-username%' - }).then(username => { - (this as any).api('users/show', { - username - }).then(user => { + }).then(acct => { + if (acct.startsWith('@')) acct = acct.substr(1); + (this as any).api('users/show', parseAcct(acct)).then(user => { this.visibleUsers.push(user); }); }); }, removeVisibleUser(user) { - this.visibleUsers = this.visibleUsers.filter(u => u != user); + this.visibleUsers = erase(user, this.visibleUsers); }, post() { @@ -354,7 +360,7 @@ export default Vue.extend({ (this as any).api('notes/create', { text: this.text == '' ? undefined : this.text, - mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, replyId: this.reply ? this.reply.id : undefined, renoteId: this.renote ? this.renote.id : undefined, poll: this.poll ? (this.$refs.poll as any).get() : undefined, @@ -391,7 +397,7 @@ export default Vue.extend({ if (this.text && this.text != '') { const hashtags = parse(this.text).filter(x => x.type == 'hashtag').map(x => x.hashtag); const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; - localStorage.setItem('hashtags', JSON.stringify(hashtags.concat(history).reduce((a, c) => a.includes(c) ? a : [...a, c], []))); + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); } }, @@ -514,7 +520,7 @@ root(isDark) margin-right 8px white-space nowrap - > .medias + > .files margin 0 padding 0 background isDark ? #181b23 : lighten($theme-color, 98%) diff --git a/src/client/app/desktop/views/components/renote-form.vue b/src/client/app/desktop/views/components/renote-form.vue index 38eab3362f..c5192ecaac 100644 --- a/src/client/app/desktop/views/components/renote-form.vue +++ b/src/client/app/desktop/views/components/renote-form.vue @@ -1,6 +1,6 @@ <template> <div class="mk-renote-form"> - <mk-note-preview :note="note"/> + <mk-note-preview class="preview" :note="note"/> <template v-if="!quote"> <footer> <a class="quote" v-if="!quote" @click="onQuote">%i18n:@quote%</a> @@ -61,7 +61,7 @@ export default Vue.extend({ root(isDark) - > .mk-note-preview + > .preview margin 16px 22px > footer diff --git a/src/client/app/desktop/views/components/settings-window.vue b/src/client/app/desktop/views/components/settings-window.vue index deb865b102..b4cc570282 100644 --- a/src/client/app/desktop/views/components/settings-window.vue +++ b/src/client/app/desktop/views/components/settings-window.vue @@ -1,13 +1,19 @@ <template> <mk-window ref="window" is-modal width="700px" height="550px" @closed="$destroy"> <span slot="header" :class="$style.header">%fa:cog%%i18n:@settings%</span> - <mk-settings @done="close"/> + <mk-settings :initial-page="initialPage" @done="close"/> </mk-window> </template> <script lang="ts"> import Vue from 'vue'; export default Vue.extend({ + props: { + initialPage: { + type: String, + required: false + } + }, methods: { close() { (this as any).$refs.window.close(); diff --git a/src/client/app/desktop/views/components/settings.drive.vue b/src/client/app/desktop/views/components/settings.drive.vue index e8a3cc9685..d254b27110 100644 --- a/src/client/app/desktop/views/components/settings.drive.vue +++ b/src/client/app/desktop/views/components/settings.drive.vue @@ -1,7 +1,6 @@ <template> <div class="root"> <template v-if="!fetching"> - <el-progress :text-inside="true" :stroke-width="18" :percentage="Math.floor((usage / capacity) * 100)"/> <p><b>{{ capacity | bytes }}</b>%i18n:max%<b>{{ usage | bytes }}</b>%i18n:in-use%</p> </template> </div> diff --git a/src/client/app/desktop/views/components/settings.profile.vue b/src/client/app/desktop/views/components/settings.profile.vue index 262583b640..d47b5b224b 100644 --- a/src/client/app/desktop/views/components/settings.profile.vue +++ b/src/client/app/desktop/views/components/settings.profile.vue @@ -19,7 +19,7 @@ </label> <label class="ui from group"> <p>%i18n:@birthday%</p> - <el-date-picker v-model="birthday" type="date" value-format="yyyy-MM-dd"/> + <input type="date" v-model="birthday"/> </label> <button class="ui primary" @click="save">%i18n:@save%</button> <section> @@ -30,6 +30,7 @@ <h2>%i18n:@other%</h2> <mk-switch v-model="$store.state.i.isBot" @change="onChangeIsBot" text="%i18n:@is-bot%"/> <mk-switch v-model="$store.state.i.isCat" @change="onChangeIsCat" text="%i18n:@is-cat%"/> + <mk-switch v-model="alwaysMarkNsfw" text="%i18n:common.always-mark-nsfw%"/> </section> </div> </template> @@ -46,6 +47,12 @@ export default Vue.extend({ birthday: null, }; }, + computed: { + alwaysMarkNsfw: { + get() { return this.$store.state.i.settings.alwaysMarkNsfw; }, + set(value) { (this as any).api('i/update', { alwaysMarkNsfw: value }); } + }, + }, created() { this.name = this.$store.state.i.name || ''; this.location = this.$store.state.i.profile.location; diff --git a/src/client/app/desktop/views/components/settings.tags.vue b/src/client/app/desktop/views/components/settings.tags.vue new file mode 100644 index 0000000000..a7234f7d87 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.tags.vue @@ -0,0 +1,65 @@ +<template> +<div class="vfcitkilproprqtbnpoertpsziierwzi"> + <div v-for="timeline in timelines" class="timeline"> + <ui-input v-model="timeline.title" @change="save"> + <span>%i18n:@title%</span> + </ui-input> + <ui-textarea :value="timeline.query ? timeline.query.map(tags => tags.join(' ')).join('\n') : ''" @input="onQueryChange(timeline, $event)"> + <span>%i18n:@query%</span> + </ui-textarea> + <ui-button class="save" @click="save">%i18n:@save%</ui-button> + </div> + <ui-button class="add" @click="add">%i18n:@add%</ui-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + data() { + return { + timelines: this.$store.state.settings.tagTimelines + }; + }, + + methods: { + add() { + this.timelines.push({ + id: uuid(), + title: '', + query: '' + }); + + this.save(); + }, + + save() { + this.$store.dispatch('settings/set', { key: 'tagTimelines', value: this.timelines }); + }, + + onQueryChange(timeline, value) { + timeline.query = value.split('\n').map(tags => tags.split(' ')); + } + } +}); +</script> + +<style lang="stylus" scoped> + +root(isDark) + > .timeline + padding-bottom 16px + border-bottom solid 1px rgba(#000, 0.1) + + > .add + margin-top 16px + +.vfcitkilproprqtbnpoertpsziierwzi[data-darkmode] + root(true) + +.vfcitkilproprqtbnpoertpsziierwzi:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue index 7d6f1d55fb..312a7ed56e 100644 --- a/src/client/app/desktop/views/components/settings.vue +++ b/src/client/app/desktop/views/components/settings.vue @@ -5,6 +5,7 @@ <p :class="{ active: page == 'web' }" @mousedown="page = 'web'">%fa:desktop .fw%Web</p> <p :class="{ active: page == 'notification' }" @mousedown="page = 'notification'">%fa:R bell .fw%%i18n:@notification%</p> <p :class="{ active: page == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:@drive%</p> + <p :class="{ active: page == 'hashtags' }" @mousedown="page = 'hashtags'">%fa:hashtag .fw%%i18n:@tags%</p> <p :class="{ active: page == 'mute' }" @mousedown="page = 'mute'">%fa:ban .fw%%i18n:@mute%</p> <p :class="{ active: page == 'apps' }" @mousedown="page = 'apps'">%fa:puzzle-piece .fw%%i18n:@apps%</p> <p :class="{ active: page == 'twitter' }" @mousedown="page = 'twitter'">%fa:B twitter .fw%Twitter</p> @@ -20,12 +21,28 @@ <section class="web" v-show="page == 'web'"> <h1>%i18n:@behaviour%</h1> - <mk-switch v-model="$store.state.settings.fetchOnScroll" @change="onChangeFetchOnScroll" text="%i18n:@fetch-on-scroll%"> + <mk-switch v-model="fetchOnScroll" text="%i18n:@fetch-on-scroll%"> <span>%i18n:@fetch-on-scroll-desc%</span> </mk-switch> <mk-switch v-model="autoPopout" text="%i18n:@auto-popout%"> <span>%i18n:@auto-popout-desc%</span> </mk-switch> + + <section> + <header>%i18n:@note-visibility%</header> + <mk-switch v-model="rememberNoteVisibility" text="%i18n:@remember-note-visibility%"/> + <section> + <header>%i18n:@default-note-visibility%</header> + <ui-select v-model="defaultNoteVisibility"> + <option value="public">%i18n:common.note-visibility.public%</option> + <option value="home">%i18n:common.note-visibility.home%</option> + <option value="followers">%i18n:common.note-visibility.followers%</option> + <option value="specified">%i18n:common.note-visibility.specified%</option> + <option value="private">%i18n:common.note-visibility.private%</option> + </ui-select> + </section> + </section> + <details> <summary>%i18n:@advanced%</summary> <mk-switch v-model="apiViaStream" text="%i18n:@api-via-stream%"> @@ -43,23 +60,27 @@ <button class="ui" @click="updateWallpaper">%i18n:@choose-wallpaper%</button> <button class="ui" @click="deleteWallpaper">%i18n:@delete-wallpaper%</button> <mk-switch v-model="darkmode" text="%i18n:@dark-mode%"/> - <mk-switch v-model="$store.state.settings.circleIcons" @change="onChangeCircleIcons" text="%i18n:@circle-icons%"/> - <mk-switch v-model="$store.state.settings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="%i18n:@gradient-window-header%"/> - <mk-switch v-model="$store.state.settings.iLikeSushi" @change="onChangeILikeSushi" text="%i18n:common.i-like-sushi%"/> + <mk-switch v-model="circleIcons" text="%i18n:@circle-icons%"/> + <mk-switch v-model="reduceMotion" text="%i18n:common.reduce-motion%"/> + <mk-switch v-model="contrastedAcct" text="%i18n:@contrasted-acct%"/> + <mk-switch v-model="showFullAcct" text="%i18n:common.show-full-acct%"/> + <mk-switch v-model="gradientWindowHeader" text="%i18n:@gradient-window-header%"/> + <mk-switch v-model="iLikeSushi" text="%i18n:common.i-like-sushi%"/> </div> - <mk-switch v-model="$store.state.settings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="%i18n:@post-form-on-timeline%"/> - <mk-switch v-model="$store.state.settings.suggestRecentHashtags" @change="onChangeSuggestRecentHashtags" text="%i18n:@suggest-recent-hashtags%"/> - <mk-switch v-model="$store.state.settings.showClockOnHeader" @change="onChangeShowClockOnHeader" text="%i18n:@show-clock-on-header%"/> - <mk-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget" text="%i18n:@show-reply-target%"/> - <mk-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes" text="%i18n:@show-my-renotes%"/> - <mk-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes" text="%i18n:@show-renoted-my-notes%"/> - <mk-switch v-model="$store.state.settings.showLocalRenotes" @change="onChangeShowLocalRenotes" text="%i18n:@show-local-renotes%"/> - <mk-switch v-model="$store.state.settings.showMaps" @change="onChangeShowMaps" text="%i18n:@show-maps%"> + <mk-switch v-model="showPostFormOnTopOfTl" text="%i18n:@post-form-on-timeline%"/> + <mk-switch v-model="suggestRecentHashtags" text="%i18n:@suggest-recent-hashtags%"/> + <mk-switch v-model="showClockOnHeader" text="%i18n:@show-clock-on-header%"/> + <mk-switch v-model="alwaysShowNsfw" text="%i18n:common.always-show-nsfw%"/> + <mk-switch v-model="showReplyTarget" text="%i18n:@show-reply-target%"/> + <mk-switch v-model="showMyRenotes" text="%i18n:@show-my-renotes%"/> + <mk-switch v-model="showRenotedMyNotes" text="%i18n:@show-renoted-my-notes%"/> + <mk-switch v-model="showLocalRenotes" text="%i18n:@show-local-renotes%"/> + <mk-switch v-model="showMaps" text="%i18n:@show-maps%"> <span>%i18n:@show-maps-desc%</span> </mk-switch> - <mk-switch v-model="$store.state.settings.disableAnimatedMfm" @change="onChangeDisableAnimatedMfm" text="%i18n:common.disable-animated-mfm%"/> - <mk-switch v-model="$store.state.settings.games.reversi.showBoardLabels" @change="onChangeReversiBoardLabels" text="%i18n:common.show-reversi-board-labels%"/> - <mk-switch v-model="$store.state.settings.games.reversi.useContrastStones" @change="onChangeUseContrastReversiStones" text="%i18n:common.use-contrast-reversi-stones%"/> + <mk-switch v-model="disableAnimatedMfm" text="%i18n:common.disable-animated-mfm%"/> + <mk-switch v-model="games_reversi_showBoardLabels" text="%i18n:common.show-reversi-board-labels%"/> + <mk-switch v-model="games_reversi_useContrastStones" text="%i18n:common.use-contrast-reversi-stones%"/> </section> <section class="web" v-show="page == 'web'"> @@ -68,32 +89,31 @@ <span>%i18n:@enable-sounds-desc%</span> </mk-switch> <label>%i18n:@volume%</label> - <el-slider + <input type="range" v-model="soundVolume" - :show-input="true" - :format-tooltip="v => `${v * 100}%`" :disabled="!enableSounds" - :max="1" - :step="0.1" + max="1" + step="0.1" /> <button class="ui button" @click="soundTest">%fa:volume-up% %i18n:@test%</button> </section> <section class="web" v-show="page == 'web'"> <h1>%i18n:@mobile%</h1> - <mk-switch v-model="$store.state.settings.disableViaMobile" @change="onChangeDisableViaMobile" text="%i18n:@disable-via-mobile%"/> + <mk-switch v-model="disableViaMobile" text="%i18n:@disable-via-mobile%"/> </section> <section class="web" v-show="page == 'web'"> <h1>%i18n:@language%</h1> - <el-select v-model="lang" placeholder="%i18n:@pick-language%"> - <el-option-group label="%i18n:@recommended%"> - <el-option label="%i18n:@auto%" :value="null"/> - </el-option-group> - <el-option-group label="%i18n:@specify-language%"> - <el-option v-for="x in langs" :label="x[1]" :value="x[0]" :key="x[0]"/> - </el-option-group> - </el-select> + <select v-model="lang" placeholder="%i18n:@pick-language%"> + <optgroup label="%i18n:@recommended%"> + <option value="">%i18n:@auto%</option> + </optgroup> + + <optgroup label="%i18n:@specify-language%"> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </optgroup> + </select> <div class="none ui info"> <p>%fa:info-circle%%i18n:@language-desc%</p> </div> @@ -119,6 +139,11 @@ <x-drive/> </section> + <section class="hashtags" v-show="page == 'hashtags'"> + <h1>%i18n:@tags%</h1> + <x-tags/> + </section> + <section class="mute" v-show="page == 'mute'"> <h1>%i18n:@mute%</h1> <x-mute/> @@ -188,10 +213,6 @@ <mk-switch v-model="enableExperimentalFeatures" text="%i18n:@experimental%"> <span>%i18n:@experimental-desc%</span> </mk-switch> - <details v-if="debug"> - <summary>%i18n:@tools%</summary> - <button class="ui button block" @click="taskmngr">%i18n:@task-manager%</button> - </details> </section> </div> </div> @@ -207,9 +228,9 @@ import XApi from './settings.api.vue'; import XApps from './settings.apps.vue'; import XSignins from './settings.signins.vue'; import XDrive from './settings.drive.vue'; +import XTags from './settings.tags.vue'; import { url, langs, version } from '../../../config'; import checkForUpdate from '../../../common/scripts/check-for-update'; -import MkTaskManager from './taskmanager.vue'; export default Vue.extend({ components: { @@ -220,11 +241,18 @@ export default Vue.extend({ XApi, XApps, XSignins, - XDrive + XDrive, + XTags + }, + props: { + initialPage: { + type: String, + required: false + } }, data() { return { - page: 'profile', + page: this.initialPage || 'profile', meta: null, version, langs, @@ -233,6 +261,11 @@ export default Vue.extend({ }; }, computed: { + reduceMotion: { + get() { return this.$store.state.device.reduceMotion; }, + set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); } + }, + apiViaStream: { get() { return this.$store.state.device.apiViaStream; }, set(value) { this.$store.commit('device/set', { key: 'apiViaStream', value }); } @@ -276,7 +309,112 @@ export default Vue.extend({ enableExperimentalFeatures: { get() { return this.$store.state.device.enableExperimentalFeatures; }, set(value) { this.$store.commit('device/set', { key: 'enableExperimentalFeatures', value }); } - } + }, + + alwaysShowNsfw: { + get() { return this.$store.state.device.alwaysShowNsfw; }, + set(value) { this.$store.commit('device/set', { key: 'alwaysShowNsfw', value }); } + }, + + fetchOnScroll: { + get() { return this.$store.state.settings.fetchOnScroll; }, + set(value) { this.$store.dispatch('settings/set', { key: 'fetchOnScroll', value }); } + }, + + rememberNoteVisibility: { + get() { return this.$store.state.settings.rememberNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); } + }, + + defaultNoteVisibility: { + get() { return this.$store.state.settings.defaultNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } + }, + + showReplyTarget: { + get() { return this.$store.state.settings.showReplyTarget; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } + }, + + showMyRenotes: { + get() { return this.$store.state.settings.showMyRenotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showMyRenotes', value }); } + }, + + showRenotedMyNotes: { + get() { return this.$store.state.settings.showRenotedMyNotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showRenotedMyNotes', value }); } + }, + + showLocalRenotes: { + get() { return this.$store.state.settings.showLocalRenotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showLocalRenotes', value }); } + }, + + showPostFormOnTopOfTl: { + get() { return this.$store.state.settings.showPostFormOnTopOfTl; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showPostFormOnTopOfTl', value }); } + }, + + suggestRecentHashtags: { + get() { return this.$store.state.settings.suggestRecentHashtags; }, + set(value) { this.$store.dispatch('settings/set', { key: 'suggestRecentHashtags', value }); } + }, + + showClockOnHeader: { + get() { return this.$store.state.settings.showClockOnHeader; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showClockOnHeader', value }); } + }, + + showMaps: { + get() { return this.$store.state.settings.showMaps; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showMaps', value }); } + }, + + circleIcons: { + get() { return this.$store.state.settings.circleIcons; }, + set(value) { this.$store.dispatch('settings/set', { key: 'circleIcons', value }); } + }, + + contrastedAcct: { + get() { return this.$store.state.settings.contrastedAcct; }, + set(value) { this.$store.dispatch('settings/set', { key: 'contrastedAcct', value }); } + }, + + showFullAcct: { + get() { return this.$store.state.settings.showFullAcct; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showFullAcct', value }); } + }, + + iLikeSushi: { + get() { return this.$store.state.settings.iLikeSushi; }, + set(value) { this.$store.dispatch('settings/set', { key: 'iLikeSushi', value }); } + }, + + games_reversi_showBoardLabels: { + get() { return this.$store.state.settings.games.reversi.showBoardLabels; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.showBoardLabels', value }); } + }, + + games_reversi_useContrastStones: { + get() { return this.$store.state.settings.games.reversi.useContrastStones; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useContrastStones', value }); } + }, + + disableAnimatedMfm: { + get() { return this.$store.state.settings.disableAnimatedMfm; }, + set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); } + }, + + disableViaMobile: { + get() { return this.$store.state.settings.disableViaMobile; }, + set(value) { this.$store.dispatch('settings/set', { key: 'disableViaMobile', value }); } + }, + + gradientWindowHeader: { + get() { return this.$store.state.settings.gradientWindowHeader; }, + set(value) { this.$store.dispatch('settings/set', { key: 'gradientWindowHeader', value }); } + }, }, created() { (this as any).os.getMeta().then(meta => { @@ -284,9 +422,6 @@ export default Vue.extend({ }); }, methods: { - taskmngr() { - (this as any).os.new(MkTaskManager); - }, customizeHome() { this.$router.push('/i/customize-home'); this.$emit('done'); @@ -305,113 +440,11 @@ export default Vue.extend({ wallpaperId: null }); }, - onChangeFetchOnScroll(v) { - this.$store.dispatch('settings/set', { - key: 'fetchOnScroll', - value: v - }); - }, onChangeAutoWatch(v) { (this as any).api('i/update', { autoWatch: v }); }, - onChangeDark(v) { - this.$store.dispatch('settings/set', { - key: 'dark', - value: v - }); - }, - onChangeShowPostFormOnTopOfTl(v) { - this.$store.dispatch('settings/set', { - key: 'showPostFormOnTopOfTl', - value: v - }); - }, - onChangeSuggestRecentHashtags(v) { - this.$store.dispatch('settings/set', { - key: 'suggestRecentHashtags', - value: v - }); - }, - onChangeShowClockOnHeader(v) { - this.$store.dispatch('settings/set', { - key: 'showClockOnHeader', - value: v - }); - }, - onChangeShowReplyTarget(v) { - this.$store.dispatch('settings/set', { - key: 'showReplyTarget', - value: v - }); - }, - onChangeShowMyRenotes(v) { - this.$store.dispatch('settings/set', { - key: 'showMyRenotes', - value: v - }); - }, - onChangeShowRenotedMyNotes(v) { - this.$store.dispatch('settings/set', { - key: 'showRenotedMyNotes', - value: v - }); - }, - onChangeShowLocalRenotes(v) { - this.$store.dispatch('settings/set', { - key: 'showLocalRenotes', - value: v - }); - }, - onChangeShowMaps(v) { - this.$store.dispatch('settings/set', { - key: 'showMaps', - value: v - }); - }, - onChangeCircleIcons(v) { - this.$store.dispatch('settings/set', { - key: 'circleIcons', - value: v - }); - }, - onChangeILikeSushi(v) { - this.$store.dispatch('settings/set', { - key: 'iLikeSushi', - value: v - }); - }, - onChangeReversiBoardLabels(v) { - this.$store.dispatch('settings/set', { - key: 'games.reversi.showBoardLabels', - value: v - }); - }, - onChangeUseContrastReversiStones(v) { - this.$store.dispatch('settings/set', { - key: 'games.reversi.useContrastStones', - value: v - }); - }, - onChangeDisableAnimatedMfm(v) { - this.$store.dispatch('settings/set', { - key: 'disableAnimatedMfm', - value: v - }); - }, - onChangeGradientWindowHeader(v) { - this.$store.dispatch('settings/set', { - key: 'gradientWindowHeader', - value: v - }); - }, - onChangeDisableViaMobile(v) { - this.$store.dispatch('settings/set', { - key: 'disableViaMobile', - value: v - }); - }, checkForUpdate() { this.checkingForUpdate = true; checkForUpdate((this as any).os, true, true).then(newer => { diff --git a/src/client/app/desktop/views/components/sub-note-content.vue b/src/client/app/desktop/views/components/sub-note-content.vue index cb0374b910..6889dc231e 100644 --- a/src/client/app/desktop/views/components/sub-note-content.vue +++ b/src/client/app/desktop/views/components/sub-note-content.vue @@ -7,9 +7,9 @@ <misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/> <a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RP: ...</a> </div> - <details v-if="note.media.length > 0"> - <summary>({{ '%i18n:@media-count%'.replace('{}', note.media.length) }})</summary> - <mk-media-list :media-list="note.media"/> + <details v-if="note.files.length > 0"> + <summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary> + <mk-media-list :media-list="note.files"/> </details> <details v-if="note.poll"> <summary>%i18n:@poll%</summary> diff --git a/src/client/app/desktop/views/components/taskmanager.vue b/src/client/app/desktop/views/components/taskmanager.vue deleted file mode 100644 index 1f1385add8..0000000000 --- a/src/client/app/desktop/views/components/taskmanager.vue +++ /dev/null @@ -1,219 +0,0 @@ -<template> -<mk-window ref="window" width="750px" height="500px" @closed="$destroy" name="TaskManager"> - <span slot="header" :class="$style.header">%fa:stethoscope%%i18n:@title%</span> - <el-tabs :class="$style.content"> - <el-tab-pane label="Requests"> - <el-table - :data="os.requests" - style="width: 100%" - :default-sort="{prop: 'date', order: 'descending'}" - > - <el-table-column type="expand"> - <template slot-scope="props"> - <pre>{{ props.row.data }}</pre> - <pre>{{ props.row.res }}</pre> - </template> - </el-table-column> - - <el-table-column - label="Requested at" - prop="date" - sortable - > - <template slot-scope="scope"> - <b style="margin-right: 8px">{{ scope.row.date.getTime() }}</b> - <span>(<mk-time :time="scope.row.date"/>)</span> - </template> - </el-table-column> - - <el-table-column - label="Name" - > - <template slot-scope="scope"> - <b>{{ scope.row.name }}</b> - </template> - </el-table-column> - - <el-table-column - label="Status" - > - <template slot-scope="scope"> - <span>{{ scope.row.status || '(pending)' }}</span> - </template> - </el-table-column> - </el-table> - </el-tab-pane> - - <el-tab-pane label="Streams"> - <el-table - :data="os.connections" - style="width: 100%" - > - <el-table-column - label="Uptime" - > - <template slot-scope="scope"> - <mk-timer v-if="scope.row.connectedAt" :time="scope.row.connectedAt"/> - <span v-else>-</span> - </template> - </el-table-column> - - <el-table-column - label="Name" - > - <template slot-scope="scope"> - <b>{{ scope.row.name == '' ? '[Home]' : scope.row.name }}</b> - </template> - </el-table-column> - - <el-table-column - label="User" - > - <template slot-scope="scope"> - <span>{{ scope.row.user || '(anonymous)' }}</span> - </template> - </el-table-column> - - <el-table-column - prop="state" - label="State" - /> - - <el-table-column - prop="in" - label="In" - /> - - <el-table-column - prop="out" - label="Out" - /> - </el-table> - </el-tab-pane> - - <el-tab-pane label="Streams (Inspect)"> - <el-tabs type="card" style="height:50%"> - <el-tab-pane v-for="c in os.connections" :label="c.name == '' ? '[Home]' : c.name" :key="c.id" :name="c.id" ref="connectionsTab"> - <div style="padding: 12px 0 0 12px"> - <el-button size="mini" @click="send(c)">Send</el-button> - <el-button size="mini" type="warning" @click="c.isSuspended = true" v-if="!c.isSuspended">Suspend</el-button> - <el-button size="mini" type="success" @click="c.isSuspended = false" v-else>Resume</el-button> - <el-button size="mini" type="danger" @click="c.close">Disconnect</el-button> - </div> - - <el-table - :data="c.inout" - style="width: 100%" - :default-sort="{prop: 'at', order: 'descending'}" - > - <el-table-column type="expand"> - <template slot-scope="props"> - <pre>{{ props.row.data }}</pre> - </template> - </el-table-column> - - <el-table-column - label="Date" - prop="at" - sortable - > - <template slot-scope="scope"> - <b style="margin-right: 8px">{{ scope.row.at.getTime() }}</b> - <span>(<mk-time :time="scope.row.at"/>)</span> - </template> - </el-table-column> - - <el-table-column - label="Type" - > - <template slot-scope="scope"> - <span>{{ getMessageType(scope.row.data) }}</span> - </template> - </el-table-column> - - <el-table-column - label="Incoming / Outgoing" - prop="type" - /> - </el-table> - </el-tab-pane> - </el-tabs> - </el-tab-pane> - - <el-tab-pane label="Windows"> - <el-table - :data="Array.from(os.windows.windows)" - style="width: 100%" - > - <el-table-column - label="Name" - > - <template slot-scope="scope"> - <b>{{ scope.row.name || '(unknown)' }}</b> - </template> - </el-table-column> - - <el-table-column - label="Operations" - > - <template slot-scope="scope"> - <el-button size="mini" type="danger" @click="scope.row.close">Close</el-button> - </template> - </el-table-column> - </el-table> - </el-tab-pane> - </el-tabs> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - mounted() { - (this as any).os.windows.on('added', this.onWindowsChanged); - (this as any).os.windows.on('removed', this.onWindowsChanged); - }, - beforeDestroy() { - (this as any).os.windows.off('added', this.onWindowsChanged); - (this as any).os.windows.off('removed', this.onWindowsChanged); - }, - methods: { - getMessageType(data): string { - return data.type ? data.type : '-'; - }, - onWindowsChanged() { - this.$forceUpdate(); - }, - send(c) { - (this as any).apis.input({ - title: 'Send a JSON message', - allowEmpty: false - }).then(json => { - c.send(JSON.parse(json)); - }); - } - } -}); -</script> - -<style lang="stylus" module> -.header - > [data-fa] - margin-right 4px - -.content - height 100% - overflow auto - -</style> - -<style> -.el-tabs__header { - margin-bottom: 0 !important; -} - -.el-tabs__item { - padding: 0 20px !important; -} -</style> diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index 25fd5d36ac..d2176dee87 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -15,6 +15,7 @@ <script lang="ts"> import Vue from 'vue'; +import { HashtagStream } from '../../../common/scripts/streaming/hashtag'; const fetchLimit = 10; @@ -23,6 +24,9 @@ export default Vue.extend({ src: { type: String, required: true + }, + tagTl: { + required: false } }, @@ -31,6 +35,7 @@ export default Vue.extend({ fetching: true, moreFetching: false, existMore: false, + streamManager: null, connection: null, connectionId: null, date: null @@ -42,21 +47,14 @@ export default Vue.extend({ return this.$store.state.i.followingCount == 0; }, - stream(): any { - switch (this.src) { - case 'home': return (this as any).os.stream; - case 'local': return (this as any).os.streams.localTimelineStream; - case 'hybrid': return (this as any).os.streams.hybridTimelineStream; - case 'global': return (this as any).os.streams.globalTimelineStream; - } - }, - endpoint(): string { switch (this.src) { case 'home': return 'notes/timeline'; case 'local': return 'notes/local-timeline'; case 'hybrid': return 'notes/hybrid-timeline'; case 'global': return 'notes/global-timeline'; + case 'mentions': return 'notes/mentions'; + case 'tag': return 'notes/search_by_tag'; } }, @@ -66,13 +64,36 @@ export default Vue.extend({ }, mounted() { - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.connection.on('note', this.onNote); - if (this.src == 'home') { + if (this.src == 'tag') { + this.connection = new HashtagStream((this as any).os, this.$store.state.i, this.tagTl.query); + this.connection.on('note', this.onNote); + } else if (this.src == 'home') { + this.streamManager = (this as any).os.stream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('note', this.onNote); this.connection.on('follow', this.onChangeFollowing); this.connection.on('unfollow', this.onChangeFollowing); + } else if (this.src == 'local') { + this.streamManager = (this as any).os.streams.localTimelineStream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('note', this.onNote); + } else if (this.src == 'hybrid') { + this.streamManager = (this as any).os.streams.hybridTimelineStream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('note', this.onNote); + } else if (this.src == 'global') { + this.streamManager = (this as any).os.streams.globalTimelineStream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('note', this.onNote); + } else if (this.src == 'mentions') { + this.streamManager = (this as any).os.stream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('mention', this.onNote); } document.addEventListener('keydown', this.onKeydown); @@ -81,12 +102,27 @@ export default Vue.extend({ }, beforeDestroy() { - this.connection.off('note', this.onNote); - if (this.src == 'home') { + if (this.src == 'tag') { + this.connection.off('note', this.onNote); + this.connection.close(); + } else if (this.src == 'home') { + this.connection.off('note', this.onNote); this.connection.off('follow', this.onChangeFollowing); this.connection.off('unfollow', this.onChangeFollowing); + this.streamManager.dispose(this.connectionId); + } else if (this.src == 'local') { + this.connection.off('note', this.onNote); + this.streamManager.dispose(this.connectionId); + } else if (this.src == 'hybrid') { + this.connection.off('note', this.onNote); + this.streamManager.dispose(this.connectionId); + } else if (this.src == 'global') { + this.connection.off('note', this.onNote); + this.streamManager.dispose(this.connectionId); + } else if (this.src == 'mentions') { + this.connection.off('mention', this.onNote); + this.streamManager.dispose(this.connectionId); } - this.stream.dispose(this.connectionId); document.removeEventListener('keydown', this.onKeydown); }, @@ -101,7 +137,8 @@ export default Vue.extend({ untilDate: this.date ? this.date.getTime() : undefined, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl ? this.tagTl.query : undefined }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -124,7 +161,8 @@ export default Vue.extend({ untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl ? this.tagTl.query : undefined }); promise.then(notes => { diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue index 52a7753438..2dc84004df 100644 --- a/src/client/app/desktop/views/components/timeline.vue +++ b/src/client/app/desktop/views/components/timeline.vue @@ -2,16 +2,23 @@ <div class="mk-timeline"> <header> <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% %i18n:@home%</span> - <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% %i18n:@local%</span> - <span :data-active="src == 'hybrid'" @click="src = 'hybrid'">%fa:share-alt% %i18n:@hybrid%</span> + <span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline">%fa:R comments% %i18n:@local%</span> + <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline">%fa:share-alt% %i18n:@hybrid%</span> <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span> + <span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%</span> + <span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl">%fa:hashtag% {{ tagTl.title }}</span> <span :data-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span> - <button @click="chooseList" title="%i18n:@list%">%fa:list%</button> + <div class="buttons"> + <button @click="chooseTag" title="%i18n:@hashtag%" ref="tagButton">%fa:hashtag%</button> + <button @click="chooseList" title="%i18n:@list%" ref="listButton">%fa:list%</button> + </div> </header> <x-core v-if="src == 'home'" ref="tl" key="home" src="home"/> <x-core v-if="src == 'local'" ref="tl" key="local" src="local"/> <x-core v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/> <x-core v-if="src == 'global'" ref="tl" key="global" src="global"/> + <x-core v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/> + <x-core v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/> <mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/> </div> </template> @@ -19,7 +26,8 @@ <script lang="ts"> import Vue from 'vue'; import XCore from './timeline.core.vue'; -import MkUserListsWindow from './user-lists-window.vue'; +import Menu from '../../../common/views/components/menu.vue'; +import MkSettingsWindow from './settings-window.vue'; export default Vue.extend({ components: { @@ -29,7 +37,9 @@ export default Vue.extend({ data() { return { src: 'home', - list: null + list: null, + tagTl: null, + enableLocalTimeline: false }; }, @@ -38,16 +48,28 @@ export default Vue.extend({ this.saveSrc(); }, - list() { + list(x) { this.saveSrc(); + if (x != null) this.tagTl = null; + }, + + tagTl(x) { + this.saveSrc(); + if (x != null) this.list = null; } }, created() { + (this as any).os.getMeta().then(meta => { + this.enableLocalTimeline = !meta.disableLocalTimeline; + }); + if (this.$store.state.device.tl) { this.src = this.$store.state.device.tl.src; if (this.src == 'list') { this.list = this.$store.state.device.tl.arg; + } else if (this.src == 'tag') { + this.tagTl = this.$store.state.device.tl.arg; } } else if (this.$store.state.i.followingCount == 0) { this.src = 'hybrid'; @@ -64,7 +86,7 @@ export default Vue.extend({ saveSrc() { this.$store.commit('device/setTl', { src: this.src, - arg: this.list + arg: this.src == 'list' ? this.list : this.tagTl }); }, @@ -72,12 +94,74 @@ export default Vue.extend({ (this.$refs.tl as any).warp(date); }, - chooseList() { - const w = (this as any).os.new(MkUserListsWindow); - w.$once('choosen', list => { - this.list = list; - this.src = 'list'; - w.close(); + async chooseList() { + const lists = await (this as any).api('users/lists/list'); + + let menu = [{ + icon: '%fa:plus%', + text: '%i18n:@add-list%', + action: () => { + (this as any).apis.input({ + title: '%i18n:@list-name%', + }).then(async title => { + const list = await (this as any).api('users/lists/create', { + title + }); + + this.list = list; + this.src = 'list'; + }); + } + }]; + + if (lists.length > 0) { + menu.push(null); + } + + menu = menu.concat(lists.map(list => ({ + icon: '%fa:list%', + text: list.title, + action: () => { + this.list = list; + this.src = 'list'; + } + }))); + + this.os.new(Menu, { + source: this.$refs.listButton, + compact: false, + items: menu + }); + }, + + chooseTag() { + let menu = [{ + icon: '%fa:plus%', + text: '%i18n:@add-tag-timeline%', + action: () => { + (this as any).os.new(MkSettingsWindow, { + initialPage: 'hashtags' + }); + } + }]; + + if (this.$store.state.settings.tagTimelines.length > 0) { + menu.push(null); + } + + menu = menu.concat(this.$store.state.settings.tagTimelines.map(t => ({ + icon: '%fa:hashtag%', + text: t.title, + action: () => { + this.tagTl = t; + this.src = 'tag'; + } + }))); + + this.os.new(Menu, { + source: this.$refs.tagButton, + compact: false, + items: menu }); } } @@ -99,22 +183,24 @@ root(isDark) border-radius 6px 6px 0 0 box-shadow 0 1px isDark ? rgba(#000, 0.15) : rgba(#000, 0.08) - > button + > .buttons position absolute z-index 2 top 0 right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color isDark ? #9baec8 : #ccc + padding-right 8px - &:hover - color isDark ? #b2c1d5 : #aaa + > button + padding 0 8px + font-size 0.9em + line-height 42px + color isDark ? #9baec8 : #ccc + + &:hover + color isDark ? #b2c1d5 : #aaa - &:active - color isDark ? #b2c1d5 : #999 + &:active + color isDark ? #b2c1d5 : #999 > span display inline-block diff --git a/src/client/app/desktop/views/components/ui-notification.vue b/src/client/app/desktop/views/components/ui-notification.vue index 68413914c0..7519124870 100644 --- a/src/client/app/desktop/views/components/ui-notification.vue +++ b/src/client/app/desktop/views/components/ui-notification.vue @@ -27,7 +27,7 @@ export default Vue.extend({ translateY: -64, duration: 500, easing: 'easeInElastic', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); }, 6000); }); diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue index 6de4eaf744..ac8a6c7765 100644 --- a/src/client/app/desktop/views/components/ui.header.vue +++ b/src/client/app/desktop/views/components/ui.header.vue @@ -1,5 +1,6 @@ <template> <div class="header"> + <p class="warn" v-if="env != 'production'">%i18n:common.do-not-use-in-production%</p> <mk-special-message/> <div class="main" ref="main"> <div class="backdrop"></div> @@ -28,6 +29,7 @@ <script lang="ts"> import Vue from 'vue'; import * as anime from 'animejs'; +import { env } from '../../../config'; import XNav from './ui.header.nav.vue'; import XSearch from './ui.header.search.vue'; @@ -43,7 +45,13 @@ export default Vue.extend({ XAccount, XNotifications, XPost, - XClock, + XClock + }, + + data() { + return { + env: env + }; }, mounted() { @@ -119,6 +127,15 @@ root(isDark) width 100% box-shadow 0 1px 1px rgba(#000, 0.075) + > .warn + display block + margin 0 + padding 4px + text-align center + font-size 12px + background #f00 + color #fff + > .main height 48px diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue index 1e1755ec3c..f6d6d68a7f 100644 --- a/src/client/app/desktop/views/components/user-preview.vue +++ b/src/client/app/desktop/views/components/user-preview.vue @@ -75,7 +75,7 @@ export default Vue.extend({ 'margin-top': '-8px', duration: 200, easing: 'easeOutQuad', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } diff --git a/src/client/app/desktop/views/components/users-list.item.vue b/src/client/app/desktop/views/components/users-list.item.vue index 262fd38cd1..f42d577fce 100644 --- a/src/client/app/desktop/views/components/users-list.item.vue +++ b/src/client/app/desktop/views/components/users-list.item.vue @@ -1,17 +1,16 @@ <template> -<div class="root item"> - <mk-avatar class="avatar" :user="user"/> - <div class="main"> - <header> - <router-link class="name" :to="user | userPage" v-user-preview="user.id">{{ user | userName }}</router-link> - <span class="username">@{{ user | acct }}</span> - </header> - <div class="body"> - <p class="followed" v-if="user.isFollowed">%i18n:@followed%</p> - <div class="description">{{ user.description }}</div> +<div class="zvdbznxvfixtmujpsigoccczftvpiwqh"> + <div class="banner" :style="bannerStyle"></div> + <mk-avatar class="avatar" :user="user" :disable-preview="true"/> + <div class="body"> + <router-link :to="user | userPage" class="name">{{ user | userName }}</router-link> + <span class="username">@{{ user | acct }}</span> + <div class="description"> + <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> </div> + <p class="followed" v-if="user.isFollowed">%i18n:@followed%</p> + <mk-follow-button :user="user" :size="'big'"/> </div> - <mk-follow-button :user="user"/> </div> </template> @@ -19,76 +18,69 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['user'] + props: ['user'], + + computed: { + bannerStyle(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundColor: this.user.bannerColor && this.user.bannerColor.length == 3 ? `rgb(${ this.user.bannerColor.join(',') })` : null, + backgroundImage: `url(${ this.user.bannerUrl })` + }; + } + }, }); </script> <style lang="stylus" scoped> -.root.item - padding 16px +.zvdbznxvfixtmujpsigoccczftvpiwqh + $bg = #fff + + margin 16px auto + max-width calc(100% - 32px) font-size 16px + text-align center + background $bg + box-shadow 0 2px 4px rgba(0, 0, 0, 0.1) - &:after - content "" - display block - clear both + > .banner + height 100px + background-color #f9f4f4 + background-position center + background-size cover > .avatar display block - float left - margin 0 16px 0 0 - width 58px - height 58px - border-radius 8px - - > .main - float left - width calc(100% - 74px) - - > header - margin-bottom 2px + margin -40px auto 0 auto + width 80px + height 80px + border-radius 100% + border solid 4px $bg - > .name - display inline - margin 0 - padding 0 - color #777 - font-size 1em - font-weight 700 - text-align left - text-decoration none + > .body + padding 4px 32px 32px 32px - &:hover - text-decoration underline + @media (max-width 400px) + padding 4px 16px 16px 16px - > .username - text-align left - margin 0 0 0 8px - color #ccc + > .name + font-size 20px + font-weight bold - > .body - > .followed - display inline-block - margin 0 0 4px 0 - padding 2px 8px - vertical-align top - font-size 10px - color #71afc7 - background #eefaff - border-radius 4px + > .username + display block + opacity 0.7 - > .description - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1.1em - color #717171 + > .description + margin 16px 0 - > .mk-follow-button - position absolute - top 16px - right 16px + > .followed + margin 0 0 16px 0 + padding 0 + line-height 24px + font-size 0.8em + color #71afc7 + background #eefaff + border-radius 4px </style> diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue index 0423db8ed7..05e2f4e5b3 100644 --- a/src/client/app/desktop/views/components/users-list.vue +++ b/src/client/app/desktop/views/components/users-list.vue @@ -33,7 +33,7 @@ export default Vue.extend({ props: ['fetch', 'count', 'youKnowCount'], data() { return { - limit: 30, + limit: 20, mode: 'all', fetching: true, moreFetching: false, @@ -73,10 +73,14 @@ export default Vue.extend({ .mk-users-list height 100% - background #fff + overflow auto + background #eee > nav - z-index 1 + z-index 10 + position sticky + top 0 + background #fff box-shadow 0 1px 0 rgba(#000, 0.1) > div @@ -114,16 +118,14 @@ export default Vue.extend({ background #eee border-radius 20px - > .users - height calc(100% - 54px) - overflow auto - - > * - border-bottom solid 1px rgba(#000, 0.05) + > button + display block + width calc(100% - 32px) + margin 16px + padding 16px - > * - max-width 600px - margin 0 auto + &:hover + background rgba(#000, 0.1) > .no margin 0 diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue index ec044ad27e..30f0ec558f 100644 --- a/src/client/app/desktop/views/components/window.vue +++ b/src/client/app/desktop/views/components/window.vue @@ -106,7 +106,7 @@ export default Vue.extend({ mounted() { if (this.preventMount) { - this.$destroy(); + this.destroyDom(); return; } @@ -190,7 +190,7 @@ export default Vue.extend({ }); setTimeout(() => { - this.$destroy(); + this.destroyDom(); this.$emit('closed'); }, 300); }, diff --git a/src/client/app/desktop/views/pages/admin/admin.announcements.vue b/src/client/app/desktop/views/pages/admin/admin.announcements.vue new file mode 100644 index 0000000000..532400deb2 --- /dev/null +++ b/src/client/app/desktop/views/pages/admin/admin.announcements.vue @@ -0,0 +1,41 @@ +<template> +<div class="qldxjjsrseehkusjuoooapmsprvfrxyl mk-admin-card"> + <header>%i18n:@announcements%</header> + <textarea v-model="broadcasts"></textarea> + <button class="ui" @click="save">%i18n:@save%</button> +</div> +</template> + +<script lang="ts"> +import Vue from "vue"; + +export default Vue.extend({ + data() { + return { + broadcasts: '', + }; + }, + created() { + (this as any).os.getMeta().then(meta => { + this.broadcasts = JSON.stringify(meta.broadcasts, null, ' '); + }); + }, + methods: { + save() { + (this as any).api('admin/update-meta', { + broadcasts: JSON.parse(this.broadcasts) + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.qldxjjsrseehkusjuoooapmsprvfrxyl + textarea + width 100% + min-height 300px + +</style> diff --git a/src/client/app/desktop/views/pages/admin/admin.dashboard.vue b/src/client/app/desktop/views/pages/admin/admin.dashboard.vue index ebb54d782e..c86c30db17 100644 --- a/src/client/app/desktop/views/pages/admin/admin.dashboard.vue +++ b/src/client/app/desktop/views/pages/admin/admin.dashboard.vue @@ -1,22 +1,34 @@ <template> <div class="obdskegsannmntldydackcpzezagxqfy mk-admin-card"> <header>%i18n:@dashboard%</header> + <div v-if="stats" class="stats"> <div><b>%fa:user% {{ stats.originalUsersCount | number }}</b><span>%i18n:@original-users%</span></div> <div><span>%fa:user% {{ stats.usersCount | number }}</span><span>%i18n:@all-users%</span></div> <div><b>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</b><span>%i18n:@original-notes%</span></div> <div><span>%fa:pencil-alt% {{ stats.notesCount | number }}</span><span>%i18n:@all-notes%</span></div> </div> + <div class="cpu-memory"> <x-cpu-memory :connection="connection"/> </div> - <div> - <label> - <input type="checkbox" v-model="disableRegistration" @change="updateMeta"> - <span>disableRegistration</span> - </label> - <button class="ui" @click="invite">%i18n:@invite%</button> - <p v-if="inviteCode">Code: <code>{{ inviteCode }}</code></p> + + <div class="form"> + <div> + <label> + <input type="checkbox" v-model="disableRegistration" @change="updateMeta"> + <span>%i18n:@disableRegistration%</span> + </label> + <button class="ui" @click="invite">%i18n:@invite%</button> + <p v-if="inviteCode">Code: <code>{{ inviteCode }}</code></p> + </div> + + <div> + <label> + <input type="checkbox" v-model="disableLocalTimeline" @change="updateMeta"> + <span>%i18n:@disableLocalTimeline%</span> + </label> + </div> </div> </div> </template> @@ -33,6 +45,7 @@ export default Vue.extend({ return { stats: null, disableRegistration: false, + disableLocalTimeline: false, inviteCode: null, connection: null, connectionId: null @@ -44,6 +57,7 @@ export default Vue.extend({ (this as any).os.getMeta().then(meta => { this.disableRegistration = meta.disableRegistration; + this.disableLocalTimeline = meta.disableLocalTimeline; }); (this as any).api('stats').then(stats => { @@ -61,7 +75,8 @@ export default Vue.extend({ }, updateMeta() { (this as any).api('admin/update-meta', { - disableRegistration: this.disableRegistration + disableRegistration: this.disableRegistration, + disableLocalTimeline: this.disableLocalTimeline }); } } @@ -97,4 +112,8 @@ export default Vue.extend({ border solid 1px #eee border-radius: 8px + > .form + > div + border-bottom solid 1px #eee + </style> diff --git a/src/client/app/desktop/views/pages/admin/admin.hashtags.vue b/src/client/app/desktop/views/pages/admin/admin.hashtags.vue new file mode 100644 index 0000000000..c6bf20361f --- /dev/null +++ b/src/client/app/desktop/views/pages/admin/admin.hashtags.vue @@ -0,0 +1,41 @@ +<template> +<div class="jdnqwkzlnxcfftthoybjxrebyolvoucw mk-admin-card"> + <header>%i18n:@hided-tags%</header> + <textarea v-model="hidedTags"></textarea> + <button class="ui" @click="save">%i18n:@save%</button> +</div> +</template> + +<script lang="ts"> +import Vue from "vue"; + +export default Vue.extend({ + data() { + return { + hidedTags: '', + }; + }, + created() { + (this as any).os.getMeta().then(meta => { + this.hidedTags = meta.hidedTags.join('\n'); + }); + }, + methods: { + save() { + (this as any).api('admin/update-meta', { + hidedTags: this.hidedTags.split('\n') + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.jdnqwkzlnxcfftthoybjxrebyolvoucw + textarea + width 100% + min-height 300px + +</style> diff --git a/src/client/app/desktop/views/pages/admin/admin.vue b/src/client/app/desktop/views/pages/admin/admin.vue index 3438462cd6..510252b447 100644 --- a/src/client/app/desktop/views/pages/admin/admin.vue +++ b/src/client/app/desktop/views/pages/admin/admin.vue @@ -4,6 +4,9 @@ <ul> <li @click="nav('dashboard')" :class="{ active: page == 'dashboard' }">%fa:chalkboard .fw%%i18n:@dashboard%</li> <li @click="nav('users')" :class="{ active: page == 'users' }">%fa:users .fw%%i18n:@users%</li> + <li @click="nav('announcements')" :class="{ active: page == 'announcements' }">%fa:broadcast-tower .fw%%i18n:@announcements%</li> + <li @click="nav('hashtags')" :class="{ active: page == 'hashtags' }">%fa:hashtag .fw%%i18n:@hashtags%</li> + <!-- <li @click="nav('drive')" :class="{ active: page == 'drive' }">%fa:cloud .fw%%i18n:@drive%</li> --> <!-- <li @click="nav('update')" :class="{ active: page == 'update' }">%i18n:@update%</li> --> </ul> @@ -13,6 +16,12 @@ <x-dashboard/> <x-charts/> </div> + <div v-show="page == 'announcements'"> + <x-announcements/> + </div> + <div v-show="page == 'hashtags'"> + <x-hashtags/> + </div> <div v-if="page == 'users'"> <x-suspend-user/> <x-unsuspend-user/> @@ -28,6 +37,8 @@ <script lang="ts"> import Vue from "vue"; import XDashboard from "./admin.dashboard.vue"; +import XAnnouncements from "./admin.announcements.vue"; +import XHashtags from "./admin.hashtags.vue"; import XSuspendUser from "./admin.suspend-user.vue"; import XUnsuspendUser from "./admin.unsuspend-user.vue"; import XVerifyUser from "./admin.verify-user.vue"; @@ -37,6 +48,8 @@ import XCharts from "../../components/charts.vue"; export default Vue.extend({ components: { XDashboard, + XAnnouncements, + XHashtags, XSuspendUser, XUnsuspendUser, XVerifyUser, diff --git a/src/client/app/desktop/views/pages/deck/deck.column-core.vue b/src/client/app/desktop/views/pages/deck/deck.column-core.vue index 7f219c0be1..a320f697b3 100644 --- a/src/client/app/desktop/views/pages/deck/deck.column-core.vue +++ b/src/client/app/desktop/views/pages/deck/deck.column-core.vue @@ -6,6 +6,8 @@ <x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked"/> <x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked"/> <x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked"/> +<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked"/> +<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked"/> </template> <script lang="ts"> @@ -13,12 +15,14 @@ import Vue from 'vue'; import XTlColumn from './deck.tl-column.vue'; import XNotificationsColumn from './deck.notifications-column.vue'; import XWidgetsColumn from './deck.widgets-column.vue'; +import XMentionsColumn from './deck.mentions-column.vue'; export default Vue.extend({ components: { XTlColumn, XNotificationsColumn, - XWidgetsColumn + XWidgetsColumn, + XMentionsColumn }, props: { diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue index d59d430da6..abb09775fb 100644 --- a/src/client/app/desktop/views/pages/deck/deck.column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.column.vue @@ -3,18 +3,20 @@ @dragover.prevent.stop="onDragover" @dragenter.prevent="onDragenter" @dragleave="onDragleave" - @drop.prevent.stop="onDrop" -> + @drop.prevent.stop="onDrop"> <header :class="{ indicate: count > 0 }" draggable="true" - @click="toggleActive" + @click="goTop" @dragstart="onDragstart" @dragend="onDragend" - @contextmenu.prevent.stop="onContextmenu" - > + @contextmenu.prevent.stop="onContextmenu"> + <button class="toggleActive" @click="toggleActive" v-if="isStacked"> + <template v-if="active">%fa:angle-up%</template> + <template v-else>%fa:angle-down%</template> + </button> <slot name="header"></slot> <span class="count" v-if="count > 0">({{ count }})</span> - <button ref="menu" @click.stop="showMenu">%fa:caret-down%</button> + <button class="menu" ref="menu" @click.stop="showMenu">%fa:caret-down%</button> </header> <div ref="body" v-show="active"> <slot></slot> @@ -26,6 +28,7 @@ import Vue from 'vue'; import Menu from '../../../../common/views/components/menu.vue'; import contextmenu from '../../../api/contextmenu'; +import { countIf } from '../../../../../../prelude/array'; export default Vue.extend({ props: { @@ -115,7 +118,7 @@ export default Vue.extend({ toggleActive() { if (!this.isStacked) return; const vms = this.$store.state.settings.deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id)); - if (this.active && vms.filter(vm => vm.$el.classList.contains('active')).length == 1) return; + if (this.active && countIf(vm => vm.$el.classList.contains('active'), vms) == 1) return; this.active = !this.active; }, @@ -211,6 +214,13 @@ export default Vue.extend({ }); }, + goTop() { + this.$refs.body.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }, + onDragstart(e) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('mk-deck-column', this.column.id); @@ -302,6 +312,7 @@ root(isDark) color #bbb > header + display flex z-index 1 line-height $header-height padding 0 16px @@ -328,10 +339,8 @@ root(isDark) margin-left 4px opacity 0.5 - > button - position absolute - top 0 - right 0 + > .toggleActive + > .menu width $header-height line-height $header-height font-size 16px @@ -343,6 +352,13 @@ root(isDark) &:active color isDark ? #b2c1d5 : #999 + > .toggleActive + margin-left -16px + + > .menu + margin-left auto + margin-right -16px + > div height "calc(100% - %s)" % $header-height overflow auto diff --git a/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue new file mode 100644 index 0000000000..f38d5a6df5 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue @@ -0,0 +1,117 @@ +<template> + <x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; +import { HashtagStream } from '../../../../common/scripts/streaming/hashtag'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + tagTl: { + type: Object, + required: true + }, + mediaOnly: { + type: Boolean, + required: false, + default: false + }, + mediaView: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + + watch: { + mediaOnly() { + this.fetch(); + } + }, + + mounted() { + if (this.connection) this.connection.close(); + this.connection = new HashtagStream((this as any).os, this.$store.state.i, this.tagTl.query); + this.connection.on('note', this.onNote); + + this.fetch(); + }, + + beforeDestroy() { + this.connection.close(); + }, + + methods: { + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/search_by_tag', { + limit: fetchLimit + 1, + withFiles: this.mediaOnly, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl.query + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, + more() { + this.moreFetching = true; + + const promise = (this as any).api('notes/search_by_tag', { + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + withFiles: this.mediaOnly, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl.query + }); + + promise.then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + } else { + this.existMore = false; + } + notes.forEach(n => (this.$refs.timeline as any).append(n)); + this.moreFetching = false; + }); + + return promise; + }, + onNote(note) { + if (this.mediaOnly && note.files.length == 0) return; + + // Prepend a note + (this.$refs.timeline as any).prepend(note); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/deck/deck.list-tl.vue b/src/client/app/desktop/views/pages/deck/deck.list-tl.vue index 70048f99e3..e82e76e4d0 100644 --- a/src/client/app/desktop/views/pages/deck/deck.list-tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.list-tl.vue @@ -68,7 +68,7 @@ export default Vue.extend({ (this as any).api('notes/user-list-timeline', { listId: this.list.id, limit: fetchLimit + 1, - mediaOnly: this.mediaOnly, + withFiles: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, includeLocalRenotes: this.$store.state.settings.showLocalRenotes @@ -90,7 +90,7 @@ export default Vue.extend({ listId: this.list.id, limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id, - mediaOnly: this.mediaOnly, + withFiles: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, includeLocalRenotes: this.$store.state.settings.showLocalRenotes @@ -109,7 +109,7 @@ export default Vue.extend({ return promise; }, onNote(note) { - if (this.mediaOnly && note.media.length == 0) return; + if (this.mediaOnly && note.files.length == 0) return; // Prepend a note (this.$refs.timeline as any).prepend(note); diff --git a/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue b/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue new file mode 100644 index 0000000000..8ec10164f2 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue @@ -0,0 +1,38 @@ +<template> +<x-column :name="name" :column="column" :is-stacked="isStacked"> + <span slot="header">%fa:at%{{ name }}</span> + + <x-mentions/> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XColumn from './deck.column.vue'; +import XMentions from './deck.mentions.vue'; + +export default Vue.extend({ + components: { + XColumn, + XMentions + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + computed: { + name(): string { + if (this.column.name) return this.column.name; + return '%i18n:common.deck.mentions%'; + } + }, +}); +</script> diff --git a/src/client/app/desktop/views/pages/deck/deck.mentions.vue b/src/client/app/desktop/views/pages/deck/deck.mentions.vue new file mode 100644 index 0000000000..cecb75f067 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.mentions.vue @@ -0,0 +1,93 @@ +<template> + <x-notes ref="timeline" :more="existMore ? more : null"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + }, + + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null, + connectionId: null + }; + }, + + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('mention', this.onNote); + + this.fetch(); + }, + + beforeDestroy() { + this.connection.off('mention', this.onNote); + (this as any).os.stream.dispose(this.connectionId); + }, + + methods: { + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/mentions', { + limit: fetchLimit + 1, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, + more() { + this.moreFetching = true; + + const promise = (this as any).api('notes/mentions', { + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }); + + promise.then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + } else { + this.existMore = false; + } + notes.forEach(n => (this.$refs.timeline as any).append(n)); + this.moreFetching = false; + }); + + return promise; + }, + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/deck/deck.note.vue b/src/client/app/desktop/views/pages/deck/deck.note.vue index e6d062eac9..980fb03136 100644 --- a/src/client/app/desktop/views/pages/deck/deck.note.vue +++ b/src/client/app/desktop/views/pages/deck/deck.note.vue @@ -18,7 +18,7 @@ <div class="body"> <p v-if="p.cw != null" class="cw"> <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> - <span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@less%' : '%i18n:@more%' }}</span> + <mk-cw-button v-model="showContent"/> </p> <div class="content" v-show="p.cw == null || showContent"> <div class="text"> @@ -28,14 +28,15 @@ <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> <a class="rp" v-if="p.renote != null">RP:</a> </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files"/> </div> <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> <div class="renote" v-if="p.renote"> <mk-note-preview :note="p.renote" :mini="true"/> </div> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="false" :mini="true"/> </div> <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> </div> @@ -53,11 +54,11 @@ </article> </div> <div v-else class="srwrkujossgfuhrbnvqkybtzxpblgchi"> - <div v-if="note.media.length > 0"> - <mk-media-list :media-list="note.media"/> + <div v-if="note.files.length > 0"> + <mk-media-list :media-list="note.files"/> </div> - <div v-if="note.renote && note.renote.media.length > 0"> - <mk-media-list :media-list="note.renote.media"/> + <div v-if="note.renote && note.renote.files.length > 0"> + <mk-media-list :media-list="note.renote.files"/> </div> </div> </template> @@ -99,7 +100,7 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, @@ -370,7 +371,7 @@ root(isDark) .mk-url-preview margin-top 8px - > .media + > .files > img display block max-width 100% @@ -393,7 +394,7 @@ root(isDark) > .renote margin 8px 0 - > .mk-note-preview + > * padding 16px border dashed 1px isDark ? #4e945e : #c0dac6 border-radius 8px diff --git a/src/client/app/desktop/views/pages/deck/deck.notes.vue b/src/client/app/desktop/views/pages/deck/deck.notes.vue index f7fca5de92..2e7e30f12a 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notes.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue @@ -127,7 +127,7 @@ export default Vue.extend({ prepend(note, silent = false) { //#region 弾く const isMyNote = note.userId == this.$store.state.i.id; - const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; if (this.$store.state.settings.showMyRenotes === false) { if (isMyNote && isPureRenote) { diff --git a/src/client/app/desktop/views/pages/deck/deck.notifications.vue b/src/client/app/desktop/views/pages/deck/deck.notifications.vue index fcb74b9140..f73f221b7b 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notifications.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notifications.vue @@ -1,8 +1,7 @@ <template> <div class="oxynyeqmfvracxnglgulyqfgqxnxmehl"> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!--<transition-group name="mk-notifications" class="transition notifications">--> - <div class="notifications"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications"> <template v-for="(notification, i) in _notifications"> <x-notification class="notification" :notification="notification" :key="notification.id"/> <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> @@ -10,8 +9,7 @@ <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> </p> </template> - </div> - <!--</transition-group>--> + </component> <button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }} </button> diff --git a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue index 231b505f5d..550b1be628 100644 --- a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue @@ -6,6 +6,7 @@ <template v-if="column.type == 'hybrid'">%fa:share-alt%</template> <template v-if="column.type == 'global'">%fa:globe%</template> <template v-if="column.type == 'list'">%fa:list%</template> + <template v-if="column.type == 'hashtag'">%fa:hashtag%</template> <span>{{ name }}</span> </span> @@ -14,6 +15,7 @@ <mk-switch v-model="column.isMediaView" @change="onChangeSettings" text="%i18n:@is-media-view%"/> </div> <x-list-tl v-if="column.type == 'list'" :list="column.list" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/> + <x-hashtag-tl v-if="column.type == 'hashtag'" :tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/> <x-tl v-else :src="column.type" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/> </x-column> </template> @@ -23,12 +25,14 @@ import Vue from 'vue'; import XColumn from './deck.column.vue'; import XTl from './deck.tl.vue'; import XListTl from './deck.list-tl.vue'; +import XHashtagTl from './deck.hashtag-tl.vue'; export default Vue.extend({ components: { XColumn, XTl, - XListTl + XListTl, + XHashtagTl }, props: { @@ -65,6 +69,7 @@ export default Vue.extend({ case 'hybrid': return '%i18n:common.deck.hybrid%'; case 'global': return '%i18n:common.deck.global%'; case 'list': return this.column.list.title; + case 'hashtag': return this.$store.state.settings.tagTimelines.find(x => x.id == this.column.tagTlId).title; } } }, diff --git a/src/client/app/desktop/views/pages/deck/deck.tl.vue b/src/client/app/desktop/views/pages/deck/deck.tl.vue index a9e4d489c3..120ceb7fc2 100644 --- a/src/client/app/desktop/views/pages/deck/deck.tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue @@ -96,7 +96,7 @@ export default Vue.extend({ (this.$refs.timeline as any).init(() => new Promise((res, rej) => { (this as any).api(this.endpoint, { limit: fetchLimit + 1, - mediaOnly: this.mediaOnly, + withFiles: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, includeLocalRenotes: this.$store.state.settings.showLocalRenotes @@ -117,7 +117,7 @@ export default Vue.extend({ const promise = (this as any).api(this.endpoint, { limit: fetchLimit + 1, - mediaOnly: this.mediaOnly, + withFiles: this.mediaOnly, untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, @@ -138,7 +138,7 @@ export default Vue.extend({ }, onNote(note) { - if (this.mediaOnly && note.media.length == 0) return; + if (this.mediaOnly && note.files.length == 0) return; // Prepend a note (this.$refs.timeline as any).prepend(note); diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue index 26b989656e..aafe9a45d3 100644 --- a/src/client/app/desktop/views/pages/deck/deck.vue +++ b/src/client/app/desktop/views/pages/deck/deck.vue @@ -85,6 +85,7 @@ export default Vue.extend({ }, mounted() { + document.title = (this as any).os.instanceName; document.documentElement.style.overflow = 'hidden'; }, @@ -138,6 +139,15 @@ export default Vue.extend({ }); } }, { + icon: '%fa:at%', + text: '%i18n:common.deck.mentions%', + action: () => { + this.$store.dispatch('settings/addDeckColumn', { + id: uuid(), + type: 'mentions' + }); + } + }, { icon: '%fa:list%', text: '%i18n:common.deck.list%', action: () => { @@ -152,6 +162,20 @@ export default Vue.extend({ }); } }, { + icon: '%fa:hashtag%', + text: '%i18n:common.deck.hashtag%', + action: () => { + (this as any).apis.input({ + title: '%i18n:@enter-hashtag-tl-title%' + }).then(title => { + this.$store.dispatch('settings/addDeckColumn', { + id: uuid(), + type: 'hashtag', + tagTlId: this.$store.state.settings.tagTimelines.find(x => x.title == title).id + }); + }); + } + }, { icon: '%fa:bell R%', text: '%i18n:common.deck.notifications%', action: () => { diff --git a/src/client/app/desktop/views/pages/drive.vue b/src/client/app/desktop/views/pages/drive.vue index 217dcb7751..dec6c4551a 100644 --- a/src/client/app/desktop/views/pages/drive.vue +++ b/src/client/app/desktop/views/pages/drive.vue @@ -31,7 +31,7 @@ export default Vue.extend({ const title = folder.name + ' | %i18n:@title%'; // Rewrite URL - history.pushState(null, title, '/i/drive/folder/' + folder.id); + history.pushState(null, title, `/i/drive/folder/${folder.id}`); document.title = title; } diff --git a/src/client/app/desktop/views/pages/games/reversi.vue b/src/client/app/desktop/views/pages/games/reversi.vue index ce9b42c65f..1b0e790a22 100644 --- a/src/client/app/desktop/views/pages/games/reversi.vue +++ b/src/client/app/desktop/views/pages/games/reversi.vue @@ -16,10 +16,10 @@ export default Vue.extend({ methods: { nav(game, actualNav) { if (actualNav) { - this.$router.push('/reversi/' + game.id); + this.$router.push(`/reversi/${game.id}`); } else { // TODO: https://github.com/vuejs/vue-router/issues/703 - this.$router.push('/reversi/' + game.id); + this.$router.push(`/reversi/${game.id}`); } } } diff --git a/src/client/app/desktop/views/pages/messaging-room.vue b/src/client/app/desktop/views/pages/messaging-room.vue index 1ebd53cef4..4be33dda04 100644 --- a/src/client/app/desktop/views/pages/messaging-room.vue +++ b/src/client/app/desktop/views/pages/messaging-room.vue @@ -46,7 +46,7 @@ export default Vue.extend({ this.user = user; this.fetching = false; - document.title = 'メッセージ: ' + getUserName(this.user); + document.title = `メッセージ: ${getUserName(this.user)}`; Progress.done(); }); diff --git a/src/client/app/desktop/views/pages/stats/stats.vue b/src/client/app/desktop/views/pages/stats/stats.vue index 41005b6398..7a4e4ab5ce 100644 --- a/src/client/app/desktop/views/pages/stats/stats.vue +++ b/src/client/app/desktop/views/pages/stats/stats.vue @@ -43,7 +43,7 @@ export default Vue.extend({ > .stats display flex justify-content center - margin-bottom 16px + margin 0 auto 16px auto padding 32px background #fff box-shadow 0 2px 8px rgba(#000, 0.1) @@ -60,5 +60,6 @@ export default Vue.extend({ font-size 70% > div - max-width 850px + max-width 950px + margin 0 auto </style> diff --git a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue index e4a771910a..0e7e3f1d77 100644 --- a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue +++ b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue @@ -1,5 +1,5 @@ <template> -<div class="followers-you-know"> +<div class="vahgrswmbzfdlmomxnqftuueyvwaafth"> <p class="title">%fa:users%%i18n:@title%</p> <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p> <div v-if="!fetching && users.length > 0"> @@ -36,8 +36,8 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.followers-you-know - background #fff +root(isDark) + background isDark ? #282C37 : #fff border solid 1px rgba(#000, 0.075) border-radius 6px @@ -48,7 +48,7 @@ export default Vue.extend({ line-height 42px font-size 0.9em font-weight bold - color #888 + color isDark ? #e3e5e8 : #888 box-shadow 0 1px rgba(#000, 0.07) > i @@ -77,4 +77,10 @@ export default Vue.extend({ > i margin-right 4px +.vahgrswmbzfdlmomxnqftuueyvwaafth[data-darkmode] + root(true) + +.vahgrswmbzfdlmomxnqftuueyvwaafth:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/pages/user/user.friends.vue b/src/client/app/desktop/views/pages/user/user.friends.vue index 516eea0288..a238565588 100644 --- a/src/client/app/desktop/views/pages/user/user.friends.vue +++ b/src/client/app/desktop/views/pages/user/user.friends.vue @@ -1,5 +1,5 @@ <template> -<div class="friends"> +<div class="hozptpaliadatkehcmcayizwzwwctpbc"> <p class="title">%fa:users%%i18n:@title%</p> <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p> <template v-if="!fetching && users.length != 0"> @@ -41,7 +41,6 @@ export default Vue.extend({ <style lang="stylus" scoped> root(isDark) -.friends background isDark ? #282C37 : #fff border solid 1px rgba(#000, 0.075) border-radius 6px @@ -113,10 +112,10 @@ root(isDark) top 16px right 16px -.friends[data-darkmode] +.hozptpaliadatkehcmcayizwzwwctpbc[data-darkmode] root(true) -.friends:not([data-darkmode]) +.hozptpaliadatkehcmcayizwzwwctpbc:not([data-darkmode]) root(false) </style> diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue index d8f4656ed0..4b434ec219 100644 --- a/src/client/app/desktop/views/pages/user/user.header.vue +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -6,7 +6,7 @@ <div class="title"> <p class="name">{{ user | userName }}</p> <div> - <span class="username"><mk-acct :user="user"/></span> + <span class="username"><mk-acct :user="user" :detail="true" /></span> <span v-if="user.isBot" title="%i18n:@is-bot%">%fa:robot%</span> <span class="location" v-if="user.host === null && user.profile.location">%fa:map-marker% {{ user.profile.location }}</span> <span class="birthday" v-if="user.host === null && user.profile.birthday">%fa:birthday-cake% {{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</span> diff --git a/src/client/app/desktop/views/pages/user/user.photos.vue b/src/client/app/desktop/views/pages/user/user.photos.vue index 8397e56484..c5cd9e24fe 100644 --- a/src/client/app/desktop/views/pages/user/user.photos.vue +++ b/src/client/app/desktop/views/pages/user/user.photos.vue @@ -1,5 +1,5 @@ <template> -<div class="photos"> +<div class="dzsuvbsrrrwobdxifudxuefculdfiaxd"> <p class="title">%fa:camera%%i18n:@title%</p> <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p> <div class="stream" v-if="!fetching && images.length > 0"> @@ -24,12 +24,12 @@ export default Vue.extend({ mounted() { (this as any).api('users/notes', { userId: this.user.id, - withMedia: true, + withFiles: true, limit: 9 }).then(notes => { notes.forEach(note => { - note.media.forEach(media => { - if (this.images.length < 9) this.images.push(media); + note.files.forEach(file => { + if (this.images.length < 9) this.images.push(file); }); }); this.fetching = false; @@ -40,7 +40,6 @@ export default Vue.extend({ <style lang="stylus" scoped> root(isDark) -.photos background isDark ? #282C37 : #fff border solid 1px rgba(#000, 0.075) border-radius 6px @@ -88,10 +87,10 @@ root(isDark) > i margin-right 4px -.photos[data-darkmode] +.dzsuvbsrrrwobdxifudxuefculdfiaxd[data-darkmode] root(true) -.photos:not([data-darkmode]) +.dzsuvbsrrrwobdxifudxuefculdfiaxd:not([data-darkmode]) root(false) </style> diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue index 67987fcb94..54221380a7 100644 --- a/src/client/app/desktop/views/pages/user/user.timeline.vue +++ b/src/client/app/desktop/views/pages/user/user.timeline.vue @@ -66,7 +66,7 @@ export default Vue.extend({ limit: fetchLimit + 1, untilDate: this.date ? this.date.getTime() : undefined, includeReplies: this.mode == 'with-replies', - withMedia: this.mode == 'with-media' + withFiles: this.mode == 'with-media' }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -86,7 +86,7 @@ export default Vue.extend({ userId: this.user.id, limit: fetchLimit + 1, includeReplies: this.mode == 'with-replies', - withMedia: this.mode == 'with-media', + withFiles: this.mode == 'with-media', untilId: (this.$refs.timeline as any).tail().id }); diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue index ac2f921a21..ea1734f8c7 100644 --- a/src/client/app/desktop/views/pages/welcome.vue +++ b/src/client/app/desktop/views/pages/welcome.vue @@ -1,45 +1,145 @@ <template> <div class="mk-welcome"> - <img ref="pointer" class="pointer" src="/assets/pointer.png" alt=""> <button @click="dark"> <template v-if="$store.state.device.darkmode">%fa:moon%</template> <template v-else>%fa:R moon%</template> </button> - <div class="body"> - <div class="container"> - <div class="info"> - <span><b>{{ host }}</b></span> - <span class="stats" v-if="stats"> - <span>%fa:user% {{ stats.originalUsersCount | number }}</span> - <span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span> - </span> - </div> - <main> - <div class="about"> + + <mk-forkit class="forkit"/> + + <main> + <div class="body"> + <div class="main block"> + <div> <h1 v-if="name != 'Misskey'">{{ name }}</h1> <h1 v-else><img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name"></h1> - <p class="powerd-by" v-if="name != 'Misskey'" v-html="'%i18n:@powered-by-misskey%'"></p> - <p class="desc" v-html="description || '%i18n:common.about%'"></p> - <a ref="signup" @click="signup">📦 %i18n:@signup%</a> + + <div class="info"> + <span><b>{{ host }}</b> - <span v-html="'%i18n:@powered-by-misskey%'"></span></span> + <span class="stats" v-if="stats"> + <span>%fa:user% {{ stats.originalUsersCount | number }}</span> + <span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span> + </span> + </div> + + <div class="desc"> + <span class="desc" v-html="description || '%i18n:common.about%'"></span> + <a class="about" @click="about">%i18n:@about%</a> + </div> + + <p class="sign"> + <span class="signup" @click="signup">%i18n:@signup%</span> + <span class="divider">|</span> + <span class="signin" @click="signin">%i18n:@signin%</span> + </p> + + <img src="/assets/ai.png" alt="" title="藍" class="char"> + </div> + </div> + + <div class="announcements block"> + <header>%fa:broadcast-tower% %i18n:@announcements%</header> + <div v-if="announcements && announcements.length > 0"> + <div v-for="announcement in announcements"> + <h1 v-html="announcement.title"></h1> + <div v-html="announcement.text"></div> + </div> + </div> + </div> + + <div class="photos block"> + <header>%fa:images% %i18n:@photos%</header> + <div> + <div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div> </div> - <div class="login"> - <mk-signin/> + </div> + + <div class="tag-cloud block"> + <div> + <mk-tag-cloud/> + </div> + </div> + + <div class="nav block"> + <div> + <mk-nav class="nav"/> + </div> + </div> + + <div class="side"> + <div class="trends block"> + <div> + <mk-trends/> + </div> + </div> + + <div class="tl block"> + <header>%fa:comment-alt R% %i18n:@timeline%</header> + <div> + <mk-welcome-timeline class="tl" :max="20"/> + </div> + </div> + + <div class="info block"> + <header>%fa:info-circle% %i18n:@info%</header> + <div> + <div v-if="meta" class="body"> + <p>Version: <b>{{ meta.version }}</b></p> + <p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p> + </div> + </div> </div> - </main> - <div class="hashtags"> - <router-link v-for="tag in tags" :key="tag" :to="`/tags/${ tag }`" :title="tag">#{{ tag }}</router-link> </div> - <mk-nav class="nav"/> </div> - <mk-forkit class="forkit"/> - <img src="assets/title.dark.svg" :alt="name"> - </div> - <div class="tl"> - <mk-welcome-timeline :max="20"/> - </div> - <modal name="signup" width="500px" height="auto" scrollable> - <header :class="$style.signupFormHeader">%i18n:@signup%</header> - <mk-signup :class="$style.signupForm"/> + </main> + + <modal name="about" :class="$store.state.device.darkmode ? ['about', 'modal-dark'] : ['about', 'modal-light']" width="800px" height="auto" scrollable> + <article class="fpdezooorhntlzyeszemrsqdlgbysvxq"> + <h1>%i18n:common.intro.title%</h1> + <p v-html="'%i18n:common.intro.about%'"></p> + <section> + <h2>%i18n:common.intro.features%</h2> + <section> + <div class="body"> + <h3>%i18n:common.intro.rich-contents%</h3> + <p v-html="'%i18n:common.intro.rich-contents-desc%'"></p> + </div> + <div class="image"><img src="/assets/about/post.png" alt=""></div> + </section> + <section> + <div class="body"> + <h3>%i18n:common.intro.reaction%</h3> + <p v-html="'%i18n:common.intro.reaction-desc%'"></p> + </div> + <div class="image"><img src="/assets/about/reaction.png" alt=""></div> + </section> + <section> + <div class="body"> + <h3>%i18n:common.intro.ui%</h3> + <p v-html="'%i18n:common.intro.ui-desc%'"></p> + </div> + <div class="image"><img src="/assets/about/ui.png" alt=""></div> + </section> + <section> + <div class="body"> + <h3>%i18n:common.intro.drive%</h3> + <p v-html="'%i18n:common.intro.drive-desc%'"></p> + </div> + <div class="image"><img src="/assets/about/drive.png" alt=""></div> + </section> + </section> + <p v-html="'%i18n:common.intro.outro%'"></p> + </article> + </modal> + + <modal name="signup" :class="$store.state.device.darkmode ? 'modal-dark' : 'modal-light'" width="450px" height="auto" scrollable> + <header class="formHeader">%i18n:@signup%</header> + <mk-signup class="form"/> + </modal> + + <modal name="signin" :class="$store.state.device.darkmode ? 'modal-dark' : 'modal-light'" width="450px" height="auto" scrollable> + <header class="formHeader">%i18n:@signin%</header> + <mk-signin class="form"/> </modal> </div> </template> @@ -47,52 +147,62 @@ <script lang="ts"> import Vue from 'vue'; import { host, copyright } from '../../../config'; +import { concat } from '../../../../../prelude/array'; export default Vue.extend({ data() { return { + meta: null, stats: null, copyright, host, name: 'Misskey', description: '', - pointerInterval: null, - tags: [] + announcements: [], + photos: [] }; }, + created() { (this as any).os.getMeta().then(meta => { + this.meta = meta; this.name = meta.name; this.description = meta.description; + this.announcements = meta.broadcasts; }); (this as any).api('stats').then(stats => { this.stats = stats; }); - (this as any).api('hashtags/trend').then(stats => { - this.tags = stats.map(x => x.tag); + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; + + (this as any).api('notes/local-timeline', { + fileType: image, + limit: 6 + }).then((notes: any[]) => { + const files = concat(notes.map((n: any): any[] => n.files)); + this.photos = files.filter(f => image.includes(f.type)).slice(0, 6); }); }, - mounted() { - this.point(); - this.pointerInterval = setInterval(this.point, 100); - }, - beforeDestroy() { - clearInterval(this.pointerInterval); - }, + methods: { - point() { - const x = this.$refs.signup.getBoundingClientRect(); - this.$refs.pointer.style.top = x.top + x.height + 'px'; - this.$refs.pointer.style.left = x.left + 'px'; + about() { + this.$modal.show('about'); }, + signup() { this.$modal.show('signup'); }, + signin() { this.$modal.show('signin'); }, + dark() { this.$store.commit('device/set', { key: 'darkmode', @@ -103,11 +213,88 @@ export default Vue.extend({ }); </script> -<style> -#wait { - right: auto; - left: 15px; -} +<style lang="stylus"> +#wait + right auto + left 15px + +.v--modal-overlay + background rgba(0, 0, 0, 0.6) + +.modal-light + .v--modal-box + color #777 + + .formHeader + border-bottom solid 1px #eee + +.modal-dark + .v--modal-box + background #313543 + color #fff + + .formHeader + border-bottom solid 1px rgba(#000, 0.2) + +.modal-light +.modal-dark + .form + padding 24px 48px 48px 48px + + .formHeader + text-align center + padding 48px 0 12px 0 + margin 0 48px + font-size 1.5em + +.v--modal-overlay.about + .v--modal-box.v--modal + margin 32px 0 + +.fpdezooorhntlzyeszemrsqdlgbysvxq + padding 64px + + > p:last-child + margin-bottom 0 + + > h1 + margin-top 0 + + > section + > h2 + border-bottom 1px solid isDark ? rgba(#000, 0.2) : rgba(#000, 0.05) + + > section + display grid + grid-template-rows 1fr + grid-template-columns 180px 1fr + gap 32px + margin-bottom 32px + padding-bottom 32px + border-bottom 1px solid isDark ? rgba(#000, 0.2) : rgba(#000, 0.05) + + &:nth-child(odd) + grid-template-columns 1fr 180px + + > .body + grid-column 1 + + > .image + grid-column 2 + + > .body + grid-row 1 + grid-column 2 + + > .image + grid-row 1 + grid-column 1 + + > img + display block + width 100% + height 100% + object-fit cover </style> <style lang="stylus" scoped> @@ -116,176 +303,200 @@ export default Vue.extend({ root(isDark) display flex min-height 100vh + //background-color #00070F + //background-image url('/assets/bg.jpg') + //background-position center + //background-size cover - > .pointer - display block + > .forkit position absolute - z-index 1 top 0 right 0 - width 180px - margin 0 0 0 -180px - transform rotateY(180deg) translateX(-10px) translateY(-48px) - pointer-events none > button position fixed z-index 1 - top 0 - left 0 + bottom 16px + left 16px padding 16px font-size 18px - color #fff + color isDark ? #fff : #444 - display none // TODO + > main + margin 0 auto + padding 64px + width 100% + max-width 1200px - > .body - flex 1 - padding 64px 0 0 0 - text-align center - background #578394 - background-position center - background-size cover - - &:before - content '' - display block - position absolute - top 0 - left 0 - right 0 - bottom 0 - background rgba(#000, 0.5) - - > .forkit - position absolute - top 0 - right 0 + .block + color isDark ? #fff : #444 + background isDark ? #282C37 : #fff + box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) + //border-radius 8px + overflow auto - > img - position absolute - bottom 16px - right 16px - width 150px + > header + z-index 1 + padding 0 16px + line-height 48px + background isDark ? #313543 : #fff - > .container - $aboutWidth = 380px - $loginWidth = 340px - $width = $aboutWidth + $loginWidth + if !isDark + box-shadow 0 1px 0px rgba(0, 0, 0, 0.1) - > .info - margin 0 auto 16px auto - width $width - font-size 14px - color #fff + & + div + max-height calc(100% - 48px) - > .stats - margin-left 16px - padding-left 16px - border-left solid 1px #fff + > div + overflow auto - > * - margin-right 16px + > .body + display grid + grid-template-rows 390px 1fr 256px 64px + grid-template-columns 1fr 1fr 350px + gap 16px + height 1150px - > main - display flex - margin auto - width $width - border-radius 8px - overflow hidden - box-shadow 0 2px 8px rgba(#000, 0.3) + > .main + grid-row 1 + grid-column 1 / 3 + border-top solid 5px $theme-color - > .about - width $aboutWidth - color #444 - background #fff + > div + padding 32px + min-height 100% > h1 - margin 0 0 16px 0 - padding 32px 32px 0 32px - color #444 + margin 0 > img - width 170px - vertical-align bottom + margin -8px 0 0 -16px + max-width 280px - > .powerd-by - margin 16px - opacity 0.7 + > .info + margin 0 auto 16px auto + width $width + font-size 14px + + > .stats + margin-left 16px + padding-left 16px + border-left solid 1px isDark ? #fff : #444 + + > * + margin-right 16px > .desc - margin 0 - padding 0 32px 16px 32px + max-width calc(100% - 150px) - > a - display inline-block - margin 0 0 32px 0 - font-weight bold + > .sign + font-size 120% + margin-bottom 0 - > .login - width $loginWidth - padding 16px 32px 32px 32px - background isDark ? #2e3440 : #f5f5f5 + > .divider + margin 0 16px - > .hashtags - margin 16px auto - width $width - font-size 14px - color #fff - background rgba(#000, 0.3) - border-radius 8px + > .signin + > .signup + cursor pointer - > * - display inline-block - margin 14px + &:hover + color $theme-color + + > .char + display block + position absolute + right 16px + bottom 0 + height 320px + opacity 0.7 + + > *:not(.char) + z-index 1 + + > .announcements + grid-row 2 + grid-column 1 + + > div + padding 32px + + > div + padding 0 0 16px 0 + margin 0 0 16px 0 + border-bottom 1px solid isDark ? rgba(#000, 0.2) : rgba(#000, 0.05) + + > h1 + margin 0 + font-size 1.25em + + > .photos + grid-row 2 + grid-column 2 + + > div + display grid + grid-template-rows 1fr 1fr 1fr + grid-template-columns 1fr 1fr + gap 8px + height 100% + padding 16px + + > div + //border-radius 4px + background-position center center + background-size cover + + > .tag-cloud + grid-row 3 + grid-column 1 / 3 + + > div + height 256px + padding 32px > .nav - display block - margin 16px 0 + display flex + justify-content center + align-items center + grid-row 4 + grid-column 1 / 3 font-size 14px - color #fff - - > .tl - margin 0 - width 410px - height 100vh - text-align left - background isDark ? #313543 : #fff - > * - max-height 100% - overflow auto + > .side + display grid + grid-row 1 / 5 + grid-column 3 + grid-template-rows 1fr 350px + grid-template-columns 1fr + gap 16px -.mk-welcome[data-darkmode] - root(true) + > .tl + grid-row 1 + grid-column 1 + overflow auto -.mk-welcome:not([data-darkmode]) - root(false) + > .trends + grid-row 2 + grid-column 1 + padding 8px -</style> + > .info + grid-row 3 + grid-column 1 -<style lang="stylus" module> -.signupForm - padding 24px 48px 48px 48px + > div + padding 16px -.signupFormHeader - padding 48px 0 12px 0 - margin: 0 48px - font-size 1.5em - color #777 - border-bottom solid 1px #eee + > .body + > p + display block + margin 0 -.signinForm - padding 24px 48px 48px 48px +.mk-welcome[data-darkmode] + root(true) -.signinFormHeader - padding 48px 0 12px 0 - margin: 0 48px - font-size 1.5em - color #777 - border-bottom solid 1px #eee +.mk-welcome:not([data-darkmode]) + root(false) -.nav - a - color #666 </style> diff --git a/src/client/app/desktop/views/widgets/trends.vue b/src/client/app/desktop/views/widgets/trends.vue index c33bf2f2f2..aeaab63ac4 100644 --- a/src/client/app/desktop/views/widgets/trends.vue +++ b/src/client/app/desktop/views/widgets/trends.vue @@ -49,7 +49,7 @@ export default define({ offset: this.offset, renote: false, reply: false, - media: false, + file: false, poll: false }).then(notes => { const note = notes ? notes[0] : null; diff --git a/src/client/app/init.ts b/src/client/app/init.ts index cf97957400..db3852da60 100644 --- a/src/client/app/init.ts +++ b/src/client/app/init.ts @@ -5,31 +5,20 @@ import Vue from 'vue'; import Vuex from 'vuex'; import VueRouter from 'vue-router'; -import VModal from 'vue-js-modal'; import * as TreeView from 'vue-json-tree-view'; import VAnimateCss from 'v-animate-css'; -import Element from 'element-ui'; -import ElementLocaleEn from 'element-ui/lib/locale/lang/en'; -import ElementLocaleJa from 'element-ui/lib/locale/lang/ja'; +import VModal from 'vue-js-modal'; import App from './app.vue'; import checkForUpdate from './common/scripts/check-for-update'; import MiOS, { API } from './mios'; import { version, codename, lang } from './config'; -let elementLocale; -switch (lang) { - case 'ja-JP': elementLocale = ElementLocaleJa; break; - case 'en-US': elementLocale = ElementLocaleEn; break; - default: elementLocale = ElementLocaleEn; break; -} - Vue.use(Vuex); Vue.use(VueRouter); -Vue.use(VModal); Vue.use(TreeView); Vue.use(VAnimateCss); -Vue.use(Element, { locale: elementLocale }); +Vue.use(VModal); // Register global directives require('./common/views/directives'); @@ -42,9 +31,13 @@ require('./common/views/widgets'); require('./common/views/filters'); Vue.mixin({ - destroyed(this: any) { - if (this.$el.parentNode) { - this.$el.parentNode.removeChild(this.$el); + methods: { + destroyDom() { + this.$destroy(); + + if (this.$el.parentNode) { + this.$el.parentNode.removeChild(this.$el); + } } } }); diff --git a/src/client/app/mios.ts b/src/client/app/mios.ts index 664848b5e7..0f72cd2f34 100644 --- a/src/client/app/mios.ts +++ b/src/client/app/mios.ts @@ -3,7 +3,7 @@ import { EventEmitter } from 'eventemitter3'; import * as uuid from 'uuid'; import initStore from './store'; -import { apiUrl, swPublickey, version, lang, googleMapsApiKey } from './config'; +import { apiUrl, version, lang } from './config'; import Progress from './common/scripts/loading'; import Connection from './common/scripts/streaming/stream'; import { HomeStreamManager } from './common/scripts/streaming/home'; @@ -17,6 +17,7 @@ import Err from './common/views/components/connect-failed.vue'; import { LocalTimelineStreamManager } from './common/scripts/streaming/local-timeline'; import { HybridTimelineStreamManager } from './common/scripts/streaming/hybrid-timeline'; import { GlobalTimelineStreamManager } from './common/scripts/streaming/global-timeline'; +import { erase } from '../../prelude/array'; //#region api requests let spinner = null; @@ -230,13 +231,13 @@ export default class MiOS extends EventEmitter { //#region Init stream managers this.streams.serverStatsStream = new ServerStatsStreamManager(this); this.streams.notesStatsStream = new NotesStatsStreamManager(this); + this.streams.localTimelineStream = new LocalTimelineStreamManager(this, this.store.state.i); this.once('signedin', () => { // Init home stream manager this.stream = new HomeStreamManager(this, this.store.state.i); // Init other stream manager - this.streams.localTimelineStream = new LocalTimelineStreamManager(this, this.store.state.i); this.streams.hybridTimelineStream = new HybridTimelineStreamManager(this, this.store.state.i); this.streams.globalTimelineStream = new GlobalTimelineStreamManager(this, this.store.state.i); this.streams.driveStream = new DriveStreamManager(this, this.store.state.i); @@ -361,7 +362,7 @@ export default class MiOS extends EventEmitter { // A public key your push server will use to send // messages to client apps via a push server. - applicationServerKey: urlBase64ToUint8Array(swPublickey) + applicationServerKey: urlBase64ToUint8Array(this.meta.data.swPublickey) }; // Subscribe push notification @@ -537,7 +538,7 @@ export default class MiOS extends EventEmitter { } public unregisterStreamConnection(connection: Connection) { - this.connections = this.connections.filter(c => c != connection); + this.connections = erase(connection, this.connections); } } diff --git a/src/client/app/mobile/api/post.ts b/src/client/app/mobile/api/post.ts index 15b2f6b691..5c0f0af852 100644 --- a/src/client/app/mobile/api/post.ts +++ b/src/client/app/mobile/api/post.ts @@ -1,13 +1,12 @@ -import PostForm from '../views/components/post-form.vue'; +import PostForm from '../views/components/post-form-dialog.vue'; export default (os) => (opts) => { const o = opts || {}; - const app = document.getElementById('app'); - app.style.display = 'none'; + document.documentElement.style.overflow = 'hidden'; function recover() { - app.style.display = 'block'; + document.documentElement.style.overflow = 'auto'; } const vm = new PostForm({ diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 5b9d45462a..9412c85980 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -6,7 +6,6 @@ import VueRouter from 'vue-router'; // Style import './style.styl'; -import '../../element.scss'; import init from '../init'; diff --git a/src/client/app/mobile/views/components/dialog.vue b/src/client/app/mobile/views/components/dialog.vue index 9ee01cb782..6a0d74c752 100644 --- a/src/client/app/mobile/views/components/dialog.vue +++ b/src/client/app/mobile/views/components/dialog.vue @@ -78,7 +78,7 @@ export default Vue.extend({ scale: 0.8, duration: 300, easing: [ 0.5, -0.5, 1, 0.5 ], - complete: () => this.$destroy() + complete: () => this.destroyDom() }); }, onBgClick() { diff --git a/src/client/app/mobile/views/components/drive-file-chooser.vue b/src/client/app/mobile/views/components/drive-file-chooser.vue index d95d5fa223..92ac211af2 100644 --- a/src/client/app/mobile/views/components/drive-file-chooser.vue +++ b/src/client/app/mobile/views/components/drive-file-chooser.vue @@ -1,12 +1,12 @@ <template> -<div class="mk-drive-file-chooser"> +<div class="cdxzvcfawjxdyxsekbxbfgtplebnoneb"> <div class="body"> <header> <h1>%i18n:@select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1> <button class="close" @click="cancel">%fa:times%</button> <button v-if="multiple" class="ok" @click="ok">%fa:check%</button> </header> - <mk-drive ref="browser" + <mk-drive class="drive" ref="browser" :select-file="true" :multiple="multiple" @change-selection="onChangeSelection" @@ -31,24 +31,24 @@ export default Vue.extend({ }, onSelected(file) { this.$emit('selected', file); - this.$destroy(); + this.destroyDom(); }, cancel() { this.$emit('canceled'); - this.$destroy(); + this.destroyDom(); }, ok() { this.$emit('selected', this.files); - this.$destroy(); + this.destroyDom(); } } }); </script> <style lang="stylus" scoped> -.mk-drive-file-chooser +root(isDark) position fixed - z-index 2048 + z-index 20000 top 0 left 0 width 100% @@ -59,10 +59,11 @@ export default Vue.extend({ > .body width 100% height 100% - background #fff + background isDark ? #282c37 : #fff > header - border-bottom solid 1px #eee + border-bottom solid 1px isDark ? #1b1f29 : #eee + color isDark ? #fff : #111 > h1 margin 0 @@ -90,9 +91,15 @@ export default Vue.extend({ line-height 42px width 42px - > .mk-drive + > .drive height calc(100% - 42px) overflow scroll -webkit-overflow-scrolling touch +.cdxzvcfawjxdyxsekbxbfgtplebnoneb[data-darkmode] + root(true) + +.cdxzvcfawjxdyxsekbxbfgtplebnoneb:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/drive-folder-chooser.vue b/src/client/app/mobile/views/components/drive-folder-chooser.vue index 7934fb7816..6d3fba1efd 100644 --- a/src/client/app/mobile/views/components/drive-folder-chooser.vue +++ b/src/client/app/mobile/views/components/drive-folder-chooser.vue @@ -19,11 +19,11 @@ export default Vue.extend({ methods: { cancel() { this.$emit('canceled'); - this.$destroy(); + this.destroyDom(); }, ok() { this.$emit('selected', (this.$refs.browser as any).folder); - this.$destroy(); + this.destroyDom(); } } }); diff --git a/src/client/app/mobile/views/components/drive.file-detail.vue b/src/client/app/mobile/views/components/drive.file-detail.vue index deb9941be8..8108892597 100644 --- a/src/client/app/mobile/views/components/drive.file-detail.vue +++ b/src/client/app/mobile/views/components/drive.file-detail.vue @@ -1,5 +1,5 @@ <template> -<div class="file-detail"> +<div class="pyvicwrksnfyhpfgkjwqknuururpaztw"> <div class="preview"> <img v-if="kind == 'image'" ref="img" :src="file.url" @@ -25,7 +25,7 @@ </div> <div class="info"> <div> - <span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span> + <span class="type"><mk-file-type-icon :type="file.type"/> {{ file.type }}</span> <span class="separator"></span> <span class="data-size">{{ file.datasize | bytes }}</span> <span class="separator"></span> @@ -67,7 +67,7 @@ import Vue from 'vue'; import * as EXIF from 'exif-js'; import * as hljs from 'highlight.js'; -import gcd from '../../../common/scripts/gcd'; +import { gcd } from '../../../../../prelude/math'; export default Vue.extend({ props: ['file'], @@ -134,11 +134,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.file-detail - +root(isDark) > .preview padding 8px - background #f0f0f0 + background isDark ? #191b22 : #f0f0f0 > img display block @@ -150,7 +149,7 @@ export default Vue.extend({ > footer padding 8px 8px 0 8px font-size 0.8em - color #888 + color isDark ? #606984 : #888 text-align center > .separator @@ -179,25 +178,17 @@ export default Vue.extend({ > .info padding 14px font-size 0.8em - border-top solid 1px #dfdfdf + border-top solid 1px isDark ? #1c2023 : #dfdfdf > div max-width 500px margin 0 auto + color isDark ? #9397a2 : #9d9d9d > .separator padding 0 4px - color #cdcdcd - - > .type - > .data-size - color #9d9d9d - - > mk-file-type-icon - margin-right 4px > .created-at - color #bdbdbd > [data-fa] margin-right 2px @@ -207,7 +198,7 @@ export default Vue.extend({ > .menu padding 14px - border-top solid 1px #dfdfdf + border-top solid 1px isDark ? #1c2023 : #dfdfdf > div max-width 500px @@ -218,14 +209,14 @@ export default Vue.extend({ width 100% padding 10px 16px margin 0 0 12px 0 - color #333 + color isDark ? #dfe3e8 : #333 font-size 0.9em text-align center text-decoration none - text-shadow 0 1px 0 rgba(255, 255, 255, 0.9) - background-image linear-gradient(#fafafa, #eaeaea) - border 1px solid #ddd - border-bottom-color #cecece + text-shadow 0 1px 0 isDark ? rgba(0, 0, 0, 0.9) : rgba(255, 255, 255, 0.9) + background-image isDark ? linear-gradient(#292f3c, #1b2025) : linear-gradient(#fafafa, #eaeaea) + border 1px solid isDark ? #121417 : #ddd + border-bottom-color isDark ? #060606 : #cecece border-radius 3px &:last-child @@ -242,7 +233,7 @@ export default Vue.extend({ > .hash padding 14px - border-top solid 1px #dfdfdf + border-top solid 1px isDark ? #1c2023 : #dfdfdf > div max-width 500px @@ -252,7 +243,7 @@ export default Vue.extend({ display block margin 0 padding 0 - color #555 + color isDark ? #a8b7d0 : #555 font-size 0.9em > [data-fa] @@ -273,7 +264,7 @@ export default Vue.extend({ > .exif padding 14px - border-top solid 1px #dfdfdf + border-top solid 1px isDark ? #1c2023 : #dfdfdf > div max-width 500px @@ -283,7 +274,7 @@ export default Vue.extend({ display block margin 0 padding 0 - color #555 + color isDark ? #a8b7d0 : #555 font-size 0.9em > [data-fa] @@ -301,4 +292,10 @@ export default Vue.extend({ border-radius 2px background #f5f5f5 +.pyvicwrksnfyhpfgkjwqknuururpaztw[data-darkmode] + root(true) + +.pyvicwrksnfyhpfgkjwqknuururpaztw:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/drive.file.vue b/src/client/app/mobile/views/components/drive.file.vue index 6dec4b9f4f..4375cfdd7b 100644 --- a/src/client/app/mobile/views/components/drive.file.vue +++ b/src/client/app/mobile/views/components/drive.file.vue @@ -1,5 +1,5 @@ <template> -<a class="file" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected"> +<a class="vupkuhvjnjyqaqhsiogfbywvjxynrgsm" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected"> <div class="container"> <div class="thumbnail" :style="thumbnail"></div> <div class="body"> @@ -7,20 +7,12 @@ <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> </p> - <!-- - if file.tags.length > 0 - ul.tags - each tag in file.tags - li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name - --> <footer> <span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span> <span class="separator"></span> <span class="data-size">{{ file.datasize | bytes }}</span> <span class="separator"></span> - <span class="created-at"> - %fa:R clock%<mk-time :time="file.createdAt"/> - </span> + <span class="created-at">%fa:R clock%<mk-time :time="file.createdAt"/></span> <template v-if="file.isSensitive"> <span class="separator"></span> <span class="nsfw">%fa:eye-slash% %i18n:@nsfw%</span> @@ -73,7 +65,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.file +root(isDark) display block text-decoration none !important @@ -111,7 +103,7 @@ export default Vue.extend({ padding 0 font-size 0.9em font-weight bold - color #555 + color isDark ? #fff : #555 text-overflow ellipsis overflow-wrap break-word @@ -138,7 +130,6 @@ export default Vue.extend({ > .separator padding 0 4px - color #CDCDCD > .type color #9D9D9D @@ -164,4 +155,10 @@ export default Vue.extend({ &, * color #fff !important +.vupkuhvjnjyqaqhsiogfbywvjxynrgsm[data-darkmode] + root(true) + +.vupkuhvjnjyqaqhsiogfbywvjxynrgsm:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/drive.folder.vue b/src/client/app/mobile/views/components/drive.folder.vue index 22ff38fecb..f76ecba6ad 100644 --- a/src/client/app/mobile/views/components/drive.folder.vue +++ b/src/client/app/mobile/views/components/drive.folder.vue @@ -1,5 +1,5 @@ <template> -<a class="root folder" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`"> +<a class="jvwxssxsytqlqvrpiymarjlzlsxskqsr" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`"> <div class="container"> <p class="name">%fa:folder%{{ folder.name }}</p>%fa:angle-right% </div> @@ -24,9 +24,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.root.folder +root(isDark) display block - color #777 + color isDark ? #fff : #777 text-decoration none !important * @@ -55,4 +55,10 @@ export default Vue.extend({ > * height 100% +.jvwxssxsytqlqvrpiymarjlzlsxskqsr[data-darkmode] + root(true) + +.jvwxssxsytqlqvrpiymarjlzlsxskqsr:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue index c313d225e4..36a6ea2f40 100644 --- a/src/client/app/mobile/views/components/drive.vue +++ b/src/client/app/mobile/views/components/drive.vue @@ -1,5 +1,5 @@ <template> -<div class="mk-drive"> +<div class="kmmwchoexgckptowjmjgfsygeltxfeqs"> <nav ref="nav"> <a @click.prevent="goRoot()" href="/i/drive">%fa:cloud%%i18n:@drive%</a> <template v-for="folder in hierarchyFolders"> @@ -26,11 +26,11 @@ </p> </div> <div class="folders" v-if="folders.length > 0"> - <x-folder v-for="folder in folders" :key="folder.id" :folder="folder"/> + <x-folder class="folder" v-for="folder in folders" :key="folder.id" :folder="folder"/> <p v-if="moreFolders">%i18n:@load-more%</p> </div> <div class="files" v-if="files.length > 0"> - <x-file v-for="file in files" :key="file.id" :file="file"/> + <x-file class="file" v-for="file in files" :key="file.id" :file="file"/> <button class="more" v-if="moreFiles" @click="fetchMoreFiles"> {{ fetchingMoreFiles ? '%i18n:common.loading%' : '%i18n:@load-more%' }} </button> @@ -94,6 +94,13 @@ export default Vue.extend({ return this.selectFile; } }, + watch: { + top() { + if (this.isNaked) { + (this.$refs.nav as any).style.top = `${this.top}px`; + } + } + }, mounted() { this.connection = (this as any).os.streams.driveStream.getConnection(); this.connectionId = (this as any).os.streams.driveStream.use(); @@ -466,8 +473,8 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-drive - background #fff +root(isDark) + background isDark ? #282c37 : #fff > nav display block @@ -480,10 +487,10 @@ export default Vue.extend({ overflow auto white-space nowrap font-size 0.9em - color rgba(#000, 0.67) + color rgba(isDark ? #fff : #000, 0.67) -webkit-backdrop-filter blur(12px) backdrop-filter blur(12px) - background-color rgba(#fff, 0.75) + background-color rgba(isDark ? #313543 : #fff, 0.75) border-bottom solid 1px rgba(#000, 0.13) > p @@ -509,7 +516,7 @@ export default Vue.extend({ opacity 0.5 > .info - border-bottom solid 1px #eee + border-bottom solid 1px isDark ? #1c2023 : #eee &:empty display none @@ -520,15 +527,15 @@ export default Vue.extend({ margin 0 auto padding 4px 16px font-size 10px - color #777 + color isDark ? #606984 : #777 > .folders > .folder - border-bottom solid 1px #eee + border-bottom solid 1px isDark ? #1c2023 : #eee > .files > .file - border-bottom solid 1px #eee + border-bottom solid 1px isDark ? #1c2023 : #eee > .more display block @@ -584,4 +591,10 @@ export default Vue.extend({ > .file display none +.kmmwchoexgckptowjmjgfsygeltxfeqs[data-darkmode] + root(true) + +.kmmwchoexgckptowjmjgfsygeltxfeqs:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/follow-button.vue b/src/client/app/mobile/views/components/follow-button.vue index 360ee91d4b..ff7260edb5 100644 --- a/src/client/app/mobile/views/components/follow-button.vue +++ b/src/client/app/mobile/views/components/follow-button.vue @@ -48,12 +48,14 @@ export default Vue.extend({ onFollow(user) { if (user.id == this.u.id) { this.u.isFollowing = user.isFollowing; + this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } }, onUnfollow(user) { if (user.id == this.u.id) { this.u.isFollowing = user.isFollowing; + this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } }, @@ -66,7 +68,7 @@ export default Vue.extend({ userId: this.u.id }); } else { - if (this.u.isLocked && this.u.hasPendingFollowRequestFromYou) { + if (this.u.hasPendingFollowRequestFromYou) { this.u = await (this as any).api('following/requests/cancel', { userId: this.u.id }); diff --git a/src/client/app/mobile/views/components/friends-maker.vue b/src/client/app/mobile/views/components/friends-maker.vue index e0461d2bc2..dbb82f4b18 100644 --- a/src/client/app/mobile/views/components/friends-maker.vue +++ b/src/client/app/mobile/views/components/friends-maker.vue @@ -47,7 +47,7 @@ export default Vue.extend({ this.fetch(); }, close() { - this.$destroy(); + this.destroyDom(); } } }); diff --git a/src/client/app/mobile/views/components/media-image.vue b/src/client/app/mobile/views/components/media-image.vue index e40069bbe3..652a2ad3a4 100644 --- a/src/client/app/mobile/views/components/media-image.vue +++ b/src/client/app/mobile/views/components/media-image.vue @@ -1,5 +1,5 @@ <template> -<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide" @click="hide = false"> +<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> <div> <b>%fa:exclamation-triangle% %i18n:@sensitive%</b> <span>%i18n:@click-to-show%</span> @@ -19,12 +19,13 @@ export default Vue.extend({ }, raw: { default: false - }, - hide: { - type: Boolean, - default: true } }, + data() { + return { + hide: true + }; + } computed: { style(): any { let url = `url(${this.image.thumbnailUrl})`; @@ -65,7 +66,7 @@ export default Vue.extend({ text-align center font-size 12px - > b + > * display block </style> diff --git a/src/client/app/mobile/views/components/media-video.vue b/src/client/app/mobile/views/components/media-video.vue index aea7f41460..1e2c1ea7b0 100644 --- a/src/client/app/mobile/views/components/media-video.vue +++ b/src/client/app/mobile/views/components/media-video.vue @@ -15,25 +15,28 @@ </template> <script lang="ts"> -import Vue from 'vue' +import Vue from 'vue'; + export default Vue.extend({ props: { video: { type: Object, required: true - }, - hide: { - type: Boolean, - default: true } }, + data() { + return { + hide: true + }; + }, computed: { imageStyle(): any { return { 'background-image': `url(${this.video.url})` }; } - },}) + } +}); </script> <style lang="stylus" scoped> diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue index f9996f9da6..68be9f8ac4 100644 --- a/src/client/app/mobile/views/components/note-detail.vue +++ b/src/client/app/mobile/views/components/note-detail.vue @@ -35,20 +35,26 @@ </div> </header> <div class="body"> - <div class="text"> - <span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span> - <span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span> - <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> - </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media" :raw="true"/> - </div> - <mk-poll v-if="p.poll" :note="p"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> - <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> - <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> + <p v-if="p.cw != null" class="cw"> + <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="p.cw == null || showContent"> + <div class="text"> + <span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span> + <span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span> + <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> + </div> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files" :raw="true"/> + </div> + <mk-poll v-if="p.poll" :note="p"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> + <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="renote" v-if="p.renote"> + <mk-note-preview :note="p.renote"/> + </div> </div> </div> <router-link class="time" :to="p | notePage"> @@ -85,6 +91,7 @@ import parse from '../../../../../mfm/parse'; import MkNoteMenu from '../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './note.sub.vue'; +import { sum } from '../../../../../prelude/array'; export default Vue.extend({ components: { @@ -103,6 +110,7 @@ export default Vue.extend({ data() { return { + showContent: false, conversation: [], conversationFetching: false, replies: [] @@ -113,19 +121,20 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, + p(): any { return this.isRenote ? this.note.renote : this.note; }, + reactionsCount(): number { return this.p.reactionCounts - ? Object.keys(this.p.reactionCounts) - .map(key => this.p.reactionCounts[key]) - .reduce((a, b) => a + b) + ? sum(Object.values(this.p.reactionCounts)) : 0; }, + urls(): string[] { if (this.p.text) { const ast = parse(this.p.text); @@ -180,16 +189,19 @@ export default Vue.extend({ this.conversation = conversation.reverse(); }); }, + reply() { (this as any).apis.post({ reply: this.p }); }, + renote() { (this as any).apis.post({ renote: this.p }); }, + react() { (this as any).os.new(MkReactionPicker, { source: this.$refs.reactButton, @@ -198,6 +210,7 @@ export default Vue.extend({ big: true }); }, + menu() { (this as any).os.new(MkNoteMenu, { source: this.$refs.menuButton, @@ -328,44 +341,57 @@ root(isDark) > .body padding 8px 0 - > .text + > .cw + cursor default display block margin 0 padding 0 overflow-wrap break-word - font-size 16px color isDark ? #fff : #717171 - @media (min-width 500px) - font-size 24px + > .text + margin-right 8px + + > .content + + > .text + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 16px + color isDark ? #fff : #717171 - > .renote - margin 8px 0 + @media (min-width 500px) + font-size 24px - > .mk-note-preview - padding 16px - border dashed 1px #c0dac6 - border-radius 8px + > .renote + margin 8px 0 - > .location - margin 4px 0 - font-size 12px - color #ccc + > * + padding 16px + border dashed 1px #c0dac6 + border-radius 8px - > .map - width 100% - height 200px + > .location + margin 4px 0 + font-size 12px + color #ccc - &:empty - display none + > .map + width 100% + height 200px - > .mk-url-preview - margin-top 8px + &:empty + display none - > .media - > img - display block - max-width 100% + > .mk-url-preview + margin-top 8px + + > .files + > img + display block + max-width 100% > .time font-size 16px diff --git a/src/client/app/mobile/views/components/note-preview.vue b/src/client/app/mobile/views/components/note-preview.vue index 5d56d2d326..4c03593a9e 100644 --- a/src/client/app/mobile/views/components/note-preview.vue +++ b/src/client/app/mobile/views/components/note-preview.vue @@ -1,10 +1,16 @@ <template> -<div class="mk-note-preview" :class="{ smart: $store.state.device.postStyle == 'smart' }"> +<div class="yohlumlkhizgfkvvscwfcrcggkotpvry" :class="{ smart: $store.state.device.postStyle == 'smart' }"> <mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle != 'smart'"/> <div class="main"> <mk-note-header class="header" :note="note" :mini="true"/> <div class="body"> - <mk-sub-note-content class="text" :note="note"/> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <mk-sub-note-content class="text" :note="note"/> + </div> </div> </div> </div> @@ -14,7 +20,18 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['note'] + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } }); </script> @@ -65,16 +82,28 @@ root(isDark) > .body - > .text + > .cw cursor default + display block margin 0 padding 0 - color isDark ? #959ba7 : #717171 + overflow-wrap break-word + color isDark ? #fff : #717171 + + > .text + margin-right 8px + + > .content + > .text + cursor default + margin 0 + padding 0 + color isDark ? #959ba7 : #717171 -.mk-note-preview[data-darkmode] +.yohlumlkhizgfkvvscwfcrcggkotpvry[data-darkmode] root(true) -.mk-note-preview:not([data-darkmode]) +.yohlumlkhizgfkvvscwfcrcggkotpvry:not([data-darkmode]) root(false) </style> diff --git a/src/client/app/mobile/views/components/note.sub.vue b/src/client/app/mobile/views/components/note.sub.vue index a68aec40a1..c25f827dad 100644 --- a/src/client/app/mobile/views/components/note.sub.vue +++ b/src/client/app/mobile/views/components/note.sub.vue @@ -1,10 +1,16 @@ <template> -<div class="sub" :class="{ smart: $store.state.device.postStyle == 'smart' }"> +<div class="zlrxdaqttccpwhpaagdmkawtzklsccam" :class="{ smart: $store.state.device.postStyle == 'smart' }"> <mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle != 'smart'"/> <div class="main"> <mk-note-header class="header" :note="note" :mini="true"/> <div class="body"> - <mk-sub-note-content class="text" :note="note"/> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <mk-sub-note-content class="text" :note="note"/> + </div> </div> </div> </div> @@ -24,6 +30,12 @@ export default Vue.extend({ type: Boolean, default: true } + }, + + data() { + return { + showContent: false + }; } }); </script> @@ -77,20 +89,31 @@ root(isDark) margin-bottom 2px > .body - - > .text + > .cw + cursor default + display block margin 0 padding 0 - color isDark ? #959ba7 : #717171 + overflow-wrap break-word + color isDark ? #fff : #717171 + + > .text + margin-right 8px + + > .content + > .text + margin 0 + padding 0 + color isDark ? #959ba7 : #717171 - pre - max-height 120px - font-size 80% + pre + max-height 120px + font-size 80% -.sub[data-darkmode] +.zlrxdaqttccpwhpaagdmkawtzklsccam[data-darkmode] root(true) -.sub:not([data-darkmode]) +.zlrxdaqttccpwhpaagdmkawtzklsccam:not([data-darkmode]) root(false) </style> diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue index d0cea135f9..8787b39a93 100644 --- a/src/client/app/mobile/views/components/note.vue +++ b/src/client/app/mobile/views/components/note.vue @@ -18,7 +18,7 @@ <div class="body"> <p v-if="p.cw != null" class="cw"> <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> - <span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@less%' : '%i18n:@more%' }}</span> + <mk-cw-button v-model="showContent"/> </p> <div class="content" v-show="p.cw == null || showContent"> <div class="text"> @@ -28,16 +28,14 @@ <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/> <a class="rp" v-if="p.renote != null">RP:</a> </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files"/> </div> <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> <mk-url-preview v-for="url in urls" :url="url" :key="url"/> <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> - </div> + <div class="renote" v-if="p.renote"><mk-note-preview :note="p.renote"/></div> </div> <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> </div> @@ -70,6 +68,7 @@ import parse from '../../../../../mfm/parse'; import MkNoteMenu from '../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './note.sub.vue'; +import { sum } from '../../../../../prelude/array'; export default Vue.extend({ components: { @@ -90,7 +89,7 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, @@ -100,9 +99,7 @@ export default Vue.extend({ reactionsCount(): number { return this.p.reactionCounts - ? Object.keys(this.p.reactionCounts) - .map(key => this.p.reactionCounts[key]) - .reduce((a, b) => a + b) + ? sum(Object.values(this.p.reactionCounts)) : 0; }, @@ -353,19 +350,6 @@ root(isDark) > .text margin-right 8px - > .toggle - display inline-block - padding 4px 8px - font-size 0.7em - color isDark ? #393f4f : #fff - background isDark ? #687390 : #b1b9c1 - border-radius 2px - cursor pointer - user-select none - - &:hover - background isDark ? #707b97 : #bbc4ce - > .content > .text @@ -414,7 +398,7 @@ root(isDark) .mk-url-preview margin-top 8px - > .media + > .files > img display block max-width 100% @@ -437,7 +421,7 @@ root(isDark) > .renote margin 8px 0 - > .mk-note-preview + > * padding 16px border dashed 1px isDark ? #4e945e : #c0dac6 border-radius 8px @@ -471,10 +455,6 @@ root(isDark) &.reacted color $theme-color - &.menu - @media (max-width 350px) - display none - .note[data-darkmode] root(true) diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue index 714e521c0f..401df3ae5b 100644 --- a/src/client/app/mobile/views/components/notes.vue +++ b/src/client/app/mobile/views/components/notes.vue @@ -14,8 +14,7 @@ </div> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!-- <transition-group name="mk-notes" class="transition"> --> - <div class="transition"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition" tag="div"> <template v-for="(note, i) in _notes"> <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> @@ -23,8 +22,7 @@ <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span> </p> </template> - </div> - <!-- </transition-group> --> + </component> <footer v-if="more"> <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> @@ -125,7 +123,7 @@ export default Vue.extend({ prepend(note, silent = false) { //#region 弾く const isMyNote = note.userId == this.$store.state.i.id; - const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; if (this.$store.state.settings.showMyRenotes === false) { if (isMyNote && isPureRenote) { diff --git a/src/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue index 9f20c3fb22..11ac23f4b1 100644 --- a/src/client/app/mobile/views/components/notifications.vue +++ b/src/client/app/mobile/views/components/notifications.vue @@ -1,8 +1,7 @@ <template> <div class="mk-notifications"> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!-- <transition-group name="mk-notifications" class="transition notifications"> --> - <div class="transition notifications"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications"> <template v-for="(notification, i) in _notifications"> <mk-notification :notification="notification" :key="notification.id"/> <p class="date" :key="notification.id + '_date'" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date"> @@ -10,8 +9,7 @@ <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> </p> </template> - </div> - <!-- </transition-group> --> + </component> <button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template> diff --git a/src/client/app/mobile/views/components/notify.vue b/src/client/app/mobile/views/components/notify.vue index 6d4a481dbe..5f94b91ddd 100644 --- a/src/client/app/mobile/views/components/notify.vue +++ b/src/client/app/mobile/views/components/notify.vue @@ -1,6 +1,8 @@ <template> -<div class="mk-notify"> - <mk-notification-preview :notification="notification"/> +<div class="mk-notify" :class="pos"> + <div> + <mk-notification-preview :notification="notification"/> + </div> </div> </template> @@ -10,11 +12,16 @@ import * as anime from 'animejs'; export default Vue.extend({ props: ['notification'], + computed: { + pos() { + return this.$store.state.device.mobileNotificationPosition; + } + }, mounted() { this.$nextTick(() => { anime({ targets: this.$el, - bottom: '0px', + [this.pos]: '0px', duration: 500, easing: 'easeOutQuad' }); @@ -22,10 +29,10 @@ export default Vue.extend({ setTimeout(() => { anime({ targets: this.$el, - bottom: '-64px', + [this.pos]: `-${this.$el.offsetHeight}px`, duration: 500, easing: 'easeOutQuad', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); }, 6000); }); @@ -35,15 +42,32 @@ export default Vue.extend({ <style lang="stylus" scoped> .mk-notify + $height = 78px + position fixed - z-index 1024 - bottom -64px + z-index 10000 left 0 + right 0 width 100% - height 64px + max-width 500px + height $height + margin 0 auto + padding 8px pointer-events none - -webkit-backdrop-filter blur(2px) - backdrop-filter blur(2px) - background-color rgba(#000, 0.5) + font-size 80% + + &.bottom + bottom -($height) + + &.top + top -($height) + + > div + height 100% + -webkit-backdrop-filter blur(2px) + backdrop-filter blur(2px) + background-color rgba(#000, 0.5) + border-radius 7px + overflow hidden </style> diff --git a/src/client/app/mobile/views/components/post-form-dialog.vue b/src/client/app/mobile/views/components/post-form-dialog.vue new file mode 100644 index 0000000000..15b36db945 --- /dev/null +++ b/src/client/app/mobile/views/components/post-form-dialog.vue @@ -0,0 +1,126 @@ +<template> +<div class="ulveipglmagnxfgvitaxyszerjwiqmwl"> + <div class="bg" ref="bg"></div> + <div class="main" ref="main"> + <mk-post-form ref="form" + :reply="reply" + :renote="renote" + :initial-text="initialText" + :instant="instant" + @posted="onPosted" + @cancel="onCanceled"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + instant: { + type: Boolean, + required: false, + default: false + } + }, + + mounted() { + this.$nextTick(() => { + (this.$refs.bg as any).style.pointerEvents = 'auto'; + anime({ + targets: this.$refs.bg, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.main, + opacity: 1, + translateY: [-16, 0], + duration: 300, + easing: 'easeOutQuad' + }); + }); + }, + + methods: { + focus() { + this.$refs.form.focus(); + }, + + close() { + (this.$refs.bg as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + + (this.$refs.main as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.main, + opacity: 0, + translateY: 16, + duration: 300, + easing: 'easeOutQuad', + complete: () => this.destroyDom() + }); + }, + + onPosted() { + this.$emit('posted'); + this.close(); + }, + + onCanceled() { + this.$emit('cancel'); + this.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.ulveipglmagnxfgvitaxyszerjwiqmwl + > .bg + display block + position fixed + z-index 10000 + top 0 + left 0 + width 100% + height 100% + background rgba(#000, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 10000 + top 0 + left 0 + right 0 + height 100% + overflow auto + margin 0 auto 0 auto + opacity 0 + transform translateY(-16px) + +</style> diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue index a74df67c0a..1294273a2a 100644 --- a/src/client/app/mobile/views/components/post-form.vue +++ b/src/client/app/mobile/views/components/post-form.vue @@ -4,14 +4,14 @@ <header> <button class="cancel" @click="cancel">%fa:times%</button> <div> - <span class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</span> + <span class="text-count" :class="{ over: trimmedLength(text) > 1000 }">{{ 1000 - trimmedLength(text) }}</span> <span class="geo" v-if="geo">%fa:map-marker-alt%</span> <button class="submit" :disabled="!canPost" @click="post">{{ submitText }}</button> </div> </header> <div class="form"> - <mk-note-preview v-if="reply" :note="reply"/> - <mk-note-preview v-if="renote" :note="renote"/> + <mk-note-preview class="preview" v-if="reply" :note="reply"/> + <mk-note-preview class="preview" v-if="renote" :note="renote"/> <div v-if="visibility == 'specified'" class="visibleUsers"> <span v-for="u in visibleUsers">{{ u | userName }}<a @click="removeVisibleUser(u)">[x]</a></span> <a @click="addVisibleUser">+%i18n:@add-visible-user%</a> @@ -42,7 +42,7 @@ <span v-if="visibility === 'private'">%fa:lock%</span> </button> </footer> - <input ref="file" class="file" type="file" accept="image/*" multiple="multiple" @change="onChangeFile"/> + <input ref="file" class="file" type="file" multiple="multiple" @change="onChangeFile"/> </div> </div> <div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags"> @@ -59,6 +59,9 @@ import MkVisibilityChooser from '../../../common/views/components/visibility-cho import getFace from '../../../common/scripts/get-face'; import parse from '../../../../../mfm/parse'; import { host } from '../../../config'; +import { erase, unique } from '../../../../../prelude/array'; +import { length } from 'stringz'; +import parseAcct from '../../../../../misc/acct/parse'; export default Vue.extend({ components: { @@ -94,7 +97,7 @@ export default Vue.extend({ files: [], poll: false, geo: null, - visibility: this.$store.state.device.visibility || 'public', + visibility: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility, visibleUsers: [], useCw: false, cw: null, @@ -105,9 +108,9 @@ export default Vue.extend({ computed: { draftId(): string { return this.renote - ? 'renote:' + this.renote.id + ? `renote:${this.renote.id}` : this.reply - ? 'reply:' + this.reply.id + ? `reply:${this.reply.id}` : 'note'; }, @@ -170,12 +173,18 @@ export default Vue.extend({ }); } + this.focus(); + this.$nextTick(() => { this.focus(); }); }, methods: { + trimmedLength(text: string) { + return length(text.trim()); + }, + addTag(tag: string) { insertTextAtCursor(this.$refs.text, ` #${tag} `); }, @@ -198,12 +207,12 @@ export default Vue.extend({ attachMedia(driveFile) { this.files.push(driveFile); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, detachMedia(file) { this.files = this.files.filter(x => x.id != file.id); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, onChangeFile() { @@ -227,7 +236,7 @@ export default Vue.extend({ navigator.geolocation.getCurrentPosition(pos => { this.geo = pos.coords; }, err => { - alert('%i18n:@error%: ' + err.message); + alert(`%i18n:@error%: ${err.message}`); }, { enableHighAccuracy: true }); @@ -250,24 +259,23 @@ export default Vue.extend({ addVisibleUser() { (this as any).apis.input({ title: '%i18n:@username-prompt%' - }).then(username => { - (this as any).api('users/show', { - username - }).then(user => { + }).then(acct => { + if (acct.startsWith('@')) acct = acct.substr(1); + (this as any).api('users/show', parseAcct(acct)).then(user => { this.visibleUsers.push(user); }); }); }, removeVisibleUser(user) { - this.visibleUsers = this.visibleUsers.filter(u => u != user); + this.visibleUsers = erase(user, this.visibleUsers); }, clear() { this.text = ''; this.files = []; this.poll = false; - this.$emit('change-attached-media'); + this.$emit('change-attached-files'); }, post() { @@ -275,7 +283,7 @@ export default Vue.extend({ const viaMobile = this.$store.state.settings.disableViaMobile !== true; (this as any).api('notes/create', { text: this.text == '' ? undefined : this.text, - mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, replyId: this.reply ? this.reply.id : undefined, renoteId: this.renote ? this.renote.id : undefined, poll: this.poll ? (this.$refs.poll as any).get() : undefined, @@ -293,9 +301,6 @@ export default Vue.extend({ viaMobile: viaMobile }).then(data => { this.$emit('posted'); - this.$nextTick(() => { - this.$destroy(); - }); }).catch(err => { this.posting = false; }); @@ -303,13 +308,12 @@ export default Vue.extend({ if (this.text && this.text != '') { const hashtags = parse(this.text).filter(x => x.type == 'hashtag').map(x => x.hashtag); const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; - localStorage.setItem('hashtags', JSON.stringify(hashtags.concat(history).reduce((a, c) => a.includes(c) ? a : [...a, c], []))); + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); } }, cancel() { this.$emit('cancel'); - this.$destroy(); }, kao() { @@ -383,7 +387,7 @@ root(isDark) max-width 500px margin 0 auto - > .mk-note-preview + > .preview padding 16px > .visibleUsers diff --git a/src/client/app/mobile/views/components/sub-note-content.vue b/src/client/app/mobile/views/components/sub-note-content.vue index a4ce49786e..4d0aa25f34 100644 --- a/src/client/app/mobile/views/components/sub-note-content.vue +++ b/src/client/app/mobile/views/components/sub-note-content.vue @@ -7,9 +7,9 @@ <misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/> <a class="rp" v-if="note.renoteId">RP: ...</a> </div> - <details v-if="note.media.length > 0"> - <summary>({{ '%i18n:@media-count%'.replace('{}', note.media.length) }})</summary> - <mk-media-list :media-list="note.media"/> + <details v-if="note.files.length > 0"> + <summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary> + <mk-media-list :media-list="note.files"/> </details> <details v-if="note.poll"> <summary>%i18n:@poll%</summary> diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue index a616586c56..c9b3ab51ae 100644 --- a/src/client/app/mobile/views/components/ui.header.vue +++ b/src/client/app/mobile/views/components/ui.header.vue @@ -1,5 +1,6 @@ <template> -<div class="header"> +<div class="header" ref="root"> + <p class="warn" v-if="env != 'production'">%i18n:common.do-not-use-in-production%</p> <mk-special-message/> <div class="main" ref="main"> <div class="backdrop"></div> @@ -20,6 +21,7 @@ <script lang="ts"> import Vue from 'vue'; import * as anime from 'animejs'; +import { env } from '../../../config'; export default Vue.extend({ props: ['func'], @@ -27,7 +29,8 @@ export default Vue.extend({ return { hasGameInvitation: false, connection: null, - connectionId: null + connectionId: null, + env: env }; }, computed: { @@ -39,7 +42,7 @@ export default Vue.extend({ } }, mounted() { - this.$store.commit('setUiHeaderHeight', 48); + this.$store.commit('setUiHeaderHeight', this.$refs.root.offsetHeight); if (this.$store.getters.isSignedIn) { this.connection = (this as any).os.stream.getConnection(); @@ -133,6 +136,15 @@ root(isDark) height 3px background $theme-color + > .warn + display block + margin 0 + padding 4px + text-align center + font-size 12px + background #f00 + color #fff + > .main color rgba(#fff, 0.9) diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue index 39ea513b76..c3ae05fef6 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -34,6 +34,12 @@ <li @click="dark"><p><template v-if="$store.state.device.darkmode">%fa:moon%</template><template v-else>%fa:R moon%</template><span>%i18n:@darkmode%</span></p></li> </ul> </div> + <div class="announcements" v-if="announcements && announcements.length > 0"> + <article v-for="announcement in announcements"> + <span v-html="announcement.title" class="title"></span> + <div v-html="announcement.text"></div> + </article> + </div> <a :href="aboutUrl"><p class="about">%i18n:@about%</p></a> </div> </transition> @@ -46,23 +52,32 @@ import { lang } from '../../../config'; export default Vue.extend({ props: ['isOpen'], + data() { return { hasGameInvitation: false, connection: null, connectionId: null, - aboutUrl: `/docs/${lang}/about` + aboutUrl: `/docs/${lang}/about`, + announcements: [] }; }, + computed: { hasUnreadNotification(): boolean { return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification; }, + hasUnreadMessagingMessage(): boolean { return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage; } }, + mounted() { + (this as any).os.getMeta().then(meta => { + this.announcements = meta.broadcasts; + }); + if (this.$store.getters.isSignedIn) { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use(); @@ -71,6 +86,7 @@ export default Vue.extend({ this.connection.on('reversi_no_invites', this.onReversiNoInvites); } }, + beforeDestroy() { if (this.$store.getters.isSignedIn) { this.connection.off('reversi_invited', this.onReversiInvited); @@ -78,18 +94,22 @@ export default Vue.extend({ (this as any).os.stream.dispose(this.connectionId); } }, + methods: { search() { const query = window.prompt('%i18n:@search%'); if (query == null || query == '') return; - this.$router.push('/search?q=' + encodeURIComponent(query)); + this.$router.push(`/search?q=${encodeURIComponent(query)}`); }, + onReversiInvited() { this.hasGameInvitation = true; }, + onReversiNoInvites() { this.hasGameInvitation = false; }, + dark() { this.$store.commit('device/set', { key: 'darkmode', @@ -204,6 +224,17 @@ root(isDark) color $color opacity 0.5 + .announcements + > article + background isDark ? rgba(30, 129, 216, 0.2) : rgba(155, 196, 232, 0.2) + color isDark ? #fff : #3f4967 + padding 16px + margin 8px 0 + font-size 12px + + > .title + font-weight bold + .about margin 0 0 8px 0 padding 1em 0 diff --git a/src/client/app/mobile/views/components/ui.vue b/src/client/app/mobile/views/components/ui.vue index 7e2d39f259..d2af15d235 100644 --- a/src/client/app/mobile/views/components/ui.vue +++ b/src/client/app/mobile/views/components/ui.vue @@ -31,7 +31,14 @@ export default Vue.extend({ connectionId: null }; }, + watch: { + '$store.state.uiHeaderHeight'() { + this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; + } + }, mounted() { + this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; + if (this.$store.getters.isSignedIn) { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use(); diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue index 6be675c0a7..7cd23d6655 100644 --- a/src/client/app/mobile/views/components/user-timeline.vue +++ b/src/client/app/mobile/views/components/user-timeline.vue @@ -41,7 +41,7 @@ export default Vue.extend({ (this.$refs.timeline as any).init(() => new Promise((res, rej) => { (this as any).api('users/notes', { userId: this.user.id, - withMedia: this.withMedia, + withFiles: this.withMedia, limit: fetchLimit + 1 }).then(notes => { if (notes.length == fetchLimit + 1) { @@ -62,7 +62,7 @@ export default Vue.extend({ const promise = (this as any).api('users/notes', { userId: this.user.id, - withMedia: this.withMedia, + withFiles: this.withMedia, limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id }); diff --git a/src/client/app/mobile/views/pages/drive.vue b/src/client/app/mobile/views/pages/drive.vue index c7cbe0f72e..27ac956043 100644 --- a/src/client/app/mobile/views/pages/drive.vue +++ b/src/client/app/mobile/views/pages/drive.vue @@ -11,7 +11,7 @@ :init-folder="initFolder" :init-file="initFile" :is-naked="true" - :top="48" + :top="$store.state.uiHeaderHeight" @begin-fetch="Progress.start()" @fetched-mid="Progress.set(0.5)" @fetched="Progress.done()" @@ -80,7 +80,7 @@ export default Vue.extend({ if (!silent) { // Rewrite URL - history.pushState(null, title, '/i/drive/folder/' + folder.id); + history.pushState(null, title, `/i/drive/folder/${folder.id}`); } document.title = title; @@ -93,7 +93,7 @@ export default Vue.extend({ if (!silent) { // Rewrite URL - history.pushState(null, title, '/i/drive/file/' + file.id); + history.pushState(null, title, `/i/drive/file/${file.id}`); } document.title = title; diff --git a/src/client/app/mobile/views/pages/followers.vue b/src/client/app/mobile/views/pages/followers.vue index 421c150856..601f6670c1 100644 --- a/src/client/app/mobile/views/pages/followers.vue +++ b/src/client/app/mobile/views/pages/followers.vue @@ -49,7 +49,7 @@ export default Vue.extend({ this.user = user; this.fetching = false; - document.title = '%i18n:@followers-of%'.replace('{}', this.name) + ' | ' + (this as any).os.instanceName; + document.title = `${'%i18n:@followers-of%'.replace('{}', this.name)} | ${(this as any).os.instanceName}`; }); }, onLoaded() { diff --git a/src/client/app/mobile/views/pages/following.vue b/src/client/app/mobile/views/pages/following.vue index ff201ff2bd..0efac6110e 100644 --- a/src/client/app/mobile/views/pages/following.vue +++ b/src/client/app/mobile/views/pages/following.vue @@ -48,7 +48,7 @@ export default Vue.extend({ this.user = user; this.fetching = false; - document.title = '%i18n:@followers-of%'.replace('{}', this.name) + ' | ' + (this as any).os.instanceName; + document.title = `${'%i18n:@followers-of%'.replace('{}', this.name)} | ${(this as any).os.instanceName}`; }); }, onLoaded() { diff --git a/src/client/app/mobile/views/pages/games/reversi.vue b/src/client/app/mobile/views/pages/games/reversi.vue index d6849a1c11..bdadc88a43 100644 --- a/src/client/app/mobile/views/pages/games/reversi.vue +++ b/src/client/app/mobile/views/pages/games/reversi.vue @@ -16,10 +16,10 @@ export default Vue.extend({ methods: { nav(game, actualNav) { if (actualNav) { - this.$router.push('/reversi/' + game.id); + this.$router.push(`/reversi/${game.id}`); } else { // TODO: https://github.com/vuejs/vue-router/issues/703 - this.$router.push('/reversi/' + game.id); + this.$router.push(`/reversi/${game.id}`); } } } diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue index 416b006cd8..fecb2384ba 100644 --- a/src/client/app/mobile/views/pages/home.timeline.vue +++ b/src/client/app/mobile/views/pages/home.timeline.vue @@ -13,6 +13,7 @@ <script lang="ts"> import Vue from 'vue'; +import { HashtagStream } from '../../../common/scripts/streaming/hashtag'; const fetchLimit = 10; @@ -21,6 +22,9 @@ export default Vue.extend({ src: { type: String, required: true + }, + tagTl: { + required: false } }, @@ -29,6 +33,7 @@ export default Vue.extend({ fetching: true, moreFetching: false, existMore: false, + streamManager: null, connection: null, connectionId: null, unreadCount: 0, @@ -41,21 +46,14 @@ export default Vue.extend({ return this.$store.state.i.followingCount == 0; }, - stream(): any { - switch (this.src) { - case 'home': return (this as any).os.stream; - case 'local': return (this as any).os.streams.localTimelineStream; - case 'hybrid': return (this as any).os.streams.hybridTimelineStream; - case 'global': return (this as any).os.streams.globalTimelineStream; - } - }, - endpoint(): string { switch (this.src) { case 'home': return 'notes/timeline'; case 'local': return 'notes/local-timeline'; case 'hybrid': return 'notes/hybrid-timeline'; case 'global': return 'notes/global-timeline'; + case 'mentions': return 'notes/mentions'; + case 'tag': return 'notes/search_by_tag'; } }, @@ -65,25 +63,63 @@ export default Vue.extend({ }, mounted() { - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); - - this.connection.on('note', this.onNote); - if (this.src == 'home') { + if (this.src == 'tag') { + this.connection = new HashtagStream((this as any).os, this.$store.state.i, this.tagTl.query); + this.connection.on('note', this.onNote); + } else if (this.src == 'home') { + this.streamManager = (this as any).os.stream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('note', this.onNote); this.connection.on('follow', this.onChangeFollowing); this.connection.on('unfollow', this.onChangeFollowing); + } else if (this.src == 'local') { + this.streamManager = (this as any).os.streams.localTimelineStream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('note', this.onNote); + } else if (this.src == 'hybrid') { + this.streamManager = (this as any).os.streams.hybridTimelineStream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('note', this.onNote); + } else if (this.src == 'global') { + this.streamManager = (this as any).os.streams.globalTimelineStream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('note', this.onNote); + } else if (this.src == 'mentions') { + this.streamManager = (this as any).os.stream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('mention', this.onNote); } this.fetch(); }, beforeDestroy() { - this.connection.off('note', this.onNote); - if (this.src == 'home') { + if (this.src == 'tag') { + this.connection.off('note', this.onNote); + this.connection.close(); + } else if (this.src == 'home') { + this.connection.off('note', this.onNote); this.connection.off('follow', this.onChangeFollowing); this.connection.off('unfollow', this.onChangeFollowing); + this.streamManager.dispose(this.connectionId); + } else if (this.src == 'local') { + this.connection.off('note', this.onNote); + this.streamManager.dispose(this.connectionId); + } else if (this.src == 'hybrid') { + this.connection.off('note', this.onNote); + this.streamManager.dispose(this.connectionId); + } else if (this.src == 'global') { + this.connection.off('note', this.onNote); + this.streamManager.dispose(this.connectionId); + } else if (this.src == 'mentions') { + this.connection.off('mention', this.onNote); + this.streamManager.dispose(this.connectionId); } - this.stream.dispose(this.connectionId); }, methods: { @@ -96,7 +132,8 @@ export default Vue.extend({ untilDate: this.date ? this.date.getTime() : undefined, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl ? this.tagTl.query : undefined }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -119,7 +156,8 @@ export default Vue.extend({ untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl ? this.tagTl.query : undefined }); promise.then(notes => { diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue index 706c9cd28b..3ec2f16b75 100644 --- a/src/client/app/mobile/views/pages/home.vue +++ b/src/client/app/mobile/views/pages/home.vue @@ -6,7 +6,9 @@ <span v-if="src == 'local'">%fa:R comments%%i18n:@local%</span> <span v-if="src == 'hybrid'">%fa:share-alt%%i18n:@hybrid%</span> <span v-if="src == 'global'">%fa:globe%%i18n:@global%</span> + <span v-if="src == 'mentions'">%fa:at%%i18n:@mentions%</span> <span v-if="src == 'list'">%fa:list%{{ list.title }}</span> + <span v-if="src == 'tag'">%fa:hashtag%{{ tagTl.title }}</span> </span> <span style="margin-left:8px"> <template v-if="!showNav">%fa:angle-down%</template> @@ -24,12 +26,14 @@ <div class="body"> <div> <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% %i18n:@home%</span> - <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% %i18n:@local%</span> - <span :data-active="src == 'hybrid'" @click="src = 'hybrid'">%fa:share-alt% %i18n:@hybrid%</span> + <span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline">%fa:R comments% %i18n:@local%</span> + <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline">%fa:share-alt% %i18n:@hybrid%</span> <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span> + <span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%</span> <template v-if="lists"> <span v-for="l in lists" :data-active="src == 'list' && list == l" @click="src = 'list'; list = l" :key="l.id">%fa:list% {{ l.title }}</span> </template> + <span v-for="tl in $store.state.settings.tagTimelines" :data-active="src == 'tag' && tagTl == tl" @click="src = 'tag'; tagTl = tl" :key="tl.id">%fa:hashtag% {{ tl.title }}</span> </div> </div> </div> @@ -39,6 +43,8 @@ <x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/> <x-tl v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/> <x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/> + <x-tl v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/> + <x-tl v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/> <mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/> </div> </main> @@ -60,7 +66,9 @@ export default Vue.extend({ src: 'home', list: null, lists: null, - showNav: false + tagTl: null, + showNav: false, + enableLocalTimeline: false }; }, @@ -70,9 +78,16 @@ export default Vue.extend({ this.saveSrc(); }, - list() { + list(x) { this.showNav = false; this.saveSrc(); + if (x != null) this.tagTl = null; + }, + + tagTl(x) { + this.showNav = false; + this.saveSrc(); + if (x != null) this.list = null; }, showNav(v) { @@ -85,10 +100,16 @@ export default Vue.extend({ }, created() { + (this as any).os.getMeta().then(meta => { + this.enableLocalTimeline = !meta.disableLocalTimeline; + }); + if (this.$store.state.device.tl) { this.src = this.$store.state.device.tl.src; if (this.src == 'list') { this.list = this.$store.state.device.tl.arg; + } else if (this.src == 'tag') { + this.tagTl = this.$store.state.device.tl.arg; } } else if (this.$store.state.i.followingCount == 0) { this.src = 'hybrid'; @@ -113,7 +134,7 @@ export default Vue.extend({ saveSrc() { this.$store.commit('device/setTl', { src: this.src, - arg: this.list + arg: this.src == 'list' ? this.list : this.tagTl }); }, diff --git a/src/client/app/mobile/views/pages/selectdrive.vue b/src/client/app/mobile/views/pages/selectdrive.vue index 1a162b346c..c098b8c65e 100644 --- a/src/client/app/mobile/views/pages/selectdrive.vue +++ b/src/client/app/mobile/views/pages/selectdrive.vue @@ -5,7 +5,7 @@ <button class="upload" @click="upload">%fa:upload%</button> <button v-if="multiple" class="ok" @click="ok">%fa:check%</button> </header> - <mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="42"/> + <mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="$store.state.uiHeaderHeight"/> </div> </template> diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index 7437eb8b47..f315c058df 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -2,7 +2,7 @@ <mk-ui> <span slot="header">%fa:cog%%i18n:@settings%</span> <main :data-darkmode="$store.state.device.darkmode"> - <div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', '<b>' + name + '</b>')"></div> + <div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${name}</b>`)"></div> <div> <x-profile/> @@ -10,80 +10,120 @@ <ui-card> <div slot="title">%fa:palette% %i18n:@design%</div> - <ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch> - <ui-switch v-model="$store.state.settings.circleIcons" @change="onChangeCircleIcons">%i18n:@circle-icons%</ui-switch> - <ui-switch v-model="$store.state.settings.iLikeSushi" @change="onChangeILikeSushi">%i18n:common.i-like-sushi%</ui-switch> - <ui-switch v-model="$store.state.settings.disableAnimatedMfm" @change="onChangeDisableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch> - <ui-switch v-model="$store.state.settings.games.reversi.showBoardLabels" @change="onChangeReversiBoardLabels">%i18n:common.show-reversi-board-labels%</ui-switch> - <ui-switch v-model="$store.state.settings.games.reversi.useContrastStones" @change="onChangeUseContrastReversiStones">%i18n:common.use-contrast-reversi-stones%</ui-switch> + <section> + <ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch> + <ui-switch v-model="circleIcons">%i18n:@circle-icons%</ui-switch> + <ui-switch v-model="reduceMotion">%i18n:common.reduce-motion% (%i18n:common.this-setting-is-this-device-only%)</ui-switch> + <ui-switch v-model="contrastedAcct">%i18n:@contrasted-acct%</ui-switch> + <ui-switch v-model="showFullAcct">%i18n:common.show-full-acct%</ui-switch> + <ui-switch v-model="iLikeSushi">%i18n:common.i-like-sushi%</ui-switch> + <ui-switch v-model="disableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch> + <ui-switch v-model="alwaysShowNsfw">%i18n:common.always-show-nsfw% (%i18n:common.this-setting-is-this-device-only%)</ui-switch> + <ui-switch v-model="games_reversi_showBoardLabels">%i18n:common.show-reversi-board-labels%</ui-switch> + <ui-switch v-model="games_reversi_useContrastStones">%i18n:common.use-contrast-reversi-stones%</ui-switch> + </section> - <div> - <div>%i18n:@timeline%</div> - <ui-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget">%i18n:@show-reply-target%</ui-switch> - <ui-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes">%i18n:@show-my-renotes%</ui-switch> - <ui-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch> - <ui-switch v-model="$store.state.settings.showLocalRenotes" @change="onChangeShowLocalRenotes">%i18n:@show-local-renotes%</ui-switch> - </div> + <section> + <header>%i18n:@timeline%</header> + <div> + <ui-switch v-model="showReplyTarget">%i18n:@show-reply-target%</ui-switch> + <ui-switch v-model="showMyRenotes">%i18n:@show-my-renotes%</ui-switch> + <ui-switch v-model="showRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch> + <ui-switch v-model="showLocalRenotes">%i18n:@show-local-renotes%</ui-switch> + </div> + </section> - <div> - <div>%i18n:@post-style%</div> + <section> + <header>%i18n:@post-style%</header> <ui-radio v-model="postStyle" value="standard">%i18n:@post-style-standard%</ui-radio> <ui-radio v-model="postStyle" value="smart">%i18n:@post-style-smart%</ui-radio> - </div> + </section> + + <section> + <header>%i18n:@notification-position%</header> + <ui-radio v-model="mobileNotificationPosition" value="bottom">%i18n:@notification-position-bottom%</ui-radio> + <ui-radio v-model="mobileNotificationPosition" value="top">%i18n:@notification-position-top%</ui-radio> + </section> </ui-card> <ui-card> <div slot="title">%fa:cog% %i18n:@behavior%</div> - <ui-switch v-model="$store.state.settings.fetchOnScroll" @change="onChangeFetchOnScroll">%i18n:@fetch-on-scroll%</ui-switch> - <ui-switch v-model="$store.state.settings.disableViaMobile" @change="onChangeDisableViaMobile">%i18n:@disable-via-mobile%</ui-switch> - <ui-switch v-model="loadRawImages">%i18n:@load-raw-images%</ui-switch> - <ui-switch v-model="$store.state.settings.loadRemoteMedia" @change="onChangeLoadRemoteMedia">%i18n:@load-remote-media%</ui-switch> - <ui-switch v-model="lightmode">%i18n:@i-am-under-limited-internet%</ui-switch> + + <section> + <ui-switch v-model="fetchOnScroll">%i18n:@fetch-on-scroll%</ui-switch> + <ui-switch v-model="disableViaMobile">%i18n:@disable-via-mobile%</ui-switch> + <ui-switch v-model="loadRawImages">%i18n:@load-raw-images%</ui-switch> + <ui-switch v-model="loadRemoteMedia">%i18n:@load-remote-media%</ui-switch> + <ui-switch v-model="lightmode">%i18n:@i-am-under-limited-internet%</ui-switch> + </section> + + <section> + <header>%i18n:@note-visibility%</header> + <ui-switch v-model="rememberNoteVisibility">%i18n:@remember-note-visibility%</ui-switch> + <section> + <header>%i18n:@default-note-visibility%</header> + <ui-select v-model="defaultNoteVisibility"> + <option value="public">%i18n:common.note-visibility.public%</option> + <option value="home">%i18n:common.note-visibility.home%</option> + <option value="followers">%i18n:common.note-visibility.followers%</option> + <option value="specified">%i18n:common.note-visibility.specified%</option> + <option value="private">%i18n:common.note-visibility.private%</option> + </ui-select> + </section> + </section> </ui-card> <ui-card> <div slot="title">%fa:volume-up% %i18n:@sound%</div> - <ui-switch v-model="enableSounds">%i18n:@enable-sounds%</ui-switch> + <section> + <ui-switch v-model="enableSounds">%i18n:@enable-sounds%</ui-switch> + </section> </ui-card> <ui-card> <div slot="title">%fa:language% %i18n:@lang%</div> - <ui-select v-model="lang" placeholder="%i18n:@auto%"> - <optgroup label="%i18n:@recommended%"> - <option value="">%i18n:@auto%</option> - </optgroup> + <section class="fit-top"> + <ui-select v-model="lang" placeholder="%i18n:@auto%"> + <optgroup label="%i18n:@recommended%"> + <option value="">%i18n:@auto%</option> + </optgroup> - <optgroup label="%i18n:@specify-language%"> - <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> - </optgroup> - </ui-select> - <span>%fa:info-circle% %i18n:@lang-tip%</span> + <optgroup label="%i18n:@specify-language%"> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </optgroup> + </ui-select> + <span>%fa:info-circle% %i18n:@lang-tip%</span> + </section> </ui-card> <ui-card> <div slot="title">%fa:B twitter% %i18n:@twitter%</div> - <p class="account" v-if="$store.state.i.twitter"><a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> - <p> - <a :href="`${apiUrl}/connect/twitter`" target="_blank">{{ $store.state.i.twitter ? '%i18n:@twitter-reconnect%' : '%i18n:@twitter-connect%' }}</a> - <span v-if="$store.state.i.twitter"> or </span> - <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter">%i18n:@twitter-disconnect%</a> - </p> + <section> + <p class="account" v-if="$store.state.i.twitter"><a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> + <p> + <a :href="`${apiUrl}/connect/twitter`" target="_blank">{{ $store.state.i.twitter ? '%i18n:@twitter-reconnect%' : '%i18n:@twitter-connect%' }}</a> + <span v-if="$store.state.i.twitter"> or </span> + <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter">%i18n:@twitter-disconnect%</a> + </p> + </section> </ui-card> <ui-card> <div slot="title">%fa:sync-alt% %i18n:@update%</div> - <div>%i18n:@version% <i>{{ version }}</i></div> - <template v-if="latestVersion !== undefined"> - <div>%i18n:@latest-version% <i>{{ latestVersion ? latestVersion : version }}</i></div> - </template> - <ui-button @click="checkForUpdate" :disabled="checkingForUpdate"> - <template v-if="checkingForUpdate">%i18n:@update-checking%<mk-ellipsis/></template> - <template v-else>%i18n:@check-for-updates%</template> - </ui-button> + <section> + <div>%i18n:@version% <i>{{ version }}</i></div> + <template v-if="latestVersion !== undefined"> + <div>%i18n:@latest-version% <i>{{ latestVersion ? latestVersion : version }}</i></div> + </template> + <ui-button @click="checkForUpdate" :disabled="checkingForUpdate"> + <template v-if="checkingForUpdate">%i18n:@update-checking%<mk-ellipsis/></template> + <template v-else>%i18n:@check-for-updates%</template> + </ui-button> + </section> </ui-card> </div> @@ -129,11 +169,26 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'darkmode', value }); } }, + reduceMotion: { + get() { return this.$store.state.device.reduceMotion; }, + set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); } + }, + + alwaysShowNsfw: { + get() { return this.$store.state.device.alwaysShowNsfw; }, + set(value) { this.$store.commit('device/set', { key: 'alwaysShowNsfw', value }); } + }, + postStyle: { get() { return this.$store.state.device.postStyle; }, set(value) { this.$store.commit('device/set', { key: 'postStyle', value }); } }, + mobileNotificationPosition: { + get() { return this.$store.state.device.mobileNotificationPosition; }, + set(value) { this.$store.commit('device/set', { key: 'mobileNotificationPosition', value }); } + }, + lightmode: { get() { return this.$store.state.device.lightmode; }, set(value) { this.$store.commit('device/set', { key: 'lightmode', value }); } @@ -153,99 +208,95 @@ export default Vue.extend({ get() { return this.$store.state.device.enableSounds; }, set(value) { this.$store.commit('device/set', { key: 'enableSounds', value }); } }, - }, - mounted() { - document.title = '%i18n:@settings%'; - }, + fetchOnScroll: { + get() { return this.$store.state.settings.fetchOnScroll; }, + set(value) { this.$store.dispatch('settings/set', { key: 'fetchOnScroll', value }); } + }, - methods: { - signout() { - (this as any).os.signout(); + rememberNoteVisibility: { + get() { return this.$store.state.settings.rememberNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); } }, - onChangeFetchOnScroll(v) { - this.$store.dispatch('settings/set', { - key: 'fetchOnScroll', - value: v - }); + disableViaMobile: { + get() { return this.$store.state.settings.disableViaMobile; }, + set(value) { this.$store.dispatch('settings/set', { key: 'disableViaMobile', value }); } }, - onChangeDisableViaMobile(v) { - this.$store.dispatch('settings/set', { - key: 'disableViaMobile', - value: v - }); + loadRemoteMedia: { + get() { return this.$store.state.settings.loadRemoteMedia; }, + set(value) { this.$store.dispatch('settings/set', { key: 'loadRemoteMedia', value }); } }, - onChangeLoadRemoteMedia(v) { - this.$store.dispatch('settings/set', { - key: 'loadRemoteMedia', - value: v - }); + circleIcons: { + get() { return this.$store.state.settings.circleIcons; }, + set(value) { this.$store.dispatch('settings/set', { key: 'circleIcons', value }); } }, - onChangeCircleIcons(v) { - this.$store.dispatch('settings/set', { - key: 'circleIcons', - value: v - }); + contrastedAcct: { + get() { return this.$store.state.settings.contrastedAcct; }, + set(value) { this.$store.dispatch('settings/set', { key: 'contrastedAcct', value }); } }, - onChangeILikeSushi(v) { - this.$store.dispatch('settings/set', { - key: 'iLikeSushi', - value: v - }); + showFullAcct: { + get() { return this.$store.state.settings.showFullAcct; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showFullAcct', value }); } }, - onChangeReversiBoardLabels(v) { - this.$store.dispatch('settings/set', { - key: 'games.reversi.showBoardLabels', - value: v - }); + iLikeSushi: { + get() { return this.$store.state.settings.iLikeSushi; }, + set(value) { this.$store.dispatch('settings/set', { key: 'iLikeSushi', value }); } }, - onChangeUseContrastReversiStones(v) { - this.$store.dispatch('settings/set', { - key: 'games.reversi.useContrastStones', - value: v - }); + games_reversi_showBoardLabels: { + get() { return this.$store.state.settings.games.reversi.showBoardLabels; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.showBoardLabels', value }); } }, - onChangeDisableAnimatedMfm(v) { - this.$store.dispatch('settings/set', { - key: 'disableAnimatedMfm', - value: v - }); + games_reversi_useContrastStones: { + get() { return this.$store.state.settings.games.reversi.useContrastStones; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useContrastStones', value }); } }, - onChangeShowReplyTarget(v) { - this.$store.dispatch('settings/set', { - key: 'showReplyTarget', - value: v - }); + disableAnimatedMfm: { + get() { return this.$store.state.settings.disableAnimatedMfm; }, + set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); } }, - onChangeShowMyRenotes(v) { - this.$store.dispatch('settings/set', { - key: 'showMyRenotes', - value: v - }); + showReplyTarget: { + get() { return this.$store.state.settings.showReplyTarget; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } }, - onChangeShowRenotedMyNotes(v) { - this.$store.dispatch('settings/set', { - key: 'showRenotedMyNotes', - value: v - }); + showMyRenotes: { + get() { return this.$store.state.settings.showMyRenotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showMyRenotes', value }); } }, - onChangeShowLocalRenotes(v) { - this.$store.dispatch('settings/set', { - key: 'showLocalRenotes', - value: v - }); + showRenotedMyNotes: { + get() { return this.$store.state.settings.showRenotedMyNotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showRenotedMyNotes', value }); } + }, + + showLocalRenotes: { + get() { return this.$store.state.settings.showLocalRenotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showLocalRenotes', value }); } + }, + + defaultNoteVisibility: { + get() { return this.$store.state.settings.defaultNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } + }, + }, + + mounted() { + document.title = '%i18n:@settings%'; + }, + + methods: { + signout() { + (this as any).os.signout(); }, checkForUpdate() { @@ -273,7 +324,7 @@ export default Vue.extend({ <style lang="stylus" scoped> root(isDark) margin 0 auto - max-width 500px + max-width 600px width 100% > .signin-as diff --git a/src/client/app/mobile/views/pages/settings/settings.profile.vue b/src/client/app/mobile/views/pages/settings/settings.profile.vue index 3b797cdde1..127f531902 100644 --- a/src/client/app/mobile/views/pages/settings/settings.profile.vue +++ b/src/client/app/mobile/views/pages/settings/settings.profile.vue @@ -2,47 +2,64 @@ <ui-card> <div slot="title">%fa:user% %i18n:@title%</div> - <ui-form :disabled="saving"> - <ui-input v-model="name" :max="30"> - <span>%i18n:@name%</span> - </ui-input> + <section class="fit-top"> + <ui-form :disabled="saving"> + <ui-input v-model="name" :max="30"> + <span>%i18n:@name%</span> + </ui-input> - <ui-input v-model="username" readonly> - <span>%i18n:@account%</span> - <span slot="prefix">@</span> - <span slot="suffix">@{{ host }}</span> - </ui-input> + <ui-input v-model="username" readonly> + <span>%i18n:@account%</span> + <span slot="prefix">@</span> + <span slot="suffix">@{{ host }}</span> + </ui-input> - <ui-input v-model="location"> - <span>%i18n:@location%</span> - <span slot="prefix">%fa:map-marker-alt%</span> - </ui-input> + <ui-input v-model="location"> + <span>%i18n:@location%</span> + <span slot="prefix">%fa:map-marker-alt%</span> + </ui-input> - <ui-input v-model="birthday" type="date"> - <span>%i18n:@birthday%</span> - <span slot="prefix">%fa:birthday-cake%</span> - </ui-input> + <ui-input v-model="birthday" type="date"> + <span>%i18n:@birthday%</span> + <span slot="prefix">%fa:birthday-cake%</span> + </ui-input> - <ui-textarea v-model="description" :max="500"> - <span>%i18n:@description%</span> - </ui-textarea> + <ui-textarea v-model="description" :max="500"> + <span>%i18n:@description%</span> + </ui-textarea> - <ui-input type="file" @change="onAvatarChange"> - <span>%i18n:@avatar%</span> - <span slot="icon">%fa:image%</span> - <span slot="text" v-if="avatarUploading">%i18n:@uploading%<mk-ellipsis/></span> - </ui-input> + <ui-input type="file" @change="onAvatarChange"> + <span>%i18n:@avatar%</span> + <span slot="icon">%fa:image%</span> + <span slot="text" v-if="avatarUploading">%i18n:@uploading%<mk-ellipsis/></span> + </ui-input> - <ui-input type="file" @change="onBannerChange"> - <span>%i18n:@banner%</span> - <span slot="icon">%fa:image%</span> - <span slot="text" v-if="bannerUploading">%i18n:@uploading%<mk-ellipsis/></span> - </ui-input> + <ui-input type="file" @change="onBannerChange"> + <span>%i18n:@banner%</span> + <span slot="icon">%fa:image%</span> + <span slot="text" v-if="bannerUploading">%i18n:@uploading%<mk-ellipsis/></span> + </ui-input> - <ui-switch v-model="isCat">%i18n:@is-cat%</ui-switch> + <ui-button @click="save(true)">%i18n:@save%</ui-button> + </ui-form> + </section> - <ui-button @click="save">%i18n:@save%</ui-button> - </ui-form> + <section> + <header>%i18n:@advanced%</header> + + <div> + <ui-switch v-model="isCat" @change="save(false)">%i18n:@is-cat%</ui-switch> + <ui-switch v-model="alwaysMarkNsfw">%i18n:common.always-mark-nsfw%</ui-switch> + </div> + </section> + + <section> + <header>%i18n:@privacy%</header> + + <div> + <ui-switch v-model="isLocked" @change="save(false)">%i18n:@is-locked%</ui-switch> + </div> + </section> </ui-card> </template> @@ -62,12 +79,20 @@ export default Vue.extend({ avatarId: null, bannerId: null, isCat: false, + isLocked: false, saving: false, avatarUploading: false, bannerUploading: false }; }, + computed: { + alwaysMarkNsfw: { + get() { return this.$store.state.i.settings.alwaysMarkNsfw; }, + set(value) { (this as any).api('i/update', { alwaysMarkNsfw: value }); } + }, + }, + created() { this.name = this.$store.state.i.name || ''; this.username = this.$store.state.i.username; @@ -77,6 +102,7 @@ export default Vue.extend({ this.avatarId = this.$store.state.i.avatarId; this.bannerId = this.$store.state.i.bannerId; this.isCat = this.$store.state.i.isCat; + this.isLocked = this.$store.state.i.isLocked; }, methods: { @@ -124,7 +150,7 @@ export default Vue.extend({ }); }, - save() { + save(notify) { this.saving = true; (this as any).api('i/update', { @@ -134,7 +160,8 @@ export default Vue.extend({ birthday: this.birthday || null, avatarId: this.avatarId, bannerId: this.bannerId, - isCat: this.isCat + isCat: this.isCat, + isLocked: this.isLocked }).then(i => { this.saving = false; this.$store.state.i.avatarId = i.avatarId; @@ -142,7 +169,9 @@ export default Vue.extend({ this.$store.state.i.bannerId = i.bannerId; this.$store.state.i.bannerUrl = i.bannerUrl; - alert('%i18n:@saved%'); + if (notify) { + alert('%i18n:@saved%'); + } }); } } diff --git a/src/client/app/mobile/views/pages/user-lists.vue b/src/client/app/mobile/views/pages/user-lists.vue index abd04c1496..5ee0636dea 100644 --- a/src/client/app/mobile/views/pages/user-lists.vue +++ b/src/client/app/mobile/views/pages/user-lists.vue @@ -43,7 +43,7 @@ export default Vue.extend({ title }); - this.$router.push('/i/lists/' + list.id); + this.$router.push(`/i/lists/${list.id}`); }); } } diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index 8918847a8f..c1082f31a9 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -16,7 +16,7 @@ </div> <div class="title"> <h1>{{ user | userName }}</h1> - <span class="username"><mk-acct :user="user"/></span> + <span class="username"><mk-acct :user="user" :detail="true" /></span> <span class="followed" v-if="user.isFollowed">%i18n:@follows-you%</span> </div> <div class="description"> @@ -107,7 +107,7 @@ export default Vue.extend({ this.fetching = false; Progress.done(); - document.title = Vue.filter('userName')(this.user) + ' | ' + (this as any).os.instanceName; + document.title = `${Vue.filter('userName')(this.user)} | ${(this as any).os.instanceName}`; }); } } diff --git a/src/client/app/mobile/views/pages/user/home.photos.vue b/src/client/app/mobile/views/pages/user/home.photos.vue index 73ff1d5173..e9025ec816 100644 --- a/src/client/app/mobile/views/pages/user/home.photos.vue +++ b/src/client/app/mobile/views/pages/user/home.photos.vue @@ -26,7 +26,7 @@ export default Vue.extend({ mounted() { (this as any).api('users/notes', { userId: this.user.id, - withMedia: true, + withFiles: true, limit: 6 }).then(notes => { notes.forEach(note => { diff --git a/src/client/app/mobile/views/pages/welcome.vue b/src/client/app/mobile/views/pages/welcome.vue index 49227790ff..74f43f2c71 100644 --- a/src/client/app/mobile/views/pages/welcome.vue +++ b/src/client/app/mobile/views/pages/welcome.vue @@ -1,5 +1,5 @@ <template> -<div class="welcome"> +<div class="wgwfgvvimdjvhjfwxropcwksnzftjqes"> <div> <img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name"> <p class="host">{{ host }}</p> @@ -15,12 +15,53 @@ <mk-welcome-timeline/> </div> <div class="hashtags"> - <router-link v-for="tag in tags" :key="tag" :to="`/tags/${ tag }`" :title="tag">#{{ tag }}</router-link> + <mk-tag-cloud/> + </div> + <div class="photos"> + <div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div> </div> <div class="stats" v-if="stats"> <span>%fa:user% {{ stats.originalUsersCount | number }}</span> <span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span> </div> + <div class="announcements" v-if="announcements && announcements.length > 0"> + <article v-for="announcement in announcements"> + <span class="title" v-html="announcement.title"></span> + <div v-html="announcement.text"></div> + </article> + </div> + <article class="about-misskey"> + <h1>%i18n:common.intro.title%</h1> + <p v-html="'%i18n:common.intro.about%'"></p> + <section> + <h2>%i18n:common.intro.features%</h2> + <section> + <h3>%i18n:common.intro.rich-contents%</h3> + <div class="image"><img src="/assets/about/post.png" alt=""></div> + <p v-html="'%i18n:common.intro.rich-contents-desc%'"></p> + </section> + <section> + <h3>%i18n:common.intro.reaction%</h3> + <div class="image"><img src="/assets/about/reaction.png" alt=""></div> + <p v-html="'%i18n:common.intro.reaction-desc%'"></p> + </section> + <section> + <h3>%i18n:common.intro.ui%</h3> + <div class="image"><img src="/assets/about/ui.png" alt=""></div> + <p v-html="'%i18n:common.intro.ui-desc%'"></p> + </section> + <section> + <h3>%i18n:common.intro.drive%</h3> + <div class="image"><img src="/assets/about/drive.png" alt=""></div> + <p v-html="'%i18n:common.intro.drive-desc%'"></p> + </section> + </section> + <p v-html="'%i18n:common.intro.outro%'"></p> + </article> + <div class="info" v-if="meta"> + <p>Version: <b>{{ meta.version }}</b></p> + <p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p> + </div> <footer> <small>{{ copyright }}</small> </footer> @@ -30,39 +71,53 @@ <script lang="ts"> import Vue from 'vue'; -import { apiUrl, copyright, host } from '../../../config'; +import { copyright, host } from '../../../config'; +import { concat } from '../../../../../prelude/array'; export default Vue.extend({ data() { return { - apiUrl, + meta: null, copyright, stats: null, host, name: 'Misskey', description: '', - tags: [] + photos: [], + announcements: [] }; }, created() { (this as any).os.getMeta().then(meta => { + this.meta = meta; this.name = meta.name; this.description = meta.description; + this.announcements = meta.broadcasts; }); (this as any).api('stats').then(stats => { this.stats = stats; }); - (this as any).api('hashtags/trend').then(stats => { - this.tags = stats.map(x => x.tag); + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; + + (this as any).api('notes/local-timeline', { + fileType: image, + limit: 6 + }).then((notes: any[]) => { + const files = concat(notes.map((n: any): any[] => n.files)); + this.photos = files.filter(f => image.includes(f.type)).slice(0, 6); }); } }); </script> <style lang="stylus" scoped> -.welcome +root(isDark) text-align center //background #fff @@ -138,12 +193,21 @@ export default Vue.extend({ -webkit-overflow-scrolling touch > .hashtags - padding 16px 0 - border solid 2px #ddd - border-radius 8px + padding 0 8px + height 200px - > * - margin 0 16px + > .photos + display grid + grid-template-rows 1fr 1fr 1fr + grid-template-columns 1fr 1fr + gap 8px + height 300px + margin-top 16px + + > div + border-radius 4px + background-position center center + background-size cover > .stats margin 16px 0 @@ -156,6 +220,68 @@ export default Vue.extend({ > * margin 0 8px + > .announcements + margin 16px 0 + + > article + background isDark ? rgba(30, 129, 216, 0.2) : rgba(155, 196, 232, 0.2) + border-radius 6px + color isDark ? #fff : #3f4967 + padding 16px + margin 8px 0 + font-size 12px + + > .title + font-weight bold + + > .about-misskey + margin 16px 0 + padding 32px + font-size 14px + background #fff + border-radius 6px + overflow hidden + color #3a3e46 + + > h1 + margin 0 + + & + p + margin-top 8px + + > p:last-child + margin-bottom 0 + + > section + > h2 + border-bottom 1px solid isDark ? rgba(#000, 0.2) : rgba(#000, 0.05) + + > section + margin-bottom 16px + padding-bottom 16px + border-bottom 1px solid isDark ? rgba(#000, 0.2) : rgba(#000, 0.05) + + > h3 + margin-bottom 8px + + > p + margin-bottom 0 + + > .image + > img + display block + width 100% + height 120px + object-fit cover + + > .info + padding 16px 0 + border solid 2px #ddd + border-radius 8px + + > * + margin 0 16px + > footer text-align center color #444 @@ -165,4 +291,10 @@ export default Vue.extend({ margin 16px 0 0 0 opacity 0.7 +.wgwfgvvimdjvhjfwxropcwksnzftjqes[data-darkmode] + root(true) + +.wgwfgvvimdjvhjfwxropcwksnzftjqes:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/store.ts b/src/client/app/store.ts index 469563495f..171620ae30 100644 --- a/src/client/app/store.ts +++ b/src/client/app/store.ts @@ -4,17 +4,21 @@ import * as nestedProperty from 'nested-property'; import MiOS from './mios'; import { hostname } from './config'; +import { erase } from '../../prelude/array'; const defaultSettings = { home: null, mobileHome: [], deck: null, + tagTimelines: [], fetchOnScroll: true, showMaps: true, showPostFormOnTopOfTl: false, suggestRecentHashtags: true, showClockOnHeader: true, circleIcons: true, + contrastedAcct: true, + showFullAcct: false, gradientWindowHeader: false, showReplyTarget: true, showMyRenotes: true, @@ -24,6 +28,8 @@ const defaultSettings = { disableViaMobile: false, memo: null, iLikeSushi: false, + rememberNoteVisibility: false, + defaultNoteVisibility: 'public', games: { reversi: { showBoardLabels: false, @@ -33,6 +39,7 @@ const defaultSettings = { }; const defaultDeviceSettings = { + reduceMotion: false, apiViaStream: true, autoPopout: false, darkmode: false, @@ -43,7 +50,9 @@ const defaultDeviceSettings = { debug: false, lightmode: false, loadRawImages: false, - postStyle: 'standard' + alwaysShowNsfw: false, + postStyle: 'standard', + mobileNotificationPosition: 'bottom' }; export default (os: MiOS) => new Vuex.Store({ @@ -194,7 +203,7 @@ export default (os: MiOS) => new Vuex.Store({ removeDeckColumn(state, id) { state.deck.columns = state.deck.columns.filter(c => c.id != id); - state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id)); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); }, @@ -265,7 +274,7 @@ export default (os: MiOS) => new Vuex.Store({ stackLeftDeckColumn(state, id) { const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1); - state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id)); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); const left = state.deck.layout[i - 1]; if (left) state.deck.layout[i - 1].push(id); state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); @@ -273,7 +282,7 @@ export default (os: MiOS) => new Vuex.Store({ popRightDeckColumn(state, id) { const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1); - state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id)); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); state.deck.layout.splice(i + 1, 0, [id]); state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); }, diff --git a/src/client/app/sw.js b/src/client/app/sw.js index ac7ea20acf..d381bfb7a5 100644 --- a/src/client/app/sw.js +++ b/src/client/app/sw.js @@ -3,6 +3,7 @@ */ import composeNotification from './common/scripts/compose-notification'; +import { erase } from '../../prelude/array'; // キャッシュするリソース const cachee = [ @@ -24,8 +25,7 @@ self.addEventListener('activate', ev => { // Clean up old caches ev.waitUntil( caches.keys().then(keys => Promise.all( - keys - .filter(key => key != _VERSION_) + erase(_VERSION_, keys) .map(key => caches.delete(key)) )) ); |